八个常用的并行设计模式

来源:互联网 发布:数据库工程师考试科目 编辑:程序博客网 时间:2024/05/17 16:54
1 Agent and Repository

clip_image009

这是一个“架构模式”,它针对这样一类问题:我们有一组数据,它们会随机的被一些不同的对象进行修改。解决这一类问题的方案是,创建一个集中管理的数据仓库(data repository),然后定义一组自治的agent来操作这些数据,可能还有一个manager来对agent的操作进行协调,并保证数据仓库中数据的一致性。我们常见的源代码版本控制软件例如Perforce就是实现这种架构的典型代表:源代码都存放在一个统一的服务器中(或是一组服务器中,但对client而言是透明的),不同的程序员们使用各自的客户端对源代码文件进行读,写,加,删的操作。由Perforce负责保证源代码数据的一致性。

clip_image011

2 Map Reduce

Map Reduce这个名词原来是函数式编程里面的一个概念,但是自从Google于2004年推出同名的并行计算程序库后,提到这个名词大家大多想到的是Google的这个Framework。在这里,Map Reduce是一个“架构模式”的名称。当然,我们这里的Map Reduce指的就是类似Google Map Reduce工作原理的一类模式。

那么什么是Map Reduce的模式呢?用较为简单的语言描述,它指的是这样一类问题的解决方案:我们可以分两步来解决这类问题。第一步,使用一个串行的Mapper函数分别处理一组不同的数据,生成一个中间结果。第二步,将第一步的处理结果用一个Reducer函数进行处理(例如,归并操作),生成最后的结果。从使用Google的Map Reduce程序库的角度而言,作为应用程序员,我们只需提供一组输入数据,和两个普通的串行函数(Mapper和Reducer),Google的Map Reduce框架就会接管一切,保证输入数据有效的在一个分布式的计算机集群里面分配,然后Mapper和Reducer函数在其上有效的运行、处理,并最后汇总生成我们想要的处理结果。所有一切的细节,例如并行化、数据的分配、不同机器之间的计算误差,通通被隐藏在程序库内。

clip_image013

那么Map Reduce到底是什么样的一个过程呢?

我们讲过,使用Map Reduce,程序员必须提供一组输入数据,以及一个Mapper和一个Reducer函数。在这里,输入数据必须是一个按(input_key,input_value)方式组织的列表。

clip_image015

mapper函数的任务是处理输入列表中的某一个单元数据:mapper(input_key,input_value),并产生如下输出结果:

clip_image017

接下来,把对所有单元数据的处理结果按照intermediate_key归类:同样的intermediate_key放在一起,它们的intermediate_value简单的串接起来,得到:

clip_image019

Reducer函数的任务是对上述的中间结果进行处理:reducer(intermediate_key, intermediate_value_list),并产生如下最终输出结果:

clip_image021

我们会举两个例子说明这一过程。第一个例子是一个简单的统计单词出现次数的小程序。第二个例子是Google曾经怎样使用Map Reduce FrameWork来计算Page Rank。

第一个例子,假设我们要写一个小程序,来统计在几篇不同文章里所有出现过的单词各自总共出现的次数。我们应该怎么做呢?下面描述的利用Map Reduce的方法肯定不是大多数程序员第一感会想到的方法。但这种方法非常好的揭示了Map Reduce的基本思想。并且,这种方法很容易被扩展到处理上千万甚至是上亿的文件数据,并且能够在一个分布的计算机集群里面运行。这可不是传统的方法能够轻易做到的。

具体而言,假设我们有如下三个文本文件,a.txt, b.txt和c.txt:

clip_image023

对于输入数据而言,input_key就是文件名,input_value就是一个大的string,包含的是文件内容。所以我们的输入数据看上去会是这样的:

clip_image025

我们会写一个简单的串行的mapper(fileName, fileContent)函数。这个函数做的事情很简单,读入一个文本文件,把每一个遇到的单词当作一个新的intermediate_key,并赋其intermediate_value为1。将mapper函数处理文件a,我们会得到如下结果:

clip_image027

将所有三个文件的处理结果放在一起,我们得到:

clip_image029

然后将中间结果按intermediate_key归类:

clip_image031

最后,由reducer(intermediate_key, intermediate_value_list)对中间结果进行处理。它做的事也很简单,仅仅是把某intermediate_key对应的所有intermediate_value相加。我们于是得到最终结果:

clip_image033

