CPU上的INT4量化推理:内存带宽才是真正的性能瓶颈

CPU上的INT4量化推理:内存带宽才是真正的性能瓶颈
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)看着抽象实际代入数字就清楚了。假设一次推理里K4096、M1、N4096权重用INT4存储一个权重0.5字节激活用FP162字节计算量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的权重打包格式、反量化方式各家还不统一。你就算能调起来性能也未必好——它内部为了兼顾各种输入形状和格式做了大量分支判断在M1这种典型生成场景下反而跑不满。更重要的是标准库不会针对你的模型结构做算子融合级的带宽优化而INT4推理恰恰需要这种跨算子的配合。2.2 三条核心设计原则DBHOO-CIM的设计浓缩成三句话权重一次性读入、尽量复用用合理的cache分块让同一块权重在L1/L2里反复被使用避免反复从DRAM拉数据。权重在内存里怎么排直接决定一次读取能喂饱多少计算。能融合的算子全部融合激活值读完一遍就完成尽可能多的操作量化、反量化、残差相加、激活函数绝不写回内存再读第二遍。启动和收尾阶段不碰权重前置的量化系数处理和per-token的scale计算全部提前或延迟处理确保GEMM主循环里每一行代码都是在搬必须搬的数据。这三点不算新颖但真正落地的时候坑很多后面几节逐项展开。2.3 执行流程从模型图到最终算子的四步转换DBHOO-CIM的推理流程可以拆成四步离线预处理遍历模型图识别所有矩阵乘法、逐元素操作和它们之间的依赖关系生成一个算子融合计划。比如LayerNorm右边的残差加法合并成一个自定义算子。格式预转换模型权重全部离线完成INT4打包、重排和scale/zero-point提取生成二进制格式直接加载推理时不再做任何格式转换。分块执行GEMM按cache大小切成tile按M-block × N-block × K-block顺序执行。每个tile内的权重块预取到L2再逐小块读到寄存器。融合写回所有需要立即对结果做的事反量化、加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块取64K块取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块流程GEMM1得到中间结果加bias过激活函数残差相加写回内存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和带宽特征微调参数不要迷信任何万能配置。

最新新闻

日新闻

周新闻

月新闻