由 Suraj Subramanian、Mark Saroufim、Jerry Zhang 撰写

量化是一种简单且经济的方法,可以使您的 DNN 运行更快,并具有更低的内存需求。PyTorch 提供了一些不同的方法来量化您的模型。在这篇博客文章中,我们将快速介绍深度学习中的量化基础,然后看看每种技术在实践中的样子。最后,我们将总结文献中关于在您的流程中使用量化的建议。


图 1. PyTorch <3 量化

目录

量化基础

如果有人问你现在是几点,你不会回答“10:14:34:430705”,但你可能会说“十点一刻”。

量化起源于信息压缩;在深度网络中,它指的是降低其权重和/或激活的数值精度。

过度参数化的深度神经网络具有更多的自由度,这使得它们成为信息压缩的良好候选者[1]。当你量化一个模型时,通常会发生两件事——模型变得更小,运行效率更高。硬件供应商明确允许对 8 位数据进行更快处理(相对于 32 位数据),从而实现更高的吞吐量。较小的模型具有更低的内存占用和功耗[2],这对于边缘部署至关重要。

映射函数

映射函数正如你所猜测的那样——一个将浮点值映射到整数空间的函数。一个常用的映射函数是一个线性变换,表示为 ,其中 是输入, 是量化参数。

要重新转换为浮点空间,逆函数表示为

,它们的差构成了量化误差。

量化参数

映射函数由缩放因子 和零点 进行参数化。

是输入范围与输出范围 的比值

其中 [ ] 是输入的裁剪范围,即允许输入的边界。[ ] 是映射到量化输出空间中的范围。对于 8 位量化,输出范围

作为偏置,以确保输入空间中的 0 完美映射到量化空间中的 0。

校准

选择输入裁剪范围的过程称为校准。最简单的技术(也是 PyTorch 中的默认设置)是记录运行中的最小值和最大值,并将它们分配给 。TensorRT 还使用熵最小化(KL 散度)、均方误差最小化或输入范围的分位数。

在 PyTorch 中, Observer 模块(代码)收集输入值的统计信息并计算 qparams 。不同的校准方案会导致不同的量化输出,最好通过实验验证哪种方案最适合您的应用程序和架构(更多内容将在后面介绍)。

from torch.quantization.observer import MinMaxObserver, MovingAverageMinMaxObserver, HistogramObserver
C, L = 3, 4
normal = torch.distributions.normal.Normal(0,1)
inputs = [normal.sample((C, L)), normal.sample((C, L))]
print(inputs)

# >>>>>
# [tensor([[-0.0590,  1.1674,  0.7119, -1.1270],
#          [-1.3974,  0.5077, -0.5601,  0.0683],
#          [-0.0929,  0.9473,  0.7159, -0.4574]]]),

# tensor([[-0.0236, -0.7599,  1.0290,  0.8914],
#          [-1.1727, -1.2556, -0.2271,  0.9568],
#          [-0.2500,  1.4579,  1.4707,  0.4043]])]

observers = [MinMaxObserver(), MovingAverageMinMaxObserver(), HistogramObserver()]
for obs in observers:
  for x in inputs: obs(x) 
  print(obs.__class__.__name__, obs.calculate_qparams())

# >>>>>
# MinMaxObserver (tensor([0.0112]), tensor([124], dtype=torch.int32))
# MovingAverageMinMaxObserver (tensor([0.0101]), tensor([139], dtype=torch.int32))
# HistogramObserver (tensor([0.0100]), tensor([106], dtype=torch.int32))

线性或对称量化方案

线性或非对称量化方案将输入范围分配给观察到的最小和最大值。线性方案通常提供更紧的裁剪范围,适用于量化非负激活(如果您的输入张量永远不会为负,则不需要输入范围包含负值)。范围计算如下 。当用于权重张量时,线性量化会导致更昂贵的推理[3]。

对称量化方案将输入范围围绕 0 中心化,消除了计算零点偏移的需要。范围计算如下 。对于偏斜信号(如非负激活),这可能导致量化分辨率不佳,因为裁剪范围包括输入中从未出现过的值(请参见下面的 pyplot)。

act =  torch.distributions.pareto.Pareto(1, 10).sample((1,1024))
weights = torch.distributions.normal.Normal(0, 0.12).sample((3, 64, 7, 7)).flatten()

