ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图引擎深度解析:AI芯片的编译核心与运行时大脑

图引擎深度解析:AI芯片的编译核心与运行时大脑

这两年做AI芯片相关的系统软件,我经常被问到一个问题:“图引擎到底是个什么玩意?”很多人把模型写出来,训练跑得飞快,但对中间那一层“翻译官”的工作完全陌生。

一句话概括:深度学习模型在你眼里是一层层神经网络算子,在芯片眼里只是一堆运算指令,而图引擎(Graph Engine,GE)就是夹在中间的那个翻译官兼调度员。它既要拿到模型的计算图,又要把它切成芯片能高效吃的菜,还要在训练或推理的每一毫秒里决定“谁先算、谁后算、算完放哪”。说它是异构计算架构的编译核心和运行时大脑,一点不夸张。

这篇文章我会围绕图引擎的核心职责,拆一拆它的整体设计、编译优化管线、运行时调度机制,以及我在实际开发中反复踩过的坑。适合想切入AI编译器方向的人、正在做算子开发和系统优化的读者,以及那些被框架里“静态图转动态图”折磨过的同学。

1. 一个架构师眼中的图引擎价值

1.1 为什么深度学习需要“图”这层抽象

很多人会问:模型代码不就是在执行计算吗?为什么非要先构建一张图,再让引擎去分析、改写、调度?直接按代码顺序翻译成芯片指令不就行了?

逻辑上行得通,但性能上行不通。深度学习模型动辄几百甚至上千个节点,每个节点背后又有独立的kernel调用。如果不经过图级分析,你将遇到三个问题:

第一,每个算子独立执行,中间结果频繁写回内存。比如卷积之后接一个激活函数,如果变成图上的两次kernel,那中间张量需要完整写入HBM,再读出来做激活。这浪费的带宽远大于计算本身。

第二,内存峰值会失控。模型在训练时反向传播需要保存大量中间激活,如果不做liveness分析,所有中间结果都按最大存活时间分配内存,几个GB的显存可能几轮迭代就爆掉。

第三,异步执行没有抓手。现代芯片有CPU、GPU,还有专门的NPU,不同设备的执行速度不一样,如果没有一张统一的依赖图,系统无法判断哪些算子可以并行跑、哪些必须等待前序结果。

所以图不是人为制造的复杂度,而是编译器和运行时交换信息的核心资产。GE做的事情就是把前端产生的模型抽象成一张有向无环图(DAG),在这张图上完成所有跨算子的优化,再把优化后的结果交给各设备的kernel层执行。

1.2 GE在异构计算中的三个使命

我在内部评审PPT时经常用一张表概括图引擎的使命范围:

使命面向的问题产出物
算力抽象不同设备硬件差异巨大,上层不需要知道底层指令统一的算子表示与设备无关的IR
图级优化跨算子数据复用、内存生命周期、精度变换改写后的DAG与内存规划方案
运行时调度多设备多stream并发、同步、容错执行计划、事件流、内存池

这三个使命恰恰对应了标题里“编译核心”和“运行时大脑”两个词。编译核心主要负责前两个使命:把模型拆成最小可优化单元,并用各种pass改写图;运行时大脑负责最后一个使命:确保优化后的计划能在异构设备上稳定、高效地执行。

换句话说,GE是AI芯片系统软件里唯一一个“既懂算法又懂硬件”的位置。上层框架不懂硬件,下层设备驱动不懂神经网络结构,只有GE把这两件事串起来。

1.3 谁需要掌握图引擎

每周都有组里的新人问我该从哪块源码看起。我的建议是,不同角色看图引擎的姿势完全不同:

框架开发者关注的是前端如何将Pytorch风格的动态图转化成静态图,以及哪些控制流算子无法被GE正确捕获。算子开发关注的是GE如何调用自己新写的融合kernel,以及图优化是否覆盖了自己的算子在“偏置加、激活”链上的融合模板。性能调优工程师则需要掌握内存复用算法和stream调度策略,否则面对复杂的多设备训练任务时会两眼一抹黑。

不论你属于哪一类,核心都是建立“图的结构决定性能上界”这个观念。算子写得再快,如果图引擎把数据来回倒腾了几次,最终性能一定无法登峰造极。

2. 图引擎的编译管线到底在图什么

2.1 从前端拿到模型之后:解析与构图