第二个例子,怎样使用Map Reduce计算PageRank。什么是PageRank?可能大家都有所了解,这是Google用来量度一个网页的重要性的值。简单而言,有越多的其它网页链接到这个网页,这个网页的PageRank越高。链接到这个网页的网页PageRank越高,这个网页的PageRank也越高。假设我们一共有n个网页0, 1, …, n-1。对第j个网页我们给它赋一个PageRank值qj。所有的qj组合起来成为一个向量q = (q0,q1, …qn-1)。这个向量满足概率分布。即qj的值都在0和1之间,并且所有的qj加起来等于1。qj越大,网页的重要性越高。那么q是怎么计算出来的呢?答案是使用迭代的方法: 
clip_image035

我们从一个初始的PageRank向量分布P开始,乘以一个n*n的矩阵M,得到一个新的PageRank向量。把新的PageRank向量继续乘以M得到下一步的PageRank… … 如此迭代有限步后,PageRank向量的值会趋于收敛,于是我们得到最终的PageRank。

这里需要回答两个问题:1. 如何确定初始的PageRank,即迭代的起点?答案是任意选择一个概率分布就可以,无论你选择什么初始值,都不影响其收敛到最终的结果。我们通常使用均匀概率分布,即clip_image037。2. 如何定义M?这个问题稍显复杂,有兴趣的读者可以参见Michael Nielsen 的博文Using MapReduce to compute PageRank了解更详细的内容。在这里,我们将其简化的定义为一个描述网页间互相链接结构的超大矩阵。假设网络里有n个网页,那么我们这个矩阵就是一个n*n的方阵。矩阵的每一列代表一个网页对外的超链接情况。例如,我们定义#(j)为第j个网页对外的所有超链接的数量。那么对于矩阵M的第j列而言,如果网页j对网页k没有超链接,那么第k行元素Mkj=0,否则Mkj=1/#(j)。这里隐含的意思是当一个读者在浏览网页j时,有1/#(j)的可能性跳转到网页k。

那么如何使用Map Reduce来计算PageRank呢?虽然整个迭代的过程必须是串行的,迭代的每一步我们还是可以用Map Reduce来并行的计算的。这里也必须并行的计算因为这个矩阵和向量的规模是超大的(想象一下整个互联网的网页数量)。使用Map Reduce来计算迭代的一步实际上是用Map Reduce来计算矩阵和向量的乘法。假设我们要计算如下一个方阵和向量的乘法。其实质是将第i个向量元素的值pi乘以矩阵第i列的每一元素,然后放在矩阵元素原来的位置。最后,把矩阵第i行的所有元素相加,得到结果向量的第i个元素的值。

clip_image039

类似的,我们可以得到用MapReduce计算PageRank的方法:

第一步,输入的(input_key, input_value)。input_key是某个网页的编号,如j。input_value是一个列表,元素值是M矩阵的第j列元素,最后再加上一个pj,就是当前网页j的PageRank值。

第二步,Map。Mapper(input_key, input_value)所做的事情很简单,就是把pj乘以列表元素的每一个值,然后输出一组(intermediate_key, intermediate_value)。intermediate_key就是矩阵的行号,k。intermediate_value就是pj列表元素的值,即pj乘以矩阵第k行第j列的元素的值。

第三步,汇总。把所有intermediate_key相同的中间结果放到一起。即是把第k行所有的intermediate_value放在一个列表intermediate_value_list内。

第四步,Reduce。Reducer(intermediate_key, intermediate_value_list)做的事也很简单,就是把intermediate_value_list内所有的值相加。最后形成的(output_key, output_value)就是结果向量第k行的元素值。

以上就是利用Map Reduce计算PageRank的简略过程。这个过程相当粗略和不精确,只是为了揭示Map Reduce的工作过程和Google曾经用来计算PageRank的大致方法。认真的读者应该查阅其它更严谨的著作。

最后,和上述计算矩阵和向量乘法的例子相似,Map Reduce也可以用来计算两个向量的点乘。具体怎么做留给读者自己去思考,一个提示是我们所有的intermediate_key都是相同的,可以取同一个值例如1。

3 Data Parallelism

这是一个“算法模式”。事实上,把Data Parallelism和下节将要提到的Task Parallelism都称之为一种“算法模式”我觉得有过于笼统之嫌。到最后,哪一种并行算法不是被分解为并行执行的task呢(task parallelism)? 而并行执行的task不都是处理着各自的那份数据吗(data parallelism)?所以如果硬要把Data Parallelism和Task Parallelism称为两种算法模式,我只能说它们的地位要高于其它的算法模式。它们是其它算法模式的基础。只不过对于有些问题而言,比较明显的我们可以把它看成是Data Parallelism的或是Task Parallelism的。也许Data Parallelism模式和Task Parallelism模式特指的就是这类比较明显的问题。

