一、先说说这玩意儿是干嘛的
现在跑大模型,大家的默认思路是:上GPU、开多线程、调各种推理框架。但有时候你只是想在自己的笔记本上快速试个东西,或者你的场景根本不允许用GPU(比如嵌入式设备、边缘计算节点)。这时候,CPU单线程推理就成了唯一选择。
这个项目干的事,就是把CPU单线程推理推到了极致 。它支持Google最新的Gemma 3模型(270M和1B参数版本),用INT8量化把模型体积砍到四分之一,再用AVX2指令集把矩阵乘法向量化,最终在一个普通的Ryzen 5 CPU上,单线程解码速度达到了58.8 token/秒------同条件下llama.cpp单线程只有19.2 token/秒,快了整整3倍。
最狠的是,它真的零外部依赖。不链接BLAS、不调用MKL、不依赖任何第三方库。就一个Makefile,几行命令,就能让Gemma 3在你的CPU上吐文字。
二、具体含义:它到底包含哪些东西?
| 模块 | 干啥的 | 为什么重要 |
|---|---|---|
| Python转换脚本 | 把HuggingFace的safetensors权重转成INT8二进制格式 | 量化是省内存的关键一步 |
| Tokenizer转换 | 把SentencePiece模型转成自定义二进制格式 | 推理时快速加载,不用依赖protobuf |
| Embedding层 | Token查表+可学习的位置偏置 | Gemma的输入接口 |
| RMSNorm | 均方根归一化 | Gemma标配,比LayerNorm少一步 |
| Q/K/V投影 | INT8权重 × FP32激活值 | 模型的"肌肉",占绝大部分计算量 |
| RoPE | 旋转位置编码 | 让模型知道每个token的位置 |
| GQA注意力 | 分组查询注意力 | 减少KV Cache内存,加速解码 |
| MLP (GeGLU) | 门控线性单元 | Gemma的FFN结构 |
| KV Cache | 缓存历史K/V | 自回归生成的核心优化 |
| Min-P采样 | 动态阈值过滤+温度控制 | 比Top-K/Top-P更适合小模型 |
| 单元测试 | 跟NumPy参考实现逐层对比 | 确保每行C++代码都算对了 |
三、代码实现原理:拆开来看
1. INT8权重量化:省内存的秘诀
模型权重从HuggingFace下载下来是FP32格式(每个数4字节)。270M参数的模型,FP32要占约1GB内存。这个项目在Python转换脚本里把权重全部量化成INT8(每个数1字节),加上每层的scale因子,总体积降到约270MB。
量化逻辑很简单:
python
# 对每一层的权重矩阵
max_val = np.max(np.abs(weights))
scale = max_val / 127.0
weights_int8 = np.round(weights / scale).astype(np.int8)
推理时,C++代码把INT8权重加载到内存,计算时先反量化回FP32:
cpp
// INT8 矩阵乘法的核心逻辑
// 激活值 x 是 FP32,权重 w 是 INT8
float result = 0.0f;
for (int i = 0; i < dim; i++) {
// w_int8 反量化:w_int8 * scale
float w_dequant = static_cast<float>(w_int8[i]) * scale;
result += x[i] * w_dequant;
}
这里有个小技巧:(x · w_int8) × scale 和 x · (w_int8 × scale) 数学上是等价的,但前者可以先做整数点积,最后乘一次scale,减少浮点运算次数。
2. AVX2向量化:一次算8个INT8
矩阵乘法是推理的绝对瓶颈。这个项目用AVX2指令集(Intel Haswell/AMD Ryzen之后的CPU都支持)手写了一套向量化内核。
AVX2的256位寄存器可以一次装:
- 8个FP32(32位×8=256位)
- 16个INT16(16位×16=256位)
- 32个INT8(8位×32=256位)
对于INT8权重,最理想的方案是一次处理32个INT8。但INT8和FP32混算需要先把INT8扩展成FP32或INT16,实际代码通常用_mm256_cvtepi8_epi16把8个INT8扩展成8个INT16,然后用FP16或FP32做乘加。
核心循环大概长这样:
cpp
// AVX2 优化的 INT8 × FP32 矩阵乘法
__m256 acc = _mm256_setzero_ps(); // 8个float的累加器
for (int i = 0; i < dim; i += 8) {
// 加载8个FP32激活值
__m256 x_vec = _mm256_loadu_ps(x + i);
// 加载8个INT8权重,扩展成INT16,再扩展成FP32
__m128i w_i8 = _mm_loadu_si64(w + i); // 8个int8
__m256i w_i16 = _mm256_cvtepi8_epi16(w_i8); // 扩展成16个int16(只用低8个)
__m256 w_f32 = _mm256_cvtepi32_ps(_mm256_cvtepi16_epi32(w_i16)); // 扩展成8个float
// 反量化:w_f32 * scale
w_f32 = _mm256_mul_ps(w_f32, _mm256_set1_ps(scale));
// FMA: acc += x_vec * w_f32
acc = _mm256_fmadd_ps(x_vec, w_f32, acc);
}
// 水平求和:把8个float加起来
float result = hsum256_ps(acc);
_mm256_fmadd_ps是FMA(Fused Multiply-Add)指令,一条指令完成a*b+c,比先乘后加少一次舍入误差,而且延迟更低。
3. Gemma 3的模型结构
Gemma 3是Google开源的一系列轻量级语言模型。这个项目支持的两个版本:
| 参数 | 270M | 1B |
|---|---|---|
| 层数 | 26 | 28 |
| 注意力头数 | 4 | 4 |
| KV头数(GQA) | 1 | 1 |
| 嵌入维度 | 1536 | 2048 |
| MLP中间维度 | 6144 | 8192 |
| 上下文长度 | 128K | 128K |
Gemma 3有几个值得注意的设计:
可学习的位置偏置 :不像GPT-2用固定的正弦位置编码,Gemma的Embedding层里有一个可学习的embed_positions参数,直接加到token嵌入上。
GQA(Grouped Query Attention):4个Query头共享1个K头和1个V头。这样KV Cache的内存只需要原来的1/4,解码时加载K/V的带宽也少了3/4。
GeGLU激活 :MLP不是传统的up_proj → GELU → down_proj,而是gate_proj × SiLU(up_proj) → down_proj。门控机制让模型能选择性激活不同的神经元。
RMSNorm :和LayerNorm的区别是不减均值,直接除以均方根。公式是:output = x / sqrt(mean(x²) + eps) * weight。
4. Min-P采样:小模型的最佳采样策略
传统的采样方法有Greedy(贪心)、Top-K(保留前K个)、Top-P(核采样)。这个项目实现的是Min-P,它对小模型特别有效。
Min-P的核心思想是:先找到概率最高的token,然后只保留概率超过max_prob × p_threshold的token 。比如最高概率是0.3,p_threshold是0.1,那阈值就是0.03,所有概率低于0.03的token直接扔掉。
cpp
// Min-P 采样逻辑
float max_prob = -INFINITY;
for (int i = 0; i < vocab_size; i++) {
if (probs[i] > max_prob) max_prob = probs[i];
}
float threshold = max_prob * min_p;
int num_candidates = 0;
for (int i = 0; i < vocab_size; i++) {
if (probs[i] >= threshold) {
candidates[num_candidates++] = i;
}
}
// 在候选集里按temperature重新采样
// temperature越低,越倾向于选概率高的
Min-P的好处是:它不会死板地只保留固定数量的token(Top-K的问题),也不会因为长尾分布而保留太多垃圾token(Top-P的问题)。对小模型来说,Min-P能在"多样性"和"质量"之间找到更好的平衡。
5. KV Cache:解码速度的生命线
自回归生成时,每个新token都需要看前面所有token的K和V。如果没有Cache,每步都要重新算一遍,时间随长度平方增长。
这个项目的KV Cache实现很直接:每层预先分配一块足够大的内存,生成时把新token的K/V追加到末尾。
cpp
// KV Cache 结构(简化示意)
struct KVCache {
float* k; // [n_layers, max_seq_len, n_kv_heads, head_dim]
float* v; // 同上
int seq_len; // 当前已缓存的长度
};
// 推理时
void forward(KVCache& cache, int pos, ...) {
// 只算当前位置pos的K和V
compute_k(q_proj_output, cache.k + pos * kv_stride);
compute_v(k_proj_output, cache.v + pos * kv_stride);
// Attention计算时,从cache里读全部历史K/V
for (int t = 0; t <= pos; t++) {
float* k_t = cache.k + t * kv_stride;
float* v_t = cache.v + t * kv_stride;
// 算注意力分数...
}
}
GQA让KV Cache的内存需求大幅降低。以270M模型为例:26层、1个KV头、head_dim=384、max_seq_len=128K,总内存大约是26 × 128000 × 1 × 384 × 2(K+V) × 4字节 ≈ 10GB。实际运行时不会用到128K,通常几百到几千token就够了。
四、相关领域知识点
| 知识点 | 在这项目里的体现 |
|---|---|
| INT8量化 | 权重存INT8,计算时反量化,省75%内存 |
| AVX2 SIMD | 256位寄存器,一次处理8个FP32或32个INT8 |
| FMA指令 | _mm256_fmadd_ps,乘加融合,减少延迟 |
| GQA | 4个Query共享1个KV头,省3/4 KV Cache内存 |
| RoPE | 旋转位置编码,二维旋转注入位置信息 |
| RMSNorm | 不减均值,直接除以均方根 |
| GeGLU | 门控线性单元,SiLU作门控信号 |
| Min-P采样 | 动态阈值,比Top-K/Top-P更适合小模型 |
| INT8反量化技巧 | (x·w_int8)×scale减少浮点运算 |
| 单线程优化 | 没有线程同步开销,Cache利用率更高 |
五、设计思路:为什么这样设计?
| 设计选择 | 为什么这么做 | 生产框架会怎么做 |
|---|---|---|
| 单线程 | 没有锁竞争,Cache命中率高,代码简单 | 多线程+线程池,调度复杂 |
| INT8权重量化 | 省内存、省带宽,CPU Cache能装更多数据 | FP16/INT4,更复杂但更快 |
| AVX2手写内核 | 不依赖编译器自动向量化,可控可测 | 调OpenBLAS/MKL,黑盒 |
| 零外部依赖 | 一个Makefile搞定,任何人都能编译 | CMake+Conda+vcpkg,环境地狱 |
| C++20 | std::span、concept等新特性让代码更简洁 |
C++11/14兼容旧编译器 |
| Min-P采样 | 小模型上效果比Top-K/Top-P更好 | 通常实现Top-K+Top-P组合 |
| 逐层单元测试 | 跟NumPy参考实现对比,确保正确性 | 端到端测试,定位问题难 |
这些选择的叠加效果就是:代码量小、编译简单、运行飞快。270M模型单线程解码58.8 token/秒,意味着你输入一个问题,模型每秒能吐出将近60个字,完全够用。
六、代码实现用途
- 学习INT8量化和AVX2优化:手写代码比看框架源码直观100倍
- 理解Gemma 3的模型结构:GQA、GeGLU、可学习位置偏置,全部自己实现
- 作为嵌入式/边缘设备的起点:纯C++、单线程、无依赖,移植极简单
- 性能基准测试:单线程CPU推理的上限参考
- 教学演示:从量化到采样,完整链路一手掌握
七、流程原理图
图1:整体架构