def get_symmetric_range(x):
  beta = torch.max(x.max(), x.min().abs())
  return -beta.item(), beta.item()

def get_affine_range(x):
  return x.min().item(), x.max().item()

def plot(plt, data, scheme):
  boundaries = get_affine_range(data) if scheme == 'affine' else get_symmetric_range(data)
  a, _, _ = plt.hist(data, density=True, bins=100)
  ymin, ymax = np.quantile(a[a>0], [0.25, 0.95])
  plt.vlines(x=boundaries, ls='--', colors='purple', ymin=ymin, ymax=ymax)

fig, axs = plt.subplots(2,2)
plot(axs[0, 0], act, 'affine')
axs[0, 0].set_title("Activation, Affine-Quantized")

plot(axs[0, 1], act, 'symmetric')
axs[0, 1].set_title("Activation, Symmetric-Quantized")

plot(axs[1, 0], weights, 'affine')
axs[1, 0].set_title("Weights, Affine-Quantized")

plot(axs[1, 1], weights, 'symmetric')
axs[1, 1].set_title("Weights, Symmetric-Quantized")
plt.show()


图 2.仿射和对称方案的裁剪范围(紫色)

在 PyTorch 中,初始化 Observer 时可以指定仿射或对称方案。请注意,并非所有观察者都支持这两种方案。

for qscheme in [torch.per_tensor_affine, torch.per_tensor_symmetric]:
  obs = MovingAverageMinMaxObserver(qscheme=qscheme)
  for x in inputs: obs(x)
  print(f"Qscheme: {qscheme} | {obs.calculate_qparams()}")

# >>>>>
# Qscheme: torch.per_tensor_affine | (tensor([0.0101]), tensor([139], dtype=torch.int32))
# Qscheme: torch.per_tensor_symmetric | (tensor([0.0109]), tensor([128]))

每张量和每通道量化方案

可以对层的整个权重张量作为一个整体计算量化参数,或者分别对每个通道进行计算。在每张量方案中,相同的裁剪范围应用于层的所有通道


图 3. 每个通道使用一组 qparams,而每个张量使用相同的 qparams。

对于权重量化,对称通道量化提供了更好的精度;张量量化表现不佳,可能由于批归一化折叠导致的通道间卷积权重方差较大[3]。

from torch.quantization.observer import MovingAveragePerChannelMinMaxObserver
obs = MovingAveragePerChannelMinMaxObserver(ch_axis=0)  # calculate qparams for all `C` channels separately
for x in inputs: obs(x)
print(obs.calculate_qparams())

# >>>>>
# (tensor([0.0090, 0.0075, 0.0055]), tensor([125, 187,  82], dtype=torch.int32))

后端引擎

目前,量化算子通过 FBGEMM 后端在 x86 机器上运行,或在 ARM 机器上使用 QNNPACK 原语。服务器 GPU(通过 TensorRT 和 cuDNN)的后端支持即将推出。了解更多关于扩展量化到自定义后端的信息:RFC-0019。

backend = 'fbgemm' if x86 else 'qnnpack'
qconfig = torch.quantization.get_default_qconfig(backend)  
torch.backends.quantized.engine = backend

QConfig

QConfig (代码)NamedTuple 存储了用于量化激活和权重的观察者和量化方案。

请确保传递 Observer 类(而非实例),或一个可以返回 Observer 实例的可调用对象。使用 with_args() 来覆盖默认参数。

my_qconfig = torch.quantization.QConfig(
  activation=MovingAverageMinMaxObserver.with_args(qscheme=torch.per_tensor_affine),
  weight=MovingAveragePerChannelMinMaxObserver.with_args(qscheme=torch.qint8)
)
# >>>>>
# QConfig(activation=functools.partial(<class 'torch.ao.quantization.observer.MovingAverageMinMaxObserver'>, qscheme=torch.per_tensor_affine){}, weight=functools.partial(<class 'torch.ao.quantization.observer.MovingAveragePerChannelMinMaxObserver'>, qscheme=torch.qint8){})

在 PyTorch 中

PyTorch 允许您以几种不同的方式量化您的模型,具体取决于

  • 您是否更喜欢灵活但手动的方式,或者受限的自动过程(急切模式与 FX 图模式)
  • 如果量化参数(层输出)是针对所有输入预先计算的,还是每个输入都重新计算(静态与动态),
  • 如果量化参数是在重新训练或无重新训练的情况下计算(量化感知训练与后训练量化)

