一、先说说这玩意儿是干嘛的
现在想跑个大模型,大多数人的第一反应是装PyTorch、下HuggingFace权重、调pipeline()。这一套确实方便,但代价是:你的电脑里突然多了几个G的依赖,GPU驱动要配,CUDA版本要对,连跑个Demo都得等半天环境搭建。
这个项目干的事,就是把这些全部抛掉。只用C++标准库 ,从零实现一个能在CPU上跑Transformer推理的引擎。它支持GPT(文本生成)和Whisper(语音识别),而且权重用FP16(半精度浮点数)存储,配合Intel CPU的AVX指令集,推理速度能比纯标量实现快4倍。
最狠的是,它真的零外部依赖。不链接PyTorch、不调用OpenBLAS、不依赖任何第三方库。就一个头文件+一个cpp文件,加上模型权重,就能让GPT在你的笔记本上吐文字。
二、具体含义:它到底包含哪些东西?
| 模块 | 干啥的 | 为什么重要 |
|---|---|---|
| Tensor系统 | 自己管理多维数组,支持FP16/FP32/Int32 | 所有运算的底座 |
| FP16转换器 | 手写半精度↔单精度的位运算转换 | 省一半内存,Cache能多装一倍数据 |
| SIMD向量化 | AVX指令一次算8个float | 矩阵乘法的核心加速 |
| Activation Cache | 缓存中间结果,增量推理 | 生成第N个token时不用重算前N-1个 |
| Embedding层 | Token查表+位置编码 | 输入接口 |
| LayerNorm | 均值为0、方差为1的归一化 | 训练稳定性,推理必备 |
| Linear层 | 矩阵乘法+偏置 | 模型的"肌肉",占绝大部分计算量 |
| GELU激活 | 非线性变换 | 让模型能拟合复杂函数 |
| 多头注意力 | Self-Attention + Causal Mask | Transformer的核心 |
| 残差连接 | 跨层跳跃,防止梯度消失 | 深网络能训练的关键 |
| 采样器 | Top-k + Temperature | 控制输出随机性 |
| 性能分析器 | 每个模块单独计时 | 知道瓶颈在哪 |
三、代码实现原理:拆开来看
1. Tensor系统:shared_ptr做数据共享
这个项目的Tensor设计非常精简。它用std::shared_ptr管理底层数据缓冲区,这意味着多个Tensor可以共享同一块内存而不拷贝。
cpp
class Tensor {
std::shared_ptr<std::byte> data_; // 共享所有权
int32_t shape_[3]{0, 0, 0}; // 最多3维
Dtype dtype_{Dtype::Float32}; // FP16/FP32/Int32
int32_t numel_{0}; // 元素个数
size_t storage_capacity_{0}; // 分配的内存容量
};
关键设计是storage_capacity_:Tensor创建时按最大可能尺寸分配内存,之后resize()只改shape和numel,不重新分配。这在推理时特别重要------因为每层的输出尺寸在变(token数在增长),但内存只需要申请一次。
cpp
// 创建时按最大尺寸分配
Tensor acv_{Tensor({max_ctx, d_embed}, kFloat16)};
// 推理时只resize,不malloc
acv_.resize({n_ctx, d_embed}); // n_ctx <= max_ctx
2. FP16推理:省内存,但计算时转FP32
模型权重全部存成FP16(每个数2字节),比FP32(4字节)省一半内存。GPT Medium有3.45亿参数,FP32要1.38GB,FP16只要0.69GB。
但CPU做FP16运算很慢,所以项目里计算时先把FP16转成FP32,算完再转回去:
cpp
// FP16 -> FP32 的位运算转换(不用硬件指令,兼容所有CPU)
inline Float32 fp16_to_fp32(Float16 h) noexcept {
const uint32_t w = (uint32_t) h << 16;
const uint32_t sign = w & UINT32_C(0x80000000);
// ... 一系列位运算提取指数和尾数
return fp32_from_bits(result);
}
// FP32 -> FP16
inline Float16 fp32_to_fp16(Float32 f) noexcept {
const float scale_to_inf = fp32_from_bits(UINT32_C(0x77800000));
// ... 位运算压缩到16位
return (sign >> 16) | nonsign;
}
这些转换函数全是inline的,编译器会把它直接嵌入到调用处,没有函数调用开销。
3. SIMD加速:一次算8个数
矩阵乘法是推理的瓶颈。这个项目手写了一套AVX向量化封装,核心就三个函数:
cpp
Vec_f32x8 vec_f32x8_load(const Float16* ptr); // 加载8个FP16,转FP32
Vec_f32x8 vec_f32x8_fma(Vec_f32x8 a, Vec_f32x8 b, Vec_f32x8 c); // 乘加融合
void vec_f32x8_store(Vec_f32x8 val, Float16* ptr); // 存回FP16
float vec_f32x8_sum(Vec_f32x8 val); // 8个数求和
Linear层(全连接层)的核心计算就是这样:
cpp
for (int ctx_idx = ctx_start_idx; ctx_idx < n_ctx; ctx_idx++) {
for (int out_idx = 0; out_idx < d_out; out_idx++) {
Vec_f32x8 dot_accum = vec_f32x8_setzero();
// 每次处理8个维度
for (int i = 0; i < d_embed; i += 8) {
Vec_f32x8 x = vec_f32x8_load(inp_data + (ctx_idx * d_embed + i));
Vec_f32x8 w = vec_f32x8_load(weight_data + (out_idx * d_embed + i));
dot_accum = vec_f32x8_fma(x, w, dot_accum); // dot_accum += x * w
}
float bias = fp16_to_fp32(bias_data[out_idx]);
float res = vec_f32x8_sum(dot_accum) + bias;
acv_data[...] = fp32_to_fp16(res);
}
}
AVX寄存器一次能装8个float,所以内层循环步长是8。原本要循环768次,现在只要96次,理论加速比就是8倍。实际测下来大约快4倍(因为还有数据加载、转换等开销)。
4. Activation Cache:增量推理的精髓
这是让生成速度不随长度爆炸的关键。假设你已经生成了10个token,现在要预测第11个。
没有Cache时:你把全部10个token塞进模型,12层Transformer每层都算10个位置,输出10个结果,但你只关心最后一个。
有Cache时 :第一次处理prompt时,所有层的输出都存到acv_里。之后每来一个新token,每层只算最后一行。
代码里每个模块都有这么一段逻辑:
cpp
Tensor LayerNorm::forward_impl(const Tensor &inp) {
acv_.resize({inp.size(0), inp.size(1)});
if (acv_cached_) {
// 只算新来的那个token对应的一行
const int ctx_offset = (n_ctx - 1) * d_embed;
// ... 对这一行做mean、std、normalize
} else {
acv_cached_ = true;
// 第一次:全部算
for (int ctx_i = 0; ctx_i < n_ctx; ctx_i++) {
// ... 对每一行做normalize
}
}
return acv_;
}
Embedding、Linear、GELU、Residual都是同样的套路。MultiHeadSelfAttn稍微复杂一点,它的Cache分两部分:
- Q/K/V的Linear投影:只算新token的投影(因为输入只多了一个token)
- Attention计算:Q只和新token交互,但K和V要能看到全部历史
cpp
Tensor MultiHeadSelfAttn::masked_qkv_attn(const Tensor &q, const Tensor &k, const Tensor &v) {
if (qkv_cached_) {
// 只算最后一行query跟所有key的分数
qk_attn_matmul(q, k, qk_acv_, n_heads_, /*masked=*/true, /*ctx_offset=*/n_ctx-1);
qk_softmax(qk_acv_, n_heads_, /*ctx_offset=*/n_ctx-1);
// 只算最后一行的加权求和
qkv_attn_matmul(qk_acv_, v, qkv_acv_, n_heads_, /*ctx_offset=*/n_ctx-1);
} else {
qkv_cached_ = true;
// 全算
qk_attn_matmul(q, k, qk_acv_, n_heads_, /*masked=*/true);
qk_softmax(qk_acv_, n_heads_);
qkv_attn_matmul(qk_acv_, v, qkv_acv_, n_heads_);
}
}
ctx_offset参数告诉矩阵乘法:从哪一行开始算。之前的结果已经在acv_里了,不用动。
5. 多头注意力:三个步骤清清楚楚
注意力计算被拆成了三个独立的函数,比包成一个黑盒好懂多了:
Step 1: Q @ K^T(算分数)
cpp
static void qk_attn_matmul(const Tensor& q, const Tensor& k, Tensor& qk_out,
const int n_head, const bool mask_output, int ctx_offset=0) {
const int d_head = d_embed / n_head;
const Float32 scale_factor = 1.0f / sqrtf(d_head);
for (int head = 0; head < n_head; head++) {
for (int q_row = ctx_offset; q_row < q_ctx; q_row++) {
// Causal Mask:只看前面的token
const int k_max = mask_output ? q_row + 1 : k_ctx;
for (int k_col = 0; k_col < k_max; k_col++) {
// 算这一行query和这一列key的点积
Vec_f32x8 dot_accum = vec_f32x8_setzero();
for (int i = 0; i < d_head; i += 8) {
Vec_f32x8 qw = vec_f32x8_load(q_data + head*d_head + q_row*d_embed + i);
Vec_f32x8 kw = vec_f32x8_load(k_data + head*d_head + k_col*d_embed + i);
dot_accum = vec_f32x8_fma(qw, kw, dot_accum);
}
qk_data[...] = fp32_to_fp16(vec_f32x8_sum(dot_accum) * scale_factor);
}
}
}
// Causal Mask:把后面的位置填 -INFINITY
if (mask_output) {
for (...) {
for (int k_col = q_row + 1; k_col < k_ctx; k_col++) {
qk_data[...] = fp32_to_fp16(-INFINITY);
}
}
}
}
Step 2: Softmax(归一化)
cpp
static void qk_softmax(Tensor& qk_acv, int n_heads, int ctx_offset=0) {
for (int head = 0; head < n_heads; head++) {
for (int q_row = ctx_offset; q_row < q_ctx; q_row++) {
// 找最大值(数值稳定性)
Float32 max = -INFINITY;
for (int i = 0; i < k_ctx; i++) {
if (qk_data[i] > max) max = qk_data[i];
}
// 算exp并求和
Float32 sum_exp = 0;
for (int i = 0; i < k_ctx; i++) {
Float32 exp_val = expf(qk_data[i] - max);
qk_data[i] = fp32_to_fp16(exp_val);
sum_exp += exp_val;
}
// 归一化
for (int i = 0; i < k_ctx; i++) {
qk_data[i] = fp32_to_fp16(fp16_to_fp32(qk_data[i]) / sum_exp);
}
}
}
}
Step 3: Attn @ V(加权求和)
cpp
static void qkv_attn_matmul(const Tensor& qk, const Tensor& v, Tensor& qkv_acv,
int n_head, int ctx_offset=0) {
for (int head = 0; head < n_head; head++) {
for (int qk_row = ctx_offset; qk_row < q_ctx; qk_row++) {
for (int v_row = 0; v_row < d_head; v_row++) {
// 先向量化算大部分
Vec_f32x8 dot_prod_accum = vec_f32x8_setzero();
for (int i = 0; i < vec_n_ctx; i += 8) {
Vec_f32x8 qkw = vec_f32x8_load(qk_data + ...);
Vec_f32x8 vw = vec_f32x8_load(v_data + ...);
dot_prod_accum = vec_f32x8_fma(qkw, vw, dot_prod_accum);
}
// 尾部用标量处理(长度不是8的倍数时)
float dot_prod = vec_f32x8_sum(dot_prod_accum);
for (int i = vec_n_ctx; i < kv_ctx; i++) {
dot_prod += fp16_to_fp32(qk_data[i]) * fp16_to_fp32(v_data[i]);
}
qkv_data[...] = fp32_to_fp16(dot_prod);
}
}
}
}
三个函数独立、可测、好懂。向量化部分处理8的倍数,尾部用标量收尾,避免越界。
6. Whisper支持:Encoder-Decoder架构
除了GPT,这个项目还支持Whisper(OpenAI的语音识别模型)。Whisper比GPT复杂,因为它有两个部分:
AudioEncoder:把音频转成特征向量
- 两层Conv1d(降采样)
- N个Transformer Block(没有Causal Mask,因为音频是完整的)
Decoder:把特征向量转成文字
- Self-Attention(有Causal Mask,自回归生成)
- Cross-Attention(看Encoder的输出,类似翻译模型的注意力)
- MLP
Cross-Attention的实现很有意思:Key和Value来自Encoder,只算一次就缓存起来;Query来自Decoder,每步更新。
cpp
Tensor MultiHeadCrossAttn::forward(const Tensor& x, const Tensor& xa) {
Tensor q = query.forward(x);
if (!kv_cached_) {
k_cache = key.forward(xa); // Encoder输出,只算一次
v_cache = value.forward(xa); // 同上
kv_cached_ = true;
}
const Tensor qkv = qkv_attn(q, k_cache, v_cache);
return qkv_proj.forward(qkv);
}
Whisper的Decoder还有一套Timestamp Rules:强制模型按"时间戳+文本+时间戳"的格式输出,这样转录结果能跟音频时间对齐。
四、相关领域知识点
| 知识点 | 在这项目里的体现 |
|---|---|
| FP16推理 | 权重存FP16,计算转FP32,省内存、保精度 |
| SIMD向量化 | AVX 256位寄存器一次处理8个float |
| FMA融合乘加 | a*b+c一条指令完成,减少延迟 |
| Activation Cache | 增量推理的核心,避免重复计算 |
| Causal Mask | 自回归生成的核心约束,用-INFINITY实现 |
| LayerNorm | 减均值、除标准差、乘权重、加偏置 |
| GELU激活 | 0.5*x*(1+tanh(sqrt(2/π)*(x+0.044715*x^3))) |
| Shared Pointer | C++的引用计数,多个Tensor共享同一块内存 |
| Im2Col | 把1D卷积转成矩阵乘法,复用Linear的优化 |
| Temperature采样 | 除temperature后再softmax,控制随机性 |
五、设计思路:为什么这样设计?
| 设计选择 | 为什么这么做 | 生产框架会怎么做 |
|---|---|---|
| 纯C++17,零依赖 | 任何人都能编译,不用配环境 | 链接PyTorch、CUDA、cuBLAS |
| 单头文件+单cpp | 代码最短,结构最清晰 | 几百个文件,复杂的构建系统 |
| FP16权重 | 省一半内存,Cache命中率更高 | 量化到INT8/INT4,更复杂 |
| 手动SIMD封装 | 不依赖编译器自动向量化,可控 | 调OpenBLAS/MKL,黑盒 |
| Activation Cache | 推理速度不随长度增长 | 更复杂的KV Cache管理 |
| 每个模块独立Timer | 一眼看出瓶颈在哪 | TensorBoard、Chrome Trace |
| 3维Tensor上限 | 推理场景够用了,代码简单 | 支持任意维度,更通用但更复杂 |
六、代码实现用途
- 学习Transformer推理的完整链路:从Embedding到采样,每一层都是手写的
- 理解FP16和SIMD优化:看手写代码比看编译器生成的汇编直观多了
- 作为嵌入式/边缘设备的起点:纯C++、无依赖,容易移植到各种平台
- 性能分析教学:每个模块的耗时统计,是理解推理瓶颈的好教材
- Whisper理解:Encoder-Decoder+Cross-Attention的完整实现
七、流程原理图
图1:整体架构

