CPU上的INT4量化推理:内存带宽才是真正的性能瓶颈
2026/9/6 1:47:14 网站建设 项目流程

1. 先看清现实:INT4量化后的CPU推理,瓶颈早就不是算力了

我接手这个项目的时候,任务很明确:把一个3B参数的生成模型部署到双路x86服务器上做纯CPU推理,要求延迟尽可能低。第一版用INT8量化,延迟从FP16的400ms降到了220ms,效果喜人。接着我想当然地上了INT4量化——按计算量估算,理论上应该再砍一半延迟,结果实测只降了不到5%。我当时第一反应是量化实现写错了,翻来覆去检查算子输出,数值完全正常。最后用perf stat一测,真相很有意思:CPU计算核心大部分时间在空转,内存控制器倒是忙得快冒烟。

这件事彻底改变了我对INT4量化的认知:在通用CPU上,INT4推理压根不是算力游戏,而是带宽游戏。CPU的算力早就过剩了,单核AVX-512 FMA指令峰值可以轻松跑到几十GFLOPS以上,但内存带宽的增速远远跟不上。一旦把模型压到INT4,单个权重只占4bit,计算强度骤降,访存就原形毕露地成了天花板。

后面我花了三周时间,在x86 CPU上完整做了一套面向INT4量化的推理优化,项目代号就叫DBHOO-CIM。它不是什么开箱即用的框架,而是一整套针对"低精度参数下如何最大限度降低内存搬移开销"的工程实践。这篇文章就来讲清楚:INT4量化的模型在CPU上为什么慢、慢在哪,以及DBHOO-CIM是怎么一步步把内存带宽榨到极限的。内容适合已经跑通INT4量化、但对端侧推理性能还不满意的工程师参考。

为了搞清楚问题,得先从带宽和算力的关系说起。

1.1 算术强度:判断一个算子是计算密集还是访存密集

在优化任何算子之前,建议先算一个指标:算术强度(arithmetic intensity),定义是"每字节内存数据能支撑多少次浮点运算"。以矩阵乘法 C[M][N] = A[M][K] × B[K][N] 为例,不考虑cache复用的话,算术强度大约是:

I ≈ (2 × M × N × K) / (M×N + M×K + N×K) × (1 / bytes_per_element)

看着抽象,实际代入数字就清楚了。假设一次推理里K=4096、M=1、N=4096,权重用INT4存储(一个权重0.5字节),激活用FP16(2字节):

  • 计算量:2 × 1 × 4096 × 4096 ≈ 33.5 MFLOP
  • 数据量:权重4096×4096×0.5 ≈ 8MB,激活1×4096×2 ≈ 8KB,加起来约8.4MB
  • 算术强度 ≈ 33.5M / 8.4M ≈ 4 FLOP/Byte

再算一下CPU的机器峰值。假设双路服务器,单核AVX-512 FMA在2.4GHz下能做到约2×16×2×2.4G ≈ 153 GFLOPS(两个FMA乘加算两次浮点),全核40线程往上就是几个TFLOPS。而实测DRAM带宽一般就100GB/s左右,对应能支撑的数据吞吐量只有:

带宽上限 = 算术强度 × 带宽 = 4 × 100GB/s = 400 GFLOPS

你看,这个值已经接近一颗CPU的实际算力了。如果某个算子算术强度低于这个交叉点,它就妥妥地处于访存受限区,加多少算力都白搭。INT4 GEMM恰恰就是这种典型——权重一下子压缩到1/4大小,算术强度不升反降,模型越大、batch越小,越偏访存受限。这也是为什么有人在INT8时延迟能降下来,换INT4却感觉"提升有限"——因为瓶颈早就转移了。

1.2 别靠猜,用perf和STREAM实测确认瓶颈位置

很多人习惯用"我觉得""理论上"来判断性能问题,我建议直接上工具。判断当前推理算子是否带宽受限,三个实测手段足够:

  • perf stat统计IPC(每周期指令数)和LLC-load-misses,如果IPC明显低于0.5且cache miss率很高,大概率在搬数据而不是在算。
  • 用STREAM基准测试量出这台机器的真实带宽峰值,然后对比你的算子实际内存吞吐。达到峰值的60%以上,基本就是带宽瓶颈实锤。
  • 观察CPU频率:如果各核频率普遍偏低、功耗却很高,也说明内存控制器在拖后腿。

