想象你走进一家现代化后厨,墙上挂着一张巨幅菜品流程图:洗菜、切菜、热锅、下料、翻炒、装盘,箭头把每一道工序连起来。餐厅经理不关心菜谱是哪位大厨写的,只看这张图就能判断哪些环节可以并行、哪些必须等待,甚至能把“翻炒”和“调味”合并成一个动作。AI 模型在框架里也藏着这样一张“菜品流程图”,计算机科学叫它数据流图:节点是算子,边是张量数据。AI 编译器拿到这张图后,就能像经理一样安排每次计算,这就是模型能被优化、加速、部署到芯片上的起点。 本文从零讲清数据流图的节点、边、构建、优化,以及 TensorFlow 与 PyTorch 的差异。读完后你会发现,后端编译器那些“高大上”的优化,本质上都是在给后厨流程图做减法、调顺序。
学懂数据流图,等于拿到 AI 编译器优化流水线的第一把钥匙;它能帮你把模型从“能跑”变成“跑得快”。
可视化演示
展示数据流图三维度:DAG结构(节点=算子Conv/BN/Relu/Add、边=张量数据流、拓扑排序确定执行顺序)、静态vs动态图(TF先构建后优化执行 vs PT边构建边执行eager模式)、算子融合(Conv+BN+Relu三算子融合为单个Fused算子减少内存读写),三模式切换理解AI编译器核心中间表示。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
1. 图的两大要素
在现代化后厨里,一张流程图只要说清楚两件事:有哪些工序,以及半成品如何流向下一道工序。数据流图同样只关心两件事:节点和边。
节点表示计算动作,也就是算子,比如矩阵乘法、卷积、加法;边表示张量数据在算子之间的流动方向。前端框架把模型翻译成图时,先把每个算子放进节点,再把“谁的结果给谁用”记录成有向边。整张图就是一份“谁等谁、谁喂谁”的流水线说明。
因为数据只能从生产它的算子流到消费它的算子,不能反过来,所以数据流图通常是有向无环图(DAG)。写成数学形式:
回到后厨比喻:节点就是“切菜”“炒菜”这些灶台,边就是“把切好的菜递到炒锅旁”的传送带。只要把这两大要素画清楚,后面所有优化才能展开。
- 节点集合 \(V\)图中所有算子/计算的集合,每个 \(v_i\) 对应一个工序。
- 边集合 \(E\)图中所有有向连接的集合,\(e_{ij}\) 表示从 \(v_i\) 到 \(v_j\) 的数据流动。
- 有向性边从生产者指向消费者,方向不能随意颠倒。
2. 节点身份
流程图上每个工序有不同的职责:备料、切菜、炒菜、装盘;数据流图上的节点也分身份。节点类型是编译器理解“这一步能不能被替换、能不能合并”的重要依据。
最常见的节点是算子节点,它描述计算本身,例如矩阵乘法、卷积、激活函数。其次有常量节点和变量节点:常量保存固定权重,变量保存训练中会更新的参数。还有输入/输出节点,负责和外部交换数据。部分框架还有控制节点,用来表达分支、循环等流程控制。
可以把节点类型看成一个标签函数:
在后厨里,备料、切菜、炒菜的“身份”决定了谁可以提前做、谁必须等。数据流图中的节点身份也决定了编译器能对它们做哪类优化。
- 函数 \(\tau\)它给每个节点 \(v_i\) 打上一个类型标签。
- 算子节点 \(\mathrm{OP}\)执行真正的数学运算,比如乘、加、卷积。
- 常量与变量 \(\mathrm{CONST}/\mathrm{VAR}\)常量不可训练,变量会在反向传播中被更新。
- 输入输出与控制 \(\mathrm{IO}/\mathrm{CTRL}\)输入输出对应“上菜口”,控制节点决定流程什么时候开火。
3. 边与依赖
传送带不只会运菜,还能告诉工人:必须先放好料,下一步才能开工。数据流图中的边,正是这种“依赖关系”的载体。
边分为两类。数据边携带张量,把上游算子的输出传给下游算子;控制边不携带张量,只约束执行先后顺序,比如“必须先初始化显存,再运行卷积”。有了边,整个图形成偏序关系,编译器才能确定执行计划。
形状传播也沿着边进行:输入张量形状一路往前推断,得到每个中间算子的输出形状。为了让所有依赖都有先来后到,我们需要拓扑排序:
后厨的传送带严格规定了“先切菜,再下锅”。拓扑排序就是给每一位厨师一张排班表,保证每道菜都不会在材料没到齐时提前开工。
- 边集约束遍历所有有向边。
- 位置函数 \(\mathrm{pos}\)表示节点在拓扑排序中的序号。
- 严格小于生产者必须先执行,消费者必须后执行。
4. 构建与校验
主厨拿到菜谱后会先画成工序图,画完还要检查有没有漏掉前序步骤。编译器前端做的是同一件事:把框架里的模型定义翻译成数据流图。
构建过程大致是:前端解析用户代码 → 遇到算子就创建节点 → 把算子间数据传递记录成边 → 补充输入输出节点。TensorFlow 早期通过 Python API 直接构建 GraphDef;PyTorch 的 eager 模式则在运行时由 autograd 逐步记录。无论哪种方式,建图完成后都要做两项校验:拓扑排序查环和静态形状校验。
一个数据流图是否合法,关键看它是不是 DAG。写成逻辑等价关系:
如果后厨流程图中出现了“炒菜要等装盘,装盘又要等炒菜”的死循环,这顿饭永远做不完。拓扑排序就是那个及时喊停的质检员。
- 前件 \(\mathrm{DAG}(G)\)表示图 \(G\) 没有环。
- 存在量词 \(\exists\)至少能找到一种执行顺序。
- 全称量词 \(\forall\)对于每一条边,起点都要排在终点前。
5. 优化与代码生成
厨师长拿到流程图后会合并工序、去掉空转等待。AI 编译器基于数据流图做一系列 pass(优化遍),让模型跑得更快、占内存更少。
常见的图优化包括:常量折叠(先算好固定结果)、公共子表达式消除(相同计算只做一次)、算子融合(把卷积、批归一化、激活合并成一个算子)、内存复用(根据张量生命周期复用缓冲区)。每一步优化都会产生一张新的数据流图,但外部看到的输入输出不变。最后经过代码生成,把图里的算子映射到 GPU/NPU 上的实际内核。
优化过程可以写成:
后厨里,把“调味”和“翻炒”合并到一个灶台,既省时间又少洗一个锅。编译器里的算子融合,就是为了少一次读内存、少一次 kernel 启动。
- 初始图 \(G_0\)框架前端刚构建出来的数据流图。
- 优化链 \(G_1\to\cdots\to G_k\)每一轮 pass 把图改写一次。
- 语义等价优化前后的执行函数对同样的输入产生同样结果。
6. 框架实现差异
不同餐厅管理流程图的方式不同。一家要求所有菜必须先写成标准流程图再开火;另一家允许厨师边做菜边记下新步骤。前者像 TensorFlow 的静态图,后者像 PyTorch 的动态图。
TensorFlow 早期默认先把模型构造成静态数据流图,再交给会话执行。静态图便于编译期优化,但调试不够直观。PyTorch 默认采用 eager 模式,每执行一行代码就动态构建一块图,灵活、好调试,但每次迭代都要重复建图,带来额外开销。
为了兼得二者,PyTorch 推出了 TorchScript 和 torch.fx,可以把动态模型“追踪”成静态数据流图;ONNX 则是一种通用图格式,让不同框架之间互转模型。动态图转静态图可看成:
后厨里,动态图是大厨自由的“边想边做”,静态图是标准化门店的“按卡出餐”。TorchScript 就像把大厨的即兴发挥拍成教学视频,之后每个学徒都能照做,还更容易算清楚要买多少食材。
- 模型函数 \(f_\theta\)带可学习参数 \(\theta\) 的神经网络。
- 追踪 \(\mathrm{Trace}\)让模型真实跑一次,记录经过的算子连成的图。
- 捕获输入 \(x_t\)用一批代表样本固定下数据流的“形状足迹”。
AI 应用场景
场景 1:AI 编译器的图优化 pass
阶段:图优化
解决的问题:没有数据流图时,每个算子都是独立的“孤岛”,一次推理要启动几十次内核,性能很差。有了图,编译器能统一看到算子间的依赖,把卷积、批归一化、ReLU 融合成一个 kernel。
具体案例:TensorRT 接收 ONNX 模型后,将计算图解析出来,执行 Conv+BatchNorm+ReLU 融合、精度校准等优化,再把最终图映射到 Tensor Core 指令。
场景 2:内存规划与复用
阶段:代码生成与内存调度
解决的问题:张量在哪个节点产生、在哪个节点后不再使用,都写在数据流图的边上。编译器可以利用边的生命周期做内存规划,避免每个算子都新开一块显存。
具体案例:TVM 在对模型做内存规划时,用数据流图计算张量的“活跃区间”,让不同时间使用的算子共享同一块缓冲区,从而把部署模型的峰值显存压到最低。
场景 3:动态图模型转静态图部署
阶段:模型转换与推理加速
解决的问题:PyTorch 动态图灵活但运行时开销大,部署到服务端时希望把模型固定成静态图,再进行算子融合和内存优化。
具体案例:使用 torch.jit.trace 或 torch.onnx.export 把 PyTorch 模型导出成静态数据流图,再交给 ONNX Runtime 或 TensorRT 执行,在保持精度接近不变的前提下显著降低延迟。
Python 演示
class DataFlowGraph: def __init__(self): self.nodes = {} self.nexts = {} self.prevs = {} def add_node(self, name, op_type): self.nodes[name] = op_type self.nexts[name] = [] self.prevs[name] = [] def add_edge(self, src, dst): self.nexts[src].append(dst) self.prevs[dst].append(src) def topo_sort(self): indegree = {name: 0 for name in self.nodes} for src in self.nexts: for dst in self.nexts[src]: indegree[dst] += 1 queue = [name for name in self.nodes if indegree[name] == 0] order = [] while queue: cur = queue.pop(0) order.append(cur) for dst in self.nexts[cur]: indegree[dst] -= 1 if indegree[dst] == 0: queue.append(dst) if len(order) != len(self.nodes): raise ValueError("cycle detected") return order def run_node(self, name, values): op = self.nodes[name] if op == "input" or op == "const": return values[name] if op == "mul": a = self.prevs[name][0] b = self.prevs[name][1] return values[a] * values[b] if op == "add": a = self.prevs[name][0] b = self.prevs[name][1] return values[a] + values[b] def execute(self, inputs): values = dict(inputs) for name in self.topo_sort(): if name not in values: values[name] = self.run_node(name, values) return values def main(): g = DataFlowGraph() g.add_node("x", "input") g.add_node("w", "const") g.add_node("b", "const") g.add_node("mul", "mul") g.add_node("add", "add") g.add_edge("x", "mul") g.add_edge("w", "mul") g.add_edge("mul", "add") g.add_edge("b", "add") result = g.execute({"x": 3.0, "w": 2.0, "b": 1.0}) print("mul =", result["mul"], "add =", result["add"]) if __name__ == "__main__": main()
g.add_node("x", "input")注册一个输入节点,相当于在流程图上画一个“备料”工序。g.add_edge("x", "mul")建立一条从 \(x\) 到 \(mul\) 的数据边,告诉编译器结果要流向乘法算子。indegree = {name: 0 for name in self.nodes}统计每个节点的前驱数量,前驱越少说明越可能先执行。queue = [name for name in self.nodes if indegree[name] == 0]没有前驱的节点可以放进“待执行队列”。order.append(cur)每处理一个节点,就是为它在流水线上安排好一个位置。for dst in self.nexts[cur]:当前节点完成以后,通知下游:它的前置依赖又少了一个。if len(order) != len(self.nodes):如果最终顺序装不下所有工序,说明流程图里存在环,不能执行。values[name] = self.run_node(name, values)按拓扑序真正执行算子,模拟数据在这张图上流动。
常见误区
误区我以为是“输入层、隐藏层、输出层”的示意图,数据流图就是每一层画的方块。
正解数据流图描述的是“算子 + 张量依赖”,不是抽象的层结构。一个卷积层可能在图中展开成卷积、批归一化、激活等多个算子节点。层是研究者的视角,数据流图是编译器的施工图。后厨流程图上画的是“切菜”“焯水”这些具体动作,不是“这一节叫水煮鱼”这种菜名。
为何常见:框架文档里经常用层接口描述模型,而数据流图是框架内部自动生成的,用户平时看不到,容易混淆。
误区PyTorch 是动态图,运行时不构建图,那 AI 编译器是不是就没办法优化它了?
正解PyTorch 的 eager 模式确实边执行边建图,但每个 Batch 仍会建立 Autograd 图;而且还有 TorchScript、torch.fx 可以把动态模型追踪成静态数据流图。只要得到静态图,编译器照样能做融合和内存优化。动态图是“边做菜边记菜谱”,记完以后同样可以拿菜谱去优化。
为何常见:很多人把“动态图”误解为“没有图”,实际上动态图是运行时暂存一张临时图,执行完释放,而不是不存在图。
误区RNN、循环神经网络看起来要循环,数据流图里应该也有环吧?
正解数据流图是 DAG,循环结构会被“展开/网络展开”成一段重复的节点序列,或者用控制节点表示循环条件。因为真实的张量依赖永远从生产者流向消费者,不可能让一个算子既等输入又产生自己的输入。后厨的工序可以循环使用同一个灶台,但不能让“炒菜”等待“炒菜”完成才拿到自己的下一批菜。
为何常见:看到模型定义里有 for 循环,就觉得计算图里有环,忽略了编译器会展开或引入控制边的处理方式。
误区数据流图里的边就是张量管道,只要数据到了就能算,没必要额外管顺序。
正解数据边确实携带张量,但控制边专门表达“先做什么、后做什么”。并且数据边本身也是一种依赖:下游必须等上游数据就绪才能开始。依赖关系才是编译器做调度、并行和内存规划的基础。
为何常见:初学者容易只看到“数据在流动”,忽略了边还定义了偏序关系。就像传送带不仅运菜,也决定了下一道工序不能提前开工。
面试题
Q1. 数据流图最基本的两个组成要素是什么?
- A.节点与有向边← 正确答案
- B.算子与优化器
- C.损失与回归指标
- D.输入与输出张量
答案:A。数据流图的本质就是节点集合 \(V\) 和边集合 \(E\)。节点表示算子/常量/变量,有向边表示张量流动与依赖关系。后厨流程图只需要“工序”和“传送带”两样东西就能画清楚。
Q2. 在数据流图中,边通常代表什么?
- A.张量数据的流向← 正确答案
- B.算子执行的时间
- C.神经网络的层数
- D.损失函数的大小
答案:A。边表示张量从生产者流向消费者,同时表达数据依赖。它不表示时间、层数或损失值。就像传送带上流动的是半成品菜,不是烹饪时间。
Q3. 以下哪个是数据流图中的算子节点?
- A.矩阵乘法算子← 正确答案
- B.学习率超参数
- C.验证集图片
- D.训练日志文本
答案:A。矩阵乘法是典型算子,会作为数据流图中的一个节点。学习率是优化器参数,验证集图片是输入数据,训练日志是外部输出,它们都不属于算子节点。
Q4. TensorFlow 传统上默认采用哪种计算图模式?
- A.静态数据流图← 正确答案
- B.动态数据流图
- C.混合概率图
- D.随机生成图
答案:A。TensorFlow 早期把模型先构建成静态图,再交给 Session 执行。静态图类似“先写好标准流程图再开店”,编译期优化空间大,但调试不直观。
Q5. PyTorch 的 autograd 反向传播依赖什么机制?
- A.运行期动态建图← 正确答案
- B.预编译梯度函数
- C.手工推导导数
- D.静态符号微分
答案:A。PyTorch eager 模式在每次前向执行时动态记录算子与张量关系,构成随用随建的计算图。它像厨师边做菜边记步骤,灵活但每次迭代都要重新建图。
Q6. 对数据流图做拓扑排序后,可保证什么性质?
- A.每条边的起点先于终点← 正确答案
- B.每个节点只执行一次
- C.输出节点一定最先执行
- D.图的节点数保持不变
答案:A。拓扑排序要求对所有边 \((v_i,v_j)\) 都满足 \(\mathrm{pos}(v_i)<\mathrm{pos}(v_j)\),从而保证数据依赖被满足。后厨排班表的核心就是“让切菜先于炒菜”。
Q7. 关于数据流图中的控制边,下列说法正确的是?
- A.只约束顺序不传数据← 正确答案
- B.只传梯度不传激活
- C.增加模型表达上限
- D.把循环展开成无环
答案:A。控制边不携带张量,只用来约束算子间的执行先后。它像后厨里的“对讲机”,只在节奏协调时起作用,不真正递菜。
Q8. 以下哪些信息是进行数据流图“形状推断”时需要知道的?
- A.输入张量的形状← 正确答案
- B.算子自身的规则← 正确答案
- C.训练时的随机种子
- D.输出张量的形状
答案:AB。形状推断从输入形状出发,依据算子的 shape 规则推出输出形状。随机种子和输出形状不需要在推断前已知。就像知道食材分量和切菜手法,就能预判每道菜装盘后的份量。
Q9. 以下哪些说法符合数据流图作为 AI 编译器中间表示的特点?
- A.给优化 pass 统一视图← 正确答案
- B.直接绑定特定硬件指令
- C.记录算子间数据依赖← 正确答案
- D.只在训练阶段被使用
答案:AC。数据流图是设备无关的中间表示,给上层优化提供统一视图,并记录依赖关系。它不是最终硬件指令,推理阶段同样需要它。
Q10. 与静态图相比,动态图通常有哪些特点?
- A.便于打印中间结果调试← 正确答案
- B.每次迭代重新建图开销← 正确答案
- C.编译期更易跨算子融合
- D.更容易全局复用内存
答案:AB。动态图边跑边建图,所以方便打印中间结果调试,但每次迭代都重建图,开销更大。跨算子融合和全局内存复用是静态图在编译期的优势。