If you need the complete source code, please add the WeChat number (c17865354792)
图2:Activation Cache 增量计算

图3:FP16 + SIMD 加速

八、怎么跑起来?一步步来
1. 跑 GPT
bash
cd gpt/
# 基础编译(任何CPU都能跑)
g++ -std=c++17 -O3 -I../ -ffast-math gpt.cpp -o gpt
# 如果你的Intel CPU支持AVX和F16C(2013年之后的CPU基本都支持),加这两个flag快4倍
g++ -std=c++17 -O3 -I../ -ffast-math -mavx -mf16c gpt.cpp -o gpt
# 运行:单条prompt
./gpt -p "Once upon a time"
# 运行:聊天模式(交互式)
./gpt
# 指定模型大小
./gpt -sm -p "Hello" # Small (117M)
./gpt -md -p "Hello" # Medium (345M,默认)
./gpt -lg -p "Hello" # Large (762M)
# 调整采样参数
./gpt -md --temp 0.5 --len 100 -p "The future of AI is"
第一次运行会自动下载模型权重(通过Python脚本),下载完后存到本地,下次直接用。
2. 跑 Whisper(语音识别)
bash
cd whisper/
# 编译
g++ -std=c++17 -O3 -I../ -ffast-math -mavx -mf16c whisper.cpp -o whisper
# 运行(需要16kHz采样率的wav文件)
./whisper assets/Elon.wav
# 指定模型大小
./whisper -tiny your_audio.wav
./whisper -base your_audio.wav
./whisper -small your_audio.wav
3. 看性能报告
GPT运行完后会打印每层耗时:
--------------------------------------
LAYER/OP TIME PER TOKEN TIME TOTAL
--------------------------------------
Embedding | 2ms | 400ms
Embedding proj | 5ms | 1000ms
Pos embedding | 1ms | 200ms
Linear(qkv+mlp) | 15ms | 3000ms
Attention | 8ms | 1600ms
Layer norm | 1ms | 200ms
Gelu | 2ms | 400ms
Residual | 1ms | 200ms
Sampler | 3ms | 600ms
--------------------------------------
TOTAL | 38ms | 7600ms
--------------------------------------
一眼就能看出:Linear层(矩阵乘法)和Attention层是大头,优化这两块收益最大。
4. 常见问题
- 编译报错找不到AVX指令 :去掉
-mavx -mf16c,用基础版本 - 模型下载失败 :检查网络,或者手动运行
python model_dl.py下载 - 内存不够 :用
-sm跑小模型(117M参数,约200MB内存) - Whisper音频格式不对:必须是16kHz、单声道、16bit的wav文件
九、总结一下
这个项目最大的价值,是让你看到一个"能跑"的Transformer推理引擎可以有多小、多直白。
它用纯C++实现了:
- 完整的Tensor系统(FP16/FP32/Int32)
- 手写的FP16↔FP32位运算转换
- AVX SIMD向量化加速
- Activation Cache增量推理
- GPT和Whisper两个完整模型
而且全部代码加起来可能就几千行,没有PyTorch的层层封装,没有CUDA的复杂配置,每个vec_f32x8_fma、每个acv_cached_标志都清清楚楚。
如果你想真正理解"CPU推理是怎么优化的",而不是只会调包,这个项目就是最好的起点。把代码通读一遍,亲手改改temperature、层数、采样策略,你会对Transformer推理有一个完全不一样的体感。
Welcome to follow WeChat official account【程序猿编码】