在AI模型越来越大、计算需求越来越高的今天,如何在保持精度的同时让模型"瘦身",成为了每个AI工程师的必修课。
量化 的核心思想很简单:用更低精度的数据类型(如8位整数)来表示原本高精度(如32位浮点数)的模型权重和激活值。
量化和反量化
用低精度的量化值来近似表示高精度
的真实值,就是量化:
就是 量化值 ≈ 真实值
/ 缩放值
+ 零点值
反过来说,将低精度的量化值还原为高精度
的真实值,就是反量化:
真实值 ≈ 缩放值
×(量化值
- 零点值
)
所有的量化后操作都是为了近似实现在真实值上的操作。
由此可知,一个量化变量包括了三部分:量化值、缩放值、零点值,用来代替其真实值。
对于深度模型量化后的一层网络来说,它的输入变量(input,是int或uint)、权重参数(weight,是int或uint)、偏置参数(bias,是float)、输出变量(output,是int或uint),除了偏置外,其他是量化变量。
下面用卷积层和线性层来说明量化过程:
(1)卷积量化计算过程
输入变量(量化值x_input、输入零点input_zero_point、输入尺度input_scale):
↓ 数值类型转换
如果是uint8 → 减输入零点(x_input-input_zero_point) → 有符号(int32)
如果是int8 → 减输入零点(x_input-input_zero_point) → 有符号(int32)
↓ 输入
量化卷积层(权重值w、权重零点w_zero_point、权重尺度w_scale):
权重weight是int8权重 → 减权重零点(w - w_zero_point) → 有符号权重(int32)
↓
减去零点的输入(int32) 和 减去零点的权重(int32) 进行整数卷积 = 卷积结果(int32)
↓
数值类型转换:卷积结果从int32转float
↓
卷积结果 × (输入尺度input_scale × 权重尺度weight_scale) = 反量化卷积结果(float)
↓
反量化卷积结果 + 卷积偏置(float) = 反量化卷积加偏置结果(float)
↓
反量化卷积加偏置结果 ÷ 输出尺度 output_scale + 输出零点 output_zero_point = 反量化输出(float)
↓
量化输出,从float转uint8或int8:
反量化卷积加偏置结果 ÷ 输出尺度 output_scale + 输出零点 output_zero_point = 量化输出
如果是uint8,会发生量化激活,也就是负数值在量化时会被自动截断到0。
如果是int8,直接输出
↓输出
输出变量(量化值x_output、输出零点output_zero_point、输出缩放值output_scale)
量化激活(不是平时说的激活层)是指,当**使用无符号量化(如uint8,范围0-255)时,负数值在量化时会被自动截断到0。**这会产生类似ReLU的效果,但不是网络中本来就有网络层。
(2)线性层量化计算过程
输入变量(量化值x_input、输入零点input_zero_point、输入尺度input_scale):
↓ 数值类型转换
如果是uint8 → 减输入零点(x_input-input_zero_point) → 有符号(int32)
如果是int8 → 减输入零点(x_input-input_zero_point) → 有符号(int32)
↓ 输入
线性层(权重值w、权重零点w_zero_point、权重尺度w_scale):
权重weight是int8权重 → 减权重零点(w - w_zero_point) → 有符号权重(int32)
↓
减去零点的输入(int32) 和 减去零点的权重(int32) 进行整数矩阵乘法 = 乘法结果(int32)
↓
数值类型转换:乘法结果从int32转float
↓
卷积结果 × (输入尺度input_scale × 权重尺度weight_scale) = 反量化卷积结果(float)
↓
反量化卷积结果 + 卷积偏置(float) = 反量化乘法加偏置结果(float)
↓
反量化输出进行四舍五入取整
↓
量化输出,从float转uint8或int8:
反量化卷积加偏置结果 ÷ 输出尺度 output_scale + 输出零点 output_zero_point = 量化输出
如果是uint8,会发生量化激活,也就是负数值在量化时会被自动截断到0。
如果是int8,直接输出
↓输出
输出变量(量化值x_output、输出零点output_zero_point、输出缩放值output_scale)
为什么不直接存储int32?
因为内存带宽是AI计算的真正瓶颈,而类型提升成本很低。在CPU流水线中,类型提升只是数据通路的一部分,几乎不增加时钟周期。内存访问是瓶颈,类型提升在寄存器中完成。4倍的内存节约 = 4倍的缓存效率。此外,整数运算单元比浮点单元更小、更快、更省电。
1. 内存带宽节省(最重要的原因)
从内存加载数据到CPU:
uint8:一次加载32字节 → 32个数值
int32:一次加载32字节 → 8个数值
内存访问次数减少4倍!
2. 缓存效率提升
// 同样大小的L1缓存:
uint8数组:可以缓存 32KB ÷ 1字节 = 32,768个元素
int32数组:只能缓存 32KB ÷ 4字节 = 8,192个元素
缓存命中率更高!
3. 计算量的真实对比
假设有1000万个权重参数:
// 方案A:全程使用int32_t(纯浮点网络的量化模拟)
内存占用:1000万 × 4字节 = 40MB
内存带宽需求:40MB × 2(读写) = 80MB
计算:浮点乘法1000万次
// 方案B:使用uint8/int8 + 类型提升
内存占用:1000万 × 1字节 = 10MB ✅
内存带宽需求:10MB × 2 = 20MB ✅
计算:
- 类型提升:1000万次(几乎零成本)
- int8×int8乘法:1000万次(比浮点快3-4倍)
- 累加到int32:1000万次
总计算量:约2000万次整数操作
4. 实际性能差异
# 假设特征维度:4096
# 批量大小:128
# float32版本
内存加载:128 × 4096 × 4字节 = 2MB
计算:128 × 4096 × 4096次浮点乘加 ≈ 2.15亿次浮点运算
# int8版本
内存加载:128 × 4096 × 1字节 = 0.5MB (快4倍)
计算:128 × 4096 × 4096次int8乘加 + 类型提升
≈ 2.15亿次整数运算 + 500万次类型提升
整数运算比浮点运算快3-4倍
打个比方
想象你要搬1000本书:
float32方案:一次搬4本书,需要搬250次
int8方案:一次搬16本书,需要搬63次,但每搬一次需要花1秒钟整理
整理时间(类型提升)远小于搬运次数减少带来的收益
看似多了一步类型提升,但整体性能提升3-4倍!
训练后量化 和 量化感知训练
训练后量化(Post-Training Quantization,PTQ) 就像是给已经训练好的模型"减肥"。你先正常训练一个浮点模型 ,然后再对它进行量化处理。这种方法简单快捷,不需要重新训练,但可能会损失一定的精度。适用场景:快速部署需求、资源受限的环境、对精度损失有一定容忍度的应用
量化感知训练(Quantization-Aware Training,QAT) 则是"从娃娃抓起"的策略。你在训练过程中就模拟量化的效果 ,让模型在学习过程中就适应低精度表示。这种方法通常能获得更好的精度,但需要更多的训练时间和计算资源。适用场景:对精度要求极高的应用、有足够时间和资源进行重新训练、复杂模型的量化。
普通训练: FP32训练 → 完成模型
训练后量化: FP32训练 → FP32模型 → 量化 → 量化模型
量化感知训练: FP32训练 → 插入量化模拟 → QAT训练 → 量化模型
量化感知训练技术,也叫 **伪量化技术。**它的目的不是直接产生一个低精度(如INT8)的模型,而是在训练阶段就让模型"提前适应"将来量化时会带来的精度损失,从而在最终真正量化后,模型性能下降最小。
要理解伪量化,首先要明白标准的训练后量化流程的缺点:
标准训练后量化流程:用高精度(FP32)训练一个模型。训练完成后,直接将权值和激活值转换为低精度(如INT8)。
问题是:这个转换过程会引入误差(舍入误差、截断误差),模型在训练时从未见过这种误差,因此量化后的模型精度可能会有明显下降,尤其是对于敏感的任务或轻量级模型。
伪量化的解决方案:在训练阶段,就在前向传播中插入模拟量化操作,让模型在FP32的计算中,"感受"到量化带来的数值变化和误差。在反向传播时,使用技巧(如直通估计器)让梯度能够正常回传。这样训练出的模型,其权重本身就"知道"将来会被量化,从而学会了在量化噪声下保持鲁棒性。最终量化时,精度损失非常小,甚至没有损失。
动态量化 和 静态量化
动态量化(Dynamic Quantization) 是一种"运行时决定"的策略。在这种方法中,模型的权重在加载时就被量化,但激活值(activations)的量化是在推理过程中动态进行的 。工作原理 :模型权重在推理前就量化为8位整数;激活值在推理过程中根据实际数据范围动态量化;量化参数(如缩放因子)在每次推理时重新计算。优点 :适应性强,能处理输入数据分布变化大的情况;实现相对简单;内存节省明显。缺点:推理时有额外的计算开销(需要计算量化参数);可能不如静态量化高效。
静态量化(Static Quantization) 采用"预先计划"的策略。它需要在离线阶段收集代表性数据 ,统计出激活值的分布范围,然后固定量化参数。工作原理 :使用代表性校准数据集运行模型;收集各层激活值的统计信息(最小/最大值或分布);根据统计信息确定每层的量化参数(缩放因子和零点);应用固定的量化参数进行推理。优点 :推理速度快,无额外计算开销;更极致的性能优化;适合硬件加速。缺点:需要代表性校准数据;对数据分布变化的适应性较差;实现更复杂。
通过一个具体的数值例子来解释动态量化和静态量化的区别。
假设我们有一个简单的神经网络层,处理一个输入向量 x = [1.0, -2.0, 3.0]。该层的权重为 W = [0.5, 0.8, -0.2](为了简化,我们只做逐元素乘,类似卷积核)。
浮点运算过程如下:
输出 y = x * W = [1.0×0.5, -2.0×0.8, 3.0×(-0.2)] = [0.5, -1.6, -0.6]
现在我们要对这个计算进行8位整数量化(范围0-255)。
(1)动态量化的过程
步骤1:权重预先量化(离线)
权重 W 量化:
原始 W = [0.5, 0.8, -0.2]
观察范围:min=-0.2,max=0.8
计算缩放因子(scale_w)= (max - min) / 255 = (0.8 - (-0.2)) / 255 = 1.0 / 255 ≈ 0.00392
计算零点(zero_point_w)= round(0 - min/scale_w) = round(0 - (-0.2)/0.00392) = round(51.02) = 51
量化公式:W_q = round(W / scale_w + zero_point_w)
0.5/0.00392 + 51 ≈ 127.55 + 51 ≈ 178.55 → 179
0.8/0.00392 + 51 ≈ 204.08 + 51 ≈ 255.08 → 255
-0.2/0.00392 + 51 ≈ -51.02 + 51 ≈ -0.02 → 0
量化后权重:W_q = [179, 255, 0]
步骤2:推理时激活值动态量化(在线)
输入 x = [1.0, -2.0, 3.0] 的量化:
实时观察这批输入的范围:min=-2.0,max=3.0
计算缩放因子(scale_x)= (3.0 - (-2.0)) / 255 = 5.0 / 255 ≈ 0.0196
计算零点(zero_point_x)= round(0 - (-2.0)/0.0196) = round(102.04) = 102
量化输入:x_q = round(x / scale_x + zero_point_x)
1.0/0.0196 + 102 ≈ 51.02 + 102 ≈ 153.02 → 153
-2.0/0.0196 + 102 ≈ -102.04 + 102 ≈ -0.04 → 0
3.0/0.0196 + 102 ≈ 153.06 + 102 ≈ 255.06 → 255
量化后输入:x_q = [153, 0, 255]
步骤3:整数计算
# 整数计算(需要反量化才能得到浮点结果)
# 先反量化权重和输入
W_deq = (W_q - zero_point_w) × scale_w
= ([179-51, 255-51, 0-51]) × 0.00392
= [128, 204, -51] × 0.00392
≈ [0.502, 0.800, -0.200] # 接近原始权重
x_deq = (x_q - zero_point_x) × scale_x
= ([153-102, 0-102, 255-102]) × 0.0196
= [51, -102, 153] × 0.0196
≈ [1.000, -2.000, 3.000] # 接近原始输入
# 或者直接使用量化计算(需要处理缩放因子的乘积)
y_q = (W_q - zero_point_w) × (x_q - zero_point_x) # 这是整数计算
= [128, 204, -51] × [51, -102, 153]
= [6528, -20808, -7803]
# 最终反量化输出
scale_y = scale_w × scale_x = 0.00392 × 0.0196 ≈ 0.0000768
y = y_q × scale_y = [6528, -20808, -7803] × 0.0000768
≈ [0.501, -1.598, -0.599] # 接近浮点结果 [0.5, -1.6, -0.6]
动态量化特点: 这次推理的 scale_x=0.0196 和 zero_point_x=102 是针对这批输入 [1.0, -2.0, 3.0] 计算的。如果下一批输入不同,这些参数会重新计算。
(2)静态量化的过程
步骤1:使用校准数据集统计激活值范围
假设我们用代表性数据多次运行模型,收集到输入x的统计信息:
输入x的典型范围:min=-2.5,max=3.5(注意:这比单次输入的范围更广)
步骤2:预先计算并固定所有量化参数
权重量化(与动态量化相同):
W_q = [179, 255, 0]
scale_w = 0.00392,zero_point_w = 51
输入量化(使用统计范围而非实时计算):
使用统计范围:min=-2.5,max=3.5
scale_x_static = (3.5 - (-2.5)) / 255 = 6.0 / 255 ≈ 0.0235
zero_point_x_static = round(0 - (-2.5)/0.0235) = round(106.38) = 106
注意:这些参数在模型部署时就固定了
步骤3:推理时使用固定参数
# 用固定的scale_x_static和zero_point_x_static量化输入
x_q_static = round(x / 0.0235 + 106)
1.0/0.0235 + 106 ≈ 42.55 + 106 ≈ 148.55 → 149
-2.0/0.0235 + 106 ≈ -85.11 + 106 ≈ 20.89 → 21
3.0/0.0235 + 106 ≈ 127.66 + 106 ≈ 233.66 → 234
x_q_static = [149, 21, 234]
# 整数计算
y_q_static = (W_q - 51) × (x_q_static - 106)
= [128, 204, -51] × [149-106, 21-106, 234-106]
= [128, 204, -51] × [43, -85, 128]
= [5504, -17340, -6528]
# 输出反量化(输出也有固定的scale_y_static)
# scale_y_static = scale_w × scale_x_static = 0.00392 × 0.0235 ≈ 0.0000921
y_static = y_q_static × 0.0000921
≈ [0.507, -1.597, -0.601] # 仍然接近浮点结果
做个类比,动态量化相当于对数据做自身的极差归一化,而静态归一化相当于对数据做固定参数的归一化,参数是提前算好的。
如果输入变为 x = [4.0, -3.0, 2.0](超出静态统计范围):
动态量化:重新计算scale_x=0.0275,仍能较好处理
静态量化:仍用scale_x=0.0235,x=4.0会被饱和截断到255,可能损失精度
静态量化的固定参数允许硬件预分配资源,实现更高效的整数运算。动态量化的变化参数需要更灵活的处理单元。这个例子展示了为什么静态量化更快但需要代表性数据校准,而动态量化更灵活但计算开销更大。
四种量化方式
"训练后量化 vs 量化感知训练" 说的是 "什么时候做量化" 的问题。
"动态量化 vs 静态量化" 说的是 "具体怎么做量化" 的问题。
"量化具体怎么做?"
┌─────────────┬─────────────┐
│ 动态量化 │ 静态量化 │
│ (边做边定) │ (预先定好) │
┌────────┼─────────────┼─────────────┤
│训练后 │ 训练后+动态 │ 训练后+静态 │
│量化 │ (常见组合) │ (最常用组合)│
├────────┼─────────────┼─────────────┤
│量化感知│ 感知训练+动态│ 感知训练+静态│
│训练 │ (较少见) │ (常见组合) │
└────────┴─────────────┴─────────────┘
组合1:训练后 + 静态量化(最常用)
先训练好模型,然后用一批数据校准,固定所有量化参数。
python
# 1. 训练一个正常模型(精致大餐)
model = train_fp32_model()
# 2. 准备校准数据(调研顾客喜好)
calibration_data = load_calibration_dataset()
# 3. 进行训练后静态量化
quantized_model = torch.quantization.quantize_static(
model,
calibration_data
)
组合2:训练后 + 动态量化(较简单)
python
# 权重提前量化,激活值运行时动态量化
model = torch.quantization.quantize_dynamic(
model, # 训练好的模型
{torch.nn.Linear}, # 只量化这些层
dtype=torch.qint8
)
组合3:量化感知训练 + 静态量化(精度最高)
训练时就模拟量化,训练完用静态量化部署。
python
# 1. 训练时插入"伪量化"节点模拟量化效果
model.qconfig = torch.quantization.get_default_qconfig('fbgemm')
model_prepared = torch.quantization.prepare_qat(model)
model_trained = train_model(model_prepared) # 训练时就知道自己要变快餐
# 2. 转换为真正的量化模型(固定套餐)
model_quantized = torch.quantization.convert(model_trained)