用C++从零开始造一个微型GPT,不借助任何第三方库

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

这个项目本质上是在做一件"返璞归真"的事------用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产生的,我的父节点是ab。这样反向传播时,就知道"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

自动微分的做法很巧妙:

  1. 前向传播时,记录每一步操作,构建图
  2. 反向传播时,从Loss节点出发, grad初始化为1.0
  3. 拓扑排序把图倒过来遍历(从终点往起点走)
  4. 每个节点收到"上游传来的梯度",乘以"自己的局部梯度",传给父节点
  5. 叶子节点(就是我们要更新的权重)累积最终的梯度值

代码层面的核心逻辑大概是:

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):每个词"实际的内容是什么"

计算步骤(对应流程图):

  1. 输入x形状为 [batch, seq_len, dim]
  2. 分别乘以三个权重矩阵 Wq, Wk, Wv,得到Q、K、V
  3. 计算注意力分数:scores = Q @ K^T / sqrt(head_dim)
  4. Causal Mask:把"看后面词"的位置设成负无穷(上三角掩码)
  5. Softmax归一化,得到注意力权重
  6. 权重乘以V:output = softmax(scores) @ V
  7. 最后乘一个输出投影矩阵 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的工作是:

  1. 读取预分词后的二进制数据(一串整数token ID)

  2. 随机选一个起始位置

  3. 切出长度为seq_len的输入序列x

  4. 目标序列y就是x整体右移一位

  5. 打包成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实现会把所有参数的mv也放在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【程序猿编码

相关推荐
普通网友2 小时前
pytest一些常见的插件
开发语言·python·pytest
苦瓜花2 小时前
【Kotlin】初入门
android·开发语言·kotlin
Designerpupu2 小时前
纸巾亚马逊详情页怎么做?3 步用 GPT 生成高转化产品图片
gpt·ai·photoshop·ps·跨境电商·亚马逊·详情页
知福致福2 小时前
vscode连接远程服务器登录codex插件一直卡在gpt图标页,不跳转浏览器登录
服务器·vscode·gpt
qz5zwangzihan12 小时前
题解:Atcoder Beginner Contest abc466 F - Many Mod Calculation
c++·题解·优先队列·atcoder·大根堆·abc466·abc466f
cndes2 小时前
给Miniconda换源,让包下载更迅速
开发语言·python
froyoisle2 小时前
CSP 真题解析:[CSP-J 2019-T4] 加工零件
c++·算法·bfs·csp-j·算法竞赛·信息学·信奥赛
LuminousCPP2 小时前
C 语言集中实践全记录:顺序表 + 单链表原理实现与通讯录项目实战【附可运行源码】
c语言·开发语言·数据结构·经验分享·笔记
2401_869769592 小时前
内容7 内存管理 1
c++