刨根问底:手写一个 C++ 深度学习框架,把 Transformer 扒个干净
一、这到底是个啥?
简单来说,这是一个用纯 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_V
5.2 算注意力分数
把 Q 和 K 做矩阵乘法,得到"相似度分数":
scores = Q × K^T
然后除以 √d_k(缩放,防止 softmax 梯度消失),再过一个 softmax 变成概率分布:
attention_weights = softmax( scores / √d_k + Mask )
最后用这个概率分布去加权求和 V:
output = attention_weights × V
5.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
- 注意力分数计算
- 前馈网络计算
八、核心代码思路
从项目结构可以还原出核心逻辑:
张量类骨架
class Tensor {
// 数据
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; // 梯度缓冲区
bool requires_grad;
// 核心操作
static Tensor matmul(const Tensor& a, const Tensor& b);
static Tensor add(const Tensor& a, const Tensor& b);
Tensor reshape(const std::vector<int>& new_shape);
Tensor view(const std::vector<int>& new_shape);
// 反向传播入口
void backward();
};
自动求导的 backward 流程
void Tensor::backward() {
// 1. 拓扑排序:从当前节点往回走,确定计算顺序
auto topo_order = topological_sort(this);
// 2. 初始化:loss 的梯度是 1
this->grad = ones_like(this->data);
// 3. 从后往前,逐个调用 backward_fn
for (auto& node : topo_order) {
node->backward_fn(); // 每个节点知道自己该怎么传梯度
}
}
多头注意力的 forward
Tensor MultiHeadAttention::forward(const Tensor& x) {
// 1. 投影得到 Q, K, V
auto Q = linear_q(x);
auto K = linear_k(x);
auto V = linear_v(x);
// 2. 拆成多组 head
auto Q_heads = split(Q, num_heads);
auto K_heads = split(K, num_heads);
auto V_heads = split(V, num_heads);
// 3. 每个 head 独立算注意力
std::vector<Tensor> head_outputs;
for (int i = 0; i < num_heads; i++) {
auto scores = matmul(Q_heads[i], transpose(K_heads[i]));
scores = scores / sqrt(d_k);
if (use_mask) scores = scores + mask;
auto weights = softmax(scores);
auto head_out = matmul(weights, V_heads[i]);
head_outputs.push_back(head_out);
}
// 4. 拼接 + 最终投影
auto concat = concatenate(head_outputs);
return linear_out(concat);
}
If you need the complete source code, please add the WeChat number (c17865354792)
本地运行指南:从零跑起来
编译
mkdir build
cd build
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 作为训练数据。从项目根目录:
# 下载莎士比亚文本
wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
mv input.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【程序猿编码】
更多推荐

所有评论(0)