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)) vs x + 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:梯度平方的指数移动平均(像加速度的方差)
更新公式:
项目里的CUDA实现会把所有参数的m和v也放在GPU上,更新时不需要CPU和GPU来回搬运数据。
---
六、CUDA加速:让GPU跑起来
6.1 为什么需要CUDA?
CPU有几十个核心,每个核心都很"聪明",适合做复杂逻辑判断。但矩阵乘法这种"重复做同一件事几百万次"的任务,CPU的几十核心根本忙不过来。
GPU有几千个CUDA核心,虽然每个都很"笨"(只能做简单算术),但胜在人多力量大。一个矩阵乘法可以拆成几万个线程并行算,速度能快50-100倍。
6.2 手写CUDA核函数
项目里的CUDA代码没有调用cuBLAS(NVIDIA的优化库),而是手写核函数。比如向量加法:
__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]; }}调用时:
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,或者用它的命令训练:
# 下载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,词表大小4096python tokenizer.py --train TinyStories-valid.txt --vocab-size 4096 --output tokenizer.bin这会生成一个tokenizer.bin文件,就是项目推理代码里要加载的tokenizer文件。
预处理训练数据
训练数据需要是预分词后的二进制格式。同样用Llama2.c的工具处理:
# 用训练好的tokenizer把文本转成token ID序列,存为二进制python tokenizer.py --input TinyStories-valid.txt --output data.bin --tokenizer tokenizer.bindata.bin就是train.cpp要读取的训练数据文件。
---
运行训练
# 在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程序加载模型生成文本:
./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能不能跑,可以先不准备数据,直接编译后运行:
# 编译完成后,检查可执行文件ls -la build/train build/chat# 检查CUDA是否被正确链接ldd build/train | grep cuda如果编译没报错,说明代码结构没问题。下一步就是准备数据跑训练。
---
最小化测试流程
如果你只想最快看到效果,按下面顺序执行:
# 1. 环境检查nvcc --version && cmake --version && g++ --version# 2. 克隆并编译git clone https://github.com/shaleigang/tinyLLM.gitcd tinyLLM && mkdir build && cd buildcmake .. && make -j8# 3. 准备数据(去Llama2.c仓库下载tokenizer.py)cd ..python tokenizer.py --train TinyStories-valid.txt --vocab-size 4096 --output tokenizer.binpython 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【程序猿编码】
</blocks,></blocks,></tensor*></tensor*></tensor*></op>
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!