原文网址:知乎原文-量化基础知识
背景
PyTorch对量化的支持目前有如下三种方式:
Post Training Dynamic Quantization,模型训练完毕后的动态量化;
Post Training Static Quantization,模型训练完毕后的静态量化;
QAT(Quantization Aware Training),模型训练中开启量化。
Post Training Dynamic Quantization
训练完成后,再量化模型的权重参数,动态量化(下面有解释)
Pytorch有着成熟的api:
torch.quantization.quantize_dynamic(model, qconfig_spec=None, dtype=torch.qint8, mapping=None, inplace=False)
torch.quantization.quantize_dynamic可以将一个float model转化为dynamic quantized model(fp16或者qint8)。一般只转换以下op:
Linear
LSTM
LSTMCell
RNNCell
GRUCell
原因在于动态量化只讲权重参数进行量化,而这些layer的一般参数量比较大,因此边际效应高。
下面来解释参数
- model(模型)
- qconfig_spec关键
用我自己的话来说:
QConfig又封装了两个observer(activation激活函数,weight权重)。
observer就是根据(min_val,max_val,qmin,qmax)来计算量化参数scale和zero_point.(qmin和qmax是算法提前就确定好的,min_val和max_val是输入数据中得到的,因此叫observer。)
QConfig的子类QConfigDynamic,只封装了weight的observer;动态量化的qconfig使用的就是这个QConfigDynamic的实例 (因此只对weight进行量化),
qconfig_spec指定一组qconfig和op,op和qconfig互相对应
若qconfig_spec为None即为默认行为,即:
qconfig_spec = {
nn.Linear : default_dynamic_qconfig,
nn.LSTM : default_dynamic_qconfig,
nn.GRU : default_dynamic_qconfig,
nn.LSTMCell : default_dynamic_qconfig,
nn.RNNCell : default_dynamic_qconfig,
nn.GRUCell : default_dynamic_qconfig,
}
而default_dynamic_qconfig也就是QConfigDynamic的一个实例,如下:
default_dynamic_qconfig = QConfigDynamic(activation=default_dynamic_quant_observer, weight=default_weight_observer)
default_dynamic_quant_observer = PlaceholderObserver.with_args(dtype=torch.float, compute_dtype=torch.quint8)
default_weight_observer = MinMaxObserver.with_args(dtype=torch.qint8, qscheme=torch.per_tensor_symmetric)
其中activation的PlaceholderObserver就是个占位符,什么也不做
其中weight的MinMaxObserver就是记录输入tensor中的最大值和最小值,用来计算scale和zp
举例:
class CivilNet(nn.Module):
def __init__(self):
super(CivilNet, self).__init__()
gemfieldin = 1
gemfieldout = 1
self.conv = nn.Conv2d(gemfieldin, gemfieldout, kernel_size=1, stride=1, padding=0, groups=1, bias=False)
self.fc = nn.Linear(3, 2,bias=False)
self.relu = nn.ReLU(inplace=False)
def forward(self, x):
x = self.conv(x)
x = self.fc(x)
x = self.relu(x)
return x
#原始网络
CivilNet(
(conv): Conv2d(1, 1, kernel_size=(1, 1), stride=(1, 1), bias=False)
(fc): Linear(in_features=3, out_features=2, bias=False)
(relu): ReLU()
)
#quantize_dynamic 后
CivilNet(
(conv): Conv2d(1, 1, kernel_size=(1, 1), stride=(1, 1), bias=False)
(fc): DynamicQuantizedLinear(in_features=3, out_features=2, dtype=torch.qint8, qscheme=torch.per_tensor_affine)
(relu): ReLU()
)
可以看到,除了Linear,其它op都没有变动。而Linear被转换成了DynamicQuantizedLinear,DynamicQuantizedLinear就是torch.nn.quantized.dynamic.modules.linear.Linear类。没错,quantize_dynamic API的本质就是检索模型中op的type,如果某个op的type属于字典DEFAULT_DYNAMIC_QUANT_MODULE_MAPPINGS的key,那么,这个op将被替换为key对应的value:op和key对应value关系如下:
# Default map for swapping dynamic modules
DEFAULT_DYNAMIC_QUANT_MODULE_MAPPINGS = {
nn.GRUCell: nnqd.GRUCell,
nn.Linear: nnqd.Linear, ## nnqd.Linear就是DynamicQuantizedLinear就是torch.nn.quantized.dynamic.modules.linear.Linear。
nn.LSTM: nnqd.LSTM,
nn.LSTMCell: nnqd.LSTMCell,
nn.RNNCell: nnqd.RNNCell,
}
从key换到value,也就是从Linear换成DynamicQuantizedLinear,那么这个DQLinear该如何实例化?通过以下方式:
new_mod = mapping[type(mod)].from_float(mod)
from_float做的事就是:
1、使用MinMaxObserver计算模型中op权重参数中tensor的最大值最小值(这个例子中只有Linear op),缩小量化时原始值的取值范围,提高量化的精度;
2、通过上述步骤中得到四元组中的min_val和max_val,再结合算法确定的qmin, qmax计算出scale和zp,参考前文“Tensor的量化”小节,计算得到量化后的weight,这个量化过程有torch.quantize_per_tensor和torch.quantize_per_channel两种,默认是前者(因为qchema默认是torch.per_tensor_affine);
3、实例化nnqd.Linear,然后使用qlinear.set_weight_bias将量化后的weight和原始的bias设置到新的layer上。其中最后一步还涉及到weight和bias的打包,在源代码中是这样的:
#ifdef USE_FBGEMM
if (ctx.qEngine() == at::QEngine::FBGEMM) {
return PackedLinearWeight::prepack(std::move(weight), std::move(bias));
}
#endif
#ifdef USE_PYTORCH_QNNPACK
if (ctx.qEngine() == at::QEngine::QNNPACK) {
return PackedLinearWeightsQnnp::prepack(std::move(weight), std::move(bias));
}
#endif
TORCH_CHECK(false,"Didn't find engine for operation quantized::linear_prepack ",toString(ctx.qEngine()));
量化完后的模型在推理的时候有什么不一样的呢?在原始网络中,从输入到最终输出是这么计算的:
#input
torch.Tensor([[[[-1,-2,-3],[1,2,3]]]])
#经过卷积后(权重为torch.Tensor([[[[-0.7867]]]]))
torch.Tensor([[[[ 0.7867, 1.5734, 2.3601],[-0.7867, -1.5734, -2.3601]]]])
#经过fc后(权重为torch.Tensor([[ 0.4097, -0.2896, -0.4931], [-0.3738, -0.5541, 0.3243]]) )
torch.Tensor([[[[-1.2972, -0.4004], [1.2972, 0.4004]]]])
#经过relu后
torch.Tensor([[[[0.0000, 0.0000],[1.2972, 0.4004]]]])
### 在动态量化模型中,上述过程就变成了
#input
torch.Tensor([[[[-1,-2,-3],[1,2,3]]]])
#经过卷积后(权重为torch.Tensor([[[[-0.7867]]]]))
torch.Tensor([[[[ 0.7867, 1.5734, 2.3601],[-0.7867, -1.5734, -2.3601]]]])
#经过fc后(权重为torch.Tensor([[ 0.4085, -0.2912, -0.4911],[-0.3737, -0.5563, 0.3259]], dtype=torch.qint8,scale=0.0043458822183310986,zero_point=0) )
torch.Tensor([[[[-1.3038, -0.3847], [1.2856, 0.3969]]]])
#经过relu后
torch.Tensor([[[[0.0000, 0.0000], [1.2856, 0.3969]]]])
这里关键就是Linear op,scale和zero_point如何得来?
由其使用的observer计算得到的,具体来说就是默认的MinMaxObserver,
在各种observer中,计算权重的scale和zp离不开这四个变量:min_val,max_val代表op权重数据/input tensor数据分布的最小值和最大值,qmin和qmax代表量化后的取值范围的最小、最大值。
举例:
Linear op的权重为torch.Tensor([[ 0.4097, -0.2896, -0.4931], [-0.3738, -0.5541, 0.3243]]),因此其min_val和max_val分别为-0.5541 和 0.4097,在这个上下文中,max_val将进一步取这俩绝对值的最大值。由此我们就可以得到:
- scale = max_val / (float(qmax - qmin) / 2) = 0.5541 / ((127 + 128) /
2) = 0.004345882… - zp = 0
从上面我们可以得知,权重部分的量化是“静态”的,是提前就转换完毕的,而之所以叫做“动态”量化,就在于前向推理的时候动态的把input的float tensor转换为量化tensor。
在forward的时候,nnqd.Linear会调用torch.ops.quantized.linear_dynamic函数,输入正是上面(pack好后的)量化后的权重和float的bias,而torch.ops.quantized.linear_dynamic函数最终会被PyTorch分发到C++中的apply_dynamic_impl函数,在这里,或者使用FBGEMM的实现(x86-64设备),或者使用QNNPACK的实现(ARM设备上):
#ifdef USE_FBGEMM
at::Tensor PackedLinearWeight::apply_dynamic_impl(at::Tensor input, bool reduce_range) {
...
fbgemm::xxxx
...
}
#endif // USE_FBGEMM
#ifdef USE_PYTORCH_QNNPACK
at::Tensor PackedLinearWeightsQnnp::apply_dynamic_impl(at::Tensor input) {
...
qnnpack::qnnpackLinearDynamic(xxxx)
...
}
#endif // USE_PYTORCH_QNNPACK
apply_dynamic_impl函数中,使用如下逻辑进行量化:
Tensor q_input = at::quantize_per_tensor(input_contig, q_params.scale, q_params.zero_point, c10::kQUInt8);
动态量化的本质就藏身于此:基于运行时对数据范围的观察,来动态确定对输入进行量化时的scale值。这就确保 input tensor的scale因子能够基于输入数据进行优化,从而获得颗粒度更细的信息。
而模型的参数则是提前就转换为了INT8的格式(在使用quantize_dynamic API的时候)。这样,当输入也被量化后,网络中的运算就使用向量化的INT8指令来完成。 而在当前layer输出的时候,我们还需要把结果再重新转换为float32——re-quantization的scale值是依据input、 weight和output scale来确定的,定义如下:
requant_scale = input_scale_fp32 * weight_scale_fp32 / output_scale_fp32
总结下来,我们可以这么说:Post Training Dynamic Quantization,简称为Dynamic Quantization,也就是动态量化,或者叫作Weight-only的量化,是提前把模型中某些op的参数量化为INT8,然后在运行的时候动态的把输入量化为INT8,然后在当前op输出的时候再把结果requantization回到float32类型。动态量化默认只适用于Linear以及RNN的变种。
Post Training Static Quantization
先介绍statci和dynamic的区别和相同点
相同点:都是把网络的权重从float32转换为int8
不同点:需要把(训练集)或者(训练集类似的数据)喂给模型(没有反向传播),然后通过每个op的分布特点来计算activation的量化参数(scale和zp)——Calibrate(定标)。也就是静态量化包含activation,而动态量化只有weight的量化。静态量化需要activation的原因是静态量化的前向推理始终是INT计算,activation需要确保一个op的输入符合下一个op的输入。