如果你跑过 CPU 上的大模型推理,八成遇到过这种场面:模型能跑,但慢得让人怀疑人生。翻 perf 一看,CPU 占用率要么虚高但吞吐上不去,要么只有四五个核在忙、其余全在围观。很多人第一反应是“算力不够”,但我自从把 DBHOO-CIM 这套 INT4 量化部署方案从实验推到线上之后,可以负责任地说一句:对大多数生成式模型来说,CPU 推理的真正瓶颈从来不是算力,而是内存带宽。
DBHOO-CIM 说穿了就是一套面向 CPU 场景的 INT4 量化推理模块,前面那半截承担模型压缩和权重打包的脏活,后面那半截负责运行时算子和线程调度。它解决的问题很具体:模型权重太肥、搬运太慢、CPU 的计算单元大量时间在空等数据。这篇文章把我从选型、踩坑到榨带宽的全过程写清楚,包括量化参数怎么算、权重怎么打包、SIMD 怎么写、多线程怎么切分、以及最后是怎么把带宽利用率干到 85% 以上的。适合正在做 CPU 推理优化、或者准备把模型塞进没有 GPU 的机器里跑的人参考。
1. 先搞清楚瓶颈:CPU 推理慢,真的是因为算力不够吗?
1.1 算力过剩的年代,卡的是内存搬运
先说一个反常识的结论:CPU 推理大模型时,真正瓶颈是数据从内存到寄存器的搬运速度,而不是 CPU 每秒能算多少次乘加。绝大多数神经网络层,尤其是 Transformer 里的线性层和 Embedding 层,都是“数据量大、计算量小”的结构。这些算子跑一轮下来,算术强度(arithmetic intensity,即每字节数据对应的浮点运算次数)往往很低,只有个位数。这类算子叫 memory-bound 算子,跑得快不快,完全看内存带宽。
你拿两个 CPU 对比就很直观:一颗核心多但内存频率低的至强,和一颗核心少但插着四通道 DDR5 的消费级处理器,跑同样的 7B 模型 INT4 推理,后者往往更快。原因很简单,权重矩阵要一整块一整块地从内存往缓存里搬,搬得快,算得才快。计算单元在那里闲着等数据,核心再多也没用。
1.2 量化的本质:把权重变瘦,减少搬运量
量化这个词在不同领域含义差别很大,金融那边搞量化是算交易信号,我这里说的是模型量化——把 FP16 甚至 FP32 的权重,映射到更低的比特位宽上存储和计算。INT8 是现在最常见的,INT4 则是把每个权重压到 4 个比特,也就是一个字节能塞下两个权重。
为什么要干这件事?因为内存带宽是物理上限,你没法凭空提高,但模型权重的大小是可以压缩的。带宽不变,权重从 14GB 变成 3.5GB,同样搬完一整份权重的时间就缩短到原来的四分之一。对 memory-bound 算子来说,推理延迟几乎和模型大小成正比。这就是 INT4 量化最核心的收益来源:不是把计算变快,而是把“要搬的东西”变少。
1.3 INT4 理论收益:带宽压力砍到四分之一
以 7B 模型为例,我来算一笔具体的账。FP16 权重占 14GB,INT4 占 3.5GB,假设机器内存带宽实测是 50GB/s:
- FP16:加载一遍全部权重需要 14 / 50 = 0.28 秒
- INT4:加载一遍全部权重需要 3.5 / 50 = 0.07 秒
只考虑权重搬运,理想情况下 INT4 能把单次前向的耗时压到 FP16 的四分之一。这个数字放在纯 CPU 推理场景里是非常可观的,相当于把一台机器的推理吞吐直接拉高四倍,还不用换硬件。
当然,这是理想值,实际跑起来还要算上激活值的读写、反量化带来的额外指令、以及缓存命中率等因素。但方向是非常清晰的:只要权重的内存占用降下来了,推理延迟就跟着降。这也是为什么 INT4 方案值得折腾。
2. DBHOO-CIM 的量化方案:不是简单把比特砍掉
2.1 对称量化与分组量化:精度和速度的平衡点
INT4 只有 16 个可表示的等级,如果量化策略选得不好,模型精度会掉得没法看。DBHOO-CIM 里权重默认走的是对称量化,公式很直接:
q = round(clamp(w / scale, -8, 7))
scale 等于权重绝对值的最大值除以 8。对称量化的好处是实现简单,反量化时只需要乘一个 scale,不需要处理 zero-point,SIMD 写起来省好多指令。实际效果上,对大多数已经训练好的模型权重来说,对称量化损失非常小,因为权重分布基本就是近似零均值的钟形分布,正负对称。
但有一个问题:如果整层共享一个 scale,那么这一层里那些绝对值特别大的离群权重会拉高 scale,导致其他多数权重的量化颗粒度变粗,精度受损。解决办法是分组量化。DBHOO-CIM 默认把权重按每 32 个元素一组,每组单独计算一个 scale。这样离群权重只影响它所在的那个小组,其他组的量化精度不被打扰。
为什么是 32 而不是 64 或 128?这是个实用主义的选择。分组越小精度越好,但 scale 的存储和计算开销也越高。32 这个值在 AVX-512 的 64 字节向量宽度下刚好能对齐,加载一个 scale 就能覆盖一组权重,后续用 SIMD 广播很顺手。实测下来,7B 模型按 32 分组做 INT4 PTQ,困惑度变化通常在 0.1 以内,完全可用。
2.2 权重量化和激活量化必须分开处理
很多初学者一上来就想把所有东西都量化成 INT4,包括激活值。这在 CPU 上是个大坑,我劝你谨慎。
激活值的分布是动态的,依赖输入数据,而且往往存在明显的离群点。静态校准很难把这些离群点全部覆盖,一旦激活量化误差变大,每一层都会把误差往下一层传播,最后输出直接崩掉。DBHOO-CIM 的做法是:权重全部 INT4 打包存储,但激活值保持 FP16/FP32 计算。反量化后的权重直接与 FP16 激活做矩阵乘,计算仍然以浮点精度进行。
你可能要问:那算力不是没有节省吗?没错,算力确实没节省多少,但前面说过,CPU 推理瓶颈在带宽,不在算力。权重从 FP16 变 INT4,最关键的搬运量已经降下来了。激活值占的是中间结果的带宽,影响面相对小,用浮点做反而省掉了动态反量化带来的控制流开销。这套“权重 INT4、激活浮点”的混合策略,是 CPU 推理场景下性价比极高的组合。
2.3 为什么偏偏选 INT4:INT8 与 FP8 的对比
实际动手之前,我也对比过 INT8 和 FP8。先说 INT8,它做起来当然更省心,工具链成熟,精度损失更小,但收益有限:权重从 FP16 到 INT8 只砍了一半,带宽压力只降一半。INT4 能降到四分之一,这个差距在 CPU 上非常显著。
FP8 呢?FP8 在 Hopper 这类 GPU 上有硬件加速,但消费级和服务器级 CPU 的指令集里没有一个原生支持 FP8 做矩阵乘的。也就是说,FP8 在 CPU 上要么被模拟成 FP16 计算,要么需要额外转换,带宽一点没省,还可能更慢。INT4 则不同,虽然 x86 指令集同样没有原生 INT4 矩阵乘,但 INT4 可以被高效地解包成 INT8 后利用 VNNI 指令计算,整个流程是划算的。
我把三个方案放在一起对比过:
| 方案 | 权重占用 | 相对 FP16 带宽 | CPU 指令支持 | 精度风险 |
|---|---|---|---|---|
| INT8 | 7GB(7B 模型) | 50% | AVX2/VNNI 原生支持 | 很低 |
| FP8 | 7GB(7B 模型) | 50% | 无原生支持,需模拟 | 低 |
| INT4 | 3.5GB(7B 模型) | 25% | 需解包后走 VNNI | 中等,可用分组量化控制 |
结论很清楚:想榨干 CPU 的带宽,INT4 是唯一能把数据体积压到四分之一、同时还有成熟计算路径的方案。
3. 实战步骤:从 FP16 模型到 INT4 部署
3.1 校准数据与 scale 计算
量化的第一步不是写算子,而是收集校准数据。我一开始犯过一个错:直接用 100 条训练集样本做校准,结果验证集上掉点严重。后来发现问题是样本太单一,覆盖的激活范围不够。校准数据的选取原则是“多样、代表真实推理分布”,最好从验证集或者实际业务日志里抽,覆盖各种长度和内容类型的输入。
拿到校准数据后,逐层统计权重分布。对每一层每个分组,计算:
scale = max(abs(weights)) / 8
然后对权重做映射:
q = clamp(round(weight / scale), -8, 7)
这里有一个小细节:INT4 表示范围是 -8 到 7,不是 -7 到 7。对称量化时,负方向多一个档位(-8),意味着负半轴的表示范围和正半轴是不完全对称的。实际实现中要小心处理边界值,round 之后如果超出了 [-8, 7],需要 clamp。
这个步骤在 PyTorch 里做非常方便,核心逻辑可以这样写:
import torch import numpy as np def quantize_group(group_weights): # 输入: shape [n] 的 FP32/FP16 权重 scale = group_weights.abs().max() / 8.0 scale = max(scale, 1e-12) # 防止除零 q = torch.clamp(torch.round(group_weights / scale), -8, 7).to(torch.int8) return q, scale def quantize_weight_matrix(weight, group_size=32): # weight: [out_features, in_features] orig_shape = weight.shape flat = weight.reshape(-1, group_size) q_list = [] scale_list = [] for i in range(flat.shape[0]): q, scale = quantize_group(flat[i]) q_list.append(q) scale_list.append(scale) q_matrix = torch.stack(q_list).reshape(orig_shape) scales = torch.tensor(scale_list).reshape(orig_shape[0], -1) return q_matrix.to(torch.uint8), scales打包成 uint8 是为了后续按字节存储,两个 INT4 权重拼一个字节。注意这里原始权重 reshape 时是按连续内存展开的,所以“每 32 个元素一组”实际对应的是输入维度的连续切片,这样在推理时才能高效按块读取和解包。
3.2 权重打包:内存里的 INT4 组织方式
量化完的权重不能直接当成 INT4 数组扔进内存,因为 CPU 按字节寻址,最小读取单位是 8 比特。INT4 需要“打包”,最常用的方式是每两个 INT4 值共享一个字节。DBHOO-CIM 默认用低 4 位存第一个权重、高 4 位存第二个权重,即 little-endian 风格的低位优先排列。
打包的好处是权重文件直接缩小到原来的四分之一(相对 FP16)。但这引出一个性能关键点:推理时不能直接把字节数组当权重用,必须把它拆成两个 INT8 向量才能参与 SIMD 计算。解包有两种策略:
第一种是“加载时解包”。把 INT4 权重在模型加载阶段就解包成 INT8,内存里存的是 INT8 数组。这样推理时可以直接用 VNNI 指令,但权重体积又回到了 INT8 的大小,带宽收益从 4 倍降到 2 倍。
第二种是“计算时解包”。内存里始终保存紧凑的 INT4 字节数组,每次矩阵乘算子加载数据后,先用位运算把两个 INT4 拆开,再喂给 SIMD 计算单元。这样带宽收益最大,但会多出几条位操作指令。
DBHOO-CIM 选的是第二种。理由很直接:多出来的位运算在 CPU 流水线里是延迟很低的整数操作,几十个周期就能完成,而省下来的 3.5GB 内存搬运是几十毫秒级别的收益,划得来。具体做法是,对每个字节 b,拆成:
uint8_t lo = b & 0x0F; uint8_t hi = (b >> 4) & 0x0F;然后各自减去符号位偏移,映射回带符号的 [-8, 7] 范围。因为 INT4 本质上是带符号的,但打包时我们存的是无符号的 0-15,所以拆完后要做一个有符号变换。
3.3 权重反量化与矩阵乘融合:别把省下的带宽还回去
解包出 INT4 值之后,不能直接用来算,因为真正的权重是浮点值,还需要乘上每个分组的 scale。这里有一个很容易踩的性能大坑:如果你先把每个 INT4 反量化成 FP32 权重,再跟激活值做矩阵乘,那权重在内存里又变成了 4 字节的 FP32 数组,带宽占用瞬间打回原形,前面省的全白省了。
DBHOO-CIM 的解法是“融合反量化”:在 SIMD 计算流水线里,先把 INT4 解包成 INT8,乘上 scale 得到 FP32 权重,然后立刻和 FP32 激活值做乘加;权重从来不会以完整的 FP32 数组形式驻留在内存里,它只是寄存器里的一个小块数据。
这一块是实现中最容易翻车的地方,也是性能差距的主要来源。很多开源实现没有做到融合,导致 INT4 量化后虽然磁盘占用小了,但推理速度只提升了一倍多,原因就在这里——反量化后的临时数组把内存带宽又占满了。我建议你实现完以后用 profiler 看一下内存写带宽,如果发现有大块临时缓存写入,说明反量化还没有做到位。
3.4 用 DBHOO-CIM 跑通 7B 模型的前向
经过校准、量化、打包三步,权重文件已经准备好。部署侧的调用方式大概是这样的:
from dbhoo_cim import QuantizedModel, generate model = QuantizedModel.from_pretrained("path/to/int4-weights", group_size=32) output = generate(model, prompt="你好,请介绍一下你自己", max_tokens=128)from_pretrained阶段会加载 INT4 的权重文件、解析每层的 scale 元数据、预分配 KV cache 的缓冲区。生成阶段跑的是标准的 autoregressive 循环,每次前向只需要处理一个 token,这时大部分时间花在读取权重矩阵上,正好是 INT4 发挥优势的场景。
实测下来,在搭载 8 核心、四通道 DDR4、实测带宽约 45GB/s 的机器上,7B 模型 INT4 推理的单 token 延迟从 FP16 的 380ms 左右降到了 105ms 左右,速度提升 3.6 倍。虽然没到理论上的 4 倍,但也非常接近了,剩下的差距主要来自激活值搬运和缓存未命中。
4. 榨干带宽的最后一公里:内存布局、SIMD 和多线程
4.1 内存布局:对齐和连续性是第一优先级
权重矩阵在内存里的排布方式,直接影响 cache line 的利用率。cache line 一般是 64 字节,你读取数据时按整行读取效率最高。如果权重矩阵是行优先存储,那么推理时按行读取连续内存,每次加载 64 字节,几乎不会浪费。反之,如果由于打包错位导致一个分组跨越两个 cache line,那会有大量无用字节被加载进来,带宽利用率直接下降。
DBHOO-CIM 对打包后的权重有一个强制约束:每组 32 个 INT4 对应 16 个字节,要求这 16 字节按 16 字节对齐;整个权重矩阵按 64 字节对齐分配内存。对齐的另一个好处是可以使用 aligned vector load 指令,比如 AVX 的vmovaps,比 unaligned load 在某些老平台上要快不少。
另外,scale 数组不要和权重数组混在一起存。混在一起会导致读取权重时会把用不到的 scale 也带进 cache,污染 cache line。正确的做法是把所有权重主数据连续存放,scale 单独一个数组,用组号索引访问。因为 scale 是 FP32,一组权重只需要 4 字节,单独存放后整体读取量非常小,不会成为瓶颈。
4.2 SIMD 指令集:用位运算快速拆解 INT4
x86 平台上,x86 的 AVX2 和 AVX-512 都提供了很好的位操作支持,这让我们可以在十几个周期内把一个向量寄存器里的 16 或 32 个 INT4 权重展开成 INT8 值。
以 AVX2 为例。一个 256 位寄存器可以装下 32 个字节,也就是 32 个打包好的 INT4 权重。要拆成两个 32 字节的 INT8 向量,核心伪代码是:
// a: 32 个字节的打包数据 __m256i lo = _mm256_and_si256(a, _mm256_set1_epi8(0x0F)); __m256i hi = _mm256_and_si256(_mm256_srli_epi16(a, 4), _mm256_set1_epi8(0x0F)); // saturate 到 signed int8 范围 [0, 15] -> [-8, 7]srli_epi16一次移动 16 位整数,会把每个字节的高 4 位挪到低 4 位,再用掩码清掉多余位。这里有个细节:srli_epi16操作的是 16 位粒度,如果直接对 256 位寄存器用,会按 16 位为单位做右移,导致字节错位。所以必须先按 128 位甚至更小的粒度处理,再做拼接。实际实现时我建议用_mm256_srli_epi32配合掩码,或者用 AVX-512 的带宽指令更省心。
之后用_mm256_maddubs_epi16或者 VNNI 的_mm512_dpbusd_epi32乘加,能在一个指令里完成多个 INT8 乘加。整个流程下来,解包加乘加的额外开销只有几十个周期,但换来了 4 倍的带宽节省,这个性价比在 memory-bound 场景下是极高的。
4.3 多线程分块:让所有核心一起抢带宽
单核优化做得再好,也跑不满内存带宽,因为单核能发出的内存请求数量有限。要榨干 DDR 的带宽,必须让所有核心同时保持大量在途的 cache miss 请求,这样才能填满内存控制器的队列。
DBHOO-CIM 的多线程策略很简单粗暴:按输出特征维把权重矩阵切成若干块,每个线程负责一部分输出通道。因为输出通道之间是相互独立的,切分之后没有任何写冲突,不需要加锁。分块大小要保证每个线程负责的权重块能塞进 L2 cache 的一部分,通常单块不超过 256KB。
实践中我发现,线程数的设置不能盲目的等于核心数。超线程带来的额外逻辑核,在内存带宽密集型的任务里几乎没有帮助,有时反而会因为共享 L2/L3 导致缓存抖动。在我的测试机上,8 物理核 16 线程,设置为 8 线程时带宽利用率最高,16 线程反而下降 5% 左右。小技巧:设置线程数时最好用taskset把线程绑定到物理核上,避免系统调度导致线程在不同的 NUMA 节点间漂移。
4.4 实测数据:带宽利用率是怎么算出来的
所有优化做完以后,该验证效果了。我用perf stat采集带宽相关指标,计算过程也很简单:
有效带宽 = 权重总字节数 / 单 token 推理延迟
拿前面那台 7B INT4 的机器举例:权重大小 3.5GB,单 token 延迟 105ms,算出来有效带宽是 33.3GB/s。但机器实测峰值带宽有 45GB/s,所以带宽利用率是 33.3 / 45 = 74%。
做到 74% 已经不错了,但距离“榨干”还有距离。进一步分析后我发现,因为激活值和 KV cache 也在占用带宽,实际用于搬运权重的带宽占比并没有那么高。后面我把激活矩阵做了分块缓存,并优化了 KV cache 的显式预取,带宽利用率提到了 85% 以上。这个时候再想往上走就比较难了,DDR 本身的读写效率和内存控制器开销都在那摆着。
5. 常见问题与排查技巧实录
5.1 量化后精度掉太多,从哪里下手查
如果你做完 INT4 量化后模型回答质量明显下降,先别急着怀疑 INT4 方案本身,多半是下面几个原因。
第一,检查校准数据。校准集覆盖不全会导致激活分布估计错误,但权重 scale 其实是直接从权重统计来的,不依赖校准集。权重对称量化本身的误差极小,真正容易出问题的是如果你的模型里有大量离群权重。这种情况把分组大小从 32 改成 16,通常能明显缓解。
第二,查看各层权重的最大值分布。如果一个层的权重最大值比其他层大一个数量级,那这个层用对称量化可能不够。可以对该层单独使用非对称量化,或者退一步保持该层为 FP16,其他层 INT4。混合精度部署在 DBHOO-CIM 里是支持的,部分敏感层不量化,对整体带宽影响不大。
第三,如果模型比较大,尝试对 attention 层的 QKV 投影和非线性层之间做敏感性分析,优先保住那些对输出影响大的层。经验法则是:靠近输出端的层尽量用更高精度。
5.2 性能不升反降,先看是不是没跑 memory-bound 场景
遇到 INT4 量化后推理速度没有提升甚至变慢的情况,我第一个会反问:你的模型算子到底是什么类型的?
如果你跑的是大 batch 的 prompt 处理阶段(prefill),每个 token 的激活矩阵很大,计算强度也随之上升,这时候算子可能已经从 memory-bound 变成了 compute-bound,INT4 的带宽优势不再明显,反而解包的额外指令拖累了速度。相反,在 decode 阶段(逐 token 生成)时,激活通常只有一行,权重需要全量读取,算子是典型的 memory-bound,这时 INT4 收益最大。
所以性能测试一定要分两个阶段看:prefill 和 decode 吞吐。DBHOO-CIM 针对 decode 做了专门的优化,prefill 则建议保持 bf16/fp16 计算并配合 batch 合并来提升效率。
5.3 多核扩展性差,是带宽饱和还是锁竞争
如果你的性能曲线在 4 核以后就不再增长,说明两个可能:要么内存带宽已经饱和,要么存在锁竞争或伪共享(false sharing)。
区分方法很简单:跑一个纯内存拷贝的基准程序,比如STREAM,先测出这台机器的带宽上限。如果多线程推理的聚合带宽已经接近 STREAM 的测试值,那就别折腾线程数了,换更高频的内存条或者加内存通道才是正经办法。如果离上限还远,再查代码的锁竞争。
伪共享是个隐蔽的坑:多个线程频繁修改同一 cache line 上的不同变量,会导致 cache line 在核心之间反复横跳。解决办法是每个线程的计数器和累加器做 64 字节对齐,确保它们不在同一个 cache line 上。
5.4 推荐一套排查命令组合
优化 CPU 推理性能,工具链很重要。下面是我每次调优都会跑的一套命令:
# 1. 看整体 IPC 和缓存未命中 perf stat -e cycles,instructions,cache-misses,cache-references ./your_benchmark # 2. 细化到内存带宽指标 perf stat -e offcore_response.demand_data_rd.any_response ./your_benchmark # 3. 用 STREAM 测试机器带宽上限 stream_c.exe # 4. 绑核运行,看 NUMA 影响 taskset -c 0-7 ./your_benchmark如果cache-misses比例超过 30%,说明访存局部性做得不好,优先去看权重矩阵是否按块连续读取。如果instructions远高于预期,可能是解包逻辑中出现了大量多余的分支或者重复计算,需要反汇编检查热点循环。
注意:不同 CPU 平台对
offcore_response事件的支持不一样,AMD 和 Intel 的事件名有差异。跑之前先查一下当前平台支持的 PMU 事件列表,避免花半天时间在错误的事件名上。
写在最后的经验之谈
DBHOO-CIM 这套方案从立项到跑通,最让我意外的是:真正的难点不在数学原理,而在系统和工程细节。scale 怎么布局、cache line 怎么对齐、线程怎么绑核,每一个细节都能让性能差出 20% 到 30%。INT4 量化本身只是给模型减了个肥,但能不能把这身肌肉用好,就看运行时把数据喂给计算单元的功夫深不深。
我踩过最冤的一个坑,是权重打包时把两个 INT4 的字节序搞反了,导致低半字节和高半字节的顺序和量化时的约定不一致。模型跑出来的结果是完全错乱的,但又不报错,排查了很久才发现是低位优先和高位优先不一致的问题。后来我把打包器的字节序写成了单元测试,每次改完代码先跑一遍,再也没有犯过同样的错。
如果你准备在自己的 CPU 推理项目里做 INT4,我的建议是分三步走:先把量化精度压到可接受范围,再把权重打包和反量化融合做到位,最后再抠多线程和内存布局的细节。每一步都有明确的验收标准,不要一上来就追求最极致的性能,否则问题混在一起会非常难排查。