模型部署与推理优化02量化原理与实践INT8 矩阵乘、校准、QAT 与 LLM 量化量化这个事我这两年接触得越多越觉得它被低估了。很多人提到INT8量化第一反应是“降精度换速度”好像就是个简单粗暴的取舍。但实际上量化是模型部署里少有的、能同时砍内存带宽、降显存占用、提吞吐量的手段而且做得好的时候精度损失可以控制在几个点以内。这个系列第二篇我就把量化这件事从头到尾拆开讲清楚INT8矩阵乘到底怎么算的校准为什么是生死线QAT有没有必要以及LLM量化跟前两者有多大不同。内容偏实操适合已经在做推理部署、想搞清楚量化原理而不是只会调接口的工程师。1. 量化到底在做什么一个映射问题背后的工程账本1.1 量化不是“丢精度”而是“换一种数制”我们说的量化在推理场景里通常指把模型的权重和激活值从FP32或FP16/BF16映射到更低位宽的整数格式比如INT8。这里面最核心的数学操作其实只是一个线性映射把浮点范围 [r_min, r_max] 映射到整数范围 [q_min, q_max]映射关系写成公式就是q round(r / scale) zero_point r (q - zero_point) * scale其中 scale 是缩放系数可以理解为“整数里的1代表多大浮点数”zero_point 是零点偏移负责对齐两个数制的零点。举一个具体例子假设某个张量的浮点范围是 [-1.0, 1.0]要映射到 INT8 的 [-128, 127]那么 scale 2.0 / 255 ≈ 0.007843由于浮点0在这个范围内zero_point 恰好为0这就是对称量化。如果浮点范围是 [0.5, 3.5]零点不在浮点范围中心就需要非对称量化zero_point 通常是个非零整数。很多人误以为量化是“把小数点后面的数砍掉”这句描述其实很不准确。量化是重新选择一种数制去表达权重和激活精度损失来自两个地方一是映射时的舍入误差整数格点不可能精确表达所有浮点值二是裁剪误差超出映射范围的浮点值会被截断到边界。所以量化方案的几乎所有设计包括校准、对称非对称选择、per-channel 策略本质上都是在权衡“舍入误差”和“裁剪误差”这两笔账。1.2 对称与非对称、per-tensor 与 per-channel四个选项怎么配对称量化下 zero_point 0计算更快因为矩阵乘时不需要额外做零点减法但遇到激活值分布明显偏移的时候量化格点浪费严重。非对称量化格点利用率更高但每个张量多了一个zero_point实际计算时要把这个偏移考虑进去算力上稍微多一点点开销。另一个维度是粒度。per-tensor 是整个张量共用一个scale实现简单、开销小per-channel 是每个输出通道各用各的scale对权重量化尤为重要。因为卷积层或线性层的权重各通道分布差异可能很大如果强制共用一格scale少数数值较大的通道会压缩其他通道的量化精度。实操层面的常规组合是权重用 per-channel 对称量化激活用 per-tensor 非对称量化。权重静态已知可以事先算好每个通道的scaleper-channel没有额外运行时成本激活值分布依赖输入用per-tensor是出于计算效率考虑。这套组合在 TensorRT、ONNX Runtime、PyTorch 的 INT8 部署里都适用。1.3 量化省下来的到底是什么量化带来的收益要分三块看。第一块是内存和带宽一个 FP16 的 7B 模型权重占 14GB 左右INT8 直接砍到 7GB这决定了你能不能把模型塞进一张 8GB 或者 16GB 的卡里。第二块是计算吞吐INT8 矩阵乘相比 FP16 在很多 GPU 上吞吐量能翻倍甚至更多这里说的不是“纸面算力”而是实际部署时通过 TensorRT、TensorRT-LLM、vLLM 这类引擎能直接拿到的收益。第三块是缓存命中率更小的权重意味着 weight 在 L2 缓存里可以放更多层的数据访存压力下降之后资源密集型算子比如大矩阵乘的瓶颈会被明显缓解。格式位宽相对FP32内存典型用途FP3232bit1x训练、精度基线FP1616bit0.5x训练、部分推理BF1616bit0.5x训练稳定、大模型推理INT88bit0.25x推理加速、显存优化INT44bit0.125xLLM低比特推理GGUF等从这张表能看出来FP16到INT8 是“位宽减半、内存减半”但INT8到INT4 是进一步压缩代价是精度控制和实现复杂度明显上升。这也是为什么我个人建议大多数场景先从INT8入手跑通全链路再考虑更激进的方案。2. INT8矩阵乘是如何真正算起来的2.1 一个INT8 GEMM要处理的不只是乘法如果你以为INT8矩阵乘就是把两个INT8矩阵丢给算子库那有一半的细节被忽略了。神经网络里的矩阵乘 ( Y XW b ) 中X 和 W 在量化后各自都有scale和可能的zero_point那么真正要计算的数学表达式变成了Y_fp32 scale_x * scale_w * (X_int8 - zp_x) * (W_int8 - zp_w) b这一步的工程量在“融合”两个字上。实际部署中权重 W_int8、scale_w、zp_w 都是离线算好存下来的预测时只需要对激活 X 做动态量化得到 X_int8 和 scale_x。算完整数矩阵乘后结果要乘上 scale_x * scale_w 还原成浮点再叠加偏置。而偏置本身在训练时的尺度也是浮点尺度所以偏置可以预先除以 (scale_x * scale_w)这样反量化之后只需要一次浮点乘加。2.2 参考实现先搞清楚数据流再谈优化我用一个PyTorch风格的伪代码把数据流写下来你跟着走一遍就会明白整个INT8算子的骨架是什么样的# 假设 W_int8: [out_features, in_features], scale_w: [out_features, 1] def int8_linear(x_fp32, W_int8, scale_w, zp_w0, biasNone): # 1. 动态量化激活 scale_x (x_fp32.max() - x_fp32.min()) / 255.0 zp_x -round(x_fp32.min() / scale_x) - 128 x_int8 torch.clamp(torch.round(x_fp32 / scale_x) zp_x, -128, 127).to(torch.int8) # 2. 整数矩阵乘核心 y_int32 torch.matmul( (x_int8.to(torch.int32) - zp_x), (W_int8.to(torch.int32) - zp_w) ) # 3. 反量化回浮点 y_fp32 y_int32 * (scale_x * scale_w) # scale_w: [out,1]广播 # 4. 叠加偏置如果偏置没预缩放 if bias is not None: y_fp32 y_fp32 bias return y_fp32一段接一段看第一步是激活量化只做一次因为这是唯一的在线计算第二步是真正的整数矩阵乘注意要在 INT32 精度上累加避免8bit乘法的中间结果溢出第三步把乘积累积的 scale 一次性乘回来。这套流程是 INT8 推理最朴素的形态TensorRT 和 ONNX Runtime 里做得更彻底比如融合了激活算子和反量化但核心就是这个四段式。2.3 工程上的几个进阶细节vNNI、零点融合与算子融合CPU 和 GPU 底层的整数指令是这套流程能跑快的底层原因。x86 的 VNNI 指令和 ARM 的 Dot Product 指令都能在一条指令里完成多个 INT8 乘加相比之下 FP32 要一条条算乘和加。不过这里有个容易被忽视的点如果你的矩阵乘代码还在写(x_int8 - zp_x)这种Python层减法那性能一定好不到哪去。工程实现上通常会把 zero_point 融合进权重矩阵如果zp_w 0、zp_x也压缩进前置的量化算子主矩阵乘部分就可以直接算x_int8 * W_int8的纯 GEMM。另一个细节是层融合。实际部署时不会每个算子单独跑一个内核而是把“量化 - 矩阵乘 - 反量化 - 激活函数 - 下一个量化”融合成一个kernel减少多次访存和kernel启动开销。这同时也是为什么量化工作最好用 TensorRT、ONNX Runtime、TensorRT-LLM 这类推理引擎来做而不是自己手写一堆 INT8 算子——你要的是引擎把卷积/矩阵乘和外围算子按拓扑融合编排好而不是每个算子都跑一遍Python层的张量运算。2.4 为什么模型越小量化收益越不明显这里想提一个容易被忽略的经验小模型做INT8量化有时反而看不到加速。原因不复杂量化省下的主要是访存开销和部分算力提升如果你的模型算力占比本来就不高或者被小算子和数据搬运拖住那么量化带来的收益可能被几个副作用抵消反量化操作新增了计算、算子融合不充分导致额外的kernel切换、部分量化算子退化成低效的参照实现。我见过有人在 CPU 上量化一个 MobileNet 类的小模型延迟反而变慢了。所以做量化前先看一眼 profile模型是不是已经算力密集、带宽受限看不出明显瓶颈就去量化往往白忙一场。3. 校准选错INT8就是灾难3.1 校准的目标不是找min/max这么简单权重是静态的量化参数离线就能算好。激活值的问题是动态的——它取决于输入数据。校准calibration做的就是用一小部分有代表性的输入观测激活值的分布然后给每一层激活定好一个合理的量化范围。最朴素的办法是直接取观测到的 min/max但如果激活里有几个极端离群点min/max会把整个量化范围拉得很宽大部分数值落到少数几个格点上精度损失非常大。校准的核心就是在“范围够宽别全截断”和“范围够窄别浪费格点”之间找平衡。3.2 四种校准方法对比方法思路优点缺点典型场景MinMax取观测最小/最大值简单直观对离群值极度敏感分布均匀、无离群值Percentile取P99.9等分位数对长尾分布更稳分位点需要调激活分布有长尾熵校准/KL散度使量化前后的分布KL散度最小均衡效果好计算量大需调参TensorRT默认CNN/BERT常见MSE最小化量化前后的均方误差量化误差直接反映非凸需搜索/迭代需要精细控制误差的场景MinMax 适合像卷积网络里比较均匀的权重分布Percentile 在 NLP 模型里更容易获得稳定范围因为激活明显有长尾KL散度校准是 TensorRT 之前的默认方案它统计量化前后两个直方图的 KL 散度选一个能让信息丢失最小的截断阈值MSE 更像一个全局的、以数值误差为目标的搜索方法。实际项目中很少有人只用一种方法定全局往往先跑一版 minmax 或 percentile 看精度不过关了再针对个别层换方法。3.3 校准集怎么选才靠谱校准集的质量比数量更重要。数量上一般几百到几千条样本就够CNN 分类任务I通常512张图就能得到一个稳定的激活分布但如果你做的是检测模型或者 NLP 模型样本多样性比数量更关键。选校准样本要覆盖数据的主要分布模式不能只挑简单的或者只挑难的。比如人脸检测模型你得保证校准集里同时有正脸、侧脸、戴眼镜、光线不同的样本不然校正出来的量化范围会偏向某一类输入。还有一个经常踩的坑校准时的模型一定得是 eval 模式。别小看这问题BatchNorm 层在 train 模式下会使用当前 batch 的统计量和部署时的累计统计量不一致你拿到的激活范围本身就不可信。如果模型里有 Dropout也要记得关闭。3.4 实操心得校准完先看动态范围再谈精度我做完一次校准之后第一个动作永远是看一眼每一层的激活范围。用 2D 对比图把校准前后的浮点分布和量化分桶画出来比跑整个验证集方便得多能快速发现问题。比如某层激活几乎只在 [0.0001, 0.01] 之间但存在一个0.5的离群点这时 MinMax 校准出来的 scale 会让几乎所有激活落到同一个整数格点上这种层就是典型的“脱不开精度损失”的层。遇到这种情况我一般会针对该层单独手工选一个百分位阈值或者直接把这层保持 FP16 不量化这种混合精度的操作在 TensorRT 里通过 per-layer 精度设置就能实现。很多“量化后精度崩了”的模型其实不是量化不行而是个别层的量化范围选崩了。4. QAT量化感知训练把误差“练”进去4.1 PTQ 和 QAT 什么时候该选谁PTQ训练后量化是默认优先选择的方案成本低、流程快、不需要访问训练数据和训练代码精度损失在可接受范围内就直接用。QAT量化感知训练是在 PTQ 无法满足精度要求时才会考虑的方案它的核心思想是在训练阶段就模拟量化的舍入和裁剪行为让模型的参数去适应量化误差。什么场景要上 QAT经验上目标检测、分割这类对细节敏感的任务往往比分类更容易在 PTQ 掉点超低比特比如 INT4几乎必须配合 QAT 才有实用精度另外当你的 PTQ 精度只差一点点就能达标也可以试试针对尾部敏感层做部分 QAT。QAT 的成本明显更高——你需要重新训练通常还要调低学习率、拉长训练步数相当于为部署多做了一个回合的工程。4.2 伪量化节点与直通估计器STEQAT 里最关键的技术点是“如何在训练的前向过程中模拟量化误差同时让反向传播还是可导的”。如果直接在 forward 里做 round梯度对 round 来说几乎处处为0那这个节点参数的梯度就没了。直通估计器STE的做法是forward 用量化模拟backward 直接让梯度穿过舍入节点。用伪代码说就是class FakeQuantize(torch.autograd.Function): staticmethod def forward(ctx, x, scale, zero_point, qmin, qmax): x_int torch.clamp(torch.round(x / scale) zero_point, qmin, qmax) x_deq (x_int - zero_point) * scale return x_deq staticmethod def backward(ctx, grad_output): # STE: 梯度直接返回不做任何处理 return grad_output, None, None, None, None看起来是绕过舍入符让梯度原样通过这正是 STE 的精髓前传模拟量化带来的误差反传假装没有量化误差让模型在训练过程中“看见”量化噪声然后慢慢调整权重来降低它对量化的敏感度。QAT 里还有几个细节值得留意。伪量化节点的 scale 不能每步都重新从 min/max 取否则训练不稳定常用的方案是使用移动平均值EMA平滑scale或者先跑一段 PTQ校正再固定这些参数只训练权重。另一个难点是损失函数和超参量化感知训练通常要把学习率调低到正常训练的一半甚至更低并且克隆一个预训练模型做初始化而不是从头训。4.3 BN折叠与QAT实操流程QAT 训练中还有一个容易被忽略的环节BatchNorm 层怎么处理。大部分推理引擎在导出时会把 BN 折叠进卷积层但如果你在 QAT 阶段还在用 BN那么训练时的统计行为可能和推理时不一致导致量化误差被低估。实操上常见的做法是先训练一段带 BN 的 QAT在最后一小段 epoch 冻结 BN 的 mean/var伪量化节点继续更新这样更接近最终部署形态。一个建议的 QAT 流程以 PyTorch 生态为例基于一个已经收敛的模型在关键层插入 FakeQuantize 节点PyTorch 里可以用torch.quantization.QuantStub/DeQuantStub或者 torchao 的 API。先用 PTQ 跑一版校准得到每层初始 scale 和 zero_point。用较小的学习率比如正常训练的 1/10训练模型控制 10~20 个 epoch观察验证集精度。最后几个 epoch 冻结 BN再微调一下。导出时直接使用伪量化节点中记录的 scale 生成 INT8 模型。实测下来QAT 通常能把掉点从 3~5 个点压到 1 个点以内但代价是流程复杂性上来了所以我还是建议先 PTQ 再考虑 QAT不要一上来就动 QAT。4.4 QAT 在 LLM 上的现实限制QAT 的思路听起来百搭但在 LLM 场景几乎没人用全量 QAT。原因很简单LLM 参数量动辄 7B、13B做一次完整训练或微调的成本高到产业链都算不拢。而且 LLM 更多是生成式任务损失函数和逐层精度之间的关系复杂QAT 很难带来稳定收益。因此 LLM 量化目前的重点是 PTQ 路线用更聪明的量化策略SmoothQuant、AWQ、GPTQ替代高成本的训练方案。后面第5节就专门展开这部分内容。5. LLM量化异常值、SmoothQuant、AWQ/GPTQ 与生态实操5.1 LLM量化为什么比CNN更难把 CNN 上的 INT8 量化经验直接套到 LLM 上往往不work。原因是 LLM 的激活分布和 CNN 很不一样transformer 架构的激活里存在明显的“异常值通道”少数几个通道的绝对值远大于其他通道而且这种现象随 token 位置变化不稳定。如果按照全局 min/max 去定激活量化范围绝大多数 token 的激活都会挤在几个低比特格点上量化噪声就放大了。更麻烦的是LLM 是自回归生成一个 token 的小误差会通过 attention 传播累积生成内容越长误差被放大得越明显。5.2 SmoothQuant把激活难度转移到权重SmoothQuant 的思路可以概括成一句话激活难量化那就把量化难度往权重那边匀一匀。具体做法是对每层的激活和权重同时做一次数学等价变换给每个输入通道乘一个平滑因子 s权重按倒数做缩放保持矩阵乘结果数学上不变。激活的范围被压下来以后量化就好做了权重的范围虽然变大但权重是静态的可以用 per-channel 精度更高的量化来处理。那个平滑因子的选择有讲究一般按通道统计激活的绝对值均值然后用一个平滑系数 α常见 0.5 左右在激活和权重之间做“难度迁移”。SmoothQuant 在保持数学等价的前提下把激活的量化难度大幅降低这让很多 LLM 在 W8A8权重INT8、激活INT8下也能做到精度基本不损失。5.3 AWQ 和 GPTQ两种主流低比特路线AWQ激活感知权重量化关注的是权重哪些通道更重要。它发现按激活值的幅度统计出的“重要通道”对量化精度影响很大于是对这部分通道的权重在量化时放大它原本的作用通过通道级缩放因子量化后再按缩放因子还原保住关键权重的精度。AWQ 的优势是无需反向传播和重建直接用统计和缩放就能完成速度快精度损失也小。GPTQ 走的则是逐层误差最小化的路线。它基于一个观察某一层的权重误差会通过层间传播如果在量化每一层时直接最小化该层输出误差使用 OBS 近似整体精度就能保住。GPTQ 会先按列对权重做贪心量化再对尚未量化部分做一次误差补偿更新。这个过程有一个比较昂贵的离线预计算但跑完后部署就是纯推理不需要额外运行时开销。方案核心思路是否需要训练/反传量化比特适合场景SmoothQuant激活难度转移到权重否W8A8完整INT8服务通用GPU部署AWQ按激活幅度保护重要权重通道否W4/W8快速压缩兼顾精度GPTQ逐层重建误差最小化否离线贪心W4/W3显存极紧张时的极限压缩QAT训练中模拟量化误差是任意小模型精度不足时兜底5.4 量化档位、GGUF与KV Cache量化的实际选择在 LLM 部署实操里很多人是通过 llama.cpp 和 GGUF 格式接触量化的。GGUF 里的 q4_0、q4_K_M、q5_1、q8_0 这些档位就是把 transformer 权重里的不同张量按不同比特位宽和量化策略打包。q 表示量化4/5/6/8 是平均位宽K 表示针对不同张量用了混合量化策略比如 attention 权重用高比特FFN 权重用低比特M 是中等大小版本。我建议的直接经验显存允许时7B 模型优先 q8_0 或 q6_K追求速度和显存控制的用 q4_K_M尽管做不做得到要看具体显存余量但别盲目上 q2/q3除非你非常清楚自己的精度需求可以接受。除了权重LLM 推理时 KV Cache 也很占内存。长上下文的 KV Cache 增长极快比如 7B 模型在 4K 上下文下的 KV Cache 可能就要几百MB到几GB。KV Cache 量化通常做法是 INT8 或 FP8它能显著缓解生成阶段的显存和带宽压力但对实现细节要求高dq 和 scale 的布局、per-head/per-token 粒度都影响最终精度。在 TensorRT-LLM 或 vLLM 的框架里开启 KV Cache 量化通常就一行配置但你要理解它的代价——精度和显存的权衡在不同模型上不完全一样。5.5 LLM量化配置建议速查模型规模推荐起始方案显存预期约精度预期1B~3BW8A8 / q8_02~4GB基本无损7B~8BW4A16 GPTQ / q4_K_M4~6GB可接受掉点13B~14BW4A16 AWQ / q4_K_M7~10GB小幅掉点30B以上W4A16 AWQ/GPTQ KV Cache INT8按需需要评测后取舍这个表只是起点不是结论。LLM 模型家族内部差异很大同一个档位在不同模型上的表现可能差得很多所以正规流程永远是选定档位 - 跑一版量化模型 - 用你自己的评测集和指标测精度与性能 - 再决定要不要加减档。6. 常见问题与排查实录6.1 校准做完精度反而崩了先怀疑哪几件事校准环节最容易出问题的有三件事第一校准集和真实部署数据分布差太多校准范围完全没代表性第二忘记切 eval 模式BN 统计量不稳定第三校准样本量太少或太相似激活范围覆盖不足。我排查的时候一般先从这三件事抓起这三条占了校准问题的大多数。如果都没问题那再看是不是个别层离群值太多个别层用混合精度处理。6.2 量化后推理延迟没降甚至变慢这种问题在小型模型上特别常见。可能的原因包括没有用支持INT8算子加速的推理引擎纯Python层面模拟量化当然慢、算子融合做得不好、反量化算子频繁产生额外访存、或者模型本身算力占比太低提速效果被访存和kernel开销抵消。排查方法是先用 profile 工具看 kernel 时间和访存占比再决定是换引擎、配融合策略还是干脆保持 FP16。这里也顺带提醒一下量化从来不是自动加速器它只是降低数据和计算成本的手段收益取决于工程实现。6.3 精度掉点集中在个别层怎么定位用敏感度分析逐层或逐块把量化换成FP16跑一遍评测看哪层恢复精度最明显。这能帮你找到精度最敏感的一小撮层然后把它们排除在量化集合外。操作层面TensorRT 支持 per-layer 精度设置PyTorch 里也可以用混合精度量化 API 做类似的事。定位到敏感层之后通常能解决一大半精度问题而不需要全模型回退 FP16。6.4 常见问题速查表现象可能原因检查/解法校准后精度明显掉校准集分布偏差重新选取多样校准样本检查 eval 模式精度掉点集中某几层激活有离群、该层量化敏感做敏感度分析对该层做混合精度量化后延迟反而变慢算子未融合/引擎不支持用 TensorRT/ONNX Runtime 做融合优化输出数值明显异常/错位维度或 scale 配置不匹配检查量化导出脚本的scale和输出shape某些batch偶尔精度低激活不确定性、边界超出校准范围增大校准集考虑动态量化替代最后一个我也实操见过有人量化导出后发现模型输出和原模型完全对不上排查半天发现是前面某个预处理算子的输入输出维度没对齐导致的并不是量化参数本身的问题。维度和scale不匹配这类低级错误往往比想象中更容易发生所以每次导出后先跑一个固定输入的对比验证这个小习惯能省掉大量排查时间。聊到这我觉得有必要把个人经验再压一压。量化部署这件事我已经很少把它当成一个“一次性优化动作”了它更像一个流程节点先做 PTQ 校准检查每层动态范围和精度指标不行就针对敏感层做混合精度再不行才考虑 QAT 或者其他特殊方案。LLM 那边就换成 SmoothQuant/AWQ/GPTQ 去评测对比。你只要把“量化参数的来历”和“误差从哪来”搞明白不管是校准集选择、per-channel 开关还是 QAT 调参都是同一套思路下的不同旋钮。这篇文章写到的内容是我从多个项目的排查中沉淀出来的实操记录希望对正在做部署优化的你有用。下回我大概率会接着写推理引擎层面的算子融合和显存管理那些内容和量化配合起来才是一个完整的部署加速闭环。