手把手拆解一个纯C++手写的Transformer:每个乘加都看得见
一、这篇文章在讲什么?
现在市面上讲Transformer的文章很多,但大部分都停留在"调用PyTorch的nn.MultiheadAttention"这个层面。你点进去看源码,底层是C++写的、调了BLAS库、可能还有CUDA核函数,黑盒一层套一层,根本看不清数据到底是怎么流动的。
这个项目不一样——它用纯C++、纯标量运算、纯嵌套for循环,把GPT风格的Transformer从头到尾写了一遍。没有矩阵库,没有SIMD,没有BLAS,连一个*矩阵乘法都没有。每一个乘法、每一个加法,都是一行你能打断点调试的代码。
而且它不只做推理(forward),连反向传播(backward)和SGD训练也手写了,并且和PyTorch对过答案,误差在1e-6级别。
如果你一直好奇"Transformer里面到底在算什么",但又不想被PyTorch的自动求导和底层优化搞晕,这个项目就是最好的"显微镜"。
二、它到底"透明"在哪里?
2.1 没有黑盒矩阵运算
普通代码里你看到的是:
attn_output=torch.matmul(Q,K.transpose(-2,-1))这个项目里你看到的是:
for(inth=0;h<N_HEADS;h++){for(inti=0;i<SEQ_LEN;i++){for(intj=0;j<=i;j++){// 注意 j <= i,这就是因果性floatscore=0.0f;for(intd=0;d<D_HEAD;d++){score+=Q[i][h][d]*K[j][h][d];// 一次乘加,清清楚楚}score/=sqrtf(D_HEAD);// 缩放// ... softmax ...}}}每一个score都是一次手算的乘加。你可以在这个score += ...这一行打断点,看看到底是哪个权重乘了哪个激活值。
2.2 因果性不靠mask矩阵,靠循环边界
大模型里常说的"因果注意力"(causal attention),意思是位置i只能看到位置0到i的信息,不能偷看未来。
很多实现是用一个上三角mask矩阵(未来位置填-inf)来做的。这个项目更直接——它直接在循环里写j <= i。j超过i的循环根本不执行,自然就没有信息泄露。没有mask,没有inf,就是一个简单的循环条件。
2.3 反向传播也手写,不用autograd
PyTorch的.backward()是自动的,你根本看不到梯度怎么传的。这个项目里,从loss到每个权重的梯度,都是一行一行用链式法则手推的。
比如MLP的第一层权重W1,它的梯度是:
// dW1[d][m] += x[i][d] * d_hidden[i][m]for(inti=0;i<SEQ_LEN;i++){for(intm=0;m<D_MLP;m++){for(intd=0;d<D_MODEL;d++){d_W1[d][m]+=x[i][d]*d_hidden[i][m];}}}这就是大学课本里教的链式法则,没有任何魔法。
三、模型长什么样?
If you need the complete source code, please add the WeChat number (c17865354792)
别看它"手写",架构和真正的GPT是一模一样的:
| 组件 | 说明 |
|---|---|
| 词嵌入 | 查表,没有位置编码(测试用,简化) |
| 4层Transformer | 每层 = LayerNorm → 多头注意力 → 残差 → LayerNorm → MLP → 残差 |
| 多头注意力 | 4个头,每个头8维,Q/K/V分开投影 |
| MLP | 先扩到128维,ReLU激活,再压回32维 |
| Final LayerNorm | 输出前再做一次归一化 |
| 输出投影 | 线性映射到256维词表,得到logits |
模型维度故意设得很小(D_MODEL=32, SEQ_LEN=16),这样验证跑起来不到一秒,但架构和真实模型完全一致。
四、核心模块逐个拆解
4.1 词嵌入:就是一个查表
输入是[3, 7, 1, 9, ...]这样的token ID。嵌入层做的事情很简单:
for(inti=0;i<SEQ_LEN;i++){inttoken=input_ids[i];for(intd=0;d<D_MODEL;d++){residual[i][d]=embedding_table[token][d];}}没有花里胡哨的nn.Embedding,就是一个二维数组查表。residual这个词很重要——它是贯穿整个模型的"残差流",每层都在它上面做加减。
4.2 多头注意力:你在看谁?
这是Transformer最核心的部分。大白话讲:每个位置的每个"头",都要问一句"我和前面每个位置有多像?像的话,它的信息我该复制多少过来?"
具体步骤(全是标量运算):
Step 1:投影出Q、K、V
// Q[i][h][d] = sum_k x[i][k] * Wq[k][h][d]fori,h,d:fork:Q[i][h][d]+=x[i][k]*Wq[k][h][d];K和V同理。
Step 2:算相似度分数
// score[i][j][h] = sum_d Q[i][h][d] * K[j][h][d]fori,h:forj=0;j<=i;j++:// 因果性!ford:score+=Q[i][h][d]*K[j][h][d];score/=sqrt(D_HEAD);// 缩放,防止softmax爆炸Step 3:softmax归一化
把j=0到i的分数变成概率和为1的权重。
Step 4:加权求和
// out[i][h][d] = sum_j attn_weight[i][j][h] * V[j][h][d]fori,h,d:forj=0;j<=i;j++:out[i][h][d]+=attn_weight[i][j][h]*V[j][h][d];Step 5:输出投影
把多头结果拼起来,再投影回D_MODEL维。
4.3 MLP:先膨胀,再压缩,中间ReLU"过滤"
// Step 1: 扩到128维hidden[i][m]=sum_d x[i][d]*W1[d][m];// Step 2: ReLU,负数变0if(hidden[i][m]<0)hidden[i][m]=0;// Step 3: 压回32维out[i][d]=sum_m hidden[i][m]*W2[m][d];为什么要先膨胀再压缩?给信息"腾地方"。32维可能不够用,拉到128维让模型有更多"容量"去学复杂的映射;ReLU负责"筛选"有用的信号(负数扔掉);最后再压回32维,和残差流汇合。
4.4 LayerNorm:让每个位置"自我标准化"
不是对整个batch做,而是对单个序列位置的每个维度做:
// 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-otransformer_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-otransformer_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【程序猿编码】
