『7x24小时有问必答』

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

现在你想跑个 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_; }

       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=Release

cmake --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_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【程序猿编码】

</float></float></prompt></prompt>

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

本帖子中包含更多资源

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

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

本版积分规则

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

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

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


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