一、这到底是个啥?
简单来说,这是一个用纯 C++17从零开始实现的深度学习小框架。它的特点很鲜明:
- 零依赖:不装 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 + b |
| Multi-Head Attention | 多头注意力,Transformer 的灵魂 |
| Feed-Forward Network | 位置前馈网络,每个位置独立过两个全连接 |
| Layer Normalization | 层归一化,稳定训练 |
| Dropout | 随机丢弃神经元,防止过拟合 |
| Embedding | 把字符/词转成向量 |
| Positional Encoding | 给每个位置加上"位置信息" |
第 3 层:模型架构
把上面的积木堆起来,就是完整的 Transformer:
- Encoder Stack:N 层编码器,每层 = Self-Attention + Feed-Forward + Norm
- Decoder Stack:N 层解码器,每层 = Masked Self-Attention + Encoder-Decoder Attention + Feed-Forward + Norm
第 4 层:数据处理与配置
- 字符级分词:把文本拆成单个字符,每个字符对应一个 ID
- DataLoader:把数据切成 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 的梯度 += dz,y 的梯度 += dz
然后反向传播的时候,从 loss 节点开始,沿着计算图一层一层往回传,用链式法则把梯度"分发"给每个参数。
动态图 vs 静态图
这里用的是动态图(也叫 define-by-run)。啥意思?就是前向传播跑一遍,计算图就自动建好了。好处是你可以用 C++ 的if、for、while这些控制流,图会随着实际执行路径动态变化。PyTorch 也是这个思路。
内存怎么释放?
靠引用计数。当反向传播做完,loss 张量出了作用域,引用计数归零,整个计算图就自动被回收了。不需要写垃圾回收器,C++ 的shared_ptr帮你搞定。
五、Multi-Head Attention:Transformer 的灵魂
注意力机制是 Transformer 的核心,也是很多人第一次看论文时最懵的地方。其实拆开来看,就是一套"查字典"的流程:
5.1 三步投影:Q、K、V
输入一个序列X,先过三个不同的线性层,得到三个"分身":
- Q(Query):我要查什么?
- K(Key):字典里每个条目的"关键词"
- V(Value):字典里每个条目的"实际内容"
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 )最后用这个概率分布去加权求和 V:
output = attention_weights × V5.3 多头 = 多组"查字典"的视角
上面的过程只算了一组 Q、K、V。多头注意力就是同时算多组,每组关注不同的"角度"。比如:
- 第 1 个头关注语法关系
- 第 2 个头关注语义关系
- 第 3 个头关注位置关系
具体实现上,就是把embed_dim切成num_heads份,每份独立算注意力,最后把结果拼接起来,再过一个线性层投影回去。
5.4 Mask 是干嘛的?
在 Decoder 里,预测第 t 个词的时候,不能"偷看"后面的词。所以用一个上三角掩码把未来的位置分数变成-inf,softmax 之后这些位置的概率就是 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、div)
- Softmax 归一化
- Layer Normalization
- 注意力分数计算
- 前馈网络计算
八、核心代码思路
从项目结构可以还原出核心逻辑:
张量类骨架
classTensor{// 数据std::shared_ptr<std::vector<float>>data;// 元数据std::vector<int>shape;std::vector<int>stride;// 自动求导std::vector<std::shared_ptr<Tensor>>parents;// 父节点std::function<void()>backward_fn;// 反向传播函数std::shared_ptr<std::vector<float>>grad;// 梯度缓冲区boolrequires_grad;// 核心操作staticTensormatmul(constTensor&a,constTensor&b);staticTensoradd(constTensor&a,constTensor&b);Tensorreshape(conststd::vector<int>&new_shape);Tensorview(conststd::vector<int>&new_shape);// 反向传播入口voidbackward();};自动求导的 backward 流程
voidTensor::backward(){// 1. 拓扑排序:从当前节点往回走,确定计算顺序autotopo_order=topological_sort(this);// 2. 初始化:loss 的梯度是 1this->grad=ones_like(this->data);// 3. 从后往前,逐个调用 backward_fnfor(auto&node:topo_order){node->backward_fn();// 每个节点知道自己该怎么传梯度}}多头注意力的 forward
TensorMultiHeadAttention::forward(constTensor&x){// 1. 投影得到 Q, K, VautoQ=linear_q(x);autoK=linear_k(x);autoV=linear_v(x);// 2. 拆成多组 headautoQ_heads=split(Q,num_heads);autoK_heads=split(K,num_heads);autoV_heads=split(V,num_heads);// 3. 每个 head 独立算注意力std::vector<Tensor>head_outputs;for(inti=0;i<num_heads;i++){autoscores=matmul(Q_heads[i],transpose(K_heads[i]));scores=scores/sqrt(d_k);if(use_mask)scores=scores+mask;autoweights=softmax(scores);autohead_out=matmul(weights,V_heads[i]);head_outputs.push_back(head_out);}// 4. 拼接 + 最终投影autoconcat=concatenate(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.ini:
inference_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【程序猿编码】