ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

近数据计算架构如何破解图计算访存瓶颈?

近数据计算架构如何破解图计算访存瓶颈?

图计算这几年被提得越来越多,从社交关系链分析、推荐系统里的用户行为图,到风控场景中的资金流转网络,底层都绕不开"图"这种数据结构。但真正动手做过图计算的人都知道,它和传统的矩阵运算、向量计算完全不是一个路数:访存随机、局部性差、数据规模一大就疯狂跨节点通信,CPU和内存之间的带宽成了最硬的瓶颈。我接触过几个做图计算加速的团队,几乎所有人最后都会撞到同一堵墙——算力明明还有富余,时间全耗在数据搬运上了。这篇内容就围绕"针对图计算的近数据计算架构"这个方向,把内存计算在这类场景下的研究进展、架构取舍、实操中容易踩的坑,结合我自己的理解和实践,掰开揉碎讲清楚。适合做体系结构、图计算系统、存储层次优化的同学参考,也适合想搞清楚"近数据计算到底解决了什么问题"的工程同学入门。

1. 图计算为什么天然和传统内存架构犯冲

1.1 图计算的访存模式:随机、稀疏、不可预测

要理解近数据计算为什么在图计算里这么受关注,得先把图计算的访存特征说透。图计算的核心操作是"沿着边遍历顶点",无论是BFS、PageRank还是连通分量,本质都是从一个顶点出发,找到它的邻居,再读邻居的属性或继续扩展。问题在于,图的顶点编号和它的物理存储位置之间几乎没有关系——你访问完顶点0,下一个可能是顶点100万,再下一个可能是顶点37。这种随机性让缓存几乎失效,预取器也基本抓瞎。

对比一下就很清楚。矩阵乘法里,数据是按行或按块连续访问的,硬件预取能提前把下一块数据拉进缓存,缓存命中率可以做到很高。而图计算里,一次遍历的下一跳地址要等当前顶点的邻居列表读出来才知道,这是典型的指针追逐(pointer chasing),每一跳都得等内存返回。实测数据里,图遍历场景下最后一级缓存(LLC)的缺失率经常在50%以上,有些稀疏图甚至能到80%。这意味着大部分时间CPU都在等内存,算力单元空转。

更麻烦的是稀疏性。真实图里绝大多数顶点的度数很低,少数超级顶点度数极高(比如社交网络里某些大V)。这种幂律分布导致负载极度不均,做并行的时候,一个线程可能瞬间处理几百万条边,另一个线程几十条就完事了。这种不均衡进一步放大了访存的不确定性,让静态的优化策略很难奏效。

1.2 冯诺依曼瓶颈在图场景下被放大

经典的冯诺依曼架构里,计算单元和内存是分离的,数据要通过总线来回搬。这个"搬运开销"在计算密集场景下还能被算力掩盖,但在图计算这种访存密集场景下就彻底暴露了。业内常说的"内存墙",在图计算里表现得尤其刺眼:一次简单的邻居读取,可能要花上百个时钟周期等内存,而真正做加法或比较只需要一两个周期。

我做过一个粗略的估算,在一个典型的图遍历内核里,有效计算指令占总指令流的比例经常不到10%,剩下90%都是地址计算、加载、等待。也就是说,你花大价钱堆的算力,九成时间在"等饭吃"。这就是为什么单纯提升主频或者加核,对图计算的加速效果非常有限——瓶颈根本不在算,而在搬。

近数据计算(Near-Data Computing,有时也叫近存计算)的思路,就是针对这个矛盾:既然搬运贵,那就别搬了,把计算挪到数据旁边去。这个思路听起来简单,但落地时有大量架构层面的取舍,下面几节会逐一拆开。

1.3 一个容易混淆的点:近数据计算不等于存内计算

