计算图基础:从概念到执行

各位同学,今天我们来聊聊计算图。这东西听起来有点抽象,但说白了,它就是深度学习框架的「骨架」。我刚开始接触推理引擎时,总觉得计算图是个理论概念,直到有一次在优化一个模型时,发现算子顺序调一下,性能差了30%——嗯,从那以后我再也不敢小看它了。

一、计算图是什么?

计算图,本质上是一个有向图。节点代表操作(比如卷积、ReLU、矩阵乘法),边代表数据流动(也就是张量)。你想想看,一个神经网络前向传播的过程,其实就是数据沿着这些边,从一个节点流向下一个节点。

核心要点:计算图将复杂的数学运算拆解成一系列原子操作,每个操作都可以独立优化和执行。

举个例子,一个简单的表达式 z = (x + y) * w,在计算图中会被拆成两个节点:一个加法节点和一个乘法节点。数据从 x、y 流入加法节点,结果再和 w 一起流入乘法节点,最终输出 z。

// 伪代码表示
Node add = Add(x, y);
Node mul = Mul(add, w);

我在项目中遇到过一个问题:有些框架会把多个小操作合并成一个「融合算子」,比如把 Conv + BN + ReLU 合并成一个节点。这样做的好处是减少内存读写,提升推理速度。但代价是计算图变得不那么「透明」,调试起来更麻烦。

二、静态图 vs 动态图

这是面试高频题,也是实际工程中必须做的选择。我个人的习惯是:训练阶段用动态图,部署阶段用静态图。为什么?

特性 静态图 动态图
定义方式 先构建图,再执行 边执行边构建
灵活性 低(图结构固定) 高(可动态改变)
性能 高(可全局优化) 较低(每次重新构建)
调试难度 高(错误定位困难) 低(逐行执行)
典型框架 TensorFlow 1.x, ONNX PyTorch, TensorFlow Eager

静态图就像写一个完整的剧本,演员按剧本演。动态图则像即兴表演,演到哪算哪。你想想看,静态图能提前知道整个计算流程,所以可以做很多优化:内存复用、算子融合、执行顺序调整。而动态图每次执行都可能不同,优化空间就小很多。

个人经验:我曾经把一个 PyTorch 模型转成 ONNX(静态图)部署,推理速度提升了 2.3 倍。原因就是静态图允许我们把多个小算子合并,减少了大量的 kernel launch 开销。

但动态图也有它的优势。我记得有一次调试一个复杂的 Transformer 模型,如果用的是静态图,光定位一个维度不对的错误就得花半天。动态图直接打印中间结果,几分钟就搞定了。

三、计算图的拓扑排序

有了计算图,怎么执行它?答案就是拓扑排序。说白了,就是给所有节点排个序,保证每个节点执行时,它的所有输入都已经准备好了。

拓扑排序的算法很简单:

  1. 找到所有入度为 0 的节点(没有依赖的节点)
  2. 把这些节点加入执行队列
  3. 移除这些节点,更新剩余节点的入度
  4. 重复直到所有节点都被处理
// 拓扑排序伪代码
vector<Node*> topological_sort(Graph& graph) {
    vector<Node*> result;
    queue<Node*> q;
    
    // 找到所有入度为0的节点
    for (auto& node : graph.nodes) {
        if (node.in_degree == 0) {
            q.push(node);
        }
    }
    
    while (!q.empty()) {
        Node* cur = q.front(); q.pop();
        result.push_back(cur);
        
        for (auto& next : cur.outputs) {
            next.in_degree--;
            if (next.in_degree == 0) {
                q.push(next);
            }
        }
    }
    
    return result;
}

注意:如果图中存在环,拓扑排序就无法完成。这在推理引擎中是个严重错误,意味着计算图有循环依赖。我曾经在解析一个 ONNX 模型时遇到过这种情况,最后发现是模型导出时出了问题。

四、计算图的执行

有了拓扑排序后的执行顺序,接下来就是真正的计算了。这里有几个关键点:

  • 内存分配:每个节点的输出需要分配内存。静态图可以提前计算好最大内存需求,一次性分配。
  • 算子调度:按拓扑顺序依次调用每个算子的 kernel 函数。
  • 同步与异步:GPU 执行时,要注意同步点。有些算子可以异步执行,提升吞吐量。

我建议你在实现推理引擎时,把执行器设计成「流水线」模式。什么意思呢?就是内存分配、数据加载、kernel 执行可以重叠进行。比如 GPU 在执行第 3 个算子时,CPU 已经在准备第 4 个算子的数据了。

// 简化的执行器伪代码
class Executor {
    void run(Graph& graph) {
        auto order = topological_sort(graph);
        MemoryPool pool(graph.get_max_memory());
        
        for (auto* node : order) {
            // 分配输出内存
            auto* output = pool.allocate(node->output_size);
            
            // 执行算子
            node->kernel(node->inputs, output);
            
            // 释放不再需要的输入
            for (auto* input : node->inputs) {
                if (input->ref_count-- == 0) {
                    pool.deallocate(input);
                }
            }
        }
    }
};

避坑指南:我曾经在实现内存池时,忘记考虑「内存碎片」问题。结果模型跑着跑着就 OOM 了,但实际内存使用量只有总容量的 60%。后来加了个内存整理机制才解决。

五、知识体系总览

下面这张图总结了计算图的核心知识体系,你可以把它当作本章的「思维导图」:

计算图 基本概念 节点 = 操作,边 = 数据流 静态图 vs 动态图 静态:先构建再执行 拓扑排序 入度为0 → 执行 → 更新 执行引擎 内存池 + 算子调度

这张图把计算图的四个核心维度串起来了:概念是基础,静态/动态图是设计选择,拓扑排序是执行前提,执行引擎是落地实现。每个维度都有它的坑和技巧,后面我们会逐一深入。

一句话总结:计算图就是深度学习框架的「中间表示」,它把模型从「数学描述」变成了「可执行的指令序列」。理解它,你才能真正掌握推理引擎的优化之道。


公众号:蓝海资料掘金营,微信deep3321