很多人以为图引擎拿到的是现成的图,其实不是。训练框架的前端通常会先把模型动态执行一遍,通过追踪算子的调用路径来生成一个“正向执行序列”,GE再对这个序列做解析,重建出拓扑意义上的计算图。

这里面有几个关键点:

正向图构建相对简单,难的是反向图的生成。现代GE一般会在正向图解析完成之后,自动调起自动微分过程,为每个正向算子匹配对应的反向算子,构建出损失函数对每个参数的梯度依赖子图。这一步属于“图结构生成”阶段,还没有做任何性能优化。

另一个容易出问题的地方是控制流。模型里有if、while这类语句时,前端追踪只能按当前分支记录路径。GE必须把这些控制流转换成图上的特殊子图结构,比如支路节点和循环体子图,否则训练过程中参数更新路径改变时引擎会直接崩掉。

我在实际项目里见过一个案例:模型里有一个循环条件依赖到某个tensor的具体数值,前端追踪时图是正确的,但GE在静态构图阶段把这个循环体展开了16次,结果实际运行到第17次迭代时索引越界。问题根因就是前端告诉GE的“控制流边界”不准确。这种问题排查起来极其痛苦,因为错误信息只在运行时最后一跳出现。

2.2 图优化的四把刀

GE对图的改写手段可以浓缩成四类典型操作。

第一是算子融合。将小算子合并成大kernel,消除中间结果的内存写回和读入。典型例子是“卷积+偏置+激活函数”三者融合成一个算子。融合之后,中间张量应该直接在寄存器或者片内缓存里流转,不必出现在全局内存里。

第二是常量折叠。某些算子的输入在构图阶段就是已知常量,比如shape信息、reshape的尺寸参数、归一化层的统计量。GE会提前执行这些算子并把结果替换为常量,这样运行时就不需要调度它们。

第三是死代码消除。训练过程中可能因为条件分支、临时调试代码或框架自动插入的调试算子,导致图中出现一部分对最终输出没有贡献的节点。这些节点如果不剪掉,白白占用调度周期和内存。

第四是内存布局调整。算子在异构设备上存在NCHW、NHWC、NC1HWC0等不同排布方式。GE会在图级提前插入必要的转置或格式转换节点,甚至能通过分析发现某个算子的输出格式可以被消费者直接消费,从而消除一次布局转换。这类优化的收益往往被忽视,但它对NPU尤其重要,因为NPU内部线性代数单元对数据排布极其敏感。

值得一提的是,融合策略并非越激进越好。融合后的kernel如果太大,硬件寄存器会被塞满,反而导致寄存器溢出,性能不升反降。我后面在踩坑章节再展开。

2.3 将图切给异构设备:设备分配与调度

当模型经过图优化拿到一张干净的DAG后,GE要解决的下一个问题是:这张图里的每一个节点到底跑在哪个设备上?

异构系统中的设备分配通常遵循三个原则:

算子类型优先。卷积、矩阵乘这类数值密集型算子倾向于放在加速器上,Copy类算子和维度小的算子可以留给CPU。显存约束优先。节点的输入输出tensor尺寸加起来不能超过目标设备的可用显存,否则需要插入切分节点。通信代价优先。当数据需要跨越设备边界时,就必须插入设备间拷贝节点或者集合通信节点。GE会尽量把存在数据依赖的算子放在同一设备,减少跨设备数据传输。

调度方面,GE一般会对图做拓扑排序,得到节点的执行顺序。这个顺序需要满足依赖约束,同时尽量让没有依赖的节点并行执行。现代运行时普遍采用“数据流驱动模式”,即一个算子只要所有输入就绪即可被调度,而不是等着前序算子全部跑完。

我举个例子说明差别:假设图里有两条独立路径 A->B 和 C->D,B和D都依赖各自的前序。如果按照“层顺序同步”的做法,执行完A和C之后再统一同步,接着再跑B和D,效率很差。数据流驱动模式则会在A完成时立刻启动B,在C完成时立刻启动D,两个路径并行推进,整体延迟几乎能砍半。

3. 手把手实现一个精简图引擎的核心路径

3.1 设计一张最小的Graph IR

为了真正理解GE,最好的办法是动手写一个缩小版图引擎。不需要几十万行代码,只需要把核心路径跑通即可。

