作者:京东科技 王军
前言
Gemini是目前state-of-art的分布式内存图计算引擎,由清华陈文光团队的朱晓伟博士于2016年发表的分布式静态数据分析引擎。Gemini使用以计算为中心的共享内存图分布式HPC引擎。通过自适应选择双模式更新(pull/push),实现通信与计算负载均衡[1]。图计算研究的图是数据结构中的图,非图片。实际应用中遇到的图,如社交网络中的好友关系、蛋白质结构、电商等[2]等,其特点是数据量大(边多,点多),边服从指数分布(power-law)[7],通常满足所谓的二八定律:20%的顶点关联了80%的边,其中1%的点甚至关联了50%的边。
如何存储大图随着社交媒体、零售电商等业务的发展。图数据的规模也在急剧增长。如标准测试数据集clueweb-12,生成后的文本数据大小780+GB。单机存储已经不能满足需求。必须进行图切分。常见的图切分方式有:切边、切点。
切点:又称“以边为中心的切图”,保证边不被切开,一条边在一台机器上被存储一次,被切的点创建多个副本,副本点所在的机器不清楚关于此点的相关边。如上图所示,中间点被分别保存三个版本,此点会分别出现在三台机器上,在做更新时需要更新三次。切边:又称以“顶点为中心的切图”,相比于切点,保证点不被切开。边会被保存两次,作为副本点所在机器能清楚感知到此点的相关边。如上图所示信息只进行一次更新。Gemini采用切边的方式进行存储。定义抽象图为G(V,E),Gemini定义了主副本(master)与镜像副本(mirror),计算时是以master为中心进行计算。如下图所示,集群每台机器上仅保存mirror到master的子图拓扑结构,而mirror点并未被实际存储(比如权重值),每台机器负责一部分master存储(
)。如下图所示,Gemini将图按照partition算法切分到2个不同的机器。其中 mirror作为逻辑结构,没有为其分配实际存储空间;但每条边被存储了两次。
优点:单机可以完整获取master的拓扑结构,不需要全局维护节点状态。图存储图的常见存储方式:邻接矩阵、邻接表、十字链表,此处不作详细解释,有兴趣可参照[3]。| 表示方法 | 邻接矩阵 | 邻接表 | 十字链表 | | 优点 | 存储结构简单,访问速度快,顺序遍历边 | 节省空间,访问速度较快 | 在邻接表基础上进一步,节省存储空间。 | | 缺点 | 占用空间很大(nn存储空间) | 存储使用指针,随遍历边结构,为提高效率,需要同时存储出边入边数据。 | 表示很复杂,大量使用了指针,随机遍历边,访问慢。 |分析上表优缺点,可见:上述三种表示方式都不适合幂律分布的graph存储。压缩矩阵算法图计算问题其实是一个HPC(High Performance Computing)问题,HPC问题一般会从计算机系统结构的角度来进行优化,特别在避免随机内存访问和缓存的有效利用上。有没有一种既保证访问效率,又能满足内存的局部性,还能节省空间的算法呢?压缩矩阵存储。常见的图压缩矩阵算法有三种coordinate list(COO)、Compressed sparse row(CSR)、Compressed sparse column (CSC)算法进行压缩8。COO压缩算法COO使用了坐标矩阵实现图存储(row,collumn,value),空间复杂度3|E|;对于邻接矩阵来说,如果图中的边比较稀疏,那么COO的性价比是比较高。
CSR/CSC压缩算法CSC/CSR都存储了column/row列,用于记录当前行/列与上一个行/列的边数。Index列存储边的所在row/column的index。CSC/CSR是在COO基础上进行了行/列压缩,空间复杂度2|E|+n,实际业务场景中的图,边往往远多于点,所以CSR/CSC相对COO具有更好压缩比。
优点:存储紧密,内存局部性强;缺点:遍历边时,需要依赖上一个点的最后一条边的index,所以只能单线程遍历。压缩矩阵算法无法实时更新拓扑结构,所以压缩矩阵算法只适用静态或者对数据变化不敏感的场景。| CSC伪代码 | CSR伪代码 | | loc← 0 for vi←0 to colmns for idx ←0 to colmn[i] do //输出到指定行的列 edgevi] ←value[loc] loc← loc+1 end end | loc← 0 for vi←0 to rows for idx ←0 to row[i] do //输出到指定列的行 edge [ index[idx]] [vi] ←value[loc] loc← loc+1 end end |Gemini的图压缩Gemini对 CSC/CSR存储并进行了改进,解释了压缩算法的原理。Gemini在论文中指出,index的存储空间复杂度是O(V),会成为系统的瓶颈。引出了两种算法:Bitmap Assisted Compressed Sparse Row(bitmap辅助压缩CSR)和Doubly Compressed Sparse Column(双压缩CSC),空间复杂度降到O(|V'|),|V'|为含有入边点的数量。
Gemini改进后的CSR算法使用bitmap替换CSR原有的Rows结构:• ext为bitmap,代码此bit对应的vid是否存在出边,如上id为0/2/4的点存在出边。• nbr为出边id;• ndx表示保存了边的nbr的index范围;如上图CSR图,点0存在出边(ext[0]为1),通过idx的差值计算出0点存在一条出边(idx[1]-idx[0]=1),相对于存储0点第一条出边的nbr的下标为0(idx[0]);同理可推得点1无出边。Gemini 双压缩CSC算法将idx拆分成vtx及off两个结构:• vtx代表存在入边的点集合;• nbr为入边数组;• Off表示保存入边nbr的index偏移范围;如上图CSC算法:vtx数组表示点1,2,3,5存在入边,使用5个元素的off存储每个点的偏移量。如点2存在由0指向自己的入边(0ff[2]-off[1]=1), 所以nbr[1]存储的就是点2的入边id(0)。优点:通过改进后的存储结构,同时支持多线程并行。Gemini的双模式更新双模式更新是Gemini的核心:Gemini采用BSP计算模型,在通信及计算阶段独创性地引入QT中的signal、slot的概念;计算模式上借鉴了ligra的设计[5]。Gemini沿用Ligra对双模式阈值定义:当活跃边数量小于(|E|/20,|E|为总边数)时,下一轮计算将使用push模式(sparse图);否则采用pull模式(dense图)。这个值为经验值,可根据场景进行调整。
在开始计算前,都需要统计活跃边的数量,确定图模式。在迭代过程中,每一个集群节点只保存部分计算结果。在分布式系统中,消息传播直接涉及到通信量,间接意味着阈值强相关网络带宽和引擎的计算效率。双模式直接平衡了计算负载与通信负载。圆角矩形标识操作是在本地完成的,Gemini将大量的需通信工作放在本地完成。Gemini 节点构图Gemini在实现上,增加numa特性。如何分配点边,如何感知master在哪台机器,哪个socket上,都直接影响到引擎计算效率。
location aware和numa aware两个feature去解决了上述问题;由于Graph幂律分布的特点,运行时很难获得很好的负载均衡效果,所以在partition时,也引入了平衡因子α,达到通信与计算负载均衡。在partition阶段通过增加index结构:partition_offset, local_partition_offset。(partition_offset记录跨机器的vid offset,local_partition_offset记录跨numa的vid offset)。Location-aware以边平均算法为例,集群规模partitions = 4(台),图信息见下表。点边分布情况| 点s | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | | Out Edge | 0 | 3 | 5 | 30 | 2 | 4 | 6 | 2 | 20 |存在出边sum = 72| 切图轮次 | 1 | 2 | 3 | | 剩余边 | 72 | 34 | 22 | | 平均分配 | 18 | 12 | | | Master分配结果 | 0: 0~3 | | | | 1: | 4~6 | | | 2: | | 7~8 | | 3: | | |从上表分析可见:• 编号为0的机器分配4点38条边;• 编号为1的机器分配3点12条边;• 编号为2的机器分配2点22条边;• 编号为3的机器分配0点0条边。此方法分配会造成负载的偏斜,影响到引擎的计算效率。Gemini在切图时,每个partition分配点个数遵循公式
,其中平衡因子定义为α=8*(partitions-1)。仍然以上图为例,Gemini通过ɑ因子平衡了边的分布。| 切图轮次 | 1 | 2 | 3 | 4 | | 剩余权重边 | 288 | 208 | 128 | 44 | | 平均分配 | 72 | 70 | 64 | 44 | | Master分配结果 | 0: 0~2 | | | | | 1: | 3~4 | | | | 2: | | 5~7 | | | 3: | | | 8 |对比两次切分的结果,添加α增加了出边较少的点的权重。通过实际场景应用发现:按照论文中α平衡因子设定,很可能出现内存的倾斜(内存分配上相差20%左右,造成oom kill)。在实际生产场景中,我们根据时间场景和集群配置,重新调整了α参数取值设置,内存分配基本浮动在5%左右。Numa-awareNUMA介绍根据处理器的访问内存的方式不同,可将计算机系统分类为UMA(Uniform-Memory-Access,统一内存访问)和 NUMA(Non-Uniform Memory Access, 非一致性内存访问)。
在UMA架构下,所有cpu都通过相同的总线以共享的方式访问内存。在物理结构上,UMA就不利于cpu的扩展(总线长度、数据总线带宽都限制cpu的上限)。Numa (Non-Uniform Memory Access, 非一致性内存访问)是目前内核设计主流方向。每个cpu有独立的内存空间(独享),可通过QPI(quick path Interconnect)实现互相访问。由于硬件的特性,所以跨cpu访问要慢[11]。
相对于UMA来说,NUMA解决cpu扩展,提高数据总线宽度总线长度带来的问题,每个cpu都有自己独立的缓存。根据NUMA的硬件特性分析,NUMA具有更高本地内存的访问效率,方便CPU扩展。HPC需要数据访问的高效性,所以NUMA架构更适合HPC场景(UMA与NUMA无优劣之分)。Gemini充分利用了NUMA对本socket内存访问低延迟、高带宽的特性,将本机上的点跨多socket数据实现NUMA-aware 切分(切分单位CHUNKSIZE)。切分算法参考Location-aware。Gemini的任务调度Gemini计算采用BSP模型(Bulk Synchronous Parallel)。为提高CPU和IO的利用率做了哪些工作呢?Gemini提出了两个设计:计算通信协同调度、work stealing(偷任务)。计算通信系统调度Geimini在计算过程中引入了任务调度控制。他的调度算法设计比较简单,可简单理解为使用机器节点ID按照规定顺序收发数据,避免收发任务碰撞。Gemini将一轮迭代过程称为一个step,把每一个step又拆分为多个mini step(数量由集群规模确定)。• computation communication interleave为了提高效率,减少线程调度的开销,Gemini将一次迭代计算拆分成了computation和communication两个阶段。在时间上,每一轮迭代都是先计算,再进行通信,通信任务调度不会掺杂任何计算的任务。这样设计的好处在于既保证上下文切换的开销,又保证内存的局部性(先计算再通信)。缺点就在于需要开辟比较大的缓存buffer。• Task Schedule简而言之:每个机器都按照特定的顺序收发数据
上图列举了集群中master分布情况,以Node0为例:节点Node 0Master范围0、1阶段1将数据向Node1发送关于点2的数据,接收来自Node2数据阶段2将数据向Node2发送关于点5的数据,接收来自Node1数据阶段3处理自身的数据(本地数据不经网络传输)在整个过程中,node0按照机器id增序发送,按照机器id降序接收,这个feature可以一定程度避免出现:同时多台机器向同一台机器发送数据的情况,降低通信信道竞争概率。Work stealing该设计是为了解决分布式计算系统中常见的straggler问题。当某个cpu task处理完成所负责的id,会先判断同一个socket下的其他cpu task是否已完成。如果存在未完成任务,则帮助其他的core处理任务。(跨机器的work stealing没有意义了,需要经历两次网络io,而网络io延迟是大于处理延迟。)Gemini开源代码中定义线程状态管理结构,下图引用了开源代码的数据结构,并对变量进行了说明。
开始计算时,每个core均按照自己的threadstate进行处理数据,更大提升cpu使用效率。该设计是以点为单位进行的数据处理,但未解决热点的难题(这也是业界难题,可以对热点再次切分,也是需要突破的一个问题)。下面是2 core的work stealing示意图:
其中在初始情况T0时刻,core1与core2同时开始执行,工作状态都为working;在T1时刻, core2的任务首先执行完成,core1还未完成。为了提高core2的利用率,就可以将core1的任务分配给core2去做。为了避免core1、core2访问冲突,此处使用原子操作获取stealing要处理id范围,处理完成之后,通过socket内部写入指定空间。在T2时刻,core2更新工作状态为stealing,帮助core1完成任务。在开源代码中,在构图设计tune chunks过程,可以实现跨机器的连续数据块读取,提升跨socket的效率。注:开源代码中,push模式下并未使用到tread state结构,所以tune chunks中可以省略push模式thread state的初始化工作。其中在初始情况T0时刻,core1与core2同时开始执行,工作状态都为working;Gemini API接口设计API设计上借鉴了Ligra,设计了一种双相信号槽的分布式图数据处理机制来分离通信与计算的过程。屏蔽底层数据组织和计算分布式的细节。算法移植更加方便,简化开发难度。并且可以实现类Pregel系统的combine操作。将图的稀疏、稠密性作为双模式区分标志。Gemini算法调用使用c++11的lambda函数表达式,将算法实现与框架解耦。
Gemini在框架设计中创新的使用signal、slot。将每轮迭代分为两个阶段:signal(数据发送),slot(消息处理),此处实现了通信与数据处理过程的解耦。Gemini源码分析Gemini代码可以分为初始化,构图,计算三部分。初始化:设置集群配置信息,包括mpi、numa、构图时所需的buffer开销的初始化;构图:依据算法输入的数据特征,实现有/无向图的构造;计算:在已构造完成的图上,使用双模式计算引擎计算。Gemini构图代码分析Gemini在构图时,需要事先统计每个点的出边、入边信息,再依据统计信息切图,申请存储图所需的空间。以无向图构建为例,整个构图过程经历了3次文件读取:统计入边信息;生成图存储结构(bitmap、index);边数据存储。入口函数:load_undirected_from_directed开源源码Gemini集群同时分段读取同一份binary文件,每台机器都分段读取一部分数据。
出边信息统计
上图代码分段读取文件,统计每个点的出边信息,见line 456、457,通过openmpi通信,聚合所有点出边信息line 460。Line 451:原理上可以使用omp并发,但由于原子操作锁竞争比较大效率并不高。Location aware 代码实现Gemini在location aware解决了地址感知,集群负载平衡的工作。
解释最后一行:owned_vertices记录当前机器master点个数,partition_offset[partition_id]记录master节点vid的下限,partition_offset[partition_id+1] 记录master节点vid的上限。好处:提升了内存的访问效率;减少了内存的零头(在这个过程中,Gemini为提高内存块读取的效率,使用pagesize进行内存对齐。)。NUMA aware代码实现NUMA aware作用是在socket上进行了partition,平衡算力和cpu的负载,程序实现与Location aware过程类似。
NUMA aware也进行了a因子平衡和pagesize对齐。总结:机器机器共享同一份出边统计数据,所以在location aware和numa aware阶段的结果都是相同的,partition结果也不会出现冲突的情况。注:aware阶段都是对master的切分,未统计mirror的状态;而构图过程是从mirror的视角实现的,所以下一个阶段就需要统计mirror信息。构建边管理结构在完成Location aware和NUMA aware之后,需要考虑为边allocate存储空间。由于Gemini使用一维数组存储边,所以必须事先确定所需的存储空间,并allocate相应的内存管理结构。Gemini使用二级索引实现点边遍历。读者很可能出现这样的误区:建立master->mirror关系映射。这样会带来什么问题?超级顶点。也就意味着通信和计算负载都会上升。这对图计算引擎的效率影响很大。可自行计算万亿级别点,每个socket上存储的index占用的空间。
单节点处理本地数据(按照CHUNCKSIZE大小,分批向集群其他节点分发边数据)。记录mirror点的bitmap及出边信息。
数据发送过程是按照CHUNCKSIZE大小,分批发送。
在发送结束时,需确保所用的数据发送完成,发送字符‘\0‘作为结束符。图存储依据上一阶段构建的管理结构实现边的存储,管理结构解释:Bitmap的作用是确定在此socket下,此mirror点是否存在边;Index标识边的起始位置(见图压缩章节介绍)。下图注释内容介绍了index的构建过程,构建过程中使用了单线程,cpu利用率较低,可自行测试一下。
在边存储时,数据分发实现了并发传输。代码实现过程,见下图代码注释。
边数据分发过程代码:
任务调度代码实现构建任务调度数据结构ThreadState,参数配置tune_chunks代码实现,使用了α因子进行平衡。逻辑上将同一个socket的边数据,按照线程进行二次划分(balance)。
计算源码分析双模式的核心思想:尽可能将通信放到本地内存,减少网络IO开销。以dense模式为例:pull模式将集群中的其他节点的部分结果pull到本地,实现同步计算。
处理模块代码定义
注意:line1796 send_queue_mutex的使用,通过锁控制发送模块的先后顺序。任务调度算法实现:
为保证每台机器上的计算结果一致,所以在传播过程中每个机器都会接收到相同的数据,在进行计算。总结Gemini的关键设计:• 自适应双模式计算平衡了通信和计算的负载问题;• 基于块的Partition平衡了集群单机计算负载;• 图压缩降低了内存的消耗。Gemini可继续优化方向:• Proces_edges过程中,发送/接收buffer开辟空间过大,代码如下:
在切换双模运算时,调用了resize方法,此方法实现:当仅超过capacity时,才重新alloc内存空间,未实现进行缩容(空间
)。
a• adj_index会成为系统瓶颈论文中也提到adj_index一级索引会占用大部分空间(论文中也提到了会成为瓶颈)。改进后的CSC压缩算法使用二级索引结构。在计算时会影响数据访问速度,无向图中压缩效果不好,远高于一级索引的空间复杂度(幂律分布决定,极大部分点存在1条以上的出边,易得空间复杂度2|V’|>|V|)。• α因子调整α因子应该根据图的特征进行动态调整,否则很容易造成内存partition偏斜。• 动态更新由于压缩矩阵和partition方式都限制了图的更新。可通过改变parition切分方式,牺牲numa特性带来的局部性,通过snapshot实现增量图。• 外存扩展Gemini是共享内存的分布式引擎。在实际生产环境中,通过暴力增加机器解决内存不足的问题,不是最优解。大容量外存不失为更好的解决方案。
参考文献11
- Gemini: A Computation-Centric Distributed Graph Processing System
- https://zh.wikipedia.org/wiki...(%E6%95%B0%E5%AD%A6)
- https://oi-wiki.org/graph/save/
- https://github.com/thu-pacman...
- Ligra: A Lightweight Graph Processing Framework for Shared Memory
- Pregel:a system for large-scale graph processing.
- Powergraph: Distributed graph-parallel computation on natural graphs
- https://en.wikipedia.org/wiki...(COO)
- https://programmer.ink/think/...
- https://frankdenneman.nl/2016...
- https://frankdenneman.nl/2016...
内容来源:京东云开发者社区https://www.jdcloud.com/