If you need the complete source code, please add the WeChat number (c17865354792)
图2:INT8权重量化

图3:性能对比

八、怎么跑起来?一步步来
1. 安装Python依赖
bash
pip install -r requirements.txt
2. 下载模型权重
需要先登录HuggingFace(需要token):
bash
huggingface-cli login
# 或者
hf auth login
# 下载270M指令模型的tokenizer和权重
hf download google/gemma-3-270m-it tokenizer.model --local-dir weights
hf download google/gemma-3-270m-it model.safetensors --local-dir weights
4. 转换权重和tokenizer
bash
# 把safetensors转成INT8二进制格式
python3 -m py.convert \
--weights-in-path 'weights/model.safetensors' \
--weights-out-path 'weights/gemma_i8.bin' \
--model-size '270m' \
--tok-in-path 'weights/tokenizer.model' \
--tok-out-path 'weights/tokenizer_gemma3.bin'
转换完成后,weights/目录下会有:
gemma_i8.bin:INT8量化后的模型权重tokenizer_gemma3.bin:二进制格式的tokenizer
5. 编译
bash
make gemma
要求:
- GCC 10+ 或 Clang(支持C++20)
- CPU支持AVX2(2013年之后的Intel/AMD CPU基本都支持)
6. 运行推理
带Min-P采样的对话模式:
bash
./build/gemma \
--weights_path weights/gemma_i8.bin \
--model_size "270m" \
--n_dec 250 \
--minp 0.1 \
--temp 0.7 \
--prompt "What is a transformer?" \
--terminate_on_eos 1 \
--chat_format 1
参数说明:
--weights_path:模型权重文件路径--model_size:模型大小(270m或1B)--n_dec:最多生成多少个token--minp:Min-P采样阈值(0~1,越小越保守)--temp:温度(越低越确定,越高越随机)--prompt:输入提示--terminate_on_eos:遇到结束符就停止--chat_format:使用Gemma的对话格式
贪心解码(最确定的输出):
bash
./build/gemma \
--weights_path weights/gemma_i8_1B.bin \
--model_size "1B" \
--n_dec 250 \
--prompt "What is one difference between GPT2 and BERT?" \
--terminate_on_eos 1 \
--chat_format 1
不加--minp就是贪心解码,每次都选概率最高的token。
7. 跑Passkey测试(长上下文)
bash
make passkey
./build/passkey \
--weights_path weights/gemma_i8.bin \
--model_size "270m" \
--n_garbage 16384
这个测试会在prompt里塞16384个随机字符,然后在中间藏一个"密码",看模型能不能在生成时正确回忆出来。用来验证长上下文能力。
注意:长prompt在单线程CPU上处理很慢,可能需要几分钟。
8. 跑单元测试
bash
# 编译测试
make unittests
# 生成参考数据(用NumPy实现跑一遍,保存中间结果)
./tests/scripts/unittests/create_test_data.sh
# 运行测试
./tests/scripts/unittests/run_tests.sh
单元测试会把C++的每一层输出跟NumPy参考实现对比,确保量化、矩阵乘法、注意力、MLP等每个模块都算对了。
集成测试(需要下载完整的safetensors权重):
bash
make integrations
./tests/scripts/integrations/create_test_weights_logits.sh
./tests/scripts/integrations/run_test.sh
集成测试跑255步贪心解码,对比C++和Python输出的token序列和logits分布。
9. 常见问题
- 编译报错找不到AVX2 :检查CPU是否支持AVX2(
cat /proc/cpuinfo | grep avx2),不支持的话这个项目跑不了 - hf download失败:检查HuggingFace登录状态和token权限,Gemma模型需要同意使用协议
- 内存不足:270M模型需要约300MB内存,1B模型需要约1GB内存
- 生成速度很慢 :确认编译时开了优化(
make会自动加-O3 -march=native),检查是否在用Debug模式
九、总结一下
这个项目最大的价值,是让你看到CPU单线程推理可以有多快。
它用纯C++20实现了:
- INT8权重量化(省75%内存)
- AVX2向量化矩阵乘法(一次算8个FP32)
- Gemma 3的完整模型结构(GQA、GeGLU、RMSNorm、RoPE)
- Min-P采样策略
- 逐层单元测试确保正确性
而且全部代码加起来可能就几千行,没有复杂的构建系统,没有依赖地狱,一个make就能编译,一条命令就能运行。
270M模型单线程58.8 token/秒的解码速度,意味着你在一个普通的办公笔记本上,就能流畅地跟Gemma 3对话。如果你想理解"CPU推理是怎么优化的",或者需要一个轻量、可移植、无依赖的推理方案,这个项目就是最好的起点。把代码通读一遍,亲手改改量化精度、采样温度、模型大小,你会对INT8量化和AVX2优化有一个完全不一样的体感。
Welcome to follow WeChat official account【程序猿编码】