☰
模型部署与推理优化:INT8量化原理与实践全解析
2026/9/29 9:36:39 网站建设 项目流程

模型部署与推理优化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 + 非对称量化。权重静态已知,可以事先算好每个通道的scale,per-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_w=0, bias=None): # 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 伪量化节点与直通估计器(STE)

QAT 里最关键的技术点是“如何在训练的前向过程中模拟量化误差,同时让反向传播还是可导的”。如果直接在 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 生态为例:

  1. 基于一个已经收敛的模型,在关键层插入 FakeQuantize 节点(PyTorch 里可以用torch.quantization.QuantStub/DeQuantStub或者 torchao 的 API)。
  2. 先用 PTQ 跑一版校准,得到每层初始 scale 和 zero_point。
  3. 用较小的学习率(比如正常训练的 1/10)训练模型,控制 10~20 个 epoch,观察验证集精度。
  4. 最后几个 epoch 冻结 BN,再微调一下。
  5. 导出时直接使用伪量化节点中记录的 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 调参,都是同一套思路下的不同旋钮。这篇文章写到的内容,是我从多个项目的排查中沉淀出来的实操记录,希望对正在做部署优化的你有用。下回我大概率会接着写推理引擎层面的算子融合和显存管理,那些内容和量化配合起来,才是一个完整的部署加速闭环。

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

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

立即咨询