这里必须澄清一个常见误解。很多资料把近数据计算和存内计算(In-Memory Computing / Processing-in-Memory)混着说,其实两者有区别。存内计算通常指在存储单元内部直接做计算,比如用忆阻器、SRAM交叉阵列做模拟计算,计算和存储物理上融为一体。近数据计算则宽松得多,指的是把计算单元放到离数据近的地方,比如放在DRAM芯片的逻辑层、放在3D堆叠的底层die、或者放在存储控制器旁边,数据不需要搬到CPU就能处理。

对图计算来说,近数据计算是更现实的选择。原因很直接:图计算需要的是灵活的地址跳转和复杂的控制逻辑(比如判断顶点是否被访问过、更新距离值),这些用模拟存内计算做起来非常别扭,而放在DRAM旁边的逻辑单元里用数字电路实现就自然得多。所以下面讨论的架构,主要落在近数据计算这个范畴。

2. 近数据图计算架构的几种主流形态

2.1 基于3D堆叠DRAM的逻辑层计算

这是目前研究里最常见的一类。3D堆叠DRAM(比如HBM系列)把多层DRAM die堆在一个逻辑die上面,中间用硅通孔(TSV)连接。传统用法里,这个逻辑die只做接口转换,把数据往CPU送。近数据计算的思路是:既然逻辑die本来就在数据旁边,为什么不在这里塞几个简单的处理单元?

这类架构的典型做法是在逻辑层放一组轻量级的PE(Processing Element),每个PE负责一部分存储体(bank)附近的数据处理。图计算的任务被拆成"在存储体附近能做完的部分"和"必须回CPU做的部分"。比如顶点度数统计、边的过滤、局部聚合这类操作,完全可以在逻辑层完成,只把聚合后的少量结果送回CPU。有研究显示,把PageRank的局部贡献计算下沉到逻辑层,跨总线的数据量能降一个数量级。

但这里有个硬约束:逻辑层的面积和功耗预算非常紧张。TSV占地方,散热也难,所以PE不能做得太复杂,通常只有简单的ALU和少量寄存器,指令集也很精简。这就决定了它只能处理"规则、简单、可流水"的局部计算,复杂的图算法控制流还是得靠主机。设计时的核心权衡就是:下沉多少计算,才能既显著减少数据搬运,又不让逻辑层变成新的瓶颈。

2.2 存储控制器旁的计算卸载

相比3D堆叠,这个方案改动更小、更容易落地。思路是在内存控制器(Memory Controller)旁边加一个协处理器或者加速单元,数据从DRAM读出来、还没上总线之前,先在这个单元里过一遍。对于图计算,可以做的是"读过滤":比如遍历时只需要度数大于阈值的顶点,那就在控制器旁先把不满足条件的过滤掉,只把有用的数据送上总线。

这种架构的好处是对现有系统侵入性小,不需要改DRAM颗粒,主板层面加个单元就行。缺点是它能看到的"视野"有限——内存控制器通常只管一个通道,跨通道的图操作(比如一个顶点的邻居分散在多个通道)它协调不了。所以它更适合做单通道内的局部过滤和预聚合,全局性的图遍历还是得靠上层调度。

我在一个模拟环境里对比过这两种形态:3D堆叠方案的理论带宽收益更高,但工程复杂度和成本也高得多;控制器旁方案收益中等,但改造成本低,适合渐进式演进。实际选型要看你的图规模和数据分布——如果图大到必须跨多节点,那单机内的近数据优化收益会被通信开销稀释,这时候控制器旁方案性价比反而更好。

2.3 近数据架构下的图分区策略调整

这一点很多人会忽略:近数据计算不是简单地把计算挪个位置就完事,它会反过来影响你的图分区策略。传统图计算里,分区主要考虑负载均衡和跨分区边数最小化。但在近数据架构下,分区还要考虑"数据放在哪个存储体/哪个通道",因为计算单元是绑定在特定存储位置旁边的。

换句话说,分区粒度要和近数据计算单元的粒度对齐。如果PE是按bank组织的,那分区最好也按bank来切,让每个PE处理本地数据,避免跨bank访问。这就带来一个新问题:图的自然社区结构和物理存储布局往往对不上,强行按物理布局分区会切碎图的局部性,增加跨分区边。所以近数据图计算的分区,本质是在"物理局部性"和"图局部性"之间找平衡。

