『7x24小时有问必答』

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

现在跑大模型,大家的默认思路是:上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。
量化逻辑很简单:
# 对每一层的权重矩阵

max_val = np.max(np.abs(weights))

scale = max_val /  127.0

weights_int8 = np.round(weights / scale).astype(np.int8)

推理时,C++代码把INT8权重加载到内存,计算时先反量化回FP32:
// 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) * scale;

      result += x * 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做乘加。
核心循环大概长这样:
// 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直接扔掉。
// Min-P 采样逻辑

float  max_prob = -INFINITY;

for  (int  i =  0; i < vocab_size; i++) {

       if  (probs > max_prob) max_prob = probs;

}

float  threshold = max_prob * min_p;

int  num_candidates =  0;

for  (int  i =  0; i < vocab_size; i++) {

       if  (probs >= 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追加到末尾。
// 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依赖

pip install -r requirements.txt

2. 下载模型权重

需要先登录HuggingFace(需要token):
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

# 把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. 编译

make gemma

要求:
GCC 10+ 或 Clang(支持C++20)
CPU支持AVX2(2013年之后的Intel/AMD CPU基本都支持)

6. 运行推理

带Min-P采样的对话模式:
./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的对话格式
贪心解码(最确定的输出):
./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测试(长上下文)

make passkey

./build/passkey \

      --weights_path weights/gemma_i8.bin \

      --model_size  "270m"  \

      --n_garbage 16384

这个测试会在prompt里塞16384个随机字符,然后在中间藏一个"密码",看模型能不能在生成时正确回忆出来。用来验证长上下文能力。
注意:长prompt在单线程CPU上处理很慢,可能需要几分钟。

8. 跑单元测试

# 编译测试

make unittests

# 生成参考数据(用NumPy实现跑一遍,保存中间结果)

./tests/scripts/unittests/create_test_data.sh

# 运行测试

./tests/scripts/unittests/run_tests.sh

单元测试会把C++的每一层输出跟NumPy参考实现对比,确保量化、矩阵乘法、注意力、MLP等每个模块都算对了。
集成测试(需要下载完整的safetensors权重):
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【程序猿编码】

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

本帖子中包含更多资源

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

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

本版积分规则

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

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

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


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