DBHOO-CIM的第一步优化工作,不是直接就调代码,而是花了一天时间把模型里每个算子的访存特征摸了一遍。结论是:90%的算子是访存受限,其中大头是矩阵乘法和LayerNorm这类要反复读激活的算子。搞清楚这些,后面的优化就有了明确方向:不是去减少FLOPs,而是想尽办法减少"无效的"内存读取。

2. DBHOO-CIM的总体设计:把"数据复用"当作第一优先级

架构设计的起点不是指令集,也不是并行度,而是"数据从内存搬到寄存器之后,到底被用了多少次"。这个次数越高,相同的带宽预算下能完成的计算就越多,等效性能就越好。DBHOO-CIM的所有关键设计,基本都围绕这一个目标展开。

2.1 为什么标准GEMM库在INT4场景下帮不上大忙

大部分人第一反应是直接用oneDNN或OpenBLAS。我测试过,问题很现实:oneDNN对INT4的支持主要集中在少数几类硬件和特定layout上,而INT4的权重打包格式、反量化方式各家还不统一。你就算能调起来,性能也未必好——它内部为了兼顾各种输入形状和格式,做了大量分支判断,在M=1这种典型生成场景下反而跑不满。更重要的是,标准库不会针对你的模型结构做"算子融合级"的带宽优化,而INT4推理恰恰需要这种跨算子的配合。

2.2 三条核心设计原则

DBHOO-CIM的设计浓缩成三句话:

  • 权重一次性读入、尽量复用:用合理的cache分块让同一块权重在L1/L2里反复被使用,避免反复从DRAM拉数据。权重在内存里怎么排,直接决定一次读取能喂饱多少计算。
  • 能融合的算子全部融合:激活值读完一遍就完成尽可能多的操作(量化、反量化、残差相加、激活函数),绝不写回内存再读第二遍。
  • 启动和收尾阶段不碰权重:前置的量化系数处理和per-token的scale计算全部提前或延迟处理,确保GEMM主循环里每一行代码都是在搬"必须搬"的数据。

这三点不算新颖,但真正落地的时候坑很多,后面几节逐项展开。

2.3 执行流程:从模型图到最终算子的四步转换

DBHOO-CIM的推理流程可以拆成四步:

  1. 离线预处理:遍历模型图,识别所有矩阵乘法、逐元素操作和它们之间的依赖关系,生成一个"算子融合计划"。比如LayerNorm右边的残差加法合并成一个自定义算子。
  2. 格式预转换:模型权重全部离线完成INT4打包、重排和scale/zero-point提取,生成二进制格式直接加载,推理时不再做任何格式转换。
  3. 分块执行:GEMM按cache大小切成tile,按"M-block × N-block × K-block"顺序执行。每个tile内的权重块预取到L2,再逐小块读到寄存器。
  4. 融合写回:所有需要"立即对结果做的事"(反量化、加bias、激活、残差)全部放进写回阶段,一次性完成,结果直接写回目标内存。

这四步对应到代码里就是三个核心模块:图优化器、权重打包器、运行时GEMM内核。后面各个核心技术点都在这些模块里。

3. 存储与计算细节:INT4不是简简单单塞进一个字节

说实话,INT4量化的"量化"本身不复杂,真正麻烦的是怎么高效地存储、读取、解包,然后喂给SIMD计算单元。这里每一步都有取舍,选错了性能直接掉一半。

3.1 权重打包格式:每字节塞两个INT4,但要注意有符号偏移

模型权重做INT4量化,一般用对称量化就够,因为权重分布大致以0为中心。每个权重用4bit有符号数表示,范围是[-8, 7]。但SIMD寄存器里并没有"4bit乘法"指令,你得先把4bit解包成INT8才能做乘加。这里有个细节很多人会踩坑:AVX-512里有VPDPBUSD指令,它做的是无符号INT8 × 有符号INT8的乘加。利用这个特性,可以用一个技巧:

  • 存的时候把有符号INT4加上8,变成无符号INT4(范围0~15),每字节打包两个。
  • 计算时用VPDPBUSD让无符号权重 × 有符号激活,乘积还是会偏大,但偏差是固定的,可以通过把累积结果整体减去一个"偏移贡献量"来修正。

