趋近智
大师班
如前所述,对模型权重进行量化可以显著节省内存,但对激活进行量化会带来它自己的一系列困难和考量。激活,即神经元层的输出作为输入传递到下一层,本质上是动态的。与训练后固定的权重不同,激活值会随模型处理的每个输入样本而变动。这种动态特性使其量化更复杂但也同样重要,能实现推理速度的最大提升和内存带宽的减少,尤其是在有专用低精度计算单元的硬件上。
量化激活的主要难题源于其可能很宽且不可预测的动态范围。考虑ReLU或GeLU等激活函数的输出。这些非线性特性可以产生跨越多个数量级的值,并且与通常遵循某种可预测分布(例如,围绕零居中)的权重不同,激活分布可以根据输入数据和网络中特定层而明显不同。
激活范围在Transformer块内不同层之间明显不同。前馈网络(FFN)中的ReLU或GeLU等非线性函数尤其可以扩展动态范围。
使用低精度格式(如INT8)量化具有如此宽范围的张量迫使做出权衡。如果按比例量化以适应极端最大值和最小值(异常值),那么表示大多数值(可能聚集在小得多的范围里)的精度会变得非常粗糙。这种精度损失,称为量化误差,会显著降低模型精度。反之,如果您为值的密集聚类优化尺度,异常值将被截断,可能丢失重要信息。激活,尤其是在注意力机制或中间FFN层内,可能对此截断或精度损失特别敏感。
为了有效映射激活的浮点范围到低精度整数范围,我们需要确定适当的量化参数:一个尺度因子 (s) 和一个零点 (z)。一般映射是:
量化值=取整(s浮点值)+z找到最优 s 和 z 值的过程称为校准。它通常涉及通过模型输入一个代表性数据集(训练或验证数据的一个子集,通常是几百到几千个样本),并观察网络中不同点激活值的范围。存在几种校准方法:
最小-最大校准: 这是最简单的方法。记录校准期间观察到的最小 (xmin) 和最大 (xmax) 激活值。然后计算尺度 s 和零点 z,将这个观察到的范围 [xmin,xmax] 映射到目标整数范围(例如,有符号INT8的 [−128,127])。
均方误差(MSE)校准: 此方法迭代观察到的最小-最大范围内的不同潜在截断范围(阈值)。对于每个阈值,它计算量化参数并测量原始浮点激活与其量化-反量化等效值之间的平均平方误差。选择使MSE最小的阈值。
熵(KL散度)校准: 此方法旨在最小化量化过程中的信息损失。它选择量化参数 (s 和 z),使得原始浮点激活分布与量化-反量化激活分布之间的Kullback-Leibler(KL)散度最小化。
以下是PyTorch风格的示例,说明了在校准特定激活张量期间如何使用观察器:
import torch
# Assume 'activation_tensor' holds the activations from a specific layer
# during a forward pass with calibration data.
# 假设 'activation_tensor' 在使用校准数据进行前向传播期间,保存了来自特定层的激活值。
# --- Calibration Phase ---
# --- 校准阶段 ---
# Observer object tracks statistics
# 观察器对象跟踪统计信息
# Example: MinMaxObserver
# 示例:MinMaxObserver
class MinMaxObserver:
def __init__(self):
self.min_val = torch.tensor(float('inf'))
self.max_val = torch.tensor(float('-inf'))
def forward(self, x):
# Detach tensor to avoid tracking
# gradients during observation
# 分离张量以避免在观察期间追踪梯度
x_detached = x.detach()
self.min_val = torch.min(x_detached, self.min_val)
self.max_val = torch.max(x_detached, self.max_val)
return x # Pass input through unmodified
# during calibration
# 在校准期间,不修改地通过输入
def calculate_qparams(self, dtype=torch.qint8):
# Determine scale and zero_point based on
# observed min/max
# 根据观察到的最小值/最大值确定尺度和零点
qmin = torch.iinfo(dtype).min
qmax = torch.iinfo(dtype).max
scale = (self.max_val - self.min_val) / float(qmax - qmin)
# Ensure scale is not zero
if scale == 0.0:
scale = torch.tensor(1e-8) # Small epsilon or
# handle appropriately
# 确保尺度不为零
# 很小的epsilon值,或适当地处理
zero_point = qmin - torch.round(self.min_val / scale)
zero_point = torch.clamp(zero_point, qmin, qmax)
zero_point = zero_point.to(torch.int) # Clamp to valid
# range
# 限制在有效范围
return scale, zero_point
# In your model's forward pass during calibration:
# 在模型校准阶段的前向传播中:
# observer = MinMaxObserver() # Or other observer type
# (MSE, Entropy based)
# 观察器 = MinMaxObserver() (或其他观察器类型,如基于MSE、基于熵的)
# ... layer computation ...
# ... 层计算 ...
# activations = some_layer(input)
# activations = observer(activations) # Observer watches
# the activations
# 激活值 = 某个层(输入)
# 激活值 = 观察器(激活值) (观察器观察激活值)
# ... rest of forward pass ...
# ... 前向传播的其余部分 ...
# After running calibration data through:
# 运行校准数据后:
# scale, zero_point = observer.calculate_qparams()
# print(f"Calculated Scale: {scale}, Zero-Point: {zero_point}")
# print(f"计算出的尺度: {scale}, 零点: {zero_point}")
# --- Inference Phase ---
# --- 推理阶段 ---
# Use the calculated scale and zero_point
# to quantize activations
# 使用计算出的尺度和零点来量化激活值
# quantized_activations = torch.quantize_per_tensor(
# activation_tensor, scale, zero_point, torch.qint8
# )
就像权重一样,激活可以使用不同粒度进行量化:
选择取决于特定的层类型、观察到的激活分布以及可接受的性能开销。每张量量化因其简单性和效率而普遍使用,但更细粒度的量化可能需要在敏感层中恢复精度。
如前所述,异常值严重影响最小-最大校准,也可能对MSE/熵方法产生负面影响。一种常见的缓解方法是截断。在计算量化参数之前,观察到的激活范围根据百分位数进行截断。例如,不是使用绝对最小值和最大值,可以使用第1和第99百分位数,或第0.1和第99.9百分位数。
截断在计算量化参数前移除极端异常值,可能提高大多数值的精度,代价是截断的异常值被饱和。
截断有助于将量化范围集中在数据主体上,提高典型值的精度。然而,它对截断的异常值引入了饱和,如果这些异常值携带重要信息,可能有害。选择正确的截断阈值通常需要经验调整。
激活量化参数可以离线(静态)或即时(动态)确定:
量化感知训练直接将量化效果的模拟(包括权重和激活)集成到训练循环中。它使用“伪量化”节点来模拟在前向传播期间量化和反量化激活的过程,同时允许梯度在反向传播期间通过。
import torch
import torch.nn as nn
import torch.ao.quantization as quant
class QuantizableLayer(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(128, 256)
self.relu = nn.ReLU()
# QAT uses observers during training which double as fake quantizers
# QAT 在训练期间使用观察器,这些观察器也兼作伪量化器
# Placeholder for where activation quantization simulation happens
# 激活量化模拟发生处的占位符
self.activation_quant_stub = quant.QuantStub()
self.activation_dequant_stub = quant.DeQuantStub()
def forward(self, x):
# Simulate quantization of input activation to this layer
# 模拟输入到该层的激活量化
x = self.activation_quant_stub(x)
x = self.linear(x)
x = self.relu(x)
# Dequantize output activation before passing to next
# (potentially float) layer
# 在传递到下一个(可能是浮点)层之前,反量化输出激活
x = self.activation_dequant_stub(x)
return x
# During QAT, these stubs (along with weight fake quantization)
# simulate quantization errors, allowing the model to adapt.
# 在 QAT 期间,这些桩(以及权重伪量化)
# 模拟量化误差,使模型能够适应。
# After QAT, the model can be converted to a true quantized model
# using the statistics gathered by the observers within the stubs.
# QAT 结束后,模型可以使用桩内观察器收集到的统计数据转换为真正的量化模型。
通过在训练期间让模型接触量化噪声,QAT 允许网络调整其权重和激活分布,变得对量化误差更具弹性。这通常会产生明显更高的精度,尤其是在目标位宽非常低(如 INT4)时,或处理高度敏感的激活分布时。
重要考量包括:
成功应对这些考量可以显著减少内存带宽并潜在提升计算速度,使大型模型更适合部署。
这部分内容有帮助吗?
© 2026 ApX Machine Learning用心打造