实践中一个可行的做法是两阶段分区:先按图的社区结构做逻辑分区,再在逻辑分区内部按物理bank做二次映射,尽量让一个社区的数据落在少数几个bank里。这个映射可以离线算好,运行时按映射表调度。代价是需要维护一张映射表,增加了元数据开销,但对大图来说这笔开销是值得的。

3. 把图算法拆开:哪些能下沉,哪些不能

3.1 可下沉计算的判定标准

不是所有图计算都适合下沉到近数据单元。我总结了一个简单的判定框架,主要看三条:数据局部性、控制复杂度、归约友好度。

数据局部性指的是这个操作是否只依赖本地存储的数据。比如"统计某顶点的邻居数",只需要读这个顶点的邻接表,天然局部,适合下沉。"计算全图的最短路径",每一轮都要全局同步,就不适合整体下沉,只能把单轮内的局部扩展下沉。

控制复杂度指的是操作的分支和循环是否规则。近数据单元的指令能力弱,复杂分支处理起来效率低。像BFS里"判断邻居是否已访问并决定是否入队"这种带条件的分支,如果下沉,需要PE有足够的条件判断能力,设计上要权衡。

归约友好度指的是下沉后的结果能否高效汇总。如果每个PE算出的部分结果需要复杂的跨PE合并,那下沉的收益就被抵消了。理想情况是每个PE输出一个可简单相加或取最值的标量,主机端做最终归约。

用这个框架过一遍常见图算法,结论大致如下:

图算法/操作数据局部性控制复杂度归约友好度下沉建议
顶点度数统计高低高完全下沉
边过滤/条件筛选高低高完全下沉
PageRank单轮贡献中低高局部下沉
BFS单层扩展中中中部分下沉
连通分量低高低谨慎下沉
全图最短路径低高低不下沉

这张表不是绝对的,具体还要看图的规模和硬件能力,但作为一个快速判断的起点很实用。

3.2 PageRank的局部下沉实操拆解

拿PageRank举例,因为它是最典型的"可局部下沉"算法。标准PageRank每一轮要做的是:对每个顶点,把它当前的rank值按出度均分给邻居,然后每个顶点收集邻居传来的贡献求和,再做阻尼衰减。这个"收集邻居贡献求和"就是典型的局部归约——每个顶点的贡献只来自它的邻居,如果邻居数据都在同一个近数据单元附近,那这个求和完全可以在本地做完。

具体下沉时,逻辑层PE的工作流是这样的:读入本地的边列表和邻居rank值,对每条边算一个贡献值,累加到本地顶点的累加器里,一轮结束后把每个顶点的新rank写回。主机端只负责发起轮次、做全局的阻尼计算和收敛判断。这样跨总线的数据从"每轮全图的边数据"降到"每轮全图的顶点rank值",数据量能降一到两个数量级,因为边数通常远大于顶点数。

这里有个实操细节要注意:累加器的精度。近数据单元的寄存器资源有限,如果用低精度浮点累加,多轮迭代后误差会累积。我的经验是,局部累加用定点数或者较高精度的浮点,写回时再转成目标精度,这样既省资源又控误差。另外,超级顶点的贡献值可能很大,累加器要留够位宽,否则会溢出——这个坑我在模拟时踩过,一个度数几十万的顶点直接把16位累加器干溢出了,结果全错。

3.3 BFS下沉的边界:为什么只能做一半

BFS比PageRank难下沉,原因在于它的"前沿(frontier)"是动态增长的,而且下一层要访问哪些顶点,取决于当前层的结果。这种跨轮次的依赖让完全下沉不现实。但单层内的扩展是可以下沉的:给定当前层的前沿顶点集合,让近数据单元去读这些顶点的邻居,做"是否已访问"的过滤,输出下一层的前沿候选。

