想象你走进一家现代化后厨,墙上挂着一张巨幅菜品流程图:洗菜、切菜、热锅、下料、翻炒、装盘,箭头把每一道工序连起来。餐厅经理不关心菜谱是哪位大厨写的,只看这张图就能判断哪些环节可以并行、哪些必须等待,甚至能把“翻炒”和“调味”合并成一个动作。AI 模型在框架里也藏着这样一张“菜品流程图”,计算机科学叫它数据流图:节点是算子,边是张量数据。AI 编译器拿到这张图后,就能像经理一样安排每次计算,这就是模型能被优化、加速、部署到芯片上的起点。 本文从零讲清数据流图的节点、边、构建、优化,以及 TensorFlow 与 PyTorch 的差异。读完后你会发现,后端编译器那些“高大上”的优化,本质上都是在给后厨流程图做减法、调顺序。

岗位关联

学懂数据流图,等于拿到 AI 编译器优化流水线的第一把钥匙;它能帮你把模型从“能跑”变成“跑得快”。

目录
  1. 可视化演示
  2. 1. 图的两大要素
  3. 2. 节点身份
  4. 3. 边与依赖
  5. 4. 构建与校验
  6. 5. 优化与代码生成
  7. 6. 框架实现差异
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示数据流图三维度:DAG结构(节点=算子Conv/BN/Relu/Add、边=张量数据流、拓扑排序确定执行顺序)、静态vs动态图(TF先构建后优化执行 vs PT边构建边执行eager模式)、算子融合(Conv+BN+Relu三算子融合为单个Fused算子减少内存读写),三模式切换理解AI编译器核心中间表示。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+C1+'

1. 图的两大要素

在现代化后厨里,一张流程图只要说清楚两件事:有哪些工序,以及半成品如何流向下一道工序。数据流图同样只关心两件事:节点和边。

节点表示计算动作,也就是算子,比如矩阵乘法、卷积、加法;边表示张量数据在算子之间的流动方向。前端框架把模型翻译成图时,先把每个算子放进节点,再把“谁的结果给谁用”记录成有向边。整张图就是一份“谁等谁、谁喂谁”的流水线说明。

因为数据只能从生产它的算子流到消费它的算子,不能反过来,所以数据流图通常是有向无环图(DAG)。写成数学形式:

回到后厨比喻:节点就是“切菜”“炒菜”这些灶台,边就是“把切好的菜递到炒锅旁”的传送带。只要把这两大要素画清楚,后面所有优化才能展开。

G=(V,E),V={v1,v2,…,vn},E={eij=(vi,vj)}\displaystyle G=(V,E),\quad V=\{v_1,v_2,\dots,v_n\},\quad E=\{e_{ij}=(v_i,v_j)\}
这张图的意思是:数据流图由节点集合 \(V\) 和有向边集合 \(E\) 组成,边 \(e_{ij}\) 表示从节点 \(v_i\) 指向节点 \(v_j\)。
  1. 节点集合 \(V\)图中所有算子/计算的集合,每个 \(v_i\) 对应一个工序。
    V={v1,v2,…,vn}\displaystyle V=\{v_1,v_2,\dots,v_n\}
  2. 边集合 \(E\)图中所有有向连接的集合,\(e_{ij}\) 表示从 \(v_i\) 到 \(v_j\) 的数据流动。
    E={eij=(vi,vj)}\displaystyle E=\{e_{ij}=(v_i,v_j)\}
  3. 有向性边从生产者指向消费者,方向不能随意颠倒。
    eij≠eji\displaystyle e_{ij}\neq e_{ji}

2. 节点身份

流程图上每个工序有不同的职责:备料、切菜、炒菜、装盘;数据流图上的节点也分身份。节点类型是编译器理解“这一步能不能被替换、能不能合并”的重要依据。

最常见的节点是算子节点,它描述计算本身,例如矩阵乘法、卷积、激活函数。其次有常量节点和变量节点:常量保存固定权重,变量保存训练中会更新的参数。还有输入/输出节点,负责和外部交换数据。部分框架还有控制节点,用来表达分支、循环等流程控制。

