本文介绍几种MapReduce算法设计的技巧,全部内容翻译自《Data-Intensive Text Processing with MapReduce》。
Local Aggregation
说到Local Aggregation,你可能会想不就是Combiner吗。实际上在mapper中进行combining比使用真正的combiner高效得多。首先combiner只是作为MapReduce的可选优化方案(就像inline对于C++编译器是一种可选优化方案一样),不一定会被执行。其次在mapper中进行combining可以减少很多的I/O操作,提高效率,毕竟mapper的每个结果都个结果都要被写入磁盘,我们当然希望写入磁盘的数据越少越好。
回想一下WordCount的例子,在mapper中Emit(word,1),在reducer中对相同的键值Emit(word,sum),而combiner采用和reducer一样的例程。
现在我们要在mapper中就把combiner的事情给做了。
class Mapper method setup H := new AssociativeArray method map(docid a,doc d) foreach term in doc H{term} := H{term}+1 method cleanup foreach term in H Emit(term,value of H{term})
但是上述方法在可扩展性上会遇到瓶颈:我们必须在内存中维持一个关联数组H,当mapper接收到的数据分片很大,里面有很多word时,H就会很大,甚至超出内存。解决办法有两种:
- 设定一个值K,mapper每读入K条键值对,就把H中的内容全部Emit,然后把H清空接着读入键值对。类似于先读满一个buffer,再把它flush掉,然后buffer重复利用。
- 在mapper中监控内存的使用率,当达到一定阈值时,就把H的内容flush。
使用Local Aggregation设计算法的正确性
有一个计算平均值的例子。输入文件中的每一行记录着(string,count)表示一个单词出现的次数,在所有的记录中相同的string会出现多次,现在我们要计算每一个string的平均count。
求和是一种“分配型”的聚合,即
但求平均不是分配型的,即
一种直接的想法是:
class Mapper method map(string word,integer count) Emit(word,count) class Reducer method reduce(string word,integer-list [c1,c2,...]) sum := 0 cnt := 0 foreach integer c in [c1,c2,...] sum := sum+c cnt := cnt+1 avg := sum/cnt Emit(word,avg)
现在我们想使用combiner进行local aggregation。在上面的代码中,很容易地可以将reducer的部分工作放到combiner中来完成。
class Mapper method map(string word,integer count) Emit(word,count) class Combiner method reduce(string word,integer-list [c1,c2,...]) sum := 0 cnt := 0 foreach integer c in [c1,c2,...] sum := sum+c cnt := cnt+1 Emit(word,pair(sum,cnt)) class Reducer method reduce(string word,pairs[(s1,c1),(s2,c2),...]) sum := 0 cnt := 0 foreach pair(s,c) in pairs[(s1,c1),(s2,c2),...] sum := sum+s cnt := cnt+c avg := sum/cnt Emit(word,avg)
很遗憾上面的代码是错误的,不符合MapReduce的规范,由于Combiner只是可选的优化方案,所以MapReduce要求在没有Combiner的情况下程序仍能正确的运行。上述代码mapper的输出键值对和reducer的输入键值对类型不匹配,导致程序无法运行。还好,我们只需要把mapper稍作修改就可以了。
class Mapper method map(string word,integer count) Emit(word,pair(count,1))
进一步,现在我们想把local aggregation从combiner中转移到mapper中,与wordcount例子中使用的技巧一样,使用关联数组!
class Mapper method setup S := new AssociativeArray C := new AssociativeArray method map(string word,integer count) S{word} := S{word}+count C{word} := C{word}+1 method cleanup foreach word in S Emit(word,pair(S{word},C{word}))
Pairs and Stripes
mapper和reducer之间能够交换的数据仅仅是一个键值对,很多时候我们为了使这一个键值对中包含更多的信息,就把键或值设计得足够复杂,比如让它是一个pair或AssociativeArray。在上文中我们已经看到这种应用实例了。
在N-Gram模型中,我们要计算一个方阵A,其规模是N×N,N是语料库中单词的个数。A[i][j]表示单词j在单词i的领域中出现的次数。
Pairs方式:
class Mapper method map(docid a,doc d) foreach term w in doc foreach term u in Neighbour(w) Emit(pair(w,u),1) class Reducer method reduce(pair p,counts [c1,c2,...]) s := 0 foreach count c in [c1,c2,...] s := s+c Emit(p,s)
Stripes方式:
class Mapper method map(docid a,doc d) foreach term w in doc H := new AssociativeArray foreach term u in Neighbour(w) H{u} := H{u}+1 Emit(w,H) class Reducer method reduce(term w,stripes [H1,H2,...]) F := new AssociativeArray foreach stripe H in [H1,H2,...] sum(F,H) Emit(w,F)
上述pairs和stripe方式都可以很轻松的写一个combiner来提高效率,并且在stripes方式中combiner发挥的作用会相对大一些。
注意在Stripes方式中,由于要为doc中的每一个term建立一个AssociativeArray,维持这么多的关联数组可能会导致内存溢出。解决方案在本文的第一小节已经提到了。
stripe方式要比pairs方式快得多,因为pairs方式Emit操作(即写磁盘操作)太频繁了。
Computing Relative Frequencies
还说N-Gram这个问题,刚才我们只是计算了单词u在单词w的领域内出现的次数,但是个次数多并不能说明u和w的关系就强烈,因为可能是w太平凡了,导致w出现的次数很多,进而导致w和u相伴出现的次数也很多。另外有一对单词p和q,它们相伴出现的次数比较少并不能说明它们的关系不强烈,可能是因为在语料库p总共出现的次数也不多。所以我们应该计算“相对频率”才是合理的:
实际上就是对已有矩阵的每一行求和,再让该行的每个元素除以这个和。
使用上面的Stripes方式代码可以很容易地扩展为计算相对频率的代码,因为在redcuer中Emit的每一个键值对刚好对应矩阵的一行数据,计算矩阵的行和非常方便。
现在我们要在pairs方式中计算相对频率该怎么做呢?因为mapper输入的键值对是(pair(w,u),1),要计算行和就必须使所相同的w都到一个reducer中,但默认情况下Hadoop只能保证相同的pair(w,u)映射到同一个reducer当中--对于复杂的键类型,MapReduce中要把它转换成bytes再进行Hash。因此我们需要重写Partitioner,仅对pair的左值(即w)进行散列,这就可以保证相同的w都映射到了同一个reducer。但是注意,映射到同一个reducer的pair的左值可能不只一种。MapReduce保证映射到同一个reducer的键值对都是按键排序好的,为了使在同一个reducer中,相同的左值是连续在一起的,我们还需要自定义的pair的比较方式:对pair进行比较时,只比较左值。
class Pair method compareTo(Pair obj) rect := this.left.compareTo(obj.left) if(rect == 0) if(this.right == "*") return -1 else if(obj.right == "*") return 1 return rect class Mapper method map(docid a,doc d) foreach term w in doc foreach term u in Neighbour(w) Emit(Pair(w,u),1) Emit(Pair(w,*),1) class Partitioner method getPartition(Pair key) return key.getLeft().hashCode() class Reducer method setup prev := NULL rowsum := 0 method reduce(pair p,counts [c1,c2,...]) foreach count c in [c1,c2,...] s := s+c if(p.left != prev) assert(p.right == "*") prev := p.left rowsum := s else Emit(p,s/rowsum)
上面的代码中,我们使mapper多输出了一项Emit(Pair(w,*),1),它的作用是使在reducer中不需要遍历所有具有相同左值的pair(w,u)就能提前计算出行和--仅依靠pair(w,*)就可计算出行和,这样在第一次遇到pair(w,u)时就可以计算出它的相对频率。为了使reducer最先遇到pair(w,*),我们定义在对pair排序时,在左值相同的情况下,右值为“*"者较小。
在一个reducer中出现的键值对的顺序是这样子的:
同样,仍然可以使用combiner进行优化,把local aggregate放在mapper中更好。
说到这里顺便扯一句,一般程序员在计算样本方差时会采用这个公式:
这个公式要求2趟扫描样本,第1趟扫描是为了计算出X的平均值,第2趟扫描是按照公式来计算。而统计学专业的学生知道计算方差还有另外一个公式:
只需要1趟扫描就可以把ΣXi2和ΣXi都算出来,直接代入公式就可算出方差。
Secondary Sorting
Hadoop只能保证同一个reducer中的健值对是按键排好序的,在Goole在MapReduce中提供一个可选的函数--在按键排序之后按value排序。考虑这么一种情形:mapper输出的键值对形如
key value
t1 (m1,r1988)
t1 (m2,r1987)
t2 (m1,r1990)
key已经排好序了,t1<t2。我们希望key相同的时候按value中的第2列排序,即希望的结果为
key value
t1 (m2,r1987)
t1 (m1,r1988)
t2 (m1,r1990)
Hadoop是不对value进行排序的,所以我们的解决方法是:把需要排序的valueu部分放到key里面去,即
key value
(t1,r1987) m2
(t1,r1988) m1
(t2,r1990) m1
当然这个时候需要自定义partitioner仅对pair的左值进行散列,且pair的比较规则是先比较左值,左值相等时再比较右值。
本文来自博客园,作者:高性能golang,转载请注明原文链接:https://www.cnblogs.com/zhangchaoyang/articles/2623905.html