想真正理解大模型?别再用PyTorch了,试试从零用C++写一个
一、先说说这玩意儿是干嘛的
现在你想跑个 GPT-2,最省事的办法是 pip install transformers,两行 Python 就搞定了。但问题是,你虽然能拿到结果,却对"结果是怎么来的"一无所知。那些矩阵乘法、分词规则、KV 缓存,全都被 PyTorch 和 HuggingFace 的层层封装盖住了。
这个项目干的事,就是把这层层封装全扒掉,只用 C++ 标准库,从头写一个能跑通 GPT-2 Small(1.24 亿参数)的推理引擎。从你把一句话输进去,到模型吐出下一个字,整条链路——分词、加载权重、矩阵乘法、注意力计算、采样——全部自己实现。
最狠的是,它算出来的 logits(也就是模型对每个候选词打的分数)跟 HuggingFace 官方实现对比,误差只有 10 的负 4 次方 级别。换句话说,几乎完全一致。这说明每一行代码都是对的,没有偷偷摸摸的近似或者偷懒。

二、具体含义:它到底包含哪些东西?
这个项目不是"调包侠",它把大模型推理的完整链路拆成了六个自己动手做的模块:
| 模块 | 干啥的 | 为什么重要 |
|---|---|---|
| 张量系统 | 自己管理多维数组(1D 到 4D),行优先存储,带 strides | 所有神经网络运算的底座 |
| 数值运算 | 矩阵乘法、softmax、GELU 激活、LayerNorm、广播加偏置 | 模型的"肌肉",决定算得快不快 |
| BPE 分词器 | 把字符串变成整数 token ID,支持字节级编码 | 分词错一个,后面全错,必须和 OpenAI 原版完全一致 |
| 权重加载器 | 解析 safetensors 格式,把 124M 参数读进内存 | 直接从 HuggingFace 下载的权重就能用 |
| Transformer 模型 | 12 层解码器,带多头因果自注意力 + MLP + KV 缓存 | 核心推理逻辑 |
| 采样器 | 支持贪心解码(greedy)和 temperature + top-k 采样 | 决定模型"说话风格"是保守还是发散 |
整个项目没有任何机器学习框架依赖,连 BLAS(线性代数库)都没用,编译只需要 C++17 + CMake。
三、代码实现原理:拆开来看
...
struct LayerTimings {
double attn_ms = 0.0;
double mlp_ms = 0.0;
double ln_ms = 0.0;
size_t forward_calls = 0;
void reset() { attn_ms = mlp_ms = ln_ms = 0.0; forward_calls = 0; }
};
class GPT2 {
public:
explicit GPT2(GPT2Weights weights);
std::vector<float> forward(const std::vector<int>& tokens, size_t n_past);
void reset_cache();
static int greedy_sample(const std::vector<float>& logits);
int temperature_sample(const std::vector<float>& logits, float temp,
int top_k);
std::string generate(const Tokenizer& tok, const std::string& prompt,
int max_tokens, float temp, int top_k,
uint64_t seed = 42);
const GPT2Config& config() const { return w_.config; }
size_t cache_len() const { return cache_len_; }
void enable_debug(const std::string& dir) { debug_ = true; debug_dir_ = dir; }
LayerTimings timings;
bool collect_timings = false;
private:
void block(size_t layer, Tensor& x, size_t n_past);
void dump_tensor(const std::string& name, const Tensor& t);
GPT2Weights w_;
size_t head_dim_;
std::vector<std::vector<float>> kcache_;
std::vector<std::vector<float>> vcache_;
size_t cache_len_ = 0;
std::mt19937_64 rng_{42};
bool debug_ = false;
std::string debug_dir_;
bool dumped_ = false;
};
...
class Tokenizer {
public:
Tokenizer(const std::string& encoder_json_path,
const std::string& vocab_bpe_path);
std::vector<int> encode(const std::string& text) const;
std::string decode(const std::vector<int>& ids) const;
size_t vocab_size() const { return id_to_token_.size(); }
private:
std::vector<std::string> pretokenize(const std::string& text) const;
std::vector<std::string> bpe(const std::string& token) const;
std::unordered_map<std::string, int> token_to_id_;
std::unordered_map<int, std::string> id_to_token_;
std::unordered_map<std::string, int> bpe_ranks_;
std::string byte_encoder_[256];
std::unordered_map<std::string, int> byte_decoder_;
};
class Tensor {
public:
Tensor() = default;
explicit Tensor(std::vector<size_t> shape, float fill = 0.0f);
Tensor(std::vector<size_t> shape, std::vector<float> data);
const std::vector<size_t>& shape() const { return shape_; }
const std::vector<size_t>& strides() const { return strides_; }
size_t ndim() const { return shape_.size(); }
size_t size() const { return data_.size(); }
size_t dim(size_t i) const { return shape_[i]; }
std::vector<float>& data() { return data_; }
const std::vector<float>& data() const { return data_; }
float* ptr() { return data_.data(); }
const float* ptr() const { return data_.data(); }
float& at(size_t i) { return data_[i]; }
float at(size_t i) const { return data_[i]; }
float& at(size_t i, size_t j) { return data_[i * strides_[0] + j * strides_[1]]; }
float at(size_t i, size_t j) const { return data_[i * strides_[0] + j * strides_[1]]; }
float& at(size_t i, size_t j, size_t k) {
return data_[i * strides_[0] + j * strides_[1] + k * strides_[2]];
}
float at(size_t i, size_t j, size_t k) const {
return data_[i * strides_[0] + j * strides_[1] + k * strides_[2]];
}
float& at(size_t i, size_t j, size_t k, size_t l) {
return data_[i * strides_[0] + j * strides_[1] + k * strides_[2] + l * strides_[3]];
}
float at(size_t i, size_t j, size_t k, size_t l) const {
return data_[i * strides_[0] + j * strides_[1] + k * strides_[2] + l * strides_[3]];
}
void reshape(std::vector<size_t> new_shape);
std::string shape_str() const;
private:
void compute_strides();
std::vector<float> data_;
std::vector<size_t> shape_;
std::vector<size_t> strides_;
};
struct Args {
std::string cmd;
std::string prompt;
int max_tokens = 50;
float temp = 0.8f;
int top_k = 40;
uint64_t seed = 42;
bool greedy = false;
bool debug = false;
std::string weights_dir = "weights";
};
const char* opt_value(int argc, char** argv, int& i) {
if (i + 1 >= argc) {
std::fprintf(stderr, "missing value for %s\n", argv[i]);
std::exit(2);
}
return argv[++i];
}
void usage() {
std::printf(
"usage:\n"
" transformer generate \"<prompt>\" [--max-tokens N] [--temp T]\n"
" [--top-k K] [--seed N] [--greedy] [--debug]\n"
" [--weights-dir DIR]\n"
" transformer logits \"<prompt>\" [--debug] [--weights-dir DIR]\n");
}
}
int main(int argc, char** argv) {
...
for (int i = 2; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--max-tokens") a.max_tokens = std::atoi(opt_value(argc, argv, i));
else if (arg == "--temp") a.temp = std::atof(opt_value(argc, argv, i));
else if (arg == "--top-k") a.top_k = std::atoi(opt_value(argc, argv, i));
else if (arg == "--seed") a.seed = std::strtoull(opt_value(argc, argv, i), nullptr, 10);
else if (arg == "--greedy") a.greedy = true;
else if (arg == "--debug") a.debug = true;
else if (arg == "--weights-dir") a.weights_dir = opt_value(argc, argv, i);
else if (arg[0] != '-') a.prompt = arg;
else { std::fprintf(stderr, "unknown option: %s\n", arg.c_str()); return 2; }
}
const std::string st_path = a.weights_dir + "/model.safetensors";
const std::string enc_path = a.weights_dir + "/encoder.json";
const std::string bpe_path = a.weights_dir + "/vocab.bpe";
std::fprintf(stderr, "loading tokenizer...\n");
Tokenizer tok(enc_path, bpe_path);
std::fprintf(stderr, "loading weights (%s)...\n", st_path.c_str());
GPT2 model(load_gpt2_weights(st_path));
if (a.debug) {
std::system("mkdir -p debug");
model.enable_debug("debug");
}
if (a.cmd == "generate") {
std::string out = model.generate(tok, a.prompt, a.max_tokens,
a.greedy ? 0.0f : a.temp, a.top_k, a.seed);
std::printf("%s\n", out.c_str());
return 0;
}
if (a.cmd == "logits") {
std::vector<int> ids = tok.encode(a.prompt);
std::fprintf(stderr, "prompt tokens (%zu): ", ids.size());
for (int id : ids) std::fprintf(stderr, "%d ", id);
std::fprintf(stderr, "\n");
model.reset_cache();
std::vector<float> logits = model.forward(ids, 0);
std::vector<int> idx(logits.size());
std::iota(idx.begin(), idx.end(), 0);
std::partial_sort(idx.begin(), idx.begin() + 5, idx.end(),
[&](int x, int y) { return logits[x] > logits[y]; });
std::printf("Top 5 token IDs: [");
for (int i = 0; i < 5; ++i)
std::printf("%d%s", idx[i], i < 4 ? ", " : "");
std::printf("]\n");
std::printf("Top 5 values: [");
for (int i = 0; i < 5; ++i)
std::printf("%.6f%s", logits[idx[i]], i < 4 ? ", " : "");
std::printf("]\n");
std::printf("Top 5 tokens: [");
for (int i = 0; i < 5; ++i) {
std::string t = tok.decode({idx[i]});
std::printf("\"%s\"%s", t.c_str(), i < 4 ? ", " : "");
}
std::printf("]\n");
return 0;
}
usage();
return 2;
}
If you need the complete source code, please add the WeChat number (c17865354792)
1. 张量系统:最底层的数据结构
神经网络里全是张量。这个项目里的张量就是一个连续的 std::vector<float>,按行优先排列。比如一个形状为 [batch, seq_len, hidden_dim] 的三维张量,内存里就是一个长数组,但代码里可以通过 at(b, s, h) 直接访问。
关键是预计算 strides。举个例子,形状 [2, 3, 4] 的 strides 是 [12, 4, 1]。你想取 tensor[1][2][3],直接算 1*12 + 2*4 + 3*1 = 23,跳到第 23 个 float 就行。没有递归、没有嵌套数组,就是一个指针偏移,快且简单。
2. 矩阵乘法:为什么循环顺序是 (i, k, j) 而不是 (i, j, k)?