第一步是定义IR。我见过很多初学者把IR直接设计成“节点带指针,指针指边”的图结构,表面上很直观,但后续做图改写时要频繁处理指针失效,维护成本极高。更稳妥的做法是用索引化设计,用id关联节点和tensor。

比如可以用下面这种Python风格的结构来描述一个最小图IR:

class OpNode: def __init__(self, op_type, attrs, inputs, outputs): self.id = None # 节点在整个图中的索引 self.op_type = op_type # 算子类型标识 self.attrs = attrs # 算子属性,如padding、stride self.inputs = inputs # 输入tensor id self.outputs = outputs # 输出tensor id class Graph: def __init__(self): self.nodes = [] # 按id存储的节点列表 self.tensors = {} # tensor id -> shape/type/device

这里的tensor并不存放真是数据,它只描述数据的shape、数据类型、所在设备、生命周期等信息。真正数据在运行时的内存池里统一管理。这个抽象的好处是:节点之间不直接持有指针,后续做节点删除和插入时只需要修改id列表,不存在悬垂指针问题。

3.2 实现算子融合:规则与匹配

有了IR之后,第一个值得实现的图优化是算子融合,它能让你很快体会到图引擎“改写一切”的快感。

我以一个简单的融合链为例:“卷积 -> 偏置加 -> ReLU”。初级的GE实现可以在图优化pass里写这样一个匹配流程:

def try_merge_conv_bias_relu(graph, node): if node.op_type != "Conv": return node # 检查卷积的唯一消费者是否为BiasAdd succ = graph.get_single_succ(node) if succ is None or succ.op_type != "BiasAdd": return node # 再检查BiasAdd的唯一消费者是否为Relu relu = graph.get_single_succ(succ) if relu is None or relu.op_type != "Relu": return node # 构建融合节点 fused = OpNode( op_type="ConvBiasRelu", attrs={**node.attrs, **succ.attrs, **relu.attrs}, inputs=node.inputs + succ.inputs[1:], # 同时接收卷积输入和bias outputs=relu.outputs # fusion后只保留Relu的输出 ) graph.replace_chain([node, succ, relu], fused) return fused

这个逻辑看起来简单,但实际工程里的匹配规则要复杂得多。你需要考虑消费者的数量:如果卷积的输出除了喂给BiasAdd之外还被另一个分支消费,那融合会改变共享语义,这时绝不容许直接替换。而如果两个分支读的是同一个数据,又可以通过引入额外拷贝节点来满足融合条件,这就变成了代价模型问题。

运行一轮优化后,用图大小和预估traffic作为指标对比一下就能看到鲜明的效果:三层算子变成一层,中间张量完全消失,图上的节点数和tensor数都显著下降。

3.3 运行时调度器:从拓扑序到事件流

图优化做完后,另一个核心模块是调度器。调度器的输入是DAG,输出是每个节点在哪个设备、哪个运行队列(stream)上执行。

我们先从最基础的拓扑排序开始。经典解法是Kahn算法:

def topo_sort(graph): in_degree = {node.id: 0 for node in graph.nodes} for node in graph.nodes: for out_t in node.outputs: for consumer in graph.consumers(out_t): in_degree[consumer.id] += 1 queue = [node.id for node in graph.nodes if in_degree[node.id] == 0] result = [] while queue: cur_id = queue.pop(0) result.append(cur_id) for out_t in graph.nodes[cur_id].outputs: for consumer in graph.consumers(out_t): in_degree[consumer.id] -= 1 if in_degree[consumer.id] == 0: queue.append(consumer.id) return result

这段代码解决了“执行顺序”问题,但还没解决“并行度”问题。真实硬件上有多个队列可以并发执行互不依赖的任务,GE需要尽量把没有依赖的节点分配到不同队列,并用事件同步机制保证跨队列依赖关系不会被打乱。

事件同步的思路是:当节点A在stream 1上运行,而节点B在stream 2上依赖A的输出,GE会在A和B之间插入一个事件记录操作,让stream 2等待该事件触发后再执行B。有人觉得这不就是图的边吗?实际上图的边只是逻辑依赖,事件的插入才是硬件层面真正能等住的同步原语。

如果漏掉了某个事件,你会看到非常经典的结果:两个stream竞争同一个buffer,后启动的任务先完成,把前序结果覆盖了,整个模型输出随机出错。这种bug定位困难,因为报错时节点已经执行完毕,现场早被破坏。

