想象一位主厨要操办五百人的婚宴。他在黑板上画下一张后厨作战图:每个圆圈代表一道菜的加工台,箭头代表“必须先完成 A,才能开始 B”——高汤没吊好,佛跳墙就不能开火。这张作战图,就是今天的主角:计算图。 AI 编译器面对神经网络时干的正是同一件事:把模型翻译成由算子和数据流组成的图,然后在图上“走来走去”完成各种优化。怎么把模型画成图(构建)?怎么把图存进电脑(存储表示)?怎么排出合理的执行顺序(拓扑排序)?怎么选择前序或后序去走图(遍历)?怎么认出图中的固定套路并整体替换(图匹配)?这篇文章用后厨作比喻,把这些问题一次讲透。
计算图的构建与遍历是 AI 编译器优化 Pass 的地基,读懂它,你就能看懂 XLA、TVM 等框架一半以上的优化逻辑,也是编译器岗位面试的必考基础。
可视化演示
展示图遍历三维度:拓扑排序(Kahn算法入度0优先入队O(V+E)保证执行顺序)、前序vs后序遍历(前序根→左→右用于常量折叠/后序左→右→根用于自动微分反向传播)、图模式匹配(Conv+BN+Relu子图模式匹配命中后替换为单个Fused算子),三模式切换理解编译器优化Pass执行机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
计算图构建
主厨画作战图的第一步,不是开火,而是把每道菜的原料关系写在黑板上:“A 菜需要 B 菜提供的半成品”。在编译器里,这一步叫计算图构建:把神经网络的前向代码逐行翻译成一张图。图中的圆圈叫节点,对应一个算子(卷积、加法、激活函数等一次运算操作);箭头叫边,对应张量的数据流向。构建流程通常是:框架解析前向函数,遇到一个算子就注册一个新节点,再把算子输入对应的上游节点连到它身上。PyTorch 的 torch.fx、TensorFlow 的 GraphDef 都在做这件事。
计算图必须满足有向无环图(DAG)约束:任何节点都不能直接或间接依赖自己,否则就会出现死循环般的依赖。后厨里不可能存在“这道菜需要自己出锅后的汤汁”这种菜谱。编译器在构建时会对每条新建的边做校验,发现环就报错拒绝编译。
作战图画好只是第一步;黑板上的图必须落成便于查阅的备忘录,否则上千道菜的关系根本记不住。这就是下一节的存储表示。
- 节点集V 是图中所有算子的集合,每个算子对应一次张量运算,相当于黑板上每个加工台。
- 边集E 是 V 上有序对的子集,边 (u,v) 表示从 u 到 v 的数据依赖,相当于“要先做好 u 才能开工 v”的箭头。
- 无环约束DAG 要求不存在从某节点出发又能绕回该节点的可达路径,否则执行顺序无法确定。
图的存储表示
后厨作战图怎么保存?有两种极端方案:一种是每个加工台贴一张“我完成后通知 B、C 两台”的小纸条;另一种是画一张超大的总表,横竖都列满所有菜名,在对应的格子里打勾。前者是邻接表,后者是邻接矩阵。
邻接表为每个节点存一份后继节点列表,内存开销是 O(V+E),适合边稀疏的大图;邻接矩阵用 n×n 的 0/1 方阵表示任意两点之间是否有边,查询只需 O(1),但要吃掉 O(V²) 空间。AI 编译器更常用的是 CSR(压缩稀疏行) 格式:把邻接表按行拼接,用三个数组记录每行的偏移、列号和数值,既省内存又利于顺序访问。CSR 相当于把小纸条按加工台顺序装订成册,每页只写“本台通知谁”。
大模型的计算图动辄上万节点、数万条边,用邻接矩阵意味着上亿个格子,绝大多数是 0,浪费惊人。因此工业界的图 IR(如 XLA 的 HLO、TVM 的 Relay)几乎都采用邻接表或 CSR 一类紧凑表示。
婚宴菜单不可能印全所有菜的两两关系;现实中的编译器几乎都选邻接表或 CSR,省下的内存留给真正有用的优化。
- 矩阵定义邻接矩阵是 n 行 n 列的方阵,行和列都按节点编号排列。
- 元素判定若边 (u,v) 存在,则 A 中对应位置为 1,否则为 0。
- 空间对比邻接矩阵固定占用 n² 个格子,邻接表只记录实际存在的边。
拓扑排序
作战图确定后,主厨要排上菜顺序:炖汤要提前三小时吊水,凉菜可以最后十分钟拼盘。规则只有一条——箭头必须从先做到后做。把这种顺序排出来,就是拓扑排序。
拓扑排序把 DAG 的所有节点排成一个线性序列,保证每条边的起点都出现在终点之前。最常用的 Kahn 算法 很直白:反复找出“入度为 0 的节点”(也就是原料齐全、随时可以开工的加工台),把它输出,再把它所有后继的入度减 1,重复直到图被清空。另一个实现来自 DFS:对每个节点做后序 DFS,把回溯完成的节点依次记录,最后将序列逆序输出(即逆后序),同样得到合法拓扑序;若输出的节点数少于总节点数,说明图中存在环——这就是环检测。
编译器拿到拓扑序后,就按这个顺序下发算子执行指令,保证每个算子执行时它的输入都已经算好。这就像主厨按拓扑序把任务单贴到各加工台,厨房才不会出现“汤还没吊好就开始炖佛跳墙”的混乱。
- 合法序列长度为 n 的排列中,任意边 (u,v) 都满足 u 的位置在 v 之前。
- Kahn 流程每轮取出入度为 0 的节点输出,再将其所有后继的入度减 1。
- 环检测若输出节点数少于 |V|,剩余节点互相依赖形成环。
前序遍历
主厨巡视厨房时有一种走法:走到一个加工台,先把任务交代清楚(“你这台负责出高汤”),再往下走到需要高汤的台子继续交代。这种“先访问父节点、再依次访问子节点”的走法,就是前序遍历。
前序遍历从根节点出发:先处理当前节点,再递归遍历它的所有直接后继。在 AI 编译器中,自顶向下的 Pass 正是依赖这种顺序——例如形状推断(shape inference):父节点把张量的形状信息传给子节点,子节点据此推导自己的输出形状,信息像水流一样从根流向叶子。前序遍历的递归实现非常简洁:先访问自己,再对每个子节点调用同样的函数。
要注意,前序遍历结果一般不满足拓扑序。举个反例:根节点 A 先访问子节点 B,再访问子节点 C,但图中存在边 C→B,序列里 B 却排在 C 前面,就违反了“起点在终点之前”的约束。所以前序适合“从根到叶传播信息”的任务,不适合安排执行顺序。
前序遍历像主厨“从上到下布置任务”,信息一路向下游传递;但布置任务和真正开火是两回事,真正的开火顺序是反过来的——这就是下一节要讲的从下往上的走法。
- 访问自身序列的第一个元素是当前节点 u。
- 递归子节点对每个直接后继 v 递归地求 pre(v)。
- 拼接顺序所有子节点的前序序列按访问次序依次拼接在 u 之后。
后序遍历
真正的开火顺序是反着的:高汤吊好、配菜切好,佛跳墙的加工台才开始拼装;拼装完成,才轮到下一道依赖它的菜。这种“子节点全部处理完,才处理父节点”的走法,就是后序遍历。
后序遍历先递归处理所有子节点,再把当前节点放进序列,因此它是自底向上 Pass 的骨架。常量折叠(constant folding)先递归把子表达式算成常量,再把父算子替换成常量结果;内存规划先处理子节点的生命周期,父节点的输出才能安全复用缓冲区。代码生成里“先编译子图、再拼接父算子”也遵循同样的逻辑。
后序遍历与拓扑排序关系密切:把后序序列取逆序,就得到一个合法的拓扑序(这正是 DFS 实现拓扑排序的原理)。动画演示里 G、E、F 先被点亮,D 在其后,A 最后——每个节点出现时它的下游都已就绪,逆序读出来就是"A → C → B → D → F → E → G"这样的执行顺序。
后厨里“备料齐了才开火”的顺序,正好就是编译器执行算子、回收内存的顺序——从上到下布置,从下到上执行,两者配合才能让整张图顺畅跑起来。
- 递归子节点先把所有子节点的后序序列按顺序拼接起来。
- 最后访问自身u 放在所有子节点之后,这是与前序唯一的顺序差异。
- 逆序即拓扑序对 DAG,后序序列的逆序满足每条边的起点在终点之前。
图匹配与替换
老师傅看到菜谱里“糖、醋、生抽、水淀粉、小火收汁”连续五步,立刻认出这就是糖醋汁配方,直接拿出调好的酱料替换整段步骤。编译器里的图匹配做的就是这件事:在大图里认出某个固定的子图结构,然后用一个等价但更高效的算子把它整体替换掉。
具体做法分三步:定义模式(pattern)——一张小 DAG,比如“Conv 后接 BN”;在目标图中寻找与模式结构相同的子图;命中后执行替换,例如把 Conv+BN 融合成一个 ConvBN 算子,省掉一次中间张量的写盘与读盘。算子融合、精度回退(fp32→fp16 的模式替换)、布局转换,都是图匹配的典型应用。
这里的“认出”不是靠算子名字比对,而是子图同构(subgraph isomorphism):存在一个从模式节点到目标图节点的映射,把模式里每条边都保持到图里的边。子图同构在一般图上是个难问题(NP 完全),所以编译器会用锚定节点、拓扑序剪枝、模式哈希库等技巧加速,这也是图匹配工程实现真正的难点。
就像老师傅用现成酱料替换五步烹制,图匹配把一个 6 节点的子图折叠成 1 个融合算子,推理引擎的延迟与显存占用因此大幅下降——这就是计算图遍历与匹配在真实世界的价值。
- 模式定义模式图 Vp 是待识别的小结构,如"Conv→BN"两个节点加一条边。
- 保边映射φ 把每个模式节点映到目标图节点,且边的关系完全保持。
- 替换动作把命中的子图折叠成一个融合算子,并保持外部边的语义不变。
AI 应用场景
推理引擎的算子融合优化
阶段:模型优化与编译
解决的问题:传统推理引擎逐算子执行,Conv 的输出要写回显存、BN 再读出来,来回搬动量巨大。AI 编译器用后序遍历 + 图匹配把 Conv、BN、ReLU 识别并融合成一个内核,消除中间张量的写读。
具体案例:ONNX Runtime 与 TensorRT 在部署 ResNet 类模型时,通过模式匹配把连续的 Conv+BN+ReLU 替换为融合算子,实测单算子 kernel 启动次数从数百降到几十,端到端延迟下降约 30%。
模型导出与静态图执行
阶段:模型部署准备
解决的问题:动态图脚本难以提前掌握整张图的结构。torch.fx、ONNX exporter 把 Python 前向代码逐算子翻译成静态计算图,再做拓扑排序确定执行顺序,保证每个算子执行时输入已就绪。
具体案例:把 PyTorch 训练的 Transformer 导出为 ONNX 时,exporter 构建约五百个节点的 DAG,拓扑排序后按序下发到 TensorRT;若图中出现环(如错误的循环控制流),编译器会直接报错拒绝导出。
自动微分的反向图构建
阶段:训练框架设计
解决的问题:反向传播需要为每个前向算子安插对应的梯度算子。框架先构建前向计算图,再以逆后序(即拓扑序)为基础,自底向上依次为每个节点插入梯度子图,最终形成可用于训练的正反向联合图。
具体案例:PyTorch 的 autograd 在前向执行时动态记录图,backward 时按拓扑逆序触发每个节点的梯度函数;理解了后序遍历与拓扑序的关系,就能明白为什么梯度链总是从 loss 一路回传到最底层参数。
Python 演示
class Node: def __init__(self, name, op): self.name = name self.op = op self.inputs = [] class Graph: def __init__(self): self.nodes = {} def add(self, name, op, inputs): n = Node(name, op) n.inputs = list(inputs) self.nodes[name] = n def successors(self, u): return [v for v, n in self.nodes.items() if u in n.inputs] def topo_order(self): indeg = {name: len(n.inputs) for name, n in self.nodes.items()} ready = [name for name, d in indeg.items() if d == 0] order = [] while ready: u = ready.pop(0) order.append(u) for v in self.successors(u): indeg[v] -= 1 if indeg[v] == 0: ready.append(v) if len(order) != len(self.nodes): raise RuntimeError("cycle detected") return order def postorder_from(self, u, visited, out): visited.add(u) for v in self.successors(u): if v not in visited: self.postorder_from(v, visited, out) out.append(u) def postorder(self): visited = set() out = [] for name, n in self.nodes.items(): if not n.inputs and name not in visited: self.postorder_from(name, visited, out) return out def match_pattern(g, src_op, dst_op): pairs = [] for dst, n in g.nodes.items(): if n.op == dst_op: for src in n.inputs: if g.nodes[src].op == src_op: pairs.append((src, dst)) return pairs def main(): g = Graph() g.add("x", "input", []) g.add("w1", "const", []) g.add("conv1", "conv", ["x", "w1"]) g.add("bn1", "bn", ["conv1"]) g.add("relu1", "relu", ["bn1"]) g.add("w2", "const", []) g.add("conv2", "conv", ["relu1", "w2"]) g.add("bn2", "bn", ["conv2"]) g.add("relu2", "relu", ["bn2"]) g.add("out", "add", ["relu1", "relu2"]) print("topo: ", g.topo_order()) print("post: ", g.postorder()) pairs = match_pattern(g, "conv", "bn") print("conv-bn pairs: ", pairs) if __name__ == "__main__": main()
self.nodes = {}用字典按名字索引节点,相当于给每个加工台贴一张名牌。def add(self, name, op, inputs)构建图的入口:每遇到一个算子就注册一个节点并记录输入依赖。indeg = {name: len(n.inputs) for name, n in self.nodes.items()}Kahn 算法的起点:统计每个节点的入度,即“还差几样原料”。ready = [name for name, d in indeg.items() if d == 0]原料齐全(入度为 0)的加工台先开工。for v in self.successors(u): indeg[v] -= 1节点完成后通知下游:入度减 1,可能有新加工台备齐原料。raise RuntimeError("cycle detected")输出节点数不足即存在环,编译器因此拒绝执行。def postorder_from(self, u, visited, out):后序 DFS 核心:先递归子节点,最后把自身放入序列。out.append(u)这一行的位置决定了是前序还是后序,是遍历中最关键的一行。pairs = match_pattern(g, "conv", "bn")图匹配入口:找出所有可融合的 Conv→BN 边。
常见误区
误区我以为一套图只有一种合法的拓扑序,编译器给出的顺序就是唯一标准答案,换个顺序就会出错。
正解只要每条边的起点都排在终点之前,任何排列都是合法拓扑序。Kahn 算法每次从多个入度为 0 的节点里任选一个,选择不同结果就不同;不同编译器甚至不同版本,都可能排出不同但同样正确的顺序。只有链路状图(每个节点只有一个前驱)才会得到唯一拓扑序。
为何常见:教材通常只演示一条执行路径,初学者容易把“一种可行解”误当成“唯一解”。
误区既然前序是从父节点开始往下走,那它一定是“先父后子”,应该天然满足拓扑排序的要求吧?
正解不一定。前序只保证“父先于子被访问”,但父节点有多个子节点时,先访问的子节点可能依赖后访问的子节点,序列就违反拓扑序。例如根 A 先访 B 再访 C,若存在边 C→B,序列中 B 在 C 前,就违背了“起点在终点之前”。真正与拓扑序有确定关系的是后序的逆序。
为何常见:把“先父后子”的直觉等同于“按依赖排序”,忽略了兄弟节点之间也可能存在依赖。
误区我把网络想象成一条或多条互不相交的链,觉得一个算子只会被一个下游算子使用,图上每个节点只有一个父节点。
正解计算图是 DAG,允许“扇出”:一个节点的输出可以喂给多个下游节点。例如残差网络里一个 ReLU 的结果同时进入两条分支。正因为存在共用子图,后序遍历才重要——保证共用子图只被处理一次;图匹配也常常要处理这类共享结构。
为何常见:初学时常从单条前向链理解网络,忽略真实模型中大量存在的分支与共享。
误区我以为只要两个节点算子名相同,就能当作同一个模式匹配上,直接替换。
正解图匹配要求子图同构:不仅算子类型要对应,边与边的连接关系也要完全保持。两个名字相同的算子如果处在不同的连接结构中,往往不能命中同一个模式。并且一般图上的子图同构是 NP 完全问题,编译器必须用锚定节点、剪枝、模式哈希库等手段加速,否则上万节点的图根本搜不完。
为何常见:人眼识别“套路”很快,容易低估程序在图上做结构性搜索的难度。
面试题
Q1. 计算图中的一个节点通常对应什么?
- A.卷积内核的权重参数
- B.一次算子计算操作← 正确答案
- C.一份训练数据集样本
- D.反向传播的梯度值
答案:B。计算图的节点表示算子(一次张量运算),边表示张量的数据流。权重是沿边流动的数据之一,属于边的“货物”而非节点本体;训练样本和梯度同样不是静态图的节点定义。理解节点=算子,是读懂一切图优化的前提。
Q2. 静态计算图必须满足哪条结构约束?
- A.必须是完全二叉树的形态
- B.必须至少包含一条反向边
- C.必须是无环的有向图 DAG← 正确答案
- D.任意两节点之间必须连通
答案:C。静态数据流计算图必须是无环有向图。有环意味着存在节点依赖自己,执行顺序无法确定。树形、连通都不是必要约束——一个合法的 DAG 可以有多个输入、多个输出,节点无需两两连通。
Q3. 常量折叠优化通常采用哪种遍历方式?
- A.前序遍历
- B.随机遍历
- C.后序遍历← 正确答案
- D.层序遍历
答案:C。常量折叠需要把子表达式先算成常量,才能替换父算子,属于“子节点处理完再处理父节点”的自底向上逻辑,正是后序遍历的典型应用。前序适合自顶向下传播信息,不适合这类需要子结果的任务。
Q4. 判断一个排列是否为合法拓扑序的依据是?
- A.所有节点的访问深度严格递增
- B.每条边的起点都排在终点前面← 正确答案
- C.所有入度为零的节点排在最前
- D.输出的节点总数恰好等于边数
答案:B。拓扑序的唯一判定标准是“每条边的起点都在终点之前”。入度为 0 的节点排在最前只是 Kahn 算法的操作方式,深度、边数都不是判定依据。牢记这个定义,就能快速检验编译器给出的执行顺序是否合法。
Q5. Kahn 算法每一轮优先处理的节点特征是?
- A.出度达到全图最大值的那个节点
- B.入度为零即原料齐全的节点← 正确答案
- C.深度最浅且层数最低的那个节点
- D.后继节点数量最多的那个计算节点
答案:B。Kahn 算法反复取出“入度为 0”的节点——相当于原料已齐、随时可开工的加工台——输出后将其后继的入度减 1。选节点的依据只有入度是否为 0,与出度、深度、后继数量无关。
Q6. 前序遍历访问某节点时,它的子节点的状态是?
- A.所有子节点已经全部访问完成
- B.子节点尚未被递归地访问到← 正确答案
- C.部分子节点已经被提前替换掉
- D.子节点按照后序完成了排序
答案:B。前序的顺序是“先访问父节点,再递归子节点”,所以访问父节点时子节点一个都还没访问。若子节点已全部访问完,那是后序。这个差异决定了前序适合自顶向下传播信息,后序适合自底向上汇总结果。
Q7. 关于 DAG 的 DFS 后序遍历,哪个说法正确?
- A.后序序列的逆序是合法的拓扑序← 正确答案
- B.后序序列本身就是唯一的拓扑序
- C.只有二叉树结构才能做后序遍历
- D.后序与前序的访问顺序完全相同
答案:A。后序访问时终点先出、起点后出,恰好与拓扑序“起点在前”相反,因此把后序序列逆序输出就是合法拓扑序,这也是 DFS 实现拓扑排序的原理。后序序列本身不是拓扑序,而且拓扑序并不唯一;后序也不只适用于二叉树。
Q8. 在 AI 编译器中,形状推断通常使用哪种遍历方式?
- A.前序遍历,信息从根向叶子传播← 正确答案
- B.后序遍历,信息从叶子向根传播
- C.随机遍历,不依赖任何固定顺序
- D.层序遍历,每次只访问同一层节点
答案:A。形状推断是自顶向下的 Pass:父节点把张量形状传给子节点,子节点据此推导输出形状,信息从根流向叶子,对应前序遍历。后序适合常量折叠这类需要子结果的任务,层序和随机遍历在编译优化中极少作为主顺序。
Q9. 关于 CSR 存储格式,下列说法正确的有?
- A.用三个数组实现稀疏图的压缩存储← 正确答案
- B.在稀疏大图上比邻接矩阵省内存← 正确答案
- C.每个节点都额外存一份完整邻接矩阵
- D.能快速定位节点的出边邻接范围← 正确答案
答案:ABD。CSR 用行偏移、列号、数值三个数组压缩存储稀疏图,内存远小于 O(V²) 的邻接矩阵,且通过偏移数组能 O(degree) 遍历某节点的出边。C 描述的是邻接矩阵而非 CSR;正因省内存,工业级图 IR 才普遍采用 CSR。
Q10. 关于计算图构建流程,下列说法正确的有?
- A.每个算子注册为图中的一个节点← 正确答案
- B.算子的输入决定了上游连接边← 正确答案
- C.构建完成后需要做环结构的检测← 正确答案
- D.图的构建完全不需要关心张量形状
答案:ABC。构建流程是“每个算子注册节点、按输入建立边、最后校验无环”,三点缺一不可。D 错误:构建阶段虽然主要记录拓扑关系,但多数框架会在建边时同时登记张量形状信息,为后续形状推断做准备。