2. 计算图基础:计算图的概念、节点与边、数据流图与控制流图
各位同学,今天我们来聊聊计算图。说实话,这个概念是算子融合的基石。你如果不理解计算图,后面讲的所有融合策略都会像空中楼阁。我当年刚接触编译器后端时,也花了不少时间才真正吃透它。
2.1 什么是计算图?
计算图,说白了就是用图的方式描述一个计算过程。它把复杂的数学运算拆解成一个个小步骤,每个步骤就是一个节点,节点之间的依赖关系就是边。
举个例子,假设我们要算 y = (a + b) * c。这个计算可以拆成两步:先加后乘。用计算图表示就是两个节点:一个加法节点,一个乘法节点。加法节点的输出连到乘法节点的输入。
核心要点:计算图是一种有向无环图(DAG)。每个节点代表一个操作,每条边代表数据的流动方向。
我个人习惯把计算图想象成工厂的流水线。原材料从入口进来,经过一道道工序,最终变成成品。每一道工序就是一个算子,流水线的走向就是数据流。
2.2 节点与边
节点和边是计算图的两个基本元素。咱们一个一个说。
2.2.1 节点(Node)
节点代表一个具体的计算操作。在深度学习框架里,节点可以是卷积、池化、激活函数、矩阵乘法等等。在编译器里,节点可以是加载、存储、算术运算、逻辑判断等等。
每个节点通常包含以下信息:
- 操作类型:比如 Conv2D、ReLU、Add
- 输入张量:操作需要的数据
- 输出张量:操作产生的结果
- 属性参数:比如卷积的 stride、padding
- 设备信息:这个节点跑在 CPU 还是 GPU 上
我的经验:在项目中,我经常发现新手会把节点和算子混为一谈。其实节点是图结构中的概念,算子是具体执行的计算单元。一个节点可以对应一个算子,也可以对应一组算子(融合后)。
2.2.2 边(Edge)
边代表数据依赖关系。如果节点 A 的输出是节点 B 的输入,那么从 A 到 B 就有一条有向边。
边有两种类型:
- 数据边:传递张量数据。这是最常见的边。
- 控制边:传递控制信号。比如 if-else 分支、循环控制。
我曾经在一个项目里踩过坑:两个节点之间明明没有数据依赖,但因为共享了同一个内存缓冲区,导致调度顺序出错。后来我才意识到,这种隐式的依赖关系也应该用边来表示,或者通过内存别名分析来处理。
2.3 数据流图与控制流图
这两个概念经常被放在一起比较。我简单说说它们的区别和联系。
2.3.1 数据流图(Data Flow Graph)
数据流图关注的是数据怎么流动。每个节点执行计算,数据沿着边从一个节点流到下一个节点。数据流图的特点是:
- 节点只要有输入数据就可以执行(数据驱动)
- 没有全局的程序计数器
- 天然支持并行执行
你想想看,在数据流图里,如果两个节点没有依赖关系,它们完全可以同时执行。这就是算子融合能带来性能提升的原因之一——融合后的算子减少了数据搬运,同时还能利用并行性。
2.3.2 控制流图(Control Flow Graph)
控制流图关注的是程序执行的顺序。它包含基本块(Basic Block)和跳转边。每个基本块是一段顺序执行的代码,跳转边表示条件分支或循环。
控制流图的特点是:
- 有明确的执行顺序
- 包含分支、循环等控制结构
- 通常用于编译器前端和中间表示
注意:在算子融合中,我们主要处理数据流图。但控制流图也很重要,因为很多模型包含条件分支(比如动态网络)或循环(比如 RNN)。融合时需要考虑控制流的影响。
2.4 计算图在编译器中的角色
在 Fusion Compiler 里,计算图是中间表示(IR)的一种形式。它位于前端解析和后端代码生成之间。前端把源代码(比如 Python 写的模型)转换成计算图,然后编译器在计算图上做各种优化,最后生成目标代码。
计算图的好处是:
- 直观:一眼就能看出计算流程
- 易优化:图算法成熟,可以做很多变换
- 平台无关:计算图本身不依赖具体硬件
嗯,这里要注意:计算图虽然好,但也不是万能的。比如动态形状(Dynamic Shape)问题,计算图就很难处理。因为图的结构在运行时才能确定。
2.5 一个简单的计算图示例
咱们来看一个具体的例子。假设有一个简单的神经网络层:
// 伪代码表示
input = placeholder(shape=[1, 3, 224, 224])
weight = placeholder(shape=[64, 3, 3, 3])
bias = placeholder(shape=[64])
conv = conv2d(input, weight, stride=2, padding=1)
bn = batch_norm(conv)
relu = relu(bn)
output = relu
对应的计算图如下:
这个图很直观吧?数据从 Input 节点流入,经过 Conv2D、BatchNorm、ReLU,最后输出。每个节点只依赖前一个节点的输出,没有分支,没有循环。这种线性结构是最容易做融合的。
我的建议:刚开始学习计算图时,可以手动画一些简单的图。把每个操作画成方框,把数据流动画成箭头。画着画着,你就对计算图的结构和优化空间有感觉了。
2.6 计算图的构建与遍历
在编译器里,计算图通常用邻接表或邻接矩阵存储。构建过程很简单:
- 为每个操作创建一个节点
- 根据数据依赖关系添加边
- 标记输入节点和输出节点
遍历计算图时,常用的算法有拓扑排序和深度优先搜索。拓扑排序可以确定节点的执行顺序,深度优先搜索可以找到从输入到输出的所有路径。
我记得有一次,我需要在一个包含 1000 多个节点的计算图上做融合。拓扑排序的结果直接影响融合策略的选择。如果排序不对,融合后的算子可能无法正确执行。
2.7 计算图的局限性
说了这么多好处,也得提提计算图的局限性。毕竟没有银弹。
- 静态图 vs 动态图:静态图在构建时就知道所有结构,优化空间大。动态图在运行时才能确定结构,灵活性高但优化难。
- 内存开销:大型计算图可能占用大量内存。每个节点和边都需要存储。
- 调试困难:计算图是中间表示,和源代码有差距。出了问题很难定位。
避坑指南:我曾经在一个项目中,计算图构建时没有正确处理控制依赖,导致融合后的算子在某些分支上执行了不该执行的操作。从那以后,我每次构建计算图都会仔细检查控制边的完整性。
2.8 小结
计算图是算子融合的基础。理解节点、边、数据流图和控制流图,是掌握后续融合策略的前提。我个人觉得,花时间把计算图搞透,绝对值。
下一节我们会深入讨论算子融合的具体策略。但在此之前,我建议你多画几个计算图,多想想哪些节点可以合并,哪些不能。这种直觉会在后面的学习中帮到你。
公众号:蓝海资料掘金营,微信deep3321