一、先搞清楚:我们到底在造什么?
这个项目本质上是在做一件"返璞归真"的事------用C++从零开始造一个微型GPT,不依赖PyTorch、TensorFlow,甚至不用Eigen之类的矩阵库。所有东西,从张量(Tensor)的内存管理、矩阵乘法的CUDA核函数,到Transformer的注意力机制、AdamW优化器,全部自己手写。
你可以把它理解成:用C++重新发明一次深度学习的轮子。不是为了造更好的轮子,而是为了看清轮子里每一根辐条是怎么转动的。
项目参考了nanoGPT和Llama2.c的设计思路,在TinyStories数据集上训练一个GPT风格的自回归语言模型。整个代码结构非常清晰,分为几个核心模块:
- Tensor系统:自带自动微分的张量,支持CPU/CUDA双端
- 计算图:动态构建前向传播图,反向传播自动求梯度
- Transformer组件:多头注意力、前馈网络、LayerNorm、残差连接
- 训练基础设施:AdamW优化器、交叉熵损失、DataLoader、Tokenizer
- CUDA加速:手写CUDA核函数,把矩阵运算搬到GPU上
模型配置很"微型":6层Transformer、64维嵌入、4个注意力头、4096词表、最大序列长度256。总参数量大概100多万,和GPT-2的1.17亿参数相比,只有它的1%左右。