难点在于"是否已访问"这个状态怎么维护。如果访问标记放在主机内存,那每次判断都要跨总线查,下沉就白做了。所以可行的做法是把访问标记也放到近数据单元本地,每个PE维护自己负责的那部分顶点的标记位。但这样又带来一致性问题:一个顶点可能被多个PE的邻居指向,标记要同步。实践中常用的是"本地标记+轮末合并",即每个PE本地记录自己发现的候选,轮末统一去重合并。这会引入一定的冗余计算,但换来了本地判断的低延迟,整体是划算的。

我实测过一个中等规模图的BFS,纯主机方案和"单层扩展下沉"方案对比,后者在跨总线数据量上降了约60%,但因为轮末合并有开销,端到端加速只有30%左右。这个数字说明:下沉的收益不是线性的,合并和同步的成本会吃掉一部分。设计时一定要把合并开销算进去,别只看数据量下降就以为加速会等比例来。

4. 落地时绕不开的三个工程难题

4.1 编程模型:怎么让程序员愿意用

近数据架构最大的落地障碍往往不是硬件,而是编程模型。你让程序员手动把图算法拆成"主机部分"和"近数据部分",还要管数据布局、同步、合并,几乎没人愿意干。所以研究里很大一块精力花在"怎么把这种拆分自动化或者半自动化"。

目前比较务实的路子是提供领域特定抽象:给图计算定义一套原语,比如"本地邻接遍历""本地归约""前沿过滤",程序员用这些原语写算法,编译器负责把原语映射到近数据单元或主机。这样程序员不用关心底层是3D堆叠还是控制器旁,只描述"我要做什么局部操作"。代价是灵活性受限,遇到原语覆盖不到的操作就得回退到主机,性能打折。

另一个思路是"标注式":程序员在现有代码上标注哪些循环适合下沉,工具链自动生成两套代码。这种方式对现有代码侵入小,但标注的准确性依赖程序员对硬件的理解,容易标错。我的看法是,短期内标注式更容易被接受,长期看领域原语更优雅,但需要生态积累。

4.2 数据一致性:近数据单元的写回时机

近数据单元在本地改了数据,什么时候写回主存、怎么保证和主机看到的一致,这是个实打实的工程问题。图计算里很多操作是"读-改-写",比如更新顶点的rank或距离值,如果近数据单元和主机同时持有同一份数据的副本,就会冲突。

常见的解法是所有权划分:把数据按顶点范围切分,每个范围明确归某个近数据单元或主机所有,只有所有者能写,其他方只能读。这样避免了写冲突,但读的时候可能读到旧值,需要版本号或者轮次屏障来保证读到的是当前轮的数据。图计算天然是轮次同步的(BFS一层一轮,PageRank一轮),所以用轮次屏障比较自然:每轮结束设一个屏障,屏障前所有写回完成,屏障后大家读到的都是新值。

要注意的是屏障的开销。轮次太多、每轮数据量又小的话,屏障同步的成本会占比很高。所以下沉的粒度要选好,别把算法切得太碎。我一般建议单轮下沉的工作量至少能掩盖屏障开销的几倍,否则不如合并几轮一起做。

4.3 负载不均:超级顶点怎么处理

前面提过图的幂律分布,超级顶点是近数据架构的噩梦。一个度数几十万的顶点,它的邻接表可能横跨多个存储体,处理它的时候,负责的那一个PE会严重拖后腿,其他PE早早干完等着。这种长尾直接拉低了整体利用率。

应对办法有几个。一是超级顶点单独处理:预处理时识别出度数超过阈值的顶点,把它们的邻接表单独存、单独调度,可以拆到多个PE上并行处理,或者干脆回主机用专门的内核处理。二是动态任务窃取:PE干完自己的活后去偷别的PE的任务,但这要求PE之间能通信,增加了架构复杂度。三是边切分:把超级顶点的边打散到多个PE,每个PE处理一部分边,最后归约,这其实就是把"顶点并行"变成"边并行"。