这是项目里最有意思的一个优化点。教科书上的矩阵乘法通常是三层循环 i → j → k,但这里改成了 i → k → j。
原因很简单:内存连续性。假设 A、B、C 都是行优先存储:
- 固定
i和k,内层循环只遍历j - 这时候你访问的是
B的一整行和C的一整行,内存是连续的 - 编译器一看,这能自动向量化(auto-vectorization),生成 SIMD 指令
GPT-2 的 MLP 里有两个大矩阵:768×3072 和 3072×768。解码时这两个乘法占了绝大部分时间,所以这个循环顺序的优化直接决定了生成速度。
3. BPE 分词器:为什么非要自己写?

分词(Tokenization)是大模型里最容易被忽视、也最容易出错的环节。如果你直接用别人的分词库,万一字节到 Unicode 的映射表差了一个字符,或者合并规则的顺序跟 OpenAI 原版不一样,那输进去的 token ID 就全乱了。更坑的是,这种错误在模型输出上是隐形的——模型不会报错,只是生成的内容变得莫名其妙。
所以这个项目把分词器从零实现了一遍:
- 字节到 Unicode 映射表:跟 OpenAI
encoder.py完全一致 - 正则预分词:按 GPT-2 的规则先把文本切成小段
- BPE 合并:按
vocab.bpe里的合并优先级,一步步把字节对合并成词
验证方式也很实在:拿各种带缩写、混合格式空格的句子去跟官方 encoder 对比,确保 encode 出来的 ID 一模一样;并且保证 decode(encode(s)) == s 对任意字节都能 round-trip。
4. 权重加载器:safetensors 格式解析
模型权重从 HuggingFace 下载下来是 .safetensors 格式。这个格式很简单:前面 8 个字节存一个 JSON 头部长度(小端序),后面跟着一段 JSON 描述每个张量的名字、形状、数据类型和偏移量,再后面就是原始二进制数据。
解析逻辑也就几十行:读 8 字节 → 读 JSON → 按偏移量 memcpy 把 float32 数据直接拷进张量的内存。因为张量本身就是行优先的,GPT-2 的 Conv1D 权重形状是 [in, out],可以直接拿来用 x @ W,加载时不需要转置。
5. Transformer 模型:12 层堆叠