4. 图引擎在真实业务中的性能调优

4.1 静态Shape与动态Shape的功守道

图引擎对静态Shape和动态Shape的处理方式完全不同,这也是最容易影响业务性能的分岔路。

静态Shape意味着每个tensor在编译期就知道维度尺寸,GE可以提前完成内存分配、算子kernel选取和融合决策。整个执行计划可以做成一份静态指令数组,运行时只需循环调度。

动态Shape则不同,比如序列长度会变的NLP模型,某个维度的尺寸需要根据输入数据运行时确定。GE被迫在每一轮执行前重新推断shape、重新选择kernel,甚至重新生成融合计划。这就是动态Shape训练比静态Shape慢很多的核心原因。

我建议的一个工程优化策略是“分桶”:把连续几个常见Shape映射到静态计划,运行时根据当前Shape查表,命中的直接走静态计划,不命中的再走慢速动态编译。比如处理变长序列时,把长度分到[64,128,256,512]几个桶,桶内padding到统一size。这种方式既保留了静态Shape的性能收益,又能容忍一定范围的变化。代价是多了padding计算,但只要GE的融合策略能吃掉padding带来的冗余,整体收益通常还是正向的。

4.2 显存复用:GE省显存的核心算法

图引擎对内存的规划往往是“复用”而不是“独占”。一开始跑大模型时,显存会因为每层都保留中间结果而瞬间爆炸,但只要分析出两个tensor的生命周期不重叠,就可以让它们共用同一块显存。

这个思路对应的是数据流分析里的活跃变量分析。每个tensor从被生产出来到最后一个消费者完成读取,构成一段生命周期。GE按拓扑顺序扫描所有tensor,维护一个“已分配块池”,新tensor需要内存时优先从池中拿出一块已经被释放的block,只有池中没有可用block时才从系统申请新内存。

在这基础上还要考虑对齐约束。很多加速器的内存管理单元要求block按256字节或更大的颗粒对齐,否则无法启动kernel。我会在分配block时统一对齐到设备要求的最大对齐尺寸,用空间换简单性。

另外,图优化阶段删掉的中间节点也会释放一批tensor,这部分tensor对应的内存块会被立即回收进内存池。所以优化前后用nvidia-smi或者设备内存监控工具看到的峰值占用可能有几倍的差异。

4.3 多设备并行与集合通信的衔接

当模型大到单卡装不下,GE就要把一张大图切分成多个子图,分别放到不同设备上。

图切分的基本单位是“设备”,切分点通常是那些batch维度的中间结果。切完之后,GE需要在边界插入通信节点,比如AllReduce、AllGather。我在做多机训练时踩过的典型问题是如何保证通信和计算重叠。如果实现不彻底,每个iteration都会计算-通信-计算依次串行,时间线上出现明显的通信空隙。

好一点的GE会在图优化阶段做“通信与计算相似性分析”,把集合通信操作和相关计算节点放到不同stream,让通信在后台进行的同时有依赖的后续节点可以先行执行无关部分。这个优化对千卡级别的分布式训练效果非常明显,通信占比能从30%降到10%左右。

有一点要特别注意:集合通信节点是全局同步点,如果不同设备上到达这个节点的时刻差异很大,整个训练速度会被最慢的设备拖住。GE在做设备分配时,最好把计算量相近的层分到不同设备上,让载入更加均匀。否则A设备多算了2秒,B设备只能空转等待,资源浪费非常可惜。

5. GE开发与运维中我踩过的坑

5.1 “算子不在支持列表”的Fallback

初版GE能支持的算子类型有限,业务方塞进来一个自定义算子时,编译器会把整个子图标记为“不可融合”,然后退化成逐算子调用,也就是fallback。理论上这是安全的兜底方案,但有个隐蔽问题:GE对自定义算子不了解,通常不会为其分配合适的融合策略和内存复用策略,导致整张图的优化效果被大幅削弱。

我处理这类问题时会在IR里增加一个“算子能力标记”。每个算子上传时都要声明它支持哪些特性,比如是否可融合、是否可重计算、是否支持fp16。图优化pass只对能力标记足够的算子下手,凡是标记模糊的一律不优化。这样既保证正确性,也不会出现“自定义算子把整图优化都拉垮”的尴尬。