那么什么是Data Parallelism? 顾名思义,就是这类问题可以表达为同样的一组操作被施加在不同的相互独立的数据上。

clip_image041

一个比较典型的例子就是计算机图形学里面的Ray tracing算法。Ray tracing算法可以大致描述为从一个虚拟相机的光心射出一条射线,透过屏幕的某个像素点,投射在要渲染的几何模型上。找到射线和物体的交点后,再根据该点的材料属性、光照条件等,算出该像素点的颜色值,赋给屏幕上的像素点。由于物体的几何模型很多个小的三角面片表示,算法的第一步就是要求出射线与哪个三角面片有交点。射线与各个单独的三角面片求交显然是相互独立的,所以这可以看做是Data Parallelism的例子。

clip_image043

4 Task Parallelism

Task Parallelism的算法模式可以表述为,一组互相独立的Task各自处理自己的数据。和Data Parallelism不同,这里关注的重点不是数据的划分,而是Task的划分。

clip_image045

如前所述,Task Parallelism和Data Parallelism是密不可分的。互相独立的Task肯定也是运行在互相独立的数据上。这主要是看我们以什么样的视角去看问题。例如,上一节RayTracing的例子中,我们也可以把射线和一个独立的三角面片求交看作是一个独立的Task。这样就也可以当它做是Task Parallelism的例子。然而,咬文嚼字的去区分到底是Task Parallelism还是Data Parallelism不是我们的目的,我们关注的应该是问题本身。对于某一个具体问题,从Data Parallelism出发考虑方便还是从Task Parallelism出发考虑方便,完全取决于问题本身的应用场景以及设计人员自身的经验、背景。事实上,很多时候,不管你是从Task Parallelism出发还是从Data Parallelism出发,经过不断的优化,最终的解决方案可能是趋同的。

下面一个矩阵乘法的例子很好的说明了这个问题。我们都知道矩阵乘法的定义:假如有n行k列的矩阵A乘以k行m列的矩阵B,那么我们可以得到一个n行m列的的矩阵C。矩阵C的第n行第m列的元素的值等于矩阵A的第n行和矩阵B的第m列的点乘。

clip_image047

从Task Parallelism的角度出发,我们可能把计算C的每一个元素当做一个独立的Task。接下来,为了提高CPU的缓存利用率,我们可能把邻近几个单元格的计算合并成一个大一点的Task。从Data Parallelism的角度出发,我们可能一开始把C按行分成不同的块。为了探索到底怎样的划分更加有效率,我们可能调整划分的方式和大小,最后,可能发现,最有效率的做法是把A,B,C都分成几个不同的小块,进行分块矩阵的乘法。可以看到,这个结果实际上和从Task Parallelism出发考虑的方案是殊途同归的。

5 Recursive Splitting

Recursive Splitting指的是这样一种算法模式:为了解决一个大问题,把它分解为可以独立求解的小问题。分解出来的小问题,可能又可以进一步分解为更小的问题。把问题分解到足够小的规模后,就可以直接求解了。最后,把各个小问题的解合并为原始的大问题的解。这实际上是我们传统的串行算法领域里面也有的“divide and conquer”的思想。

clip_image049

举两个例子。第一个是传统的归并排序。例如,要排序下面的8个元素的数组,我们不管三七二十一先把它一分为二。排序4个元素的数组还是显得太复杂了,于是又一分为二。现在,排序2个元素的数组很简单,按照大小交换顺序就行。最后,把排好序的数组按序依次组合起来,就得到我们最终的输出结果。

clip_image051

第二个例子稍微有趣一点,是一个如何用程序解“数独”游戏的例子。“数独”就是在一个9*9的大九宫格内有9个3*3的小九宫格。里面有些格子已经填入了数字,玩家必须在剩下的空格里也填入1到9的数字,使每个数字在每行、每列以及每个小九宫格内只出现一次。

clip_image053

这里作为举例说明,我们考虑一个简单一点的情况:在一个4*4的格子里填入1~4的数字,使其在每行、每列以及每个2*2的格子里只出现一次。

clip_image055

解“数独”游戏的算法可以有很多种。如果是人来解,大概会按照上图的次序依次填入1,2,3到相应的格子当中。每填入一个新数字,都会重新按规则评估周围的空格,看能否按现有情况再填入一些数字。这个方法当然没错,不过看上去不太容易并行化。下面介绍一个按照“recursive splitting”的方法可以很容易做到并行化的解法。

1) 首先,将二维的数独格子展开成一个一维的数组。已经有数字的地方是原来的数字,空格子的地方填上“0”。 
clip_image057