If you need the complete source code, please add the WeChat number (c17865354792)
二、核心设计思路:为什么要这样搭积木?
2.1 整体架构:像搭乐高一样堆叠
整个项目采用分层设计,从最底层的内存管理,一层层往上搭到模型训练:
┌─────────────────────────────────────┐
│ 应用层:train.cpp / chat.cpp │ ← 训练脚本、推理交互
├─────────────────────────────────────┤
│ 模型层:GPT、TransformerBlock │ ← 组装好的语言模型
├─────────────────────────────────────┤
│ 模块层:MultiHeadAttention、FFN │ ← 注意力、前馈网络
├─────────────────────────────────────┤
│ 函数层:MatMul、Softmax、LayerNorm │ ← 具体运算操作
├─────────────────────────────────────┤
│ 张量层:Tensor(data + grad + graph)│ ← 自动微分的核心
├─────────────────────────────────────┤
│ 计算层:CPU/CUDA Kernel │ ← 矩阵乘、向量加等
├─────────────────────────────────────┤
│ 内存层:Host/Device Memory Manager │ ← malloc/cudaMalloc
└─────────────────────────────────────┘
这种设计的好处是每一层只依赖下一层,你可以单独测试"矩阵乘法对不对",再测试"注意力机制能不能跑通",最后才组装完整模型。调试起来不会一团乱麻。
2.2 推理代码极简主义
项目的推理接口设计得非常干净,几行代码就能跑起来:
cpp
#include "gpt.h"
#include "tokenizer.h"
using namespace tllm;
int main() {
GPT gpt(6, 64, 4, 4096, 256, 0.2, false); // 声明模型:6层,64维,4头,4096词表,256长度,dropout 0.2
gpt.load("Path to saved model."); // 加载检查点
gpt.cuda(); // 搬到GPU
Tokenizer tokenizer("Path to tokenizer file.", 4096); // 加载分词器
gpt.generate("Your Prompt", tokenizer); // 生成文本
return 0;
}
这6个数字的含义(对应文章开头的流程图):
- 6:Transformer层数(layer)
- 64:嵌入维度(embed_dim),每个词用64维向量表示
- 4:注意力头数(num_heads),多头注意力拆成4个头并行计算
- 4096:词表大小(vocab_size),模型认识4096个不同的token
- 256:最大序列长度(seq_len),一次最多处理256个token
- 0.2:Dropout比率,训练时随机扔掉20%的连接防止过拟合
- false:是否使用bias(这里不用)
三、Tensor与自动微分:让C++学会"求导"
这是整个项目最精妙的部分。PyTorch的tensor.backward()背后藏着复杂的自动微分引擎,而这里用C++手写了一个简化版。
3.1 Tensor不只是"多维数组"
在PyTorch里,Tensor封装了数据、梯度、设备信息。这里的Tensor设计思路类似,但更加"裸奔"------你能直接看到指针:
cpp
// 伪代码示意,基于项目结构推断
class Tensor {
public:
float* data; // 数值数据,CPU端
float* grad; // 梯度数据,反向传播时填充
float* d_data; // GPU端数据指针(CUDA模式)
float* d_grad; // GPU端梯度指针
std::vector<int> shape; // 形状,如 {batch, seq_len, dim}
std::shared_ptr<Op> op; // 产生这个张量的操作
std::vector<Tensor*> prev; // 父节点(构建计算图)
std::vector<Tensor*> next; // 子节点
bool requires_grad; // 是否需要计算梯度(权重需要,临时变量不需要)
// 核心方法
void backward(); // 反向传播入口
void zero_grad(); // 清零梯度
void cuda(); // 搬到GPU
void cpu(); // 搬回CPU
};
3.2 计算图:像家谱一样记录运算关系
每次你做c = a + b,系统不会只算出一个结果,而是悄悄画了一张计算图:
a (Tensor) b (Tensor)
\ /
\ /
v v
AddOp (op)
|
v
c (Tensor)
c会记住:我是由AddOp产生的,我的父节点是a和b。这样反向传播时,就知道"Loss对c的梯度"要怎么分给a和b。
3.3 反向传播:链式法则的自动化
假设你有一个复杂函数 Loss = f(g(h(x))),手动求导需要反复应用链式法则:
∂ L o s s ∂ x = ∂ L o s s ∂ f ⋅ ∂ f ∂ g ⋅ ∂ g ∂ h ⋅ ∂ h ∂ x \frac{\partial Loss}{\partial x} = \frac{\partial Loss}{\partial f} \cdot \frac{\partial f}{\partial g} \cdot \frac{\partial g}{\partial h} \cdot \frac{\partial h}{\partial x} ∂x∂Loss=∂f∂Loss⋅∂g∂f⋅∂h∂g⋅∂x∂h
自动微分的做法很巧妙:
- 前向传播时,记录每一步操作,构建图
- 反向传播时,从Loss节点出发, grad初始化为1.0
- 用拓扑排序把图倒过来遍历(从终点往起点走)
- 每个节点收到"上游传来的梯度",乘以"自己的局部梯度",传给父节点
- 叶子节点(就是我们要更新的权重)累积最终的梯度值
代码层面的核心逻辑大概是:
cpp
void Tensor::backward() {
// 1. 拓扑排序:从当前节点倒推,确定计算顺序
std::vector<Tensor*> topo;
build_topo(this, topo); // 用DFS或Kahn算法
// 2. 初始化自己的梯度为1(dLoss/dLoss = 1)
this->grad[0] = 1.0f;
// 3. 按逆序遍历(从Loss往回走)
for (auto it = topo.rbegin(); it != topo.rend(); ++it) {
Tensor* node = *it;
if (node->op) {
// 调用对应操作的backward方法
// 比如 AddOp::backward 会把grad平均分给两个输入
node->op->backward(node->grad, node->prev);
}
}
}
3.4 CUDA双端支持:同一份代码,两处跑
Tensor内部同时维护了CPU指针(data/grad)和GPU指针(d_data/d_grad)。当你调用tensor.cuda()时,数据通过cudaMemcpy搬到显存;调用tensor.cpu()时再搬回来。所有运算操作(MatMul、Add等)都会检查当前Tensor在哪个设备上,自动分发到CPU实现或CUDA核函数。
四、Transformer Block:注意力机制的C++实现
4.1 多头注意力:把"看"的能力拆成多份
这是GPT最核心的部件。简单理解:每个词都要看看上下文的其他词,决定自己该"关注"谁。
在代码里,注意力通过三个投影矩阵实现:
- Q(Query):当前词"想问什么"
- K(Key):每个词"能提供什么信息"
- V(Value):每个词"实际的内容是什么"
计算步骤(对应流程图):
- 输入
x形状为[batch, seq_len, dim] - 分别乘以三个权重矩阵
Wq, Wk, Wv,得到Q、K、V - 计算注意力分数:
scores = Q @ K^T / sqrt(head_dim) - Causal Mask:把"看后面词"的位置设成负无穷(上三角掩码)
- Softmax归一化,得到注意力权重
- 权重乘以V:
output = softmax(scores) @ V - 最后乘一个输出投影矩阵
Wo
"多头"的意思是把64维拆成4个头,每个头16维,4个头并行计算,最后把结果拼接起来。就像4个人同时读一篇文章,每人关注不同的方面(语法、语义、情感、逻辑),最后汇总意见。
4.2 Pre-Norm vs Post-Norm:为什么先归一化?
Transformer有两种归一化放置方式:
- Post-Norm (原始论文):
x + Attention(LayerNorm(x)) - Pre-Norm (现代主流):
x + Attention(LayerNorm(x))wait, 不对,应该是LayerNorm(x + Attention(x))vsx + Attention(LayerNorm(x))
实际上:
- Post-Norm:先算Attention,再加残差,最后LayerNorm
- Pre-Norm:先LayerNorm,再算Attention,再加残差
Pre-Norm更稳定,训练深层模型时梯度不会爆炸。这个项目用的是Pre-Norm架构,和GPT-2保持一致。
4.3 前馈网络(FFN):给模型"非线性思考"能力
注意力做的是"加权平均",本质还是线性的。FFN引入非线性:
FFN(x) = GELU(x @ W1 + b1) @ W2 + b2
- 先把维度从64扩到256(4倍扩展,这是Transformer标配)
- 用GELU激活函数引入非线性(比ReLU更平滑)
- 再缩回64维
残差连接(Residual Connection)的作用很关键:每一层的输出不是替换输入,而是输入 + 变换结果。这样即使加了很多层,梯度也能通过"高速公路"直接传回浅层,避免梯度消失。
五、训练流水线:数据怎么流进模型
5.1 数据准备:TinyStories与BPE分词
项目用TinyStories数据集训练------这是专门给小型语言模型准备的故事数据,句子简单、语法规范,适合百万参数级别的小模型学习。
分词器(Tokenizer)直接复用了Llama2.c的实现,训练了一个词表大小4096的BPE(Byte-Pair Encoding)分词器。BPE的核心思想是:从字符开始,反复合并最常见的字符对,直到词表满为止。
比如语料里"th"出现了一万次,就把"th"当成一个新token;"the"又出现了很多次,再把"the"合并进去。这样4096个token就能覆盖大部分常见词和词片段。
5.2 DataLoader:把长文本切成训练样本
训练语言模型时,输入和目标的关系很特殊:用第1到N个词,预测第2到N+1个词。所以DataLoader的工作是:
-
读取预分词后的二进制数据(一串整数token ID)
-
随机选一个起始位置
-
切出长度为
seq_len的输入序列x -
目标序列
y就是x整体右移一位 -
打包成batch,送给模型
原始文本:Once upon a time there was ...
Token ID:[101, 234, 56, 789, 345, 112, ...]x = [101, 234, 56, 789, 345] // 输入:前5个词
y = [234, 56, 789, 345, 112] // 目标:每个词的"下一个词"
5.3 损失函数与优化
用**交叉熵损失(Cross-Entropy Loss)**衡量预测和真实的差距。模型输出的是每个位置、每个词的概率分布(经过Softmax),交叉熵就是让这个分布尽可能"贴近"真实标签。
AdamW优化器是Adam的改进版,主要加了权重衰减(Weight Decay)防止过拟合。它维护两个动量:
- 一阶动量m:梯度的指数移动平均(像速度)
- 二阶动量v:梯度平方的指数移动平均(像加速度的方差)
更新公式:
m t = β 1 m t − 1 + ( 1 − β 1 ) g t m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t mt=β1mt−1+(1−β1)gt
v t = β 2 v t − 1 + ( 1 − β 2 ) g t 2 v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 vt=β2vt−1+(1−β2)gt2
m ^ t = m t 1 − β 1 t , v ^ t = v t 1 − β 2 t \hat{m}_t = \frac{m_t}{1-\beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1-\beta_2^t} m^t=1−β1tmt,v^t=1−β2tvt
θ t = θ t − 1 − η ( m ^ t v ^ t + ϵ + λ θ t − 1 ) \theta_t = \theta_{t-1} - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}t} + \epsilon} + \lambda \theta{t-1} \right) θt=θt−1−η(v^t +ϵm^t+λθt−1)
项目里的CUDA实现会把所有参数的m和v也放在GPU上,更新时不需要CPU和GPU来回搬运数据。
六、CUDA加速:让GPU跑起来
6.1 为什么需要CUDA?
CPU有几十个核心,每个核心都很"聪明",适合做复杂逻辑判断。但矩阵乘法这种"重复做同一件事几百万次"的任务,CPU的几十核心根本忙不过来。
GPU有几千个CUDA核心,虽然每个都很"笨"(只能做简单算术),但胜在人多力量大。一个矩阵乘法可以拆成几万个线程并行算,速度能快50-100倍。
6.2 手写CUDA核函数
项目里的CUDA代码没有调用cuBLAS(NVIDIA的优化库),而是手写核函数。比如向量加法:
cuda
__global__ void vec_add_kernel(float* out, const float* a, const float* b, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
out[idx] = a[idx] + b[idx];
}
}
调用时:
cpp
int threads = 256;
int blocks = (n + threads - 1) / threads;
vec_add_kernel<<<blocks, threads>>>(d_out, d_a, d_b, n);
<<<blocks, threads>>>是CUDA的启动语法,意思是"派这么多线程去干活"。每个线程处理一个元素,几万个元素一眨眼就算完了。
矩阵乘法更复杂,需要考虑共享内存(Shared Memory)优化、分块计算(Tiling),但核心思路一样:把大矩阵拆成小方块,每个线程块负责一块。
七、涉及的核心知识点总结
做这个项目,你实际上在同时学习以下领域的知识:
1. C++工程能力
- 内存管理(raw pointer、智能指针、RAII)
- 模板元编程(如果Tensor支持多类型)
- 编译系统(CMake、CUDA nvcc联编)
2. 深度学习基础
- 张量运算与广播机制
- 反向传播与链式法则
- 优化器原理(SGD、Momentum、Adam)
3. Transformer架构
- 自注意力机制(Self-Attention)
- 多头注意力(Multi-Head Attention)
- 位置编码(Positional Encoding,虽然这个项目可能用了可学习的)
- LayerNorm与残差连接
4. CUDA并行计算
- 线程层级(Grid → Block → Thread)
- 内存层次(Global → Shared → Register)
- 核函数优化(Coalesced Memory Access、Bank Conflict避免)
5. 自然语言处理
- BPE分词算法
- 语言建模(Next Token Prediction)
- 文本生成策略(Greedy、Temperature、Top-p)
八、准备训练数据
这个项目没有自带训练好的tokenizer和预分词数据,你需要自己准备。项目复用了Llama2.c的tokenizer格式。
训练Tokenizer
去Llama2.c仓库下载tokenizer.py,或者用它的命令训练:
bash
# 下载Llama2.c的tokenizer训练脚本
wget https://raw.githubusercontent.com/karpathy/llama2.c/master/tokenizer.py
# 准备原始文本(TinyStories数据集)
wget https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStories-valid.txt
# 训练tokenizer,词表大小4096
python tokenizer.py --train TinyStories-valid.txt --vocab-size 4096 --output tokenizer.bin
这会生成一个tokenizer.bin文件,就是项目推理代码里要加载的tokenizer文件。
预处理训练数据
训练数据需要是预分词后的二进制格式。同样用Llama2.c的工具处理:
bash
# 用训练好的tokenizer把文本转成token ID序列,存为二进制
python tokenizer.py --input TinyStories-valid.txt --output data.bin --tokenizer tokenizer.bin
data.bin就是train.cpp要读取的训练数据文件。
运行训练
bash
# 在build目录下执行
./train \
--data ../data.bin \
--tokenizer ../tokenizer.bin \
--checkpoint model.bin \
--epochs 10 \
--batch_size 64 \
--learning_rate 1e-3
参数说明(根据项目代码推断):
--data:预分词后的二进制数据路径--tokenizer:tokenizer二进制文件路径--checkpoint:模型保存路径--epochs:训练轮数--batch_size:批次大小(根据显存调整,6层64维的模型很小,64应该没问题)--learning_rate:学习率
训练过程中会打印每步的Loss。作者提到Loss会从8降到3左右停滞,这是正常现象(模型容量太小)。
运行推理
训练完成后,用chat程序加载模型生成文本:
bash
./chat \
--model model.bin \
--tokenizer ../tokenizer.bin \
--prompt "Once upon a time" \
--max_tokens 100 \
--temperature 0.8
参数说明:
--model:训练保存的模型文件--tokenizer:tokenizer文件--prompt:输入提示词--max_tokens:最大生成token数--temperature:温度参数(越高越随机)
快速验证
如果你只是想验证代码能不能编译通过、CUDA能不能跑,可以先不准备数据,直接编译后运行:
bash
# 编译完成后,检查可执行文件
ls -la build/train build/chat
# 检查CUDA是否被正确链接
ldd build/train | grep cuda
如果编译没报错,说明代码结构没问题。下一步就是准备数据跑训练。
最小化测试流程
如果你只想最快看到效果,按下面顺序执行:
bash
# 1. 环境检查
nvcc --version && cmake --version && g++ --version
# 2. 克隆并编译
git clone https://github.com/shaleigang/tinyLLM.git
cd tinyLLM && mkdir build && cd build
cmake .. && make -j8
# 3. 准备数据(去Llama2.c仓库下载tokenizer.py)
cd ..
python tokenizer.py --train TinyStories-valid.txt --vocab-size 4096 --output tokenizer.bin
python tokenizer.py --input TinyStories-valid.txt --output data.bin --tokenizer tokenizer.bin
# 4. 训练
cd build
./train --data ../data.bin --tokenizer ../tokenizer.bin --checkpoint model.bin
# 5. 推理
./chat --model model.bin --tokenizer ../tokenizer.bin --prompt "Hello"
总结
现在的深度学习框架(PyTorch、JAX)太好用了,一行代码就能搭个Transformer。但"好用"往往意味着"黑盒"------你不知道反向传播时梯度到底怎么流的,不知道CUDA核函数长什么样,不知道内存是怎么分配的。
手写一遍,哪怕是最简单的版本,也能让你真正理解这些框架在幕后做了什么。 就像学开车,开自动挡很轻松,但学一下手动挡,你才明白换挡时发动机转速和扭矩是怎么配合的。
这个项目虽然只有100万参数,生成不了什么惊艳的文本,但它是一面镜子,照出了大模型背后最本质的东西:矩阵乘法、梯度下降、链式法则。这些才是AI的基石。
如果你也想动手试试,建议从Tensor和自动微分开始写起,先把两个数的加减乘除的梯度算对,再扩展到矩阵,最后组装成GPT。每一步都跑通测试,比一口气写完整模型要稳得多。
毕竟,GPT-4也是从这些最基础的运算堆起来的,只是它堆了更多层、更多数据、更多卡而已。原理上,你们用的是同一套数学。
Welcome to follow WeChat official account【程序猿编码】