我最近在整理一份关于LLM训练中FLOPs计算的笔记,前阵子发完第一篇,群里就有朋友来问:你直接给了全连接层前向的计算量公式,但真要自己估算Transformer每一层的计算量,还是容易卡壳,尤其是反向传播里的dX、dW到底怎么流动,完全看不懂。说实话,我自己当初也在这个点上绕了很久。今天就把这个最容易被忽略、却又贯穿整个训练过程的知识点掰开揉碎:反向传播中的dX到底代表什么,它怎么从最后一层沿着计算图往回传,以及标题里这个公式 dX = ∂L/∂X = ∂L/∂Y · ∂Y/∂X = GWᵀ 是怎么来的。这篇文章适合正在学LLM训练基础、想自己手推梯度、或者想搞清楚训练FLOPs估算的朋友,看完之后你至少能独立写出一个线性层的反向传播公式,并且知道它在整个训练里占了多少算力。
1. 先搞清楚dX是什么:反向传播里的“快递员”
1.1 为什么算FLOPs要先聊反向传播
我们平时说训练一个LLM要多少算力,一般都用FLOPs(浮点运算次数)来衡量。但很多教程直接给结论:训练总计算量大约是前向传播的3倍,或者反向传播的计算量是前向的2倍。这个“2倍”是从哪来的?如果你不知道反向传播里到底算了哪些矩阵乘法,这个数字对你来说就是空中楼阁,换个模型结构就不会估了。
实际训练过程分两步。前向传播把输入X经过一层层矩阵运算变成输出,最后算损失L;反向传播则从损失L出发,逐层把梯度传回每一层的参数(权重W和偏置b),然后更新这些参数。关键在于,反向传播不是凭空出现的,它要基于前向传播的中间结果做新的矩阵运算。
以最简单的线性层 Y = XW + b 为例,前向只需要一次矩阵乘法 XW。反向传播就要算两个梯度:dX 和 dW,它们各需要一次矩阵乘法。所以单看这个线性层,反向的计算量就是前向的2倍,加上前向自己,总共是3倍。这就是“训练是前向3倍”说法的来源。Transformer就是一堆矩阵乘法叠起来的,理解了这个,整个训练FLOPs估算的底子也就打牢了。
1.2 dX在计算图里的定位
dX严格写出来是 ∂L/∂X,表示损失L对当前层输入X的偏导。它回答的问题很具体:如果X里的某个元素稍微变大一丁点,最终的损失L会变大还是变小、变化量大概是多少。注意,这里X不是模型参数,它是上一层输出的结果。
那为什么需要它?因为当前层算出来的dX,要当作上游梯度传给前一层。前一层在更新自己的权重时,需要知道“我的输出对损失有多敏感”,而当前层的dX恰好在说这件事:前一层把输出交给当前层,当前层把这个输出对损失的敏感度算出来,然后递回去。所以dX在多层网络里承担的是中转站的职责。
可以类比成快递派送。损失L是最终收货人,每一层是一个中转站。前向传播是“包裹”(数据X)从第一站送到最后一站;反向传播是“签收信息”(梯度)从最后一站往回传。dX就是当前层递给上一层的信息包,上一层的权重梯度dW则依赖这个信息包才能计算出来。没有这个“信息包”,前面的层根本不知道自己该往哪个方向更新。
2. 手推核心公式:dX = ∂L/∂X = GWᵀ
2.1 先统一符号和形状
很多人在公式推导上摔跤,是因为不同教程的符号约定不一样,矩阵乘法方向也不同。我这里统一用更常见的约定:
- 输入 X 形状为 (N, D_in),N是batch size,D_in是输入维度。
- 权重 W 形状为 (D_in, D_out)。
- 偏置 b 形状为 (D_out,)。
- 输出 Y 形状为 (N, D_out),且 Y = XW + b。
- G = ∂L/∂Y,形状与Y一致,也是 (N, D_out)。
注意,这里的G通常被称为上游梯度。在PyTorch这类框架里,反向传播时每个张量对象上有一个grad字段,那个就是G。很多新手一上来就找dW,却忽略了这个G的存在,但G恰恰是连接损失和每一层的枢纽。
2.2 链式法则拆解:从L到X的路径
从X到L的路径可以写成:X → Y → L。链式法则告诉我们:
dX = ∂L/∂X = ∂L/∂Y · ∂Y/∂X = G · ∂Y/∂X
问题是,∂Y/∂X不是一个简单的矩阵。Y的每个元素都是X所有元素的函数,所以完整的∂Y/∂X是一个四维张量。理论推导很严谨,但对初学者不友好。我习惯从带下标的分量形式去理解,反而直观很多。
假设我们要算 dX 的第 i 行第 k 列元素,也就是 ∂L/∂X_{i,k}。L通过所有 Y_{i,j} 依赖 X_{i,k},于是:
∂L/∂X_{i,k} = Σ_j ∂L/∂Y_{i,j} · ∂Y_{i,j}/∂X_{i,k} = Σ_j G_{i,j} · ∂Y_{i,j}/∂X_{i,k}
关键在于 ∂Y_{i,j}/∂X_{i,k} 等于多少。从 Y_{i,j} = Σ_m X_{i,m} W_{m,j} 可以看出,只有当 m = k 时,导数才非零,且等于 W_{k,j}。所以:
dX_{i,k} = Σ_j G_{i,j} · W_{k,j}
这一步非常关键。顺着这个下标写下去,(i, k)位置的dX等于对j求和G_{i,j}乘W_{k,j}。这不就是矩阵 G 乘以 W 的转置吗?矩阵乘法要求“左矩阵的列数等于右矩阵的行数”,G 的列数是D_out,W的列数也是D_out,所以要让G能乘上W的转置,举起来就是:
dX = G Wᵀ
整个推导没有绕弯,也没有魔法,就是把偏导定义写开,然后用一次求和浓缩成矩阵乘法。
2.3 为什么是“乘W的转置”而不是“乘W”
这是初学者最常见的困惑。我遇到不止一个朋友问:前向明明是 XW,反向为什么不是 G 除以 W 之类的,或者直接乘 W?
逻辑上可以这么理解:前向传播是把输入从 D_in 维空间映射到 D_out 维空间。反向传播要把梯度从 D_out 维空间传回 D_in 维空间,所以维度必须反过来。G 的形状是 (N, D_out),我们希望最终 dX 的形状是 (N, D_in),因此中间需要乘一个能把 D_out 变成 D_in 的矩阵。W 是 (D_in, D_out),把它转置成 (D_out, D_in),恰好满足: (N, D_out) @ (D_out, D_in) = (N, D_in)。
再看分量形式更有感觉。拿单个输出举例子:如果只有 y1 = x1 w1 + x2 w2,那么损失L对 x1 的梯度是 ∂L/∂y1 · w1,对 x2 的梯度是 ∂L/∂y1 · w2。可以看到,输入位置的梯度等于上游梯度乘上“连接该输入和该输出的权重”。如果有多个输出,就得把所有经过该输入的路径都加起来。转置的作用,就是把这一系列“加总”组织成整齐的矩阵运算。
记住一句大白话:前向是“X 右乘 W”,反向就是“G 右乘 Wᵀ”。只要你在代码里看到线性层 Y = XW + b,那么对应反向传播的 dX 一定长这样。这也会帮你后面省掉大量排查时间。
3. dX 如何和 dW、db 一起协作
3.1 三个梯度的角色
一个线性层在反向传播时,其实要算三个量:
- dX = G Wᵀ,形状 (N, D_in),传给前一层当作上游梯度。
- dW = Xᵀ G,形状 (D_in, D_out),用来更新当前层的权重。
- db = Σ_i G_{i,:},形状 (D_out,),用来更新偏置。
这三者一视同仁,但作用完全不同。dX不参与当前层的参数更新,它是给上一层的“消息”;dW和db才是当前层真正用来调整自己的量。群里有人问:既然dW才是用来更新参数的,那我直接算dW不行吗?不行,因为上一层的dW依赖于我们传过去的dX。没有dX,上一层就断粮了。
我建议初次接触时把这三行公式并排写在一起对照记忆,顺手把形状也标上去。一旦习惯用形状做自检,就不容易写错了。
| 变量 | 公式 | 形状 | 用途 |
|---|---|---|---|
| dX | G Wᵀ | (N, D_in) | 传给前一层 |
| dW | Xᵀ G | (D_in, D_out) | 更新当前层权重 |
| db | Σ_i G_{i,:} | (D_out,) | 更新偏置 |
3.2 多层网络里的接力赛
假设一个只有两层的网络:输入X,经过第一层权重W1得到Y1,再经过第二层权重W2得到Y2,损失L由Y2计算。
第二层(最后一层)反向传播时,直接用从损失函数那里拿到的 G2 = ∂L/∂Y2,然后算出:
- dX2 = G2 W2ᵀ
- dW2 = Y1ᵀ G2
重点来了:因为第一层的输出Y1就是第二层的输入X,所以“第二层的dX”在数学上就等于“第一层的上游梯度G1”。也就是说,G1 = dX2。
接下来第一层反向传播时,用这个G1继续算:
- dX1 = G1 W1ᵀ
- dW1 = Xᵀ G1
于是我们看到一条清晰的接力链:最后一层的dX变成上一层的G,依此类推,一层接一层传回最前面。标题里写的“dX代表的是传给前一层来计算dW的上游梯度”,说的正是这件事。所以不要小看dX,它虽然不直接更新参数,却是整个梯度传递回路里最重要的“传令兵”。
4. 用一个小例子把公式落到数值上
4.1 构造一个具体的线性层
光看公式还是有点抽象,我习惯每次推导完都拿一组假数据验算一遍。现在构造一个最简单的例子:batch size N=2,输入维度 Din=3,输出维度 Dout=2。
假设输入 X:
[[1, 2, 3], [4, 5, 6]]权重 W:
[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]偏置 b 先设成全零,方便展示核心过程。前向传播 Y = XW,手算第一行:
Y[0,0] = 1×0.1 + 2×0.3 + 3×0.5 = 0.1 + 0.6 + 1.5 = 2.2
Y[0,1] = 1×0.2 + 2×0.4 + 3×0.6 = 0.2 + 0.8 + 1.8 = 2.8
第二行同理:Y[1,0]=4×0.1+5×0.3+6×0.5=0.4+1.5+3.0=4.9,Y[1,1]=0.8+2.0+3.6=6.4。得到Y整体是:
[[2.2, 2.8], [4.9, 6.4]]4.2 反推dX和dW
现在假装从损失函数那边拿到的上游梯度G是:
G = [[0.1, -0.2], [0.3, 0.4]]这个G代表损失对Y每个元素的敏感度。接下来用公式算dX = G Wᵀ。先把W转置:
Wᵀ = [[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]]然后:
dX第一行第一列 = 0.1×0.1 + (-0.2)×0.2 = 0.01 - 0.04 = -0.03
dX第一行第二列 = 0.1×0.3 + (-0.2)×0.4 = 0.03 - 0.08 = -0.05
dX第一行第三列 = 0.1×0.5 + (-0.2)×0.6 = 0.05 - 0.12 = -0.07
第二行依葫芦画瓢:0.3×0.1+0.4×0.2=0.03+0.08=0.11,0.3×0.3+0.4×0.4=0.09+0.16=0.25,0.3×0.5+0.4×0.6=0.15+0.24=0.39。得到:
dX = [[-0.03, -0.05, -0.07], [0.11, 0.25, 0.39]]这个dX的形状是(2, 3),恰好和X一模一样。这就是一个很好的自检信号:如果算出来不是这个形状,说明中间某一步写错了。
再看dW = XᵀG。Xᵀ形状是(3,2),G形状是(2,2),乘出来是(3,2),正好和W同形状。手算其中一两个元素:
dW[0,0] = Xᵀ的第0行(即X的第0列:[1,4])点乘G的第0列([0.1,0.3])= 1×0.1+4×0.3=0.1+1.2=1.3。
dW[1,1] = Xᵀ的第1行(即X的第1列:[2,5])点乘G的第1列([-0.2,0.4])= -0.4+2.0=1.6。
每一步都是有明确下标的,不是瞎蒙。多算几遍之后,你对GWᵀ和XᵀG这两个公式的记忆会非常牢固,因为它们不再是一串符号,而是有实际意义的运算。
4.3 用代码验证一遍
手算完了,再用代码确认一遍,免得自己粗心算错。以下是一个纯NumPy的验证脚本:
import numpy as np X = np.array([[1., 2., 3.], [4., 5., 6.]]) W = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) G = np.array([[0.1, -0.2], [0.3, 0.4]]) Y = X @ W dX = G @ W.T dW = X.T @ G print("Y shape:", Y.shape) print("dX:\n", dX) print("dX shape:", dX.shape) print("dW:\n", dW) print("dW shape:", dW.shape)运行结果里的dX和手算完全一致,形状也正确。真正在框架里写模型时,你未必会手写这个矩阵乘法,因为自动微分帮我们搞定了,但理解底层原理还是很有必要。以后你要是想做算子融合、写自定义层、或者手推FLOPs,都会需要这种肌肉记忆。
5. 从dX看LLM训练里的几个大坑
5.1 维度不匹配:最常见的报错
我见过最多的反向传播报错是矩阵乘法维度对不上。比如有人把dX写成G乘W,结果W形状是(D_in, D_out),G是(N, D_out),一乘就报错。因为 (N, D_out) @ (D_in, D_out) 根本乘不了,除非N恰好等于D_in。
解决这类报错的核心心法只有一个:dX的形状必须等于X的形状。你拿到一个梯度公式,先别管数学细节,直接检查最终形状。如果dX形状是 (N, D_out),而X是 (N, D_in),那这个公式100%有问题。用形状自检,几乎能秒杀一半的反向传播bug。
同理,dW的形状必须等于W的形状。W是 (D_in, D_out),所以dW也必须是 (D_in, D_out)。XᵀG正好满足,因为Xᵀ是(D_in, N),G是(N, D_out)。这一招在跟队友review代码时特别好用,不用逐行看逻辑,先看形状全对,再谈其他。
5.2 搞反左乘还是右乘
另一个高频坑是转置方向写反,或者把左右顺序搞混。原因在于:如果前向约定是Y = WX(输入X在右边,但在某些教材里输入写在左边),那么反向公式就会变成dX = WᵀG,dW = G Xᵀ,和我们这里的结论正好镜像。
所以我在文章开头就强调:必须固定自己的符号约定。只要你的前向写成Y = XW + b,那么反向就永远是:
- dX = G Wᵀ,G在左,Wᵀ在右
- dW = Xᵀ G,Xᵀ在左,G在右
如果哪天你在别人的代码里看到dX = Wᵀ G,别急着说别人错了,先看他前向是怎么写的。把约定统一到自己的框架之后,再去理解别人的代码就轻松很多,只需要做个左右镜像而已。
5.3 理解Transformer里的反向传播结构
真正到了LLM训练,网络里不只有线性层,还有多头注意力、LayerNorm、Embedding等。它们各自的dX公式和线性层不太一样,但排列思路完全一致:从上层接住梯度,再算“传给下一层”的梯度。
比如多头注意力里的线性投射层,本质还是线性层,直接套上面的公式没问题。但注意力内部的softmax,它的反向传播就不是简单的矩阵乘转置了,需要把softmax的雅可比矩阵算出来,再做类似链式传播。这一块我打算放到后面讲Attention的FLOPs时再展开。这里先打下一个重要认知:dX的传播形态在不同算子里长得不一样,但角色永远相同,都是从后往前递梯度。
在估算LLM训练的FLOPs时,有一个实用结论:Transformer里矩阵乘法占了绝对大头,而每个线性层反向传播需要算dX和dW,所以反向这部分大致是前向的2倍。如果你细算到每一个张量,还会发现dX和dW各一次矩阵乘法,前向一次,加起来就是3倍。这也解释了为什么训练大模型时,反向传播的计算量如此可观。
6. 几个实战经验与自查技巧
6.1 用“形状守恒”自查公式
每次写完一个反向传播的公式,我第一件事不是看推导过程,而是看形状对不对。这个方法简单到有点蠢,但极其有效。给自己定三条铁律:
- dX 的形状必须和 X 一致。
- dW 的形状必须和 W 一致。
- db 的形状必须和 b 一致。
如果不一致,别想了,必然是转置、左右顺序、或者求和维度错了一个。等以后再接触CNN、RNN、GNN,这套形状守恒法依然通用。我在写自定义算子时,就是这么挨个检查形状,很少出过岔子。
6.2 数值梯度校验:最靠谱的调试手段
公式写对了不代表代码写对了。你要是在实现自定义层的时候,反向传播结果怎么都不对,最推荐的方式是数值梯度校验。原理很简单:利用导数的定义,给某个参数加一个极小的扰动 ε,看损失变化量除以ε,是否约等于你手写的梯度值。
具体做法,选择网络里某一个参数 θ,分别计算:
- loss(θ + ε),loss(θ - ε)
- 数值梯度 ≈ (loss(θ+ε) - loss(θ-ε)) / (2ε)
然后和你的解析梯度对比,如果相对误差在 1e-6 左右,说明公式和实现基本正确。这个技巧在实现自定义反向传播的时候可以帮你省下大量时间,我在自己的项目里基本每次都会跑一遍,确保自定义算子的反向传播真的可靠。
实践中还要注意:ε 不能取得太大也不能太小,太小会有浮点舍入误差,太大则会让导数近似失真。一般取 1e-5 到 1e-6 比较合适,而且最好随机抽几个参数检验,别只测一个。
6.3 用dX的范数观察梯度异常
最后分享一个调LLM时的经验:训练刚起步时,可以每步都打印中间层的dX范数。如果某一层的dX突然变成NaN或者剧烈膨胀,往往意味着前向传播有问题,或者上游梯度爆炸了。
dX的行为能反映很多信息。因为它是所有前层梯度的起点,dX如果异常,后面所有层的dW都会受影响,模型参数更新就会乱套。所以在我自己搭训练pipeline时,会在关键层打点观察dX的范数变化,一旦异常能快速定位到是哪一层出的问题。这个方法在debug大型模型时是非常实用的经验,比直接看loss曲线直观得多。
说到底,dX就是在反向传播里“接力传递”的那个梯度。它顶着数学上吓人的偏导符号,本质上只是“损失对输入的敏感度”。把这个东西彻底想明白了,后面再去看Transformer的梯度流、手推FLOPs、甚至做分布式训练里的通信压缩,都会顺畅很多。下一步我准备写Attention层的反向传播梯度链和FLOPs细化,到时候还会再提到这个GWᵀ的套路。