// 1. 算这32个数的均值mean = sum_d x[d] / D_MODEL;// 2. 算方差var = sum_d (x[d] - mean)^2 / D_MODEL;// 3. 拉平到均值0、方差1x_norm[d] = (x[d] - mean) / sqrt(var + epsilon);// 4. 让模型自己学缩放y[d] = gamma[d] * x_norm[d]; // 这里省略了beta平移项,简化反向传播时,LayerNorm的梯度推导比较绕——因为mean和var都依赖所有输入,链式法则要拆成三部分,但代码里也就是一堆求和再除以D_MODEL,全是标量。
4.5 残差连接:梯度的高速公路
正向:y = x + f(x)
反向:dx += dy(梯度直接穿过),同时df += dy(子层也承担一份)。
**大白话:残差就像一条"高速公路"**,梯度可以不经过任何变换直接传回输入。这也是为什么深层Transformer能训练得下去——如果没有残差,梯度在几十层之后早就 vanish(消失)了。
---
五、反向传播:从loss回传,一步一步"追责"
正向是从输入到输出,反向就是从loss往回走,问"哪个权重该为loss负责?"
整体流程(从下到上):
Loss梯度:d_logits = softmax(logits) - one_hot(label),这是交叉熵的导数。
Unembedding反向:dW += x^T · d_logits,和正向的矩阵乘反过来。
Final LayerNorm反向:分别对mean和var求导。
第4层反向:MLP反向 → Attn反向 → LayerNorm反向 → 残差累加。
第3层反向:同上。
第2层反向:同上。
第1层反向:同上。
Embedding反向:把梯度累加到对应token的嵌入向量上。
每层内部的反向顺序(以一层为例):
MLP反向:先算dW2,再算d_hidden,穿过ReLU(负数位置梯度变0),再算dW1。
Attn反向:从d_attn_out反推dQ, dK, dV,再反推dWq, dWk, dWv。
LayerNorm反向:拆成对x_norm, var, mean的三部分导数。
残差:把上层传来的梯度直接加到输入梯度上。
---
六、训练验证:C++和PyTorch"对答案"
这个项目最硬核的地方——它不和PyTorch共享任何代码,但结果要对得上。
推理验证
PyTorch初始化一个随机模型,导出权重到numpy文件。
C++读取这些权重,跑同样的输入。
比较输出的logits,差异在1e-6以内 → PASS。
训练验证
PyTorch导出初始权重。
两边各自跑50步SGD,每步记录loss。
比较50个loss值,曲线完全重合 → PASS。
这意味着:C++手写的反向传播,和PyTorch的autograd算出来的梯度,在数学上是等价的。没有近似,没有偷懒,就是纯手推的链式法则做对了。
---
七、设计思路:为什么这样设计?
7.1 纯标量:为了"看得见"
用矩阵库(BLAS、MKL)当然快,但你看到的是cblas_sgemm这种黑盒函数。这个项目的目的不是快,是透明。每一个乘加都在源码里,你可以:
在任意一行打断点
打印任意中间变量的值
确认"这个位置、这个头、这个维度,到底在算什么"
7.2 无偏置:减少干扰
线性层没有bias(偏置项)。这不是偷懒——现代大模型(如GPT-2/3/4、LLaMA)的注意力层和MLP层通常都不加bias,因为LayerNorm已经做了平移,再加bias是冗余的。去掉bias让权重数量更少,代码更干净。
7.3 ReLU代替GELU:先跑通,再替换
GELU是GPT-2以后的主流激活函数,但公式比ReLU复杂(涉及erf误差函数)。先用ReLU把整体框架验证通,之后把ReLU那行换成GELU就行——因为所有运算都是手写的,换激活函数就是改一个函数体。
7.4 SGD代替Adam:最小化复杂度
Adam需要维护一阶矩(动量)和二阶矩(自适应学习率),代码量多一倍。先用最朴素的SGD(W -= lr * dW)验证训练流程正确,Adam可以后面加。
---
八、涉及的核心知识点总结
知识点 | 在这个项目里的体现 |
| 自注意力机制 | Q/K/V投影、缩放点积、softmax、因果掩码(j<=i) |
| 多头注意力 | 多个头并行计算,输出拼接后投影 |
| 残差连接 | x = x + sublayer(x) ,梯度高速公路 |
| LayerNorm | 按位置归一化,gamma缩放 |
| 前馈网络 | 扩维→激活→压缩,MLP标准结构 |
| 交叉熵损失 | 分类任务的标配loss |
| 反向传播/链式法则 | 每个操作都有对应的梯度公式,手推实现 |
| SGD优化器 | 最简单的梯度下降,无动量 |
| 因果语言模型 | 只能看过去,不能看未来 |
| 数值验证 | 独立实现交叉验证,确保数学正确性 |
---
九、适合谁看?
刚学Transformer的人:不想被PyTorch的封装搞晕,想从最底层理解数据流。
想搞自定义算子的人:看看没有autograd的情况下,反向传播怎么手推。
对数值精度敏感的人:理解浮点运算的累积误差,以及为什么1e-6的验证标准合理。
怀旧派:像80年代那样,每个FLOPS都看得见。
好,下面把推理验证和训练验证两套流程拆开讲,每一步该敲什么命令、预期看到什么输出,都给你列清楚。
十、验证推理(Inference)和 验证训练(Training)
核心思路:PyTorch 先训练一个 tiny 模型,把权重和输入数据存成文件;C++ 读取这些文件,跑一遍前向传播,最后比较两边的输出 logits 是否一致。
步骤 1:PyTorch 端生成"标准答案"
python train_and_export.py这个脚本干了几件事:
用同样的超参数(D_MODEL=32, N_HEADS=4 等)初始化一个 PyTorch 模型
生成一组随机输入序列
跑前向传播,得到参考 logits
把权重和参考 logits 导出成 .npy 文件(NumPy 格式)
跑完之后,你的目录里会多出几个 .npy 文件,比如 embed.npy、attn_q_0.npy、ref_logits.npy 这些。
步骤 2:编译 C++ 推理代码
g++ -O2 -o transformer_test transformer_test.cpp -lm-O2:开优化,跑得快一点
-lm:链接数学库(sqrtf、expf 这些需要)
编译完会生成一个可执行文件 transformer_test
步骤 3:运行 C++ 推理并自动对答案
./transformer_testC++ 程序会:
读取步骤 1 导出的 .npy 权重文件
用完全相同的输入序列跑前向传播
把自己的输出和 ref_logits.npy 逐元素比较
预期输出:
PASS: outputs match within floating-point tolerance.如果看到这一行,说明 C++ 的推理前向传播和 PyTorch 的数值误差在 1e-6 级别,验证通过。
---
验证训练(Training)
核心思路:两边从完全相同的初始权重出发,各自跑 50 步 SGD,每步记录 loss,最后看两条 loss 曲线是否重合。
步骤 1:PyTorch 端导出初始权重
python train_verify.py这个脚本干的事:
用固定随机种子初始化模型(确保可复现)
导出初始权重到 .npy 文件
自己跑 50 步 SGD,每步的 loss 保存到 pytorch_losses.npy
同时保存训练用的输入数据和目标标签
步骤 2:编译 C++ 训练代码
g++ -O2 -o transformer_train transformer_train.cpp -lm步骤 3:运行 C++ 训练并对比 loss
./transformer_trainC++ 程序会:
读取步骤 1 的初始权重
用同样的输入和标签跑 50 步 SGD
每步计算交叉熵 loss,保存到 cpp_losses.txt
自动和 pytorch_losses.npy 对比
预期输出:
PASS: training losses match closely.总结
这个项目最大的价值不是"能跑多快",而是**"能让你看懂"**。它把Transformer从"调用一个高级API"还原成了"一堆嵌套for循环里的乘加运算"。
Welcome to follow WeChat official account【程序猿编码】
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!