『7x24小时有问必答』

一、先搞清楚:我们到底在造什么?

这个项目本质上是在做一件"返璞归真"的事——用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 推理代码极简主义

项目的推理接口设计得非常干净,几行代码就能跑起来:
#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设计思路类似,但更加"裸奔"——你能直接看到指针:
// 伪代码示意,基于项目结构推断

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))),手动求导需要反复应用链式法则:
自动微分的做法很巧妙:
前向传播时,记录每一步操作,构建图
反向传播时,从Loss节点出发, grad初始化为1.0
用拓扑排序把图倒过来遍历(从终点往起点走)
每个节点收到"上游传来的梯度",乘以"自己的局部梯度",传给父节点
叶子节点(就是我们要更新的权重)累积最终的梯度值
代码层面的核心逻辑大概是:
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,词表大小4096

python 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.bin

data.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.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【程序猿编码】

</blocks,></blocks,></tensor*></tensor*></tensor*></op>

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码