GraphChi设计成可以在个人计算机上高效进行大规模计算的框架,其中在模型不能一次性读入内存的时候,用到了Parallel Sliding Window(以下简称PSW)算法,其核心是用连续读写来代替的随机读写。早前在我自己写单机pagerank算法时候也考虑过模型如果内存装不下问题,当时正好看到graphchi的PPT,也就想明白是怎么做到的,虽然我没有完整读完PSW的资料,但根据过去问题的思考,看到PSW例子我感觉自己也算明白了。可以认为PSW完全是在这种内存不足必须使用连续读写的限制下被迫发明出来的一种好办法。
在介绍PSW前,我还是先讲一下我的思路,首先回到pagerank当时的考虑。大家知道pagerank算法是为了计算每个节点的权重,因此模型其实就是一个向量。算法是挺直接的:在t轮迭代时候,节点作为source首先将自己的权重平均投票到自己所有的出度节点,然后节点作为destiny收集所有刚才节点散布给自己的权重,进行累加;每个节点这种“投票”和“累加”的计算逻辑简单而且能独立分开进行计算,很容易被分布式化,比如mapreduce中map进行投票,reduce完成累加。
实现上需要预留多一个向量,作为每个destiny的接收权重的缓存,待到累加完成后再更新到模型向量中。看到这里就很容易想到优化方案:每个节点被动的累加已经投过来票,可以改为主动去“拉票”,即主动直接去累加那些指向自己的节点的权重。原始的方法,节点先作为一次source,之后作为一次destiny;进行了一次转换;而改进的时候只需要作为destiny一直拉权重即可。
当然原来每次计算使用的数据格式 source:(destiny1,destiny2...destinyk)
要转换成 destiny:(source1,source2....sourcek)。
也就是mapreduce每次reduce操作可以省略掉,直接map中就能计算好。节点作为destiny一旦累加完权重就可以直接更新模型,还可以避免使用多一个缓存向量。这个改进的方案称算法2。
无论是否改良,只要模型在内存中,每次source[i]定位都能很轻易取到,数据集即使大,边只要是通过磁盘IO顺序读取,时间消耗依然是可以接受的。但如果内存装不下整个模型向量,会怎么样?以算法2的方式,每个节点v作为destiny需要拉取其source的权重,但source们不能全部进入内存,如果v对它的每个source都要随机读取到磁盘上的权重,那时间消耗就不可想象了。办法只能把模型向量分块,数据节点v们的source的分批次的累加当前模型块的那部分权重。
首先数据格式如下:
dest[1]:(src[1],src[4],src[7]...src[2],src[5],src[8]..src[3],src[6],src[9]..)
dest[2]:(src[1],src[4],src[7]...src[2],src[5],src[8]..src[3],src[6],src[9]..)
....
我们假设模型需要分成3块,当一个dest要做计算时候,先把src[1][4][7]...节点所需要的模型块读进来参与计算,之后是src[2][5][8]...的,再到src[3][6][9]...的模型块(当然每行括号内的id不一定有那么多,而id的划分也未必非得用求余这个方式)。显然不可能在随便一个dest待算的时候,就读三次模型数据,于是肯定是要攒一些dest再进行累加计算的。
数据逻辑格式变为:
dest[1] dest[4] dest[7] ... |
src[1],src[4],src[7].. src[1],src[4],src[7].. ... A src[1],src[7],src[10] |
src[2],src[5],src[8]... src[2],src[8],src[11].......... . B src[5],src[11],src[14] |
src[3],src[9],src[12].. src[6],src[24],src[27].. src[3],src[6],src[12] ..... C |
dest[2] dest[5] dest[8] ... |
src[1],src[4],src[7].. src[1],src[4],src[7].. ... D src[1],src[7],src[10] |
src[2],src[5],src[8]... src[2],src[8],src[11].......... .E src[5],src[11],src[14] |
src[3],src[9],src[12].. src[6],src[24],src[27].. src[3],src[6],src[12] ..... F |
dest[3] dest[6] dest[9] ... |
src[1],src[4],src[7].. src[1],src[4],src[7].. ... X src[1],src[7],src[10] |
src[2],src[5],src[8]... src[2],src[8],src[11]...... Y src[5],src[11],src[14] |
src[3],src[9],src[12].. src[3],src[6],src[9].. ... Z src[6],src[9],src[15] |
每一轮迭代计算我们按从上到下三排顺序,首先要完成第一排dest节点[1][4][7]... 也就是ABC块的计算时,我们自然需要用到所有节点[1]..[n]的模型值:
首先) 读取A,并读取[1][4][7]...所需的模型块(黄色区域),累加计算缓存这部分模型;
之后) 读取B,并读取[2][5][8]...所需的模型块(灰色区域),继续在部分模型上累加计算;
再次) 读取C,并读取[3][6][9]..所需的模型块(绿色区域),完成最后累加计算,
最后) 将[1][4][7]...的模型计算值更新到对应外存块上(黄色区域);
这就完成了第一排ABC数据的计算;同理是第二排DEF,第三排XYZ。每次读入数据块(字母)时候,我们就只需要顺序读取模型块(颜色)。只是要保证一个字母组(比如ABC)必须是在一起完成才能更新权重,不同排之间的计算也是可以并行化的,但单机的时候还是没必要,多个线程同时顺序读取也会阻塞。
我想到的办法与PSW算法有些不一样,我是想着缓存模型,PSW是需要缓存部分数据。不论怎样不可避免的是,当前计算到的节点所需的一切信息必须恰好被加载在内存里。下面直接通过例子介绍PSW的策略:
首先要求图的边以destiny作为排序,划分出多个shard,每个shard中,图的边按src排序,如下图所示
每个shard中只包含部分入边(比如shard1包含1,2作为接收方的边),完成计算需要的出边在其他shard中(比如1的另外出边1-3在shard2上,2-3在shard2,2-5在shard3),因为shard内部是按边的src排序,所以某shard中所需的出边必然也连续在一起。比如shard2所表示的3,4节点的出边在shard1 shard3中也是连续的。入下图所示:
每次计算的时候将shardi载入内存,然后获取其他shard的出边,就能完成对应块的计算了。下图显示了4个shard的计算过程:
interval就是shard的意思;可以看到如果按照shard的顺序读取数据,那么对应节点在其他shard的位置也是顺序的,因此也叫滑动窗口。这个方法支持并行操作,只是在最后修改模型数据时候要处理冲突。
这里只是简单介绍PSW思想,有兴趣可以读读原文,或者看《大数据日知录》有专门介绍这个算法。
PSW一次迭代中,shard逐个读需要一次数据集的完整遍历,滑动窗口需要完成一次,因此需要两次完整IO;而我的办法除了一次数据集完整遍历,还需要k次模型的遍历,k取决于模型分块数。通常情况下模型都是远远小于数据,即使是一亿节点,用算法2,模型向量也只需要400M内存;当然PSW是一个读取数据的遍历方式,而我这只是针对某一类算法特别设计的,可能不是太好比较。无论哪种都必须在数据预处理上做文章,后来做的feluca分布式模型就用缓存模型的思路,有兴趣的话可以读一下相关博客。