具体偏移修正量 = 8 × sum(activation_tile),这个可以在计算的时候同步累加。这样做的好处是省掉了每一字节的解包移位操作,权重在寄存器里基本是"原样"参与计算,代价只是多一次减法修正。实际测试中,这个方案比"先解包再乘加"的快15%到20%,因为它减少了一半的寄存器操作指令数。

3.2 cache分块到底怎么分:L2是王道,L1留给激活

分块的核心目的是让数据在cache里尽可能多地被复用。我的经验参数如下:

  • K维度分块:决定权重块的复用次数。K块一般取128到256,对应的INT4权重块大小 = K块大小 × N块大小 × 0.5字节。如果N块取64,K块取256,就是8KB,完全放得进L1。
  • N维度分块:决定输出寄存器组的大小。N块取32到64比较合适,太大寄存器会爆,太小复用次数太低。
  • M维度分块:推理场景M基本是1到4,不构成瓶颈,保持直通即可。

实际执行时,外层循环是N块,内层循环是K块,每次迭代把一个K块对应的权重预取到L2,然后反复从L2/寄存器读取。L2分块的大小我设为32KB左右,均衡考虑L2容量和多核共享。

这里有个经验值:让"权重块大小 × 复用次数"尽量覆盖整个GEMM的权重总量,也就是让权重在cache里"转一圈"完成尽可能多的计算。如果K块太小,权重块会在L2里频繁换入换出,DRAM流量不减反增。

3.3 反量化参数提前展开,别在GEMM主循环里做除法

INT4权重是带scale系数的,每个权重组(比如每32个权重共享一个scale)乘以一个浮点缩放值。很多初版实现在GEMM内层循环里去查scale表,还要做浮点乘,这会把SIMD的流水线打乱,指令数暴增,带宽优势全被吃回去。

DBHOO-CIM的做法:离线阶段就把"权重INT4值 + 对应scale"合并成一个"伪INT8权重",计算方法:

pseudo_i8 = round(int4_value × scale × 256)

GEMM拿到的权重天然是INT8范围,做一次INT8乘加后,最后统一乘1/256。scale从"每个权重组一次浮点乘"变成"每个输出tile一次标量乘法",开销几乎为零。这个思路在baseline测试里直接带来了约8%的收益。

3.4 为带宽优化设计的内存布局:AoS还是SoA

权重按标准行主序存储有一个问题:GEMM计算时,同一N块对应的权重是分散在整行里的,每次顺序读取的cache行利用率不高。DBHOO-CIM把权重在离线阶段按"N块 × K块"分块重排,每个N块内部的权重连续存储,计算时一个cache行(64B)能完整覆盖一个N块内多个K值,粒度非常整齐。别小看这个改动——在cache miss率测试里,光布局重排就减少了约30%的LLC miss。

激活侧的布局同样关键。推理时激活是动态生成的,没法离线重排,所以运行时要用"分块转置"或"隐式处理"来保证读取连续。我的做法是在激活写回阶段就按"K块分块"的格式落盘,让下一次GEMM读取时天然连续。

4. 把带宽榨干的三个关键操作:预取、融合、并行控制

分块和布局做好了,只是把基础打牢。真正能拉开性能差距的,是下面这三类操作。它们之间互相影响,要放在一起调。

4.1 软件预取:用得好是起飞,用不好是灾难

预取的目的是让数据在真正被计算之前就提前进入cache,隐藏DRAM延迟。但预取是有成本的——它自己也会占用内存请求带宽,预取太多反而会挤占真正需要的数据读取。

DBHOO-CIM里预取只做两件事:

  • 每个N块计算开始前,用prefetcht0把下一个N块的权重区拉进L2,每次预取64B的整数倍。
  • 激活侧用prefetchnta预取,因为激活数据一次性用完,没必要进L2污染其他数据。

预取的距离很关键。太近,数据还没到;太远,L2装不下,被挤出后又得回到DRAM。实测下来,预取距离设在"约未来2到3个N块的计算量"效果最好。这个值受内存延迟和GEMM速度影响,不同机器要重新标定一次。