可以把节点类型看成一个标签函数:

在后厨里,备料、切菜、炒菜的“身份”决定了谁可以提前做、谁必须等。数据流图中的节点身份也决定了编译器能对它们做哪类优化。

τ(vi)∈{OP,CONST,VAR,IO,CTRL}\displaystyle \tau(v_i)\in\{\mathrm{OP},\mathrm{CONST},\mathrm{VAR},\mathrm{IO},\mathrm{CTRL}\}
意思是:每个节点都会被标记成五种身份之一:算子、常量、变量、输入输出或控制节点。
  1. 函数 \(\tau\)它给每个节点 \(v_i\) 打上一个类型标签。
    τ(vi)\displaystyle \tau(v_i)
  2. 算子节点 \(\mathrm{OP}\)执行真正的数学运算,比如乘、加、卷积。
    OP={MatMul,Conv2D,Add,… }\displaystyle \mathrm{OP}=\{\text{MatMul},\text{Conv2D},\text{Add},\dots\}
  3. 常量与变量 \(\mathrm{CONST}/\mathrm{VAR}\)常量不可训练,变量会在反向传播中被更新。
    w∈VAR,c∈CONST\displaystyle w\in\mathrm{VAR},\quad c\in\mathrm{CONST}
  4. 输入输出与控制 \(\mathrm{IO}/\mathrm{CTRL}\)输入输出对应“上菜口”,控制节点决定流程什么时候开火。
    IO={Placeholder,Output}\displaystyle \mathrm{IO}=\{\text{Placeholder},\text{Output}\}

3. 边与依赖

传送带不只会运菜,还能告诉工人:必须先放好料,下一步才能开工。数据流图中的边,正是这种“依赖关系”的载体。

边分为两类。数据边携带张量,把上游算子的输出传给下游算子;控制边不携带张量,只约束执行先后顺序,比如“必须先初始化显存,再运行卷积”。有了边,整个图形成偏序关系,编译器才能确定执行计划。

形状传播也沿着边进行:输入张量形状一路往前推断,得到每个中间算子的输出形状。为了让所有依赖都有先来后到,我们需要拓扑排序:

后厨的传送带严格规定了“先切菜,再下锅”。拓扑排序就是给每一位厨师一张排班表,保证每道菜都不会在材料没到齐时提前开工。

∀(vi,vj)∈E,pos(vi)<pos(vj)\displaystyle \forall (v_i,v_j)\in E,\quad \mathrm{pos}(v_i)<\mathrm{pos}(v_j)
这句话的意思是:只要有一条从 \(v_i\) 到 \(v_j\) 的边,那么在拓扑顺序里 \(v_i\) 的位置必须排在 \(v_j\) 前面。
  1. 边集约束遍历所有有向边。
    (vi,vj)∈E\displaystyle (v_i,v_j)\in E
  2. 位置函数 \(\mathrm{pos}\)表示节点在拓扑排序中的序号。
    pos(vi)\displaystyle \mathrm{pos}(v_i)
  3. 严格小于生产者必须先执行,消费者必须后执行。
    pos(vi)<pos(vj)\displaystyle \mathrm{pos}(v_i)<\mathrm{pos}(v_j)

4. 构建与校验

主厨拿到菜谱后会先画成工序图,画完还要检查有没有漏掉前序步骤。编译器前端做的是同一件事:把框架里的模型定义翻译成数据流图。

构建过程大致是:前端解析用户代码 → 遇到算子就创建节点 → 把算子间数据传递记录成边 → 补充输入输出节点。TensorFlow 早期通过 Python API 直接构建 GraphDef;PyTorch 的 eager 模式则在运行时由 autograd 逐步记录。无论哪种方式,建图完成后都要做两项校验:拓扑排序查环和静态形状校验。

一个数据流图是否合法,关键看它是不是 DAG。写成逻辑等价关系:

如果后厨流程图中出现了“炒菜要等装盘,装盘又要等炒菜”的死循环,这顿饭永远做不完。拓扑排序就是那个及时喊停的质检员。

