CNN\LLM模型量化-以TensorRT为例
前言
- 本文主要阐述AI工具链中最需要的工具之一:量化工具。以CNN\LLM模型量化为展开,具体实例分析会以TensorRT为例,内容主要作为AI算法系列中模型量化部分的补充内容。
- 前置阅读:一文讲完模型压缩、转换、量化和优化(CNN/Transformer)
量化系统
量化对性能的影响
- 硬件平台对于低比特量化指令的支持程度不同。
- 比如部分平台支持 INT4,但部分平台只支持 INT8。即便是 8bit 计算,一些硬件平台可能支持 INT8 的计算,而另一些可能采用 FP8 计算
- 针对特定硬件的优化 Kernel 可以最大限度地利用硬件的并行计算能力和特定指令集,从而提高模型量化后的推理性能。
- 软件算法的优化并不一定可以提高模型量化的执行效率。
- 混合比特量化(Mixed Precision Quantization)需要进行量化和反向量化操作以及可能插入 Cast 算子来确保数据格式一致性。
- 降低模型参数量并不一定会降低运行时内存占用,因为在推理过程中可能需要同时存储模型参数、中间计算结果等数据。
- 模型参数量的减少并不直接意味着执行内存占用的减少。一些优化技术可能会引入额外的计算和存储开销。
- 软件算法优化时,需要综合考虑性能和精度的平衡、模型参数量和内存占用之间的差异,尽可能内存复用或针对性的优化。
为什么量化后的模型时延会增加?
- 量化会导致输入的tensor进行量化公式计算(scale、zeropoint量化参数保存起来计算浮点值的定点结果),这会带来额外的计算时间
量化存储方式
- INT4和FP4量化权重通过每字节打包两个元素来存储。第一个元素存储在4个最低位中,第二个元素存储在4个最高位中,如下图所示:
量化公式
- 将模型从高比特数(如 16 位)量化为低比特数(如 4 位)会导致更大的精度损失,模型大小与精度之间存在一种权衡关系。
- 量化包括:
量化输出 = 权重量化 * 输入量化
量化范围
- INT8 value in range [-128,127]
- FP8E4M3 value in the range [-448, 448]
- INT4 value in the range [-8, 7]
- FP4E2M1 value in the range [-6, 6]
TensorRT:在同一个模型中无法同时使用INT8和FP8
maxmin量化
- 属于非对称量化
o u t p u t _ q = r o u n d ( i n p u t _ f s c a l e + z e r o p o i n t ) \mathrm{output\_q} = \mathrm{round}\left( \frac{\mathrm{input\_f}}{\mathrm{scale}} + \mathrm{zeropoint} \right) output_q=round(scaleinput_f+zeropoint)
o u t p u t _ f = r o u n d ( s c a l e × ( i n p u t _ q − z e r o p o i n t ) ) \mathrm{output\_f} = \mathrm{round} \left( \mathrm{scale} \times (\mathrm{input\_q} - \mathrm{zeropoint})\right) output_f=round(scale×(input_q−zeropoint))
s c a l e = i n p u t _ f _ m a x − i n p u t _ f _ m i n q u a n t _ m a x − q u a n t _ m i n \mathrm{scale} = \frac{ \mathrm{input\_f\_max} - \mathrm{input\_f\_min}}{ \mathrm{quant\_max} - \mathrm{quant\_min} } scale=quant_max−quant_mininput_f_max−input_f_min
z e r o p o i n t = r o u n d ( q u a n t _ m a x − i n p u t _ f _ m a x s c a l e ) \mathrm{zeropoint} = \mathrm{round}\left( \mathrm{quant\_max}- \frac{\mathrm{input\_f\_max}}{\mathrm{scale}} \right) zeropoint=round(quant_max−scaleinput_f_max)
- 其中,
round
表示取整范围截断,在反量化时,需要保存最大的浮点数input_f_max
。ZeroPoint的存在会导致无论量化值还是浮点值的0点都是无精度损失的。- 当参数量过大时,反量化需要保存的
input_f_max
会占用很多内存,所以在LLM模型优化中有一个QLoRA的双重量化就是对input_f_max
进行量化减少内存。
- 当参数量过大时,反量化需要保存的
- minmax计算代码如下:
function quantize(float_tensor, scale, zero_point):
# 计算量化后的整数值
quantized_tensor = round(float_tensor / scale) + zero_point
# 确保结果在INT8范围内
quantized_tensor = clip(quantized_tensor, -128, 127)
return quantized_tensor
function dequantize(quantized_tensor, scale, zero_point):
# 恢复原始浮点值(近似)
float_tensor = (quantized_tensor - zero_point) * scale
return float_tensor
function calculate_quant_params(float_tensor, num_bits=8):
# 确定量化范围
min_val = min(float_tensor)
max_val = max(float_tensor)
# 计算scale和zero_point
scale = (max_val - min_val) / (2^num_bits - 1)
zero_point = round(-min_val / scale)
# 确保zero_point在INT8范围内
zero_point = clip(zero_point, -128, 127)
return scale, zero_point
absmax量化
- 属于对称量化
- 绝对值最大量化和minmax类似,通过将输入缩放到[-127,127]来实现,scale通过127除以整个张量的绝对最大值得到。
s c a l e = | i n p u t _ f _ m a x | 127 \mathrm{scale} = \frac{ \mathrm{|input\_f\_max|} }{ 127 } scale=127|input_f_max|
KLD量化
- TensorRT 中对于激活值采用的量化方法
- 这种方法不是直接将 [min, max] 映射到 [-127,127],而是去寻找一个阈值|T| < max(|max|, |min|),将其 [-T, T] 映射到 [-127, 127]。超出阈值 ±|T| 外的直接映射为阈值。
- 只要阈值选取得当,就能将阈值外的值舍弃掉,也不会对精度损失造成大的影响。
- T值通过对比量化和浮点的KL散度选择,若 KL 散度值越小,说明这两个分布越相似(这里计算KL散度前,会将每一个分布进行归一化,确保和为1后进行分布对比)
MSE量化
- 核心思想:直接最小化量化前后权重或激活的均方误差,选择最优的量化参数(scale/zero-point)
- 一种方法是对于可能的候选scale和zero-point进行网格搜索,选择最佳值
α ∗ = arg min s c a l e ∑ i = 1 N ( round ( w i s c a l e ) ⋅ s c a l e − w i ) 2 \alpha^* = \mathop{\arg\min}\limits_{scale} \sum_{i=1}^N \left( \text{round}\left( \frac{w_i}{scale} \right) \cdot scale - w_i \right)^2 α∗=scaleargmini=1∑N(round(scalewi)⋅scale−wi)2
量化类型
多种量化方式
- 量化训练QAT和训练后量化PTQ
- 对称量化和非对称量化
- 业界大多数使用的是对称量化,这是因为block最后一层是经过BN层之后的,对称量化更加简单,计算速度也会更快。
- 对称量化虽然简单,但在数据范围不对称时可能导致更大的误差。此时可以考虑非对称量化,允许正负数值映射到不同的整数范围内,减少误差。
- 饱和量化和非饱和量化
- 饱和量化超出目标范围的数值会被截断(clipping)。如使用KL散度计算一个合适的阈值T(0<T<abs_max),将绝对值T作为(-128,127)的范围,超出该阈值的直接映射为量化最值,此时量化因子scale为T/127
- 非饱和量化计算浮点类型中绝对值的最大值abs_max,将其映射为127,此时量化因子scale为abs_max/127
如果使用对称量化,如何选择量化范围?
- 使用max(abs(min_float), abs(max_float))计算范围。请注意,当abs(min_float)!= abs(max_float),求最大的动态范围方式可能会增加四舍五入误差。
TensorRT量化类型
- TensorRT量化方案是对称量化
- 量化值以有符号INT8、FP8(E4M3,简称FP8)、有符号INT4或FP4(E2M1,简称FP4)表示,从量化到非量化值的转换只有乘法计算。
- 支持INT8、FP8和FP4的激活和权重。但是INT4支持仅权重量化。
图优化在量化前还是量化后?
- 都可以。如果在层融合之前进行量化校准,校准缓存数据就可以在不同的设备之间进行移植;反之则不行。融合不能保证跨平台或设备相同,因此在层融合后进行校准可能影响校准缓存数据通用性。
插入Q/DQ节点
- 无论是ONNX还是TensorRT,量化都会显式的在模型结构中插入QDQ节点,即量化\反量化算子;最新版本的TensorRT已经摒弃了隐式量化(自动量化)而采用显式量化(插入QDQ)
- 虽然模型中的可量化层前都插入了QDQ算子,但这并不意味着量化网络的推理需要进行QDQ计算,因为在后续的优化过程中会进行图融合优化,使用量化算子层(量化计算的kernel)替换可量化算子层,这些量化算子层会使用对应量化类型的计算操作对量化数据进行计算,以加速网络推理。
- 为了尽可能进行层融合(最大化量化计算图的比例),TensorRT优化会尽可能将Q节点提前,DQ节点置后。这样中间的计算就是性能更优的量化计算了
- 推荐插入QDQ节点的位置:量化加权操作(卷积、转置卷积和GEMM)的所有输入。量化权重和激活可以降低带宽要求,并使INT8计算能够加速带宽受限和计算受限的层。
- 不要在BN层间或激活层间插入QDQ,部分情况下层输出需要保留浮点精度(如激活层)
- 插入QDQ的位置可能会影响计算图的融合,从而影响模型性能。
TensorRT使用和ONNX完全一致的网络结构,为什么结果并不二进制一致?
- ONNX模型使用显式量化表示,即模型结构中插入了QDQ。虽然TensorRT优化保留了QDQ运算符的算术语义,但其优化可能会改变模型中浮点运算的顺序,因此结果不会和原ONNX完全按位相同。
多尺度融合量化
- 多尺度融合(Multi-Scale Fusion)量化,特别是在涉及多分支结构(如FPN、BiFPN)时,需要谨慎处理QDQ节点插入位置,以避免量化误差累积和尺度间信息失真。
- 如果多尺度输入分布相似,可共享scale以减少计算
scale = m a x ( s c a l e 1 , s c a l e 2 ) \text{scale} = max(\mathrm{scale_1, scale_2}) scale=max(scale1,scale2) - 如果多尺度输入分布差异很大
- 在每个分支的输入前插入独立的 QDQ 节点,适应不同尺度的分布
- 融合后的特征图动态范围可能变化,需重新量化。在融合操作后插入新的 QDQ 节点
branch1 = DQ(Q(scale1, input1)) # 尺度1
branch2 = DQ(Q(scale2, input2)) # 尺度2
fused = DQ(Q(scale_fused, fusion_op(branch1, branch2)))
感知量化训练(QAT)
- 感知量化训练(Quantization Aware Training):通过在训练期间模拟量化操作,可以最大限度地减少量化带来的精度损失,在三种量化方式中精度最高。
- 在 QAT 过程中,所有权重和偏差都以 FP32 格式存储,反向传播照常进行。
- 在正向传播中,通过 FakeQuant 节点模拟量化。之所以称之为“fake”量化,是因为它们对数据进行量化并立即反量化(QDQ),添加了类似于在量化推理过程中可能遇到的量化噪声,以模拟训练期间量化的效果。
- QAT是不可导的,因为round取整的存在。怎么训练呢?可以假设round的导数为1,这是因为量化的趋势和浮点趋势一致,即Straight-Through Estimator (STE) 来近似梯度传播
- STE核心思想是在反向传播中绕过量化,直接传递梯度。
- STE核心思想是在反向传播中绕过量化,直接传递梯度。
- STE反响传播示例代码如下:
class QuantizeSTE(torch.autograd.Function):
@staticmethod
def forward(ctx, x, alpha, beta):
# 前向:量化 + 反量化
x_quant = torch.round(x / alpha + beta)
x_dequant = alpha * (x_quant - beta)
return x_dequant
@staticmethod
def backward(ctx, grad_output):
# 反向:直接传递梯度(STE)
return grad_output, None, None # 对 alpha 和 beta 的梯度为 None(若未学习)
# 使用方式
x_quant = QuantizeSTE.apply(x, alpha, beta)
STE为什么会导致梯度爆炸?
- STE中反向传播中假设量化是恒等映射,导数为1,但实际数学计算上导数为0,导致梯度被高估,误差逐层放大。一个解决方法是对梯度进行截断。
- QAT不稳定的原因:由于BN被融合和禁用的原因,QAT可能不稳定,这可以进行子图单独训练。
- 使用有限深度的子图(避免梯度爆炸,BFS+公共节点+最大深度)
- 子图的中间结果不被其他层所使用(防止数据偏移)
- 子图的输出不能是多个(缓存占用大)
- 增加正则化(避免梯度爆炸)
- 训练中需要出入checkpoint,在缓存中保存量化输入数据和浮点输出数据(因为缓存快,小子图训练非常快)
- 需要在训练中通过不断优化精度来获取最佳的量化参数(例如使用均方误差loss),可能只需要几个循环epoch就可以提升量化性能
- 技巧:
- 使用大batch size可能性能更优(通常为32、64)
- 使用丰富的数据集,但这种校准数据一般只需要几百张
如何量化网络的部分层?
- PTQ全部为网络的逐层量化,即每一层都有自己的量化因子(scale等)
- 手动调整插入的QDQ节点,将较为敏感的层不插入QDQ
- 对于部分层的量化,可以通过QAT量化,冻结其他层只训练部分层,在需要训练量化的部分层引入量化误差loss,对比和浮点的结果作loss计算并最小化
- 原理上,量化感知训练(QAT)致力于寻找「wide」宽域极小值以降低量化误差,因为「narrow」窄域极小值通常会产生更大的量化误差。
训练后量化(PTQ)
- PTQ本质是一个校准的工作流程,在具有代表性的输入数据上执行推理时,测量每个激活tensor中的激活分布,然后使用该分布来估计每个tensor的scale。
- 动态训练后量化(Post Training Quantization Dynamic, PTQ Dynamic)
- 动态量化根据输入数据在推理过程中计算尺度。它产生两种输出:量化数据和每块的scale。
- 仅将模型中特定算子的权重进行量化
- 但是对于不同输入值,其缩放因子是动态计算。
- 动态量化的权重是离线转换阶段量化,而激活是在运行阶段才进行量化。
- 激活是以浮点格式读取和写入内存的,在执行计算之前将激活转换为int 8(因此为“动态”)。
- 动态量化是几种量化方法中性能最差的。
- 静态训练后量化(Post Training Quantization Static, PTQ Static)
- 使用少量无标签量化校准数据。
- 核心:计算量化比例因子,确定激活范围,通过校准数据使用静态量化后的模型进行预测,根据激活结果的分布统计信息来设置缩放因子和量化范围。
- 量化值:卷积核参数、偏执、权重矩阵
- 方法包括对称量化、非对称量化方式,找最大值或者阈值的方法又有 MinMax(计算激活值的最大值和最小值,并据此设置量化范围)、KL 散度、ADMM、EQ,MSE 等方法。
- 算子操作间直接传递量化值,无需转换为浮点值再转换为整数,以加快速度。但是如果对激活未使用量化,需要将激活输入的量化值反量化为浮点数。
- PTQ 对几个超参数调整就可完成量化过程,量化校准数据会受到batch size配置的影响,过程简单快速,无需训练,业界应用广泛
量化粒度
量化的维度
- per-tensor:单个scale用于缩放整个张量。
- per-channel:scale沿固定的维度广播,通常是channel维度。
- per-block:tensor沿一个维度被划分为固定大小的块,每个块一个scale。
TensorRT:激活只能使用per-tensor进行量化。权重可以在任何量化模式下量化。convolution, deconvolution, and fully connected weights, scales为per-channel量化.
- 当使用卷积的每通道量化时,量化轴必须是输出通道轴。例如,当使用KCRS符号描述2D卷积的权重时,K是输出通道轴,权重量化可以描述为:
For each k in K:
For each c in C:
For each r in R:
For each s in S:
output[k,c,r,s] := clamp(round(input[k,c,r,s] / scale[k])) # clamp是截断数值算子 round是四舍五入算子
- 使用块量化时,例如,给定一个2-D RS权重输入,R(0维)作为块轴,B作为块大小,块轴中的scale基于块大小读取:
For each r in R:
For each s in S:
output[r,s] = clamp(round(input[r,s] / scale[r//B, s]))
量化的选择
- 可以量化的层
- 卷积层权重、激活
- 全连接层权重、激活
- QKV注意力机制、FFN
- 不可以量化或选择性量化的层
- 激活层
- BN、LN
- 敏感层
对比LLM,由于偏置项数量级(百万级)远小于权重参数(十亿级),通常采用更高精度(如INT16)存储偏置项,而量化优化的核心目标集中在权重处理。
- 量化模型的部署通常会采用静态量化的方式,即权重量化固定(卷积核参数等)、激活值量化固定(基于量化图片微调激活量化参数)
- 激活值的输出会随着输入的不同产生变化。尤其是涉及到指数、除法计算,可能会放大误差。
- 每一个的激活层的输出分布都是不一样的(但是卷积权重的量化是确定),这会导致激活层的分布难以明确,会有更多的离群值。
激活层有自己的量化缩放因子吗?
- 有,网络中每一层的输出都可能改变数值分布,需要有不同的缩放。通常激活层会将前一层输出的定点值进行反量化为浮点值,此时用的缩放因子就是激活层的量化参数,输出的浮点激活值送入下一层输入。
- 可以这样思考,如果是用8bit量化,那tanh的输入可能是一个uint8表示的数值,也就是输入是0、1、2这样,这个时候普通的tanh函数是没法使用的,因为不管输入是什么,输出都基本一样(参考tanh的函数图像)。而量化tanh的目的就是希望能继续保持tanh的映射关系
- 权重量化:通常选择 逐通道(per-channel)量化,即每个通道选取一个量化 scale。
- 权重张量在不同通道中的值分布差异很大,通道内的权重分布一般较为集中,如果使用单一的缩放因子进行量化,可能会导致较大的精度损失。通过对权重使用每通道粒度,可以在量化过程中更好地保留每个通道内的值分布。
- BN通常会和Conv融合计算,BN是逐通道的;卷积计算也是在channel间进行,跨channel可能差异很大。这也是为什么权重使用逐通道量化的另2个原因。
- 激活量化:通常选择 逐张量(per-tensor)量化,也可以选择逐 token(per-token)的量化粒度,因为需要关注重点的 token。
- 激活在不同通道之间通常较为一致,但会包含来自不同输入数据的异常值,激活值的分布和输入值是强相关的,对每层激活张量使用单一的缩放因子,有助于减小异常值的影响,特别是使用基于直方图的方法。
LLM模型量化的特殊性
- LLM模型参数量巨大,使用量化可以显著减少内存和带宽需求,LLM模型中80%以上的计算是矩阵乘计算,所以矩阵乘优化是其核心优化内容。
- LLM.int8()中,矩阵乘数据采用逐vector量化,99.9%的参数使用int8量化,但是其中0.1%的离群值采用FP16/BF16 量化。
- 这是因为实验发现在模型参数量>6.7B时,通常的int8量化会导致巨大的掉点。这是因为异常值(某些维度数值极大)对模型性能至关重要,但传统量化会丢失这些信息
- 设定阈值(如 ±6σ),超出阈值的维度视为异常值。异常值通常集中在模型的某些特定层(如注意力层的输入)
- 更多关于大模型量化可以参考一文讲完Transformer\LLM大模型优化及其训练部署
量化排查
量化差异的根本原因
- 离散化误差:随着每个量化值表示的范围变大而增加(scale和round计算)
- 截断误差:其中值被夹在一段可表示范围中(climp计算)
量化掉点分析
- 量化掉点:量化模型精度对比浮点模型精度超过1%-3%的精度降低
量化误差 = 权重误差 * 量化输入 + 输入误差 * 权重
- MSE损失:均方误差
MSE = 1 n ∑ i = 1 n ( y i − y ^ i ) 2 \text{MSE} = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 MSE=n1i=1∑n(yi−y^i)2 - 信噪比:均方误差的比值(噪声的能量/信号的能量)
SNR = M S E ∥ y i ∥ 2 2 \text{SNR} = \frac{MSE}{\mathrm{\|y_i\|_2^2}} SNR=∥yi∥22MSE
- MSE损失:均方误差
- 量化目标:找到最小化损失的缩放因子scale和截断值T
- where:哪里导致了量化掉点?
- 逐层对比:
- 敏感性分析:对比量化层和浮点层模型的精度和一致性;需要保存浮点模型的逐层结果,使用layer_compare工具的dump数据(模型参数、中间输出、梯度、激活loss值、权重loss值、预测结果等),但需要注意Dump操作会影响系统性能;
- 如将每个激活层输出的结果(激活输出通常是浮点F16,如果是定点可以计算概率分布使用KL散度)与原浮点激活层输出进行对比,可以得到每该层以及之前的累积量化误差情况。
- 如果要得到某一层的对比结果,可以将输入给该层的特征图保存,并输给该层和对比的浮点层进行一致性对比
- 对于不好评估精度的层,可以使用相似度评估(MSE均方误差,余弦相似度、KL散度、PSNR信噪比(
log_10(F_max^2/MSE)
)) - 对于融合算子的中间层,如何看对比量化结果呢?可以将中间层设置为网络的输出层,或者关闭融合算子编译
- 敏感性分析:对比量化层和浮点层模型的精度和一致性;需要保存浮点模型的逐层结果,使用layer_compare工具的dump数据(模型参数、中间输出、梯度、激活loss值、权重loss值、预测结果等),但需要注意Dump操作会影响系统性能;
- 逐层量化:逐层量化来观察哪一层导致的量化掉点
- 不量化激活值:测试权重是否掉点
- 不量化子模块:测试子模块掉点
- 逐层对比:
如何实现量化掉点的子模块搜索?
- 将模型按照树结构组织,以BFS搜索,逐个忽略同一深度下的不同模块,按照性能评估结果得到TopK性能损失的模块
量化掉点解决
- why:为什么出现量化掉点?该怎么解决?
- 不适合量化的算子
- 原因:常规算子一般不会出现量化问题,特定的层可能会对量化异常敏感。例如自定义后处理算子、cat、多尺度融合、特殊算子、网络最后一层、BN层对均值方差敏感
- 解决:混合精度量化,尽可能使用常规算子开发模型,后处理\模型分类层\输入层不推荐量化,多维度输出层信息拆解为多个head分别量化
- 特征分布
- 特征分布范围较大,有个别异常值偏离,或有大量的值在边界处
- 解决:1、模型结构拆分,如多head分别量化;2、动态调整scale计算,如非对称量化、直方图截断、离群值抑制等;3、考虑逐层量化,根据每一层的特性选择不同的 scale 值;4、量化步长校正,根据权重分布,计算合理的量化步长(步长是指每个量化值之间的间隔)等
- 累积量化掉点
- 原因:每层量化掉点+层间依赖,导致多层网络逐层累积掉点;
- 排查:可以通过逐层对比精度,如果出现精度逐层越来越低,就是逐层累积量化掉点
- 解决:重构模型结构,使用注意力机制等
- 量化超参不合理
- 原因:量化超参设置不合理,如量化位宽不合适,量化步长较大等
- 解决:超参搜索
- 高精度模型任务不适合量化
- 原因:如视觉里程计算法这种需要精确估计位姿的模型就不适合使用量化技术
- 解决:混合精度量化
- 量化数据缺失
- 原因:部分任务的量化数据缺失可能会导致部分结果掉点,如检测任务中缺少部分目标的量化图片可能会导致这个目标的检测结果掉点
- 解决:增加量化图片
- 其他原因
- 分辨率错误、图片格式错误、评价工具错误等
- 不适合量化的算子
量化调优方法
- 一个通常的调优做法是:PTQ量化 -> 部分量化 -> QAT量化
- 直方图截断:设置截断百分比,基于统计的直方图,按照百分比截断数据,对所有层截断。 但是部分激活层如sigmoid对最大值敏感。
- 逐层量化:调整每一层的量化范围。例如sigmoid非线性层,如果输入在0.5以内,但是量化按照0~1进行,就会导致误差,此时使用逐层量化就可以解决
- QAT:进行感知量化训练,有一个trick:取整一般选择四舍五入,但是有时随机舍入可能会提升模型性能
参考资料
- https://arxiv.org/pdf/2004.09602
- https://docs.nvidia.com/deeplearning/tensorrt/latest/inference-library/work-quantized-types.html
- https://github.com/OpenPPL/ppq
- https://zhuanlan.zhihu.com/p/28063135048
- https://arxiv.org/pdf/2208.07339