...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_; } 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_; } float at(size_t i) const { return data_; } 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); 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; 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 < 4 ? ", " : ""); std::printf("]\n"); std::printf("Top 5 values: ["); for (int i = 0; i < 5; ++i) std::printf("%.6f%s", logits[idx], i < 4 ? ", " : ""); std::printf("]\n"); std::printf("Top 5 tokens: ["); for (int i = 0; i < 5; ++i) { std::string t = tok.decode({idx}); 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=Releasecmake --build build编译完会生成四个可执行文件:
build/tinytransformer:主程序,生成文本或输出 logits
build/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_gpt2ln -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【程序猿编码】
</float></float></prompt></prompt>
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!