DAG(G)  ⟺  ∃ 拓扑排序 π, ∀(vi,vj)∈E, π(vi)<π(vj)\displaystyle \mathrm{DAG}(G)\iff \exists\ \text{拓扑排序}\ \pi,\ \forall (v_i,v_j)\in E,\ \pi(v_i)<\pi(v_j)
意思就是:图是有向无环图,当且仅当存在一个合法拓扑排序,让所有边都从前面指向后面。
  1. 前件 \(\mathrm{DAG}(G)\)表示图 \(G\) 没有环。
    DAG(G)\displaystyle \mathrm{DAG}(G)
  2. 存在量词 \(\exists\)至少能找到一种执行顺序。
    ∃ π\displaystyle \exists\ \pi
  3. 全称量词 \(\forall\)对于每一条边,起点都要排在终点前。
    ∀(vi,vj)∈E, π(vi)<π(vj)\displaystyle \forall (v_i,v_j)\in E,\ \pi(v_i)<\pi(v_j)

5. 优化与代码生成

厨师长拿到流程图后会合并工序、去掉空转等待。AI 编译器基于数据流图做一系列 pass(优化遍),让模型跑得更快、占内存更少。

常见的图优化包括:常量折叠(先算好固定结果)、公共子表达式消除(相同计算只做一次)、算子融合(把卷积、批归一化、激活合并成一个算子)、内存复用(根据张量生命周期复用缓冲区)。每一步优化都会产生一张新的数据流图,但外部看到的输入输出不变。最后经过代码生成,把图里的算子映射到 GPU/NPU 上的实际内核。

优化过程可以写成:

后厨里,把“调味”和“翻炒”合并到一个灶台,既省时间又少洗一个锅。编译器里的算子融合,就是为了少一次读内存、少一次 kernel 启动。

G0→G1→⋯→Gk,ExecGk(x)=ExecG0(x)\displaystyle G_0\to G_1\to\cdots\to G_k,\quad \mathrm{Exec}_{G_k}(x)=\mathrm{Exec}_{G_0}(x)
意思是:图从初始版本 \(G_0\) 不断变成新版本 \(G_k\),但同一个输入 \(x\) 在优化前后必须得到相同输出。
  1. 初始图 \(G_0\)框架前端刚构建出来的数据流图。
    G0\displaystyle G_0
  2. 优化链 \(G_1\to\cdots\to G_k\)每一轮 pass 把图改写一次。
    Gi→Gi+1\displaystyle G_i\to G_{i+1}
  3. 语义等价优化前后的执行函数对同样的输入产生同样结果。
    ExecGk(x)=ExecG0(x)\displaystyle \mathrm{Exec}_{G_k}(x)=\mathrm{Exec}_{G_0}(x)

6. 框架实现差异

不同餐厅管理流程图的方式不同。一家要求所有菜必须先写成标准流程图再开火;另一家允许厨师边做菜边记下新步骤。前者像 TensorFlow 的静态图,后者像 PyTorch 的动态图。

TensorFlow 早期默认先把模型构造成静态数据流图,再交给会话执行。静态图便于编译期优化,但调试不够直观。PyTorch 默认采用 eager 模式,每执行一行代码就动态构建一块图,灵活、好调试,但每次迭代都要重复建图,带来额外开销。

为了兼得二者,PyTorch 推出了 TorchScript 和 torch.fx,可以把动态模型“追踪”成静态数据流图;ONNX 则是一种通用图格式,让不同框架之间互转模型。动态图转静态图可看成:

后厨里,动态图是大厨自由的“边想边做”,静态图是标准化门店的“按卡出餐”。TorchScript 就像把大厨的即兴发挥拍成教学视频,之后每个学徒都能照做,还更容易算清楚要买多少食材。

