刨根问底:手写一个 C++ 深度学习框架,把 Transformer 扒个干净 📅 2026/7/6 1:30:59 一、这到底是个啥简单来说这是一个用纯 C17从零开始实现的深度学习小框架。它的特点很鲜明零依赖不装 PyTorch、不装 TensorFlow、不装任何第三方库就靠着 C 标准库硬刚自带自动求导像 PyTorch 一样你写前向传播它自动帮你算反向传播CPU 多线程优化矩阵乘法、逐元素运算这些苦力活全部交给线程池并行处理完整的 Transformer编码器 解码器 多头注意力 前馈网络论文《Attention Is All You Need》里有的它都有二、为什么要做这个用 PyTorch 写模型很爽一行nn.Linear就搞定全连接层。但爽归爽很多细节被框架藏起来了张量数据到底怎么在内存里排布的reshape的时候数据真的被拷贝了吗反向传播的时候梯度是怎么一层一层传回去的注意力机制里的 mask 到底长什么样三、整体架构四层金字塔整个项目可以看成一座四层金字塔从下往上越来越高级第 1 层核心张量引擎这是地基所有上层建筑都靠它撑着。一个张量Tensor里面装了四样东西数据缓冲区就是一个连续的一维float数组所有多维数据都塞在这里面形状 步幅shape告诉你这是几维的、每维多长stride告诉你怎么从一维数组里跳到多维的对应位置梯度缓冲区反向传播时用来存梯度的地方计算图链接指向父节点的指针用来追踪这个张量是怎么算出来的关键设计用 stride 做多维索引映射比如一个shape [2, 3]的张量它的stride [3, 1]。要取位置[i, j]的元素实际在一维数组里的索引就是flat_index i × stride[0] j × stride[1] i × 3 j × 1这个设计牛在哪reshape、view、切片这些操作很多时候只需要改改 shape 和 stride 的元数据根本不用动数据本身。内存零拷贝性能直接起飞。第 2 层神经网络层在张量引擎之上搭了一层积木组件作用Linear全连接层就是y xW bMulti-Head Attention多头注意力Transformer 的灵魂Feed-Forward Network位置前馈网络每个位置独立过两个全连接Layer Normalization层归一化稳定训练Dropout随机丢弃神经元防止过拟合Embedding把字符/词转成向量Positional Encoding给每个位置加上位置信息第 3 层模型架构把上面的积木堆起来就是完整的 TransformerEncoder StackN 层编码器每层 Self-Attention Feed-Forward NormDecoder StackN 层解码器每层 Masked Self-Attention Encoder-Decoder Attention Feed-Forward Norm第 4 层数据处理与配置字符级分词把文本拆成单个字符每个字符对应一个 IDDataLoader把数据切成 batch方便训练config.ini所有超参数学习率、层数、头数等都写在一个配置文件里改起来不用碰代码四、张量 自动求导整个项目的心脏4.1 张量长什么样上面说了张量就是一个一维数组 元数据的包装。用大白话说你看到的[2, 3, 4]这种三维张量在内存里其实就是一串float数字排成一排。shape 和 stride 就是翻译器告诉程序怎么从这一排数字里找到你要的那个。为什么用 shared_ptr 共享数据因为张量经常要做 view、reshape、切片这些操作。如果每次 reshape 都拷贝一份数据那内存早就爆了。用shared_ptr共享底层数据多个张量可以看同一块内存但各自有自己的 shape 和 stride。4.2 自动求导怎么工作的这是整个项目最精彩的部分。核心思想就一句话每个操作都记住自己是怎么来的并且知道如果输出变了输入该怎么变。具体实现上每个张量节点维护一个父节点列表。当你做z x y时z这个张量会记住我是 x 和 y 加出来的z还自带一个backward()函数如果 z 的梯度是 dz那 x 的梯度 dzy 的梯度 dz然后反向传播的时候从 loss 节点开始沿着计算图一层一层往回传用链式法则把梯度分发给每个参数。动态图 vs 静态图这里用的是动态图也叫 define-by-run。啥意思就是前向传播跑一遍计算图就自动建好了。好处是你可以用 C 的if、for、while这些控制流图会随着实际执行路径动态变化。PyTorch 也是这个思路。内存怎么释放靠引用计数。当反向传播做完loss 张量出了作用域引用计数归零整个计算图就自动被回收了。不需要写垃圾回收器C 的shared_ptr帮你搞定。五、Multi-Head AttentionTransformer 的灵魂注意力机制是 Transformer 的核心也是很多人第一次看论文时最懵的地方。其实拆开来看就是一套查字典的流程5.1 三步投影Q、K、V输入一个序列X先过三个不同的线性层得到三个分身QQuery我要查什么KKey字典里每个条目的关键词VValue字典里每个条目的实际内容Q X × W_Q K X × W_K V X × W_V5.2 算注意力分数把 Q 和 K 做矩阵乘法得到相似度分数scores Q × K^T然后除以√d_k缩放防止 softmax 梯度消失再过一个 softmax 变成概率分布attention_weights softmax( scores / √d_k Mask )最后用这个概率分布去加权求和 Voutput attention_weights × V5.3 多头 多组查字典的视角上面的过程只算了一组 Q、K、V。多头注意力就是同时算多组每组关注不同的角度。比如第 1 个头关注语法关系第 2 个头关注语义关系第 3 个头关注位置关系具体实现上就是把embed_dim切成num_heads份每份独立算注意力最后把结果拼接起来再过一个线性层投影回去。5.4 Mask 是干嘛的在 Decoder 里预测第 t 个词的时候不能偷看后面的词。所以用一个上三角掩码把未来的位置分数变成-infsoftmax 之后这些位置的概率就是 0模型就看不到未来了。六、训练 vs 推理两条不同的路6.1 训练流程加载文本 → 字符分词 → 构建 batch → Forward → 算 Loss → Backward → Adam 更新权重 → 循环用tiny_shakespeare.txt这种小数据集就能跑损失函数是交叉熵CrossEntropy优化器用 Adam带偏差修正训练完把权重保存成.bin文件6.2 推理流程加载权重 → 输入 prompt → 分词 → Forward → 取最后一个位置的预测 → 选下一个 token → 拼回序列 → 循环一次只生成一个 token然后把这个 token 拼回输入序列再预测下一个循环直到达到max_generate_length或者遇到结束符选 token 可以用 argmax贪心或者采样带温度七、CPU 多线程怎么把性能榨干深度学习框架通常跑在 GPU 上但这个是 CPU 优先的。怎么让 CPU 也跑得快答案线程池 任务分区项目里实现了一个固定大小的线程池默认 500 个线程。当遇到大矩阵乘法时把输出矩阵按行切分成若干块每块交给一个线程独立计算。因为各线程算的是输出矩阵的不同区域没有数据竞争不需要锁性能损耗很小。适用多线程的操作包括矩阵乘法matmul逐元素运算add、mul、divSoftmax 归一化Layer Normalization注意力分数计算前馈网络计算八、核心代码思路从项目结构可以还原出核心逻辑张量类骨架classTensor{// 数据std::shared_ptrstd::vectorfloatdata;// 元数据std::vectorintshape;std::vectorintstride;// 自动求导std::vectorstd::shared_ptrTensorparents;// 父节点std::functionvoid()backward_fn;// 反向传播函数std::shared_ptrstd::vectorfloatgrad;// 梯度缓冲区boolrequires_grad;// 核心操作staticTensormatmul(constTensora,constTensorb);staticTensoradd(constTensora,constTensorb);Tensorreshape(conststd::vectorintnew_shape);Tensorview(conststd::vectorintnew_shape);// 反向传播入口voidbackward();};自动求导的 backward 流程voidTensor::backward(){// 1. 拓扑排序从当前节点往回走确定计算顺序autotopo_ordertopological_sort(this);// 2. 初始化loss 的梯度是 1this-gradones_like(this-data);// 3. 从后往前逐个调用 backward_fnfor(autonode:topo_order){node-backward_fn();// 每个节点知道自己该怎么传梯度}}多头注意力的 forwardTensorMultiHeadAttention::forward(constTensorx){// 1. 投影得到 Q, K, VautoQlinear_q(x);autoKlinear_k(x);autoVlinear_v(x);// 2. 拆成多组 headautoQ_headssplit(Q,num_heads);autoK_headssplit(K,num_heads);autoV_headssplit(V,num_heads);// 3. 每个 head 独立算注意力std::vectorTensorhead_outputs;for(inti0;inum_heads;i){autoscoresmatmul(Q_heads[i],transpose(K_heads[i]));scoresscores/sqrt(d_k);if(use_mask)scoresscoresmask;autoweightssoftmax(scores);autohead_outmatmul(weights,V_heads[i]);head_outputs.push_back(head_out);}// 4. 拼接 最终投影autoconcatconcatenate(head_outputs);returnlinear_out(concat);}If you need the complete source code, please add the WeChat number (c17865354792)本地运行指南从零跑起来编译mkdirbuildcdbuild cmake..make编译完成后会生成两个可执行文件./neural_network— 主程序训练/推理./test_tensor— 张量操作测试第四步运行张量测试这是最快速验证环境的方式不需要数据文件./test_tensor预期输出类似Running tensor tests... Test 1: Tensor creation - PASSED Test 2: Matrix multiplication - PASSED Test 3: Broadcasting - PASSED Test 4: Autograd backward - PASSED ... All tests passed!第五步准备数据文件项目需要tiny_shakespeare.txt作为训练数据。从项目根目录# 下载莎士比亚文本wgethttps://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txtmvinput.txt data/tiny_shakespeare.txt第六步修改配置文件编辑config.ini# 先试试训练模式 inference_mode false data_filename ../data/tiny_shakespeare.txt # 小模型参数跑得快 embed_dim 64 num_layers 2 num_heads 2 ff_hidden_dim 256 batch_size 4 num_epochs 10 num_threads 4 # 根据你的CPU核心数调整第七步运行训练./neural_network你会看到输出Epoch 1/10, Loss: 2.3456 Epoch 2/10, Loss: 2.1234 ... Training complete. Weights saved to transformer_weights.bin第八步运行推理修改config.iniinference_mode true load_existing_weights true weights_filename transformer_weights.bin initial_prompt ROMEO: max_generate_length 50再运行./neural_network输出ROMEO: What light through yonder window breaks...十、总结这个项目教会了我们什么自动求导不只是微积分问题更是内存管理问题——计算图的生命周期、引用计数、共享所有权这些工程细节比数学公式更难搞。Stride 是 tensor 系统里最高杠杆的概念——一个设计好的 stride 机制能让 reshape、view、广播、切片全部零拷贝实现。手写注意力比看十张图更有用——当你真的把 Q×K^T、缩放、mask、softmax、乘 V 这一串操作用代码写出来注意力的直觉就建立了。框架设计全是 trade-off没有绝对的对错——动态图 vs 静态图、共享所有权 vs 唯一所有权、工厂模式 vs 直接构造……每个选择都有代价。CPU 也能跑深度学习关键看你怎么并行——线程池 任务分区 无锁设计能把 CPU 的多核优势发挥出来。Welcome to follow WeChat official account【程序猿编码】