资讯动态

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

发布时间:2026/10/22 15:07:55 来源:尧图企业网站定制
这两年做AI芯片相关的系统软件我经常被问到一个问题“图引擎到底是个什么玩意”很多人把模型写出来训练跑得飞快但对中间那一层“翻译官”的工作完全陌生。一句话概括深度学习模型在你眼里是一层层神经网络算子在芯片眼里只是一堆运算指令而图引擎Graph EngineGE就是夹在中间的那个翻译官兼调度员。它既要拿到模型的计算图又要把它切成芯片能高效吃的菜还要在训练或推理的每一毫秒里决定“谁先算、谁后算、算完放哪”。说它是异构计算架构的编译核心和运行时大脑一点不夸张。这篇文章我会围绕图引擎的核心职责拆一拆它的整体设计、编译优化管线、运行时调度机制以及我在实际开发中反复踩过的坑。适合想切入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-DB和D都依赖各自的前序。如果按照“层顺序同步”的做法执行完A和C之后再统一同步接着再跑B和D效率很差。数据流驱动模式则会在A完成时立刻启动B在C完成时立刻启动D两个路径并行推进整体延迟几乎能砍半。3. 手把手实现一个精简图引擎的核心路径3.1 设计一张最小的Graph IR为了真正理解GE最好的办法是动手写一个缩小版图引擎。不需要几十万行代码只需要把核心路径跑通即可。第一步是定义IR。我见过很多初学者把IR直接设计成“节点带指针指针指边”的图结构表面上很直观但后续做图改写时要频繁处理指针失效维护成本极高。更稳妥的做法是用索引化设计用id关联节点和tensor。比如可以用下面这种Python风格的结构来描述一个最小图IRclass 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_typeConvBiasRelu, attrs{**node.attrs, **succ.attrs, **relu.attrs}, inputsnode.inputs succ.inputs[1:], # 同时接收卷积输入和bias outputsrelu.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%的隐性崩溃都是因为某个边界被打破后错误被延迟到几十个节点之后才暴露出来。带着“边界先于算法”的思路去排查你会发现很多问题瞬间变得清晰起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