计算图与算子:深度学习的基础构件

说实话,我刚入行那会儿,对计算图和算子这两个概念的理解特别模糊。总觉得框架都封装好了,我写个 conv2d 就完事了,管它底层怎么跑。直到有一次,我手写了一个自定义算子,结果反向传播死活不对,调试了整整两天...嗯,从那以后我才真正重视起这些基础概念。

计算图是什么?

计算图,说白了就是描述计算过程的有向无环图。每个节点代表一个操作(算子),每条边代表数据的流动(张量)。

我习惯把计算图想象成一张「菜谱」:

  • 节点:就是每个烹饪步骤,比如「切菜」、「翻炒」
  • 边:就是食材在步骤之间传递,比如「切好的土豆 → 下锅」

深度学习框架(PyTorch、TensorFlow)就是用这种方式来组织你的模型计算的。你写的每一行代码,框架都会默默构建一张计算图。

核心要点:计算图有两种模式——静态图和动态图。

  • 静态图:先定义后执行(TensorFlow 1.x)。好处是优化空间大,但调试麻烦。
  • 动态图:边定义边执行(PyTorch)。灵活,调试方便,我更喜欢这种。

我个人习惯用动态图,因为开发效率高。但如果你做推理部署,静态图往往能压榨出更多性能。

算子(Operator)的定义

算子就是计算图里的节点。每个算子完成一个特定的数学运算。

举个例子:

# 这是一个简单的计算图
# 算子:add(加法)
c = a + b

# 算子:mul(乘法)
d = c * e

# 算子:relu(激活函数)
f = relu(d)

这里 add、mul、relu 都是算子。每个算子都有:

  • 输入:一个或多个张量
  • 输出:一个或多个张量
  • 属性:比如卷积的 stride、padding

我的经验:写算子时,一定要搞清楚输入输出的形状变化。我曾经因为一个 reshape 搞错了维度,导致整个模型训练 loss 直接炸了。排查了三个小时才发现是算子实现里少了一个转置。

前向与反向传播中的算子

这里有个关键点:每个算子其实对应两个函数——前向和反向。

前向传播:计算输出。比如卷积层,输入特征图,输出特征图。

反向传播:计算梯度。根据损失函数对输出的梯度,算出对输入的梯度,以及对参数(权重)的梯度。

你想想看,为什么框架能自动求梯度?就是因为每个算子都实现了自己的反向函数。框架沿着计算图反向走一遍,调用每个算子的反向函数,就能算出所有参数的梯度。

# 以矩阵乘法为例
# 前向:C = A @ B
# 反向:
#   dA = dC @ B.T
#   dB = A.T @ dC

注意:反向传播时,中间结果需要保存!比如上面的 A 和 B,反向计算梯度时需要用到。这就是为什么训练时显存占用比推理大得多——框架要缓存中间激活值。

我记得有一次优化一个 Transformer 模型,发现显存爆了。排查后发现是某个算子的反向实现里,缓存了一个巨大的中间张量。后来我手动重写了这个算子的反向逻辑,用重计算(recomputation)换显存,才把模型跑起来。

常见算子类型

咱们来看看深度学习里最常见的几种算子。这些你天天用,但你真的了解它们的计算模式吗?

1. Conv(卷积)

卷积是 CNN 的核心算子。它的计算模式是「滑动窗口 + 点积」。

我刚开始优化卷积时,直接写了个三重循环,结果慢得离谱。后来才知道,卷积可以转化为矩阵乘法(im2col + GEMM),或者用 Winograd 算法加速。

# 卷积的 naive 实现(千万别这么写!)
for n in range(N):
    for c in range(C_out):
        for h in range(H_out):
            for w in range(W_out):
                for kc in range(C_in):
                    for kh in range(K):
                        for kw in range(K):
                            output[n,c,h,w] += 
                                input[n,kc,h+kh,w+kw] * weight[c,kc,kh,kw]

优化思路:

  • im2col + GEMM:把卷积展开成矩阵乘法,利用 cuBLAS 加速
  • Winograd:减少乘法次数,适合小卷积核(3x3)
  • FFT:适合大卷积核

2. MatMul(矩阵乘法)

MatMul 是深度学习的基础操作。全连接层、注意力机制、甚至卷积优化都离不开它。

矩阵乘法的计算量是 O(n³),是典型的计算密集型算子。优化 MatMul 的关键在于:

  • 利用 GPU 的共享内存(tiling)
  • 内存对齐,避免 bank conflict
  • 使用 Tensor Core(如果硬件支持)

避坑指南:我曾经在写 MatMul 的 CUDA 实现时,忽略了矩阵的布局(行主序 vs 列主序)。结果算出来的结果全是错的。后来我养成了一个习惯——每次写矩阵运算前,先确认数据的内存布局。

3. Softmax

Softmax 常用于分类任务的输出层。它的计算很简单:

softmax(x_i) = exp(x_i) / sum(exp(x_j))

但这里有个数值稳定性问题。如果 x_i 很大,exp(x_i) 会溢出。所以实际实现中,我们会先减去最大值:

def softmax(x):
    # 减去最大值,防止溢出
    x_max = max(x)
    exp_x = [exp(v - x_max) for v in x]
    sum_exp = sum(exp_x)
    return [v / sum_exp for v in exp_x]

嗯,这里要注意:Softmax 的反向传播也很经典。它的梯度形式是:

dL/dx_i = s_i * (dL/ds_i - sum(dL/ds_j * s_j))

其中 s_i 是 softmax 的输出。这个公式在实现时,可以复用前向的结果,减少计算量。

4. 其他常见算子

算子类型 计算特点 优化要点
ReLU / GELU 逐元素操作,计算量小 内存带宽瓶颈,注意访存模式
BatchNorm / LayerNorm 需要计算均值和方差 减少全局同步,使用 Welford 算法
Embedding 查表操作 注意 embedding 矩阵的访存局部性
Attention QKV 计算 + Softmax + 加权求和 Flash Attention 优化 IO

知识体系总览

下面这张图,是我自己总结的计算图与算子的知识体系。你可以把它当作一个「思维导图」来看:

计算图与算子 计算图 静态图 动态图 自动微分 算子定义 输入/输出 属性参数 前向/反向 前向与反向传播 前向计算 梯度计算 中间缓存 常见算子类型 Conv(卷积) MatMul(矩阵乘) Softmax ReLU / GELU Attention 理解算子 = 掌握深度学习性能优化的钥匙

这张图把本章的核心内容串起来了。从计算图的两种模式,到算子的定义和前后向传播,再到常见的算子类型。你每次写算子优化时,都可以回头看看这张图,确认自己当前在哪个环节。

总结一下:

  • 计算图是框架组织计算的方式,分静态图和动态图
  • 每个算子都有前向和反向两个函数
  • 反向传播需要缓存中间结果,这是显存优化的关键
  • 不同算子的计算模式和优化策略差异很大

好了,这一章的内容就到这里。记住,理解算子的底层实现,是做好算子优化的第一步。下一章我们会深入具体的优化技巧,到时候见。

可可精品资料库