5.2 死锁:两条路径互相等待

多stream调度最恶心的问题之一是死锁,而它通常不是程序逻辑错误,而是事件等待关系配错了。

有一个真实场景:设备A上的节点X需要设备B上节点Y的输出,设备B上节点Z又需要设备A上节点W的输出。运行时里,A队列在执行W之后并没有发送任何信号,B队列在执行Y之后也没有通知A,双方都在等对方的数据到达。由于GE生成的调度顺序没有插入对资源顺序的排序,两个设备就一直僵住。

排查思路通常是先检查设备间event记录顺序。如果发现两个方向的event出现交叉等待,就手动给两条路径之间插入一个额外的barrier。后来我在调度器里加了一条规则:“任何两个存在双向间接依赖的设备,必须按设备id顺序做同步原语的获取”,彻底规避这类死锁。

5.3 内存复用导致的脏数据

内存复用带来的bug最容易在推理阶段出现,因为推理时图结构固定、tensor生命周期固定,复用关系一旦确定就不会变化。如果GE的liveness分析有误,比如误判了一个tensor生命周期已结束,就会在它还没被读完时让另一个节点覆写它,结果就是每几次推理会出现一次随机crash。

这类问题之所以难定位,是因为污染点往往不在当前执行节点上,而在几轮之前的某个已经释放的block写入。我的经验是:给每个分配的内存block绑定一个“生成版本号”,tensor访问时校验版本号是否匹配,如果复用错误,立刻抛错,而不是等数据传到用户手里才变成NaN。这个版本号校验在调试模式下打开,在生产模式下关闭,开销趋近于零。

5.4 性能反直觉:融合并非越多越好

最后讲一个经常让人怀疑人生的现象:明明是同一个图,融合了更多算子之后,性能反而变差了。

这背后的硬件原理是寄存器溢出。融合kernel把多个算子的计算逻辑塞进同一个kernel,局部变量过多,硬件寄存器放不下时,编译器不得不把变量临时存到本地内存再读出来,这个过程叫寄存器溢出。一个大规模的融合kernel可能反复溢出,每次溢出都要走一次慢速内存,其开销甚至大于原本两个kernel之间的中间结果写回。

我后来归纳出一套方针:融合前先估计融合kernel的寄存器使用峰值,超过硬件可用寄存器总数的70%就放弃融合,或者改用手动写成的高性能kernel。不要迷信“融合一定赢”,要以实测为准。

下面是几张排查经验的速查表:

症状根因排查思路解法
图优化无效果算子能力标记缺失检查IR中算子的能力属性补充能力标记,缩小优化范围
随机crash/NaN内存复用liveness错误打开版本号校验修正生命周期,或按shape分组复用
设备间卡死事件等待关系有环检查event插入顺序强制按设备id排序同步原语
融合后变慢寄存器溢出分析寄存器峰值调整融合规则或拆分kernel
动态shape反复编译未做桶化处理查看编译日志中重编译次数引入静态shape分桶计划

这些坑单看文档都不起眼,但叠加在一起就能决定一个GE是“能用”还是“好用”。我每次评审新人的设计稿时,都会盯着他们怎么处理fallback、事件同步和内存复用这三块,因为这三块几乎涵盖了我见过的全部运行时崩溃问题。

结尾

做图引擎这么多年,我最大的体会是:图引擎不是一个“写了一堆pass就完事”的模块,它是一个需要你同时理解神经网络结构、设备内存模型、并行执行语义和硬件指令调用的交叉地带。真正的GE高手,不只会在图画得好,还能在死锁日志和内存profile中一眼看出是哪一层抽象出了偏差。

如果你正准备接触图引擎,我的建议是从一个最小IR开始,把算子融合和拓扑调度跑通,然后再逐步加入动态shape、多设备切分、内存复用。很多时候,费尽心思背一堆优化文档,不如亲手把一条链路从输入跑到输出,再对着profile结果反思哪里慢了。

最后再分享一个小技巧:每当你觉得GE行为诡异,优先怀疑同步和复用的边界条件,而不是算子本身的数学逻辑。计算图里90%的隐性崩溃,都是因为某个边界被打破后,错误被延迟到几十个节点之后才暴露出来。带着“边界先于算法”的思路去排查,你会发现很多问题瞬间变得清晰起来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进