FX 图模式自动融合符合条件的模块,插入量化和去量化占位符,校准模型并返回量化模块 - 所有操作仅需两次方法调用 - 但仅适用于可符号追踪的网络。以下示例包含使用急切模式和 FX 图模式进行对比的调用。

在深度神经网络中,可量化的候选对象是 FP32 权重(层参数)和激活(层输出)。量化权重可以减小模型大小。量化激活通常会导致推理速度更快。

例如,50 层的 ResNet 网络大约有 2600 万个权重参数,在正向传播中计算大约 1600 万个激活。

训练后动态/仅权重量化

模型的权重预先量化;激活量在推理过程中动态量化(动态量化)。这是所有方法中最简单的一种,API 调用只需一行代码 torch.quantization.quantize_dynamic 。目前仅支持线性层和循环层( LSTMGRURNN )进行动态量化。

(+) 由于剪裁范围针对每个输入进行了精确校准,因此可以提高准确率 [1]。

(+) 对于 LSTMs 和 Transformers 等模型,动态量化更受欢迎,因为这些模型中写入/读取模型权重占主导带宽 [4]。

(-) 在运行时对每层的激活量进行校准和量化会增加计算开销。

import torch
from torch import nn

# toy model
m = nn.Sequential(
  nn.Conv2d(2, 64, (8,)),
  nn.ReLU(),
  nn.Linear(16,10),
  nn.LSTM(10, 10))

m.eval()

## EAGER MODE
from torch.quantization import quantize_dynamic
model_quantized = quantize_dynamic(
    model=m, qconfig_spec={nn.LSTM, nn.Linear}, dtype=torch.qint8, inplace=False
)

## FX MODE
from torch.quantization import quantize_fx
qconfig_dict = {"": torch.quantization.default_dynamic_qconfig}  # An empty key denotes the default applied to all modules
model_prepared = quantize_fx.prepare_fx(m, qconfig_dict)
model_quantized = quantize_fx.convert_fx(model_prepared)

训练后静态量化(PTQ)

PTQ 也预先量化模型权重,但与动态校准激活不同,这里的剪裁范围是预先校准并固定的(静态)使用验证数据。推理过程中激活保持在量化精度之间。大约 100 个具有代表性的数据的小批量就足以校准观察者[2]。以下示例使用随机数据在校准中方便起见 - 在您的应用程序中使用该数据将导致不良的 q 参数。

PTQ flowchart
图 4.训练后静态量化步骤

模块融合将多个连续模块(例如: [Conv2d, BatchNorm, ReLU] )合并为一个。融合模块意味着编译器只需要运行一个内核而不是多个;这可以加快速度并提高准确性,通过减少量化误差。

(+) 静态量化比动态量化具有更快的推理速度,因为它消除了层之间的浮点数与整数的转换成本。

(-) 静态量化模型可能需要定期重新校准,以保持对分布漂移的鲁棒性。

# Static quantization of a model consists of the following steps:

#     Fuse modules
#     Insert Quant/DeQuant Stubs
#     Prepare the fused module (insert observers before and after layers)
#     Calibrate the prepared module (pass it representative data)
#     Convert the calibrated module (replace with quantized version)

import torch
from torch import nn
import copy

backend = "fbgemm"  # running on a x86 CPU. Use "qnnpack" if running on ARM.

model = nn.Sequential(
     nn.Conv2d(2,64,3),
     nn.ReLU(),
     nn.Conv2d(64, 128, 3),
     nn.ReLU()
)

## EAGER MODE
m = copy.deepcopy(model)
m.eval()
"""Fuse
- Inplace fusion replaces the first module in the sequence with the fused module, and the rest with identity modules
"""
torch.quantization.fuse_modules(m, ['0','1'], inplace=True) # fuse first Conv-ReLU pair
torch.quantization.fuse_modules(m, ['2','3'], inplace=True) # fuse second Conv-ReLU pair

"""Insert stubs"""
m = nn.Sequential(torch.quantization.QuantStub(), 
                  *m, 
                  torch.quantization.DeQuantStub())

"""Prepare"""
m.qconfig = torch.quantization.get_default_qconfig(backend)
torch.quantization.prepare(m, inplace=True)