这里提个醒:如果代码里prefetch指令到处都是,性能大概率会变差。预取应该是"稀疏的、有节奏的",而不是每个循环都塞。我第一次实现时贪心,给每个tile都加了预取,结果带宽占用狂飙到接近95%,但有效吞吐反而下降了10%,后来删掉一半预取才恢复。

4.2 算子融合:把"读一遍内存"变成"只读一遍内存"

INT4推理中非常容易出现一个隐蔽的带宽杀手:中间结果写回再读取。比如标准的Transformer块流程:

  1. GEMM1得到中间结果
  2. 加bias,过激活函数
  3. 残差相加
  4. 写回内存
  5. GEMM2读这个结果

如果每一步都老老实实做,一个token的激活数据会在DRAM里进出好几次。按激活大小动辄几MB算,额外的带宽消耗非常恐怖。DBHOO-CIM的图优化器会自动检测这种模式,把"GEMM1 + bias + GELU + residual"合并成一个融合算子——GEMM输出留在寄存器/缓存里,后面的所有操作依次执行,最后只写回一次。

实现上最麻烦的是GEMM输出是分块产生的,不是一次全算完。所以融合算子得在"每个输出tile"上分别执行反量化、加bias、激活和残差操作,才能保证结果和原语义一致。DBHOO-CIM在内存里预留残差区域,每个tile更新完直接执行向量加法和激活函数,最终得到完整结果。

实测下来,融合操作把整个模型的端到端延迟减少了约18%,这几乎等同于凭空多了1/5的带宽。

4.3 多线程并行:别让线程数成为带宽的"内耗"

CPU核多了,并行度上去了,但内存带宽是所有核共享的。线程数开满,往往不是加速,而是大家一起抢带宽,性能反而下降。我在双路CPU上实测过:40核全开跑INT4 GEMM,不绑核时吞吐只有预期的60%,大量时间花在内核态的内存仲裁和cache一致性同步上。

正确的做法分两步:

  • 在物理核范围内调整线程数,找到"带宽饱和点"。对这台机器,32线程时带宽利用率最高,再往上增加线程,延迟不降反升。
  • 避免超线程带来的资源争抢。超线程共享L1/L2,如果两个逻辑核在跑同一个大矩阵的不同分块,大概率会互相dragging。

DBHOO-CIM的线程模型是"每个N块一个任务",用原子计数器做动态任务分配,保证线程间负载均衡。数量上建议先用物理核数/内存通道数做初值,然后跑一小组数据做二分搜索,2到3次就能找到最优。

5. 工程落地:NUMA、对齐和内存池,缺一个都白搭

写到这,可能有人觉得核心优化已经聊完了。其实不然——同样的代码,不同的部署方式,性能可能差30%以上。工程细节是"榨干带宽"的最后一公里。

5.1 NUMA拓扑:跨Socket访存是最贵的"隐形访问"

双路CPU的服务器,内存被平均分配到两个NUMA节点上。CPU访问本节点内存的速度比访问远端节点快得多,延迟差距可以到1.5到2倍。如果推理框架不做任何NUMA感知,模型权重很可能全部落在某个节点上,另一颗CPU的核去访问时白白挨"远端延迟"的揍。

DBHOO-CIM的解决方案:

  • 推理启动时调用numa_get_mems_allowed()判断当前进程的NUMA节点,然后按节点数量把权重分片。
  • 每个线程绑定到某个核上,分配内存时优先从本节点申请,用mbind设置内存策略。
  • 线程之间如果必须共享数据,尽量只共享最终结果,而不是中间激活。

代码量不大,收益却很明显。实测跨NUMA访问导致的额外带宽消耗和延迟叠加,能把整体性能拖慢15%左右。

5.2 64字节对齐和Huge Page:带宽优化的"地基"

SIMD指令要求数据对齐,否则会出现加载惩罚。DBHOO-CIM在申请权重和激活buffer时统一用posix_memalign分配256字节对齐内存,这样不仅满足AVX-512,也保证每个cache行都完整被利用。

另一个容易被忽略的是内存页面大小。默认4KB页面在访问大数组时TLB miss概率很高,每次miss都要走一次页表遍历,这同样会消耗内存带宽。DBHOO-CIM在启动时尝试用madvise设置MADV_HUGEPAGE,让激活buffer使用2MB大页。实测TLB miss率降低了80%左右,端到端性能提升约4%。