2) 接下来,从前到后对数组进行扫描。第一格是“3”,已经有数字了,跳过。移动搜索指针到下一格。

clip_image059

3) 第二格是“0”,意味着我们需要填入一个新的数字。这个新的数字有4种可能性:1, 2, 3, 4。所以创建4个新的搜索分支:

clip_image061

4) 接下来根据现有的数字信息检查各个搜索分支。明显,第三和第四个搜索分支是非法的。因为我们在同一行中已经有了数字“3”和“4”。所以忽略这两个分支。第一和第二条分支用现有的数字检查不出冲突,所以继续从这两个分支各派生出4条新的分支进行搜索… …

这个思路像极了我们之前的归并排序的例子,都是在算法运行的过程中不断产生出新的任务。所以实际上这也是一个“Task Parallelism”的例子。

6 Pipeline

“Pipeline”也是一种比较常见的算法模式。通常,我们都会用汽车装配中的流水线、CPU中指令执行的流水线来类比的说明这一模式。它说的是我们会对一批数据进行有序、分阶段的处理,前一阶段处理的输出作为下一阶段处理的输入。每一个阶段永远只重复自己这一阶段的任务,不停的接受新的数据进行处理。用一个软件上的例子打比方,我们要打开一批文本文件,将里面每一个单词的字母全部改成大写,然后写到一批新的文件里面去,就可以创建一条有3个stage的流水线:读文件,改大写,写文件。

clip_image063

“Pipeline”模式的概念看上去很容易理解,可是也不是每一个人都能一下子理解的那么透彻的。例如有这样一个问题:我们有一个for循环,循环体是一条有3个stage的pipeline,每个stage的运行时间分别是10, 40, 和10个CPU的时钟间隔。请问这个for循环执行N次大概需要多长时间(N是一个很大的数)?

A. 60*N

B. 10*N

C. 60

D. 40*N

请仔细思考并选择一个答案。:-)

 

 

 

 

 

 

 

 

 

 

 

答案是40*N。流水线总的执行时间是由它最慢的一个stage决定的。原因请见下图。

clip_image065

7 Geometric decomposition

接下来这两个算法模式看上去都显得比较特殊化,只针对某些特定的应用类型。“Geometric decomposition”说的是对于一些线性的数据结构(例如数组),我们可以把数据切分成几个连续的子集。因为这种切分模式看上去和把一块几何区域切分成连续的几块很类似,我们就把它叫做”Geometric decomposition”。

clip_image067

最常用的例子是分块矩阵的乘法。例如,为了计算两个矩阵A,B的乘法。我们可以把他们切分成各自可以相乘的小块。

clip_image069

clip_image071

结果矩阵当然也是分块的:

clip_image073

结果矩阵每一分块的计算按照如下公式进行:

clip_image075

例如:

clip_image077

最终的结果就是:

clip_image079

下面这幅图显示了两个4*4的分块矩阵A,B进行乘法时,计算结果矩阵C的某一分块时,需要依次访问的A,B矩阵的分块。黑色矩阵分块代表要计算的C的分块,行方向上的灰色矩阵代表要访问的矩阵A的分块,列方向上的灰色分块代表要访问的矩阵B的分块。

clip_image081

8 Non-work-efficient Parallelism

这个模式的名字取得很怪异,也有其他人把它叫做“Recursive Data”。不过相比而言,还是这个名字更为贴切。它指的是这一类模式:有些问题的处理使用传统的方法,必须依赖于对数据进行有序的访问,例如深度优先搜索,这样就很难并行化。但是假如我们愿意花费一些额外的计算量,我们就能够采用并行的方法来解决这个问题。

常用的一个例子是如下的“寻找根节点”的问题。假设我们有一个森林,里面每一个节点都只记录了自己的前向节点,根节点的前向节点就是它自己。我们要给每一个节点找到它的根节点。用传统的方法,我们只能从当前节点出发,依次查找它的前向节点,直到前向节点是它自身。这种算法对每一个节点的时间复杂度是O(N)。总的时间复杂度是N*O(N)。

clip_image083

如果我们能换一种思路来解这个问题就可以将其并行化了。我们可以给每一个节点定义一个successor(后继结点),successor的初始值都是其前向节点。然后我们可以同步的更新每一个节点的successor,令其等于“successor的successor”,直到successor的值不再变化为止。这样对于上图的例子,最快两次更新,我们就可以找到每个节点的根节点了。这种方法能同时找到所有节点的根节点,总的时间复杂度是N*log (N)。

原帖:

http://www.cnblogs.com/kaige/archive/2012/04/10/2439823.html


0 0
原创粉丝点击