"""Calibrate
- This example uses random data for convenience. Use representative (validation) data instead.
"""
with torch.inference_mode():
  for _ in range(10):
    x = torch.rand(1,2, 28, 28)
    m(x)
    
"""Convert"""
torch.quantization.convert(m, inplace=True)

"""Check"""
print(m[[1]].weight().element_size()) # 1 byte instead of 4 bytes for FP32


## FX GRAPH
from torch.quantization import quantize_fx
m = copy.deepcopy(model)
m.eval()
qconfig_dict = {"": torch.quantization.get_default_qconfig(backend)}
# Prepare
model_prepared = quantize_fx.prepare_fx(m, qconfig_dict)
# Calibrate - Use representative (validation) data.
with torch.inference_mode():
  for _ in range(10):
    x = torch.rand(1,2,28, 28)
    model_prepared(x)
# quantize
model_quantized = quantize_fx.convert_fx(model_prepared)

量化感知训练(QAT)

QAT flowchart
图 5. 量化感知训练步骤

PTQ 方法非常适合大型模型,但在小型模型中准确性会受到影响[[6]]。这当然是由于将模型从 FP32 领域转换为 INT8 领域时数值精度的损失(图 6(a))。QAT 通过将量化误差包含在训练损失中来解决这一问题,从而训练一个以 INT8 为主的模型。

Fig. 6: Comparison of PTQ and QAT
图 6. PTQ 和 QAT 收敛性比较[3]

所有权重和偏差都存储在 FP32 中,反向传播过程与通常一样。然而,在正向传播过程中,通过 FakeQuantize 模块内部模拟量化。它们被称为“假”量化,因为它们量化数据后立即反量化,添加与量化推理过程中可能遇到的类似的量化噪声。因此,最终的损失考虑了任何预期的量化误差。在此优化允许模型在损失函数中识别更广泛的区域(图 6(b)),并识别 FP32 参数,将它们量化为 INT8 不会显著影响准确性。

Fake Quantization in the forward and backward pass
图 7. 正向和反向传播过程中的假量化
图像来源:https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt

(+) QAT 的准确率高于 PTQ。

(+) Qparams 可在模型训练期间学习,以获得更精细的准确度(参见 LearnableFakeQuantize)。

(-) 在 QAT 中重新训练模型的计算成本可能需要数百个 epoch [1]。

# QAT follows the same steps as PTQ, with the exception of the training loop before you actually convert the model to its quantized version

import torch
from torch import nn

backend = "fbgemm"  # running on a x86 CPU. Use "qnnpack" if running on ARM.

m = nn.Sequential(
     nn.Conv2d(2,64,8),
     nn.ReLU(),
     nn.Conv2d(64, 128, 8),
     nn.ReLU()
)

"""Fuse"""
torch.quantization.fuse_modules(m, ['0','1'], inplace=True) # fuse first Conv-ReLU pair
torch.quantization.fuse_modules(m, ['2','3'], inplace=True) # fuse second Conv-ReLU pair

"""Insert stubs"""
m = nn.Sequential(torch.quantization.QuantStub(), 
                  *m, 
                  torch.quantization.DeQuantStub())

"""Prepare"""
m.train()
m.qconfig = torch.quantization.get_default_qconfig(backend)
torch.quantization.prepare_qat(m, inplace=True)

"""Training Loop"""
n_epochs = 10
opt = torch.optim.SGD(m.parameters(), lr=0.1)
loss_fn = lambda out, tgt: torch.pow(tgt-out, 2).mean()
for epoch in range(n_epochs):
  x = torch.rand(10,2,24,24)
  out = m(x)
  loss = loss_fn(out, torch.rand_like(out))
  opt.zero_grad()
  loss.backward()
  opt.step()

"""Convert"""
m.eval()
torch.quantization.convert(m, inplace=True)

敏感性分析

并非所有层对量化都同等敏感,有些层比其他层更容易受到精度下降的影响。确定最小化精度下降的最佳层组合是一个耗时的过程,因此[3]建议进行逐层敏感性分析以确定哪些层最敏感,并保留这些层的 FP32 精度。在他们的实验中,仅跳过 MobileNet v1 中的 2 个卷积层(总共 28 个)就能达到接近 FP32 的精度。使用 FX Graph 模式,我们可以轻松创建自定义 qconfigs 来完成这项工作:

