一、先说说这玩意儿是干嘛的

现在你想跑个 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 都是行优先存储:

  • 固定 ik,内层循环只遍历 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),包含:

  1. 多头因果自注意力:把输入投影成 Q、K、V,算注意力分数,softmax,加权求和,再投影回来
  2. MLP:先升维到 3072,过 GELU 激活,再降维回 768
  3. 残差连接:注意力输出和 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:主程序,生成文本或输出 logits
  • build/bench:性能测试工具
  • build/test_opsbuild/test_tokenizerbuild/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【程序猿编码

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