GPT-2 Small 的结构不复杂:
- 输入:token 嵌入 + 位置嵌入
- 中间:12 个相同的 Transformer 块
- 输出:LayerNorm + 词表投影(跟输入嵌入共享权重)
每个 Transformer 块内部是 Pre-Norm 结构(先 LayerNorm,再进注意力/MLP),包含:
- 多头因果自注意力:把输入投影成 Q、K、V,算注意力分数,softmax,加权求和,再投影回来
- MLP:先升维到 3072,过 GELU 激活,再降维回 768
- 残差连接:注意力输出和 MLP 输出分别加回输入
6. KV 缓存:让生成速度不随长度爆炸
这是自回归生成的核心优化。假设你已经生成了 50 个 token,现在要预测第 51 个。
没有 KV 缓存时:你得把全部 50 个 token 重新送进模型,算 51 次注意力,时间随长度平方增长(O(n²))。
有 KV 缓存时:每层维护两个 growable 的 std::vector<float>,一个存 K,一个存 V。第一次处理 prompt 时,所有 token 的 K、V 全算出来存进去。之后每生成一个新 token,只算这个新 token 的 K、V,追加到缓存末尾。注意力计算时,新 token 的 query 去跟缓存里全部历史 K 算分数,再跟全部历史 V 加权。
这样每步时间只跟当前长度线性增长(O(n)),而不是平方。而且因果掩码不需要显式加 -1e10,因为 query 在位置 p 时,循环只跑到缓存长度 p,天然就看不到未来的 token。
7. 采样器:从 logits 到具体 token
模型输出的是 50257 维的 logits(每个词一个分数)。怎么选下一个词?
- 贪心(greedy):直接选分数最高的,最确定,也最无聊
- temperature + top-k:先把 logits 除以一个 temperature(越小越保守),然后只保留分数最高的 k 个词,再按 softmax 概率抽样。这样模型有一定随机性,但不会选到太离谱的词
四、设计思路:为什么这样设计?
这个项目不是追求性能极限,而是追求**“能看懂、能验证、能学到东西”**。所以作者做了几个刻意的取舍:
| 设计选择 | 为什么这么做 | 生产环境会怎么做 |
|---|---|---|
| 纯 C++17,无外部库 | 降低依赖,任何人都能编译运行 | 用 CUDA、OpenVINO、ONNX Runtime |
| 无手写 SIMD,靠编译器自动向量化 | 代码可读性优先 | 手写 AVX/NEON 内核,或调 BLAS |
| float32,无量化 | 精度验证方便,跟 HuggingFace 对比误差极小 | int8/int4 量化,省 4~8 倍内存 |
| 单线程 | 逻辑简单,方便理解 | 多线程 + GPU 并行 |
| 简单 growable vector 做 KV 缓存 | 代码最短,原理最清晰 | 分页缓存、预分配、最大长度限制 |
| 只支持 greedy 和 top-k | 覆盖核心逻辑,代码量少 | 加 top-p、重复惩罚、beam search |
这些"缺失"恰恰是它的价值——你把这 1000 多行代码读完,就真正理解了大模型推理的每一分钱花在哪里。
五、性能长什么样?
在 Apple Silicon(ARM64)上单线程跑,-O2 编译,生成 100 个 token:
- 总时间:2.20 秒
- 吞吐:46.6 token/秒
- 平均延迟:21.5 毫秒/token
- 峰值内存:1.627 GB(主要是 float32 权重 0.5GB + 工作集)
每层的时间占比(生成阶段):
- MLP 矩阵乘法:64.5%(大头,两个大矩阵乘法)
- 注意力计算:35.0%(随上下文变长会上升)
- LayerNorm:0.5%(几乎可以忽略)
这说明在短上下文生成时,瓶颈在 MLP 的矩阵乘法;上下文长了以后,注意力计算的占比会逐渐追上。
六、怎么跑起来?一步步来
1.编译
只需要 C++17 编译器和 CMake 3.16+:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
编译完会生成四个可执行文件:
build/tinytransformer:主程序,生成文本或输出 logitsbuild/bench:性能测试工具build/test_ops、build/test_tokenizer、build/test_weights:单元测试
2. 跑起来看看效果
生成文本(带随机采样):
./build/tinytransformer generate "The quick brown fox" --max-tokens 50 --temp 0.8 --top-k 40 --seed 42
贪心生成(最确定的输出):
./build/tinytransformer generate "The meaning of life is" --max-tokens 30 --greedy
只看模型对下一个词的打分(用于验证):
./build/tinytransformer logits "The meaning of life is"
3. 跑测试
ctest --test-dir build --output-on-failure
这会跑 ops、tokenizer、weights 的单元测试,确保基础模块没问题。
4. 性能测试
./build/bench weights "The quick brown fox jumps over the" 100
最后一个参数 100 表示生成 100 个 token,会输出详细的耗时分解。
5. 跟 HuggingFace 对答案
这是最有意思的一步——验证你的实现跟官方结果是否一致。
# 装 Python 环境
python3 -m venv .venv && .venv/bin/pip install torch transformers
# 准备 HuggingFace 格式的权重目录
mkdir -p weights/hf_gpt2
ln -s ../model.safetensors weights/hf_gpt2/model.safetensors
# 注意:你还需要从 HuggingFace 的 gpt2 仓库下载 config.json 放到 weights/hf_gpt2/ 里
# 运行对比脚本
.venv/bin/python tools/validate.py "The meaning of life is" ./build/tinytransformer
如果通过,你会看到:
- top-1 预测词完全一致
- top-5 logits 的最大绝对误差小于 0.01(实际通常在 1e-4 左右)
这个误差来自单精度浮点数的累加顺序差异,不是 bug。如果误差很大,说明某个地方的符号、转置或者公式写错了。
七、总结一下
这个项目最大的价值,不是让你拿去部署生产环境(它确实不够快、不够省内存),而是把大模型推理的黑箱打开给你看。
你读完代码,会明白:
- 分词器为什么那么重要,一个字符的偏差就能让模型"胡说"
- 矩阵乘法的循环顺序怎么影响速度
- KV 缓存是怎么把生成复杂度从平方降到线性的
- 为什么 MLP 在短上下文时占大头,注意力在长上下文时占大头
- 一个没有任何 ML 框架依赖的推理引擎,到底能有多小、多干净
如果你想真正理解 Transformer 推理,而不是只会调包,这个项目就是最好的起点。把代码通读一遍,再亲手改改矩阵乘法的循环顺序、试试不同的采样温度,你会对"大模型是怎么思考的"有一个完全不一样的体感。
Welcome to follow WeChat official account【程序猿编码】
更多推荐

所有评论(0)