# ONE-AT-A-TIME SENSITIVITY ANALYSIS 

for quantized_layer, _ in model.named_modules():
  print("Only quantizing layer: ", quantized_layer)

  # The module_name key allows module-specific qconfigs. 
  qconfig_dict = {"": None, 
  "module_name":[(quantized_layer, torch.quantization.get_default_qconfig(backend))]}

  model_prepared = quantize_fx.prepare_fx(model, qconfig_dict)
  # calibrate
  model_quantized = quantize_fx.convert_fx(model_prepared)
  # evaluate(model)

另一种方法是比较 FP32 和 INT8 层的统计信息;这些常用的指标是 SQNR(信噪比)和均方误差。这种比较分析也可能有助于指导进一步的优化。

Fig 8. Comparing model weights and activations
图 8. 比较模型权重和激活

PyTorch 提供了在数值套件下帮助进行此分析的工具。从完整教程中了解更多关于使用数值套件的信息。

# extract from https://maskerprc.github.io/tutorials/prototype/numeric_suite_tutorial.html
import torch.quantization._numeric_suite as ns

def SQNR(x, y):
    # Higher is better
    Ps = torch.norm(x)
    Pn = torch.norm(x-y)
    return 20*torch.log10(Ps/Pn)

wt_compare_dict = ns.compare_weights(fp32_model.state_dict(), int8_model.state_dict())
for key in wt_compare_dict:
    print(key, compute_error(wt_compare_dict[key]['float'], wt_compare_dict[key]['quantized'].dequantize()))

act_compare_dict = ns.compare_model_outputs(fp32_model, int8_model, input_data)
for key in act_compare_dict:
    print(key, compute_error(act_compare_dict[key]['float'][0], act_compare_dict[key]['quantized'][0].dequantize()))

工作流程推荐

Suggested quantization workflow
图 9. 建议的量化工作流程

点击查看大图

注意事项

  • 大型(10M+参数)模型对量化误差的鲁棒性更强。[2]
  • 将模型从 FP32 检查点量化比从头开始训练 INT8 模型提供更好的精度。[2]
  • 分析模型运行时间是可选的,但它可以帮助识别瓶颈层。
  • 动态量化是一个简单的第一步,尤其是如果你的模型包含许多线性或循环层。
  • 使用对称通道量化与 MinMax 观察者来量化权重。使用仿射张量量化与 MovingAverageMinMax 观察者来量化激活[2, 3]。
  • 使用如 SQNR 等指标来识别哪些层最容易受到量化误差的影响。在这些层上关闭量化。
  • 使用 QAT 进行微调,大约需要原训练计划的 10%,并从初始训练学习率的 1%开始使用退火学习率计划。[3]
  • 如果上述工作流程对您不起作用,我们想了解更多。请发布一个包含您代码细节(模型架构、准确度指标、尝试的技术)的帖子。请随意@suraj.pt 联系我。

这需要消化很多东西,恭喜您坚持下来!接下来,我们将探讨对使用动态控制结构(if-else、循环)的“真实世界”模型进行量化。这些元素不允许对模型进行符号跟踪,这使得直接量化模型变得有些棘手。在系列文章的下一篇文章中,我们将深入探讨一个充满循环和 if-else 块的模型,甚至使用了 forward 调用中的第三方库。

我们还将介绍 PyTorch 量化中的一个新特性——运行时定义,它试图通过只需要模型计算图的子集不包含动态流程来放宽这一限制。查看 PTDD’21 上的 Define-by-Run 海报以获取预览。

参考文献列表

[ 1] Gholami, A.,Kim, S.,Dong, Z.,Yao, Z.,Mahoney, M. W.,& Keutzer, K. (2021). 高效神经网络推理量化方法的综述。arXiv 预印本 arXiv:2103.13630。

[2] 克里希纳穆尔蒂,R. (2018)。高效推理的深度卷积神经网络量化:一份白皮书。arXiv 预印本 arXiv:1806.08342。

[3] 吴,H.,朱德,P.,张,X.,伊萨耶夫,M.,& 米基凯维丘斯,P. (2020)。深度学习推理的整数量化:原理和实证评估。arXiv 预印本 arXiv:2004.09602。

[4] PyTorch 量化文档