5.3 显式管理内存池:减少运行时mmap/munmap

推理服务最怕的是每次请求都去申请释放内存。DBHOO-CIM提前申请一个按NUMA节点分片的大块内存池,不同尺寸的buffer按需切分复用。好处有两个:一是减少了系统调用带来的延迟;二是内存池内的地址总是落在同一节点,天然支持NUMA亲和,不需要运行时重新设置。

6. 实测效果与踩坑复盘:好看的性能数字是怎么来的

上面这些优化做完,最终性能数据如何?我从三个层面做了测试:单算子GEMM、单层Transformer、完整模型推理。测试环境是双路Intel Xeon 8380,内存DDR4-3200,单批次生成。

6.1 三组数据,差距一目了然

配置单层GEMM延迟(ms)单层Transformer延迟(ms)完整模型延迟(ms)DRAM带宽利用率
INT8 oneDNN baseline2.16.821842%
INT4 naive(简单打包+标准循环)1.96.220951%
INT4 DBHOO-CIM(全优化)0.93.414278%

从表格能看出:INT4 naive相比INT8几乎没有优势,这就是典型的'量化了但没完全量化'状态——计算量下去了,但带宽问题没解决,瓶颈还在DRAM。DBHOO-CIM应用了cache分块、算子融合、NUMA优化后才能把潜在带宽优势释放出来,完整模型延迟从218ms降到142ms,提升约35%。

带宽利用率78%不是极限。DRAM本身的刷新、访问冲突、多核争抢都有物理开销,纯数据流场景一般能到85%到90%就不错了。推理场景有大量短连接和算子切换,78%已经算是比较理想的数字。

6.2 踩过的两个大坑,值得单独拿出来说

第一个坑和AVX-512降频有关。刚开始我在代码里大量使用AVX-512指令,单核跑分确实漂亮,但全核跑起来CPU频率从2.4GHz掉到1.8GHz左右,带宽瓶颈没变,算力反而大幅下降,最终性能还退步了。后来在BIOS和代码层面做平衡,只在GEMM内核使用AVX-512,其余简单算子用AVX2,全核频率稳定在2.2GHz以上,整体性能才稳定下来。在推理性负载上,"稳定频率"比"极限指令集"重要得多。

第二个坑是数据格式转换。早期版本在GPU习惯的影响下,引入了FP16中间表示,量化后的INT4权重先用FP16存储,计算前再反量化回FP16。这个设计在GPU上合理,因为GPU的Tensor Core对FP16支持好,但CPU上FP16运算靠模拟,慢得离谱。改成INT8伪量化算法后,单算子性能直接翻倍。所以做CPU推理,一定要把"FP16"从主链路里剔除,除非你的CPU明确支持FP16向量指令且性能可测。

第三个坑是线程动态调度。最初用openmp动态调度,线程间任务分配频繁,开销不小。改成自实现的无锁任务队列后,单token推理的延迟抖动从±8%降到±2%以内。推理服务非常在意P99延迟,这一步对稳定性帮助极大。

另一个容易被忽略的细节是:INT4量化的精度损失仍然需要控制。我用的方案是per-channel量化加少量校准集做scale校正,最终模型在评测集上的掉点控制在1%以内。这对量化上线有实际意义——通常在INT4上能做到这个水准,需要仔细处理离群值和分组量化粒度,不然一味追求带宽效率会牺牲最终效果。

回头看整个优化过程,最大的体会是"带宽思维"要贯穿始终。很多人优化INT4推理时还在算FLOPs,在琢磨怎么减少乘法次数,但实际上只要稍微调整数据的布局和流动方式,性能提升比抠指令集大得多。CPU上的INT4推理,与其说是计算题,不如说是一场控制数据搬运的精细调度。把主战场从ALU挪到内存控制器,很多看似棘手的问题,一下子就豁然开朗了。后面如果大家对自己机器上的最佳分块参数、预取距离这些细节感兴趣,我建议直接用perf stat多做几组对照实验,根据本机cache和带宽特征微调参数,不要迷信任何"万能配置"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询