我个人倾向第一种,因为实现简单、可预测。预处理阶段扫一遍度数分布,定个阈值(比如度数是平均度数的100倍以上),把这些顶点拎出来走特殊路径。代价是预处理多一遍扫描,但图计算通常要迭代很多轮,这点预处理开销摊薄后可以忽略。

5. 一些实测经验和选型建议

5.1 模拟器验证比直接上硬件靠谱

近数据架构的硬件不好搞,大多数团队起步都是靠模拟器。我的建议是:先用周期级模拟器把访存行为和收益摸清楚,再考虑硬件。常见的做法是基于gem5这类框架,加上DRAM模型和近数据单元的扩展,跑真实的图数据集(比如公开的社交图、路网图),看跨总线数据量、PE利用率、端到端时间这几个指标。

模拟时容易犯的错是模型太理想化。比如假设PE和存储体之间零延迟、假设带宽无限,这样跑出来的收益会虚高。一定要把TSV带宽、PE的指令延迟、屏障同步开销都建模进去,哪怕参数是估的,也比不建强。我见过有团队模拟出来加速10倍,一上FPGA原型就剩2倍,差距全在没建模的开销上。

5.2 数据集选择直接影响结论

图计算的性能对图的结构极其敏感。用随机图(比如Erdős–Rényi)跑出来的结论,和用真实社交图跑出来的可能完全相反,因为随机图没有幂律分布、没有社区结构,访存模式均匀得多。做近数据架构评估,一定要用有代表性的真实图:社交网络图(看超级顶点)、路网图(看低度数高局部性)、网页链接图(看幂律)。至少覆盖两三种结构,结论才站得住。

另外图的规模也要注意。小图能整个塞进缓存,近数据计算的收益体现不出来;图太大又要跨节点,单机近数据的收益被通信稀释。比较合适的评估区间是"单机内存装得下但缓存装不下"这个范围,这时候近数据计算的收益最明显。

5.3 别忽视元数据开销

近数据架构为了管理数据布局、所有权、版本,需要维护不少元数据:映射表、标记位、版本号、累加器。这些元数据本身也占存储、也要访存。小规模实验里元数据开销不明显,规模一大就暴露了。我建议在设计早期就把元数据的内存预算算进去,一般控制在数据总量的5%以内比较健康,超过10%就要重新审视设计是不是太复杂了。

举个具体的例子:如果每个顶点要维护一个访问标记位、一个版本号、一个本地累加器,假设顶点数是N,那元数据就是N乘以若干字节。对于十亿级顶点的图,这就是几个GB的开销,不能不算。优化方向包括位压缩(标记位打包)、按需分配(累加器只在活跃轮次分配)等。

6. 写在最后的几点个人体会

做这个方向断断续续有段时间了,最大的感受是:近数据计算不是银弹,它的收益高度依赖"数据搬运占比"这个前提。如果你的图算法本身计算密集(比如带复杂权重计算的变种),或者图小到能进缓存,那近数据架构的复杂度就不划算。它真正发光的地方,是那种"图大到装不下缓存、算法以遍历和局部归约为主、跨总线数据量是主要瓶颈"的场景。

另一个体会是,架构研究和算法研究必须一起做。单纯改硬件,不知道上层算法怎么用,很容易设计出"理论很美、实际用不上"的单元;单纯改算法,不碰硬件,又突破不了内存墙。近数据图计算的价值,恰恰在于它逼着两边坐下来一起想:哪些计算真的必须靠近数据,哪些可以挪走,边界在哪里。这个边界不是拍脑袋定的,得靠模拟、靠实测、靠对真实图结构的理解一点点磨出来。

最后一个实操建议:如果你要入门这个方向,别一上来就啃架构论文,先拿一个真实的图数据集,在普通机器上把BFS或PageRank跑一遍,用性能分析工具看看时间到底花在哪——是计算、是访存、还是同步。把这个profile吃透,你再看近数据架构的各种设计,就会明白每个设计决策在解决哪个具体的痛点,而不是一堆抽象的名词。

返回列表