Gt=Trace(fθ)(xt)\displaystyle G_t=\mathrm{Trace}(f_\theta)(x_t)
意思是:给一个带参数 \(\theta\) 的模型 \(f_\theta\) 传入真实样本 \(x_t\),沿着实际执行路径记录算子,得到当前这张静态图 \(G_t\)。
  1. 模型函数 \(f_\theta\)带可学习参数 \(\theta\) 的神经网络。
    fθ(x)\displaystyle f_\theta(x)
  2. 追踪 \(\mathrm{Trace}\)让模型真实跑一次,记录经过的算子连成的图。
    Trace(fθ)\displaystyle \mathrm{Trace}(f_\theta)
  3. 捕获输入 \(x_t\)用一批代表样本固定下数据流的“形状足迹”。
    Gt=Trace(fθ)(xt)\displaystyle G_t=\mathrm{Trace}(f_\theta)(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 演示

python71 行
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()
目的:用不到 80 行代码实现一个迷你数据流图:注册节点、连接边、拓扑排序、按拓扑序执行算子,输出 \( (3 \times 2) + 1 = 7 \)。
  1. g.add_node("x", "input")注册一个输入节点,相当于在流程图上画一个“备料”工序。
  2. g.add_edge("x", "mul")建立一条从 \(x\) 到 \(mul\) 的数据边,告诉编译器结果要流向乘法算子。
  3. indegree = {name: 0 for name in self.nodes}统计每个节点的前驱数量,前驱越少说明越可能先执行。
  4. queue = [name for name in self.nodes if indegree[name] == 0]没有前驱的节点可以放进“待执行队列”。
  5. order.append(cur)每处理一个节点,就是为它在流水线上安排好一个位置。
  6. for dst in self.nexts[cur]:当前节点完成以后,通知下游:它的前置依赖又少了一个。
  7. if len(order) != len(self.nodes):如果最终顺序装不下所有工序,说明流程图里存在环,不能执行。
  8. values[name] = self.run_node(name, values)按拓扑序真正执行算子,模拟数据在这张图上流动。

常见误区

误区我以为是“输入层、隐藏层、输出层”的示意图,数据流图就是每一层画的方块。

正解数据流图描述的是“算子 + 张量依赖”,不是抽象的层结构。一个卷积层可能在图中展开成卷积、批归一化、激活等多个算子节点。层是研究者的视角,数据流图是编译器的施工图。后厨流程图上画的是“切菜”“焯水”这些具体动作,不是“这一节叫水煮鱼”这种菜名。

为何常见:框架文档里经常用层接口描述模型,而数据流图是框架内部自动生成的,用户平时看不到,容易混淆。

误区PyTorch 是动态图,运行时不构建图,那 AI 编译器是不是就没办法优化它了?

正解PyTorch 的 eager 模式确实边执行边建图,但每个 Batch 仍会建立 Autograd 图;而且还有 TorchScript、torch.fx 可以把动态模型追踪成静态数据流图。只要得到静态图,编译器照样能做融合和内存优化。动态图是“边做菜边记菜谱”,记完以后同样可以拿菜谱去优化。

为何常见:很多人把“动态图”误解为“没有图”,实际上动态图是运行时暂存一张临时图,执行完释放,而不是不存在图。

误区RNN、循环神经网络看起来要循环,数据流图里应该也有环吧?

正解数据流图是 DAG,循环结构会被“展开/网络展开”成一段重复的节点序列,或者用控制节点表示循环条件。因为真实的张量依赖永远从生产者流向消费者,不可能让一个算子既等输入又产生自己的输入。后厨的工序可以循环使用同一个灶台,但不能让“炒菜”等待“炒菜”完成才拿到自己的下一批菜。

为何常见:看到模型定义里有 for 循环,就觉得计算图里有环,忽略了编译器会展开或引入控制边的处理方式。

误区数据流图里的边就是张量管道,只要数据到了就能算,没必要额外管顺序。

正解数据边确实携带张量,但控制边专门表达“先做什么、后做什么”。并且数据边本身也是一种依赖:下游必须等上游数据就绪才能开始。依赖关系才是编译器做调度、并行和内存规划的基础。

为何常见:初学者容易只看到“数据在流动”,忽略了边还定义了偏序关系。就像传送带不仅运菜,也决定了下一道工序不能提前开工。

面试题

Q1. 数据流图最基本的两个组成要素是什么?

单选题 · easy

  • A.节点与有向边← 正确答案
  • B.算子与优化器
  • C.损失与回归指标
  • D.输入与输出张量

答案:A。数据流图的本质就是节点集合 \(V\) 和边集合 \(E\)。节点表示算子/常量/变量,有向边表示张量流动与依赖关系。后厨流程图只需要“工序”和“传送带”两样东西就能画清楚。

Q2. 在数据流图中,边通常代表什么?

单选题 · easy

  • A.张量数据的流向← 正确答案
  • B.算子执行的时间
  • C.神经网络的层数
  • D.损失函数的大小

答案:A。边表示张量从生产者流向消费者,同时表达数据依赖。它不表示时间、层数或损失值。就像传送带上流动的是半成品菜,不是烹饪时间。

Q3. 以下哪个是数据流图中的算子节点?

单选题 · easy

  • A.矩阵乘法算子← 正确答案
  • B.学习率超参数
  • C.验证集图片
  • D.训练日志文本

答案:A。矩阵乘法是典型算子,会作为数据流图中的一个节点。学习率是优化器参数,验证集图片是输入数据,训练日志是外部输出,它们都不属于算子节点。

Q4. TensorFlow 传统上默认采用哪种计算图模式?

单选题 · mid

  • A.静态数据流图← 正确答案
  • B.动态数据流图
  • C.混合概率图
  • D.随机生成图

答案:A。TensorFlow 早期把模型先构建成静态图,再交给 Session 执行。静态图类似“先写好标准流程图再开店”,编译期优化空间大,但调试不直观。

Q5. PyTorch 的 autograd 反向传播依赖什么机制?

单选题 · mid

  • A.运行期动态建图← 正确答案
  • B.预编译梯度函数
  • C.手工推导导数
  • D.静态符号微分

答案:A。PyTorch eager 模式在每次前向执行时动态记录算子与张量关系,构成随用随建的计算图。它像厨师边做菜边记步骤,灵活但每次迭代都要重新建图。

Q6. 对数据流图做拓扑排序后,可保证什么性质?

单选题 · mid

  • A.每条边的起点先于终点← 正确答案
  • B.每个节点只执行一次
  • C.输出节点一定最先执行
  • D.图的节点数保持不变

答案:A。拓扑排序要求对所有边 \((v_i,v_j)\) 都满足 \(\mathrm{pos}(v_i)<\mathrm{pos}(v_j)\),从而保证数据依赖被满足。后厨排班表的核心就是“让切菜先于炒菜”。

Q7. 关于数据流图中的控制边,下列说法正确的是?

单选题 · hard

  • A.只约束顺序不传数据← 正确答案
  • B.只传梯度不传激活
  • C.增加模型表达上限
  • D.把循环展开成无环

答案:A。控制边不携带张量,只用来约束算子间的执行先后。它像后厨里的“对讲机”,只在节奏协调时起作用,不真正递菜。

Q8. 以下哪些信息是进行数据流图“形状推断”时需要知道的?

多选题 · mid

  • A.输入张量的形状← 正确答案
  • B.算子自身的规则← 正确答案
  • C.训练时的随机种子
  • D.输出张量的形状

答案:AB。形状推断从输入形状出发,依据算子的 shape 规则推出输出形状。随机种子和输出形状不需要在推断前已知。就像知道食材分量和切菜手法,就能预判每道菜装盘后的份量。

Q9. 以下哪些说法符合数据流图作为 AI 编译器中间表示的特点?

多选题 · hard

  • A.给优化 pass 统一视图← 正确答案
  • B.直接绑定特定硬件指令
  • C.记录算子间数据依赖← 正确答案
  • D.只在训练阶段被使用

答案:AC。数据流图是设备无关的中间表示,给上层优化提供统一视图,并记录依赖关系。它不是最终硬件指令,推理阶段同样需要它。

Q10. 与静态图相比,动态图通常有哪些特点?

多选题 · extreme

  • A.便于打印中间结果调试← 正确答案
  • B.每次迭代重新建图开销← 正确答案
  • C.编译期更易跨算子融合
  • D.更容易全局复用内存

答案:AB。动态图边跑边建图,所以方便打印中间结果调试,但每次迭代都重建图,开销更大。跨算子融合和全局内存复用是静态图在编译期的优势。