不用GPU,不用多线程,我用C++20手写了一个Gemma 3推理引擎

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

现在跑大模型,大家的默认思路是:上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) × scalex · (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::spanconcept等新特性让代码更简洁 C++11/14兼容旧编译器
Min-P采样 小模型上效果比Top-K/Top-P更好 通常实现Top-K+Top-P组合
逐层单元测试 跟NumPy参考实现对比,确保正确性 端到端测试,定位问题难

这些选择的叠加效果就是:代码量小、编译简单、运行飞快。270M模型单线程解码58.8 token/秒,意味着你输入一个问题,模型每秒能吐出将近60个字,完全够用。

六、代码实现用途

  1. 学习INT8量化和AVX2优化:手写代码比看框架源码直观100倍
  2. 理解Gemma 3的模型结构:GQA、GeGLU、可学习位置偏置,全部自己实现
  3. 作为嵌入式/边缘设备的起点:纯C++、单线程、无依赖,移植极简单
  4. 性能基准测试:单线程CPU推理的上限参考
  5. 教学演示:从量化到采样,完整链路一手掌握

七、流程原理图

图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:模型大小(270m1B
  • --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【程序猿编码

相关推荐
云和数据.ChenGuang1 小时前
fastapi的参数剖析
人工智能·深度学习·机器学习·语言模型·状态模式·fastapi
咖啡星人k2 小时前
想私有化部署 AI 开发平台?MonkeyCode 给出的答案是开源 + 离线
人工智能·大模型·ai编程·monkeycode
ArkAPI3 小时前
Claude Code 连发安全修复:AI 编程 Agent 的权限,正在成为新的“安全事故高发区”
人工智能·大模型·api·codex·ai工具·claude code·arkapi
cfm_29144 小时前
SpringAI + Ollama 本地大模型
java·开发语言·人工智能·语言模型
Tisfy5 小时前
Codex:通过编辑配置文件添加带Bearer的自定义MCP
数据库·大模型·agent·codex·mcp
dogstarhuang8 小时前
大模型 API 停服怎么办:用 API 网关实现多模型统一接入与可切换架构
人工智能·后端·架构·大模型·api·数字化转型·ai应用
thesky1234569 小时前
27届大模型面试准备(三十二):RAG 进阶——从朴素 RAG 到 Agentic RAG 与多模态检索增强
大模型·rag·agentic rag·graph rag·进阶rag·self-rag·corrective rag
thesky12345611 小时前
27届大模型面试准备(三十一):多模态推理与视觉思维链——从“看见“到“想明白“
大模型·具身推理·空间推理·多模态推理·视觉思维链·visual cot·图表推理
男孩李12 小时前
浅谈JiuwenSwarm安装
人工智能·语言模型·自然语言处理