AGI 之 【Hugging Face】 的【Transformer模型优化】的 [利用量化技术使模型运算] / [ 基准测试量化模型 ] / [ONNX和ONNX Runtime ] 的简单整理

AGI 之 【Hugging Face】 的【Transformer模型优化】的 [利用量化技术使模型运算] / [ 基准测试量化模型 ] / [ONNX和ONNX Runtime  ] 的简单整理

目录

AGI 之 【Hugging Face】 的【Transformer模型优化】的 [利用量化技术使模型运算] / [ 基准测试量化模型 ] / [ONNX和ONNX Runtime  ] 的简单整理

一、简单介绍

二、Transformer 模型调优

三、利用量化技术使模型运算更快

1、浮点数和定点数基础介绍

四、基准测试量化模型

五、使用ONNX和ONNX Runtime进行推理优化

六、使用权重剪枝使模型更稀疏

1、深度神经网络中的稀疏性

2、权重剪枝方法


一、简单介绍

AGI,即通用人工智能(Artificial General Intelligence),是一种具备人类智能水平的人工智能系统。它不仅能够执行特定的任务,而且能够理解、学习和应用知识于广泛的问题解决中,具有较高的自主性和适应性。AGI的能力包括但不限于自我学习、自我改进、自我调整,并能在没有人为干预的情况下解决各种复杂问题。

  • AGI能做的事情非常广泛:

    跨领域任务执行:AGI能够处理多领域的任务,不受限于特定应用场景。
    自主学习与适应:AGI能够从经验中学习,并适应新环境和新情境。
    创造性思考:AGI能够进行创新思维,提出新的解决方案。
    社会交互:AGI能够与人类进行复杂的社会交互,理解情感和社会信号。

  • 关于AGI的未来发展前景,它被认为是人工智能研究的最终目标之一,具有巨大的变革潜力:

    技术创新:随着机器学习、神经网络等技术的进步,AGI的实现可能会越来越接近。
    跨学科整合:实现AGI需要整合计算机科学、神经科学、心理学等多个学科的知识。
    伦理和社会考量:AGI的发展需要考虑隐私、安全和就业等伦理和社会问题。
    增强学习和自适应能力:未来的AGI系统可能利用先进的算法,从环境中学习并优化行为。
    多模态交互:AGI将具备多种感知和交互方式,与人类和其他系统交互。

Hugging Face作为当前全球最受欢迎的开源机器学习社区和平台之一,在AGI时代扮演着重要角色。它提供了丰富的预训练模型和数据集资源,推动了机器学习领域的发展。Hugging Face的特点在于易用性和开放性,通过其Transformers库,为用户提供了方便的模型处理文本的方式。随着AI技术的发展,Hugging Face社区将继续发挥重要作用,推动AI技术的发展和应用,尤其是在多模态AI技术发展方面,Hugging Face社区将扩展其模型和数据集的多样性,包括图像、音频和视频等多模态数据。

  • 在AGI时代,Hugging Face可能会通过以下方式发挥作用:

        模型共享:作为模型共享的平台,Hugging Face将继续促进先进的AGI模型的共享和协作。
        开源生态:Hugging Face的开源生态将有助于加速AGI技术的发展和创新。
        工具和服务:提供丰富的工具和服务,支持开发者和研究者在AGI领域的研究和应用。
        伦理和社会责任:Hugging Face注重AI伦理,将推动负责任的AGI模型开发和应用,确保技术进步同时符合伦理标准。

AGI作为未来人工智能的高级形态,具有广泛的应用前景,而Hugging Face作为开源社区,将在推动AGI的发展和应用中扮演关键角色。

(注意:以下代码运行,可能需要科学上网)

二、Transformer 模型调优

Transformer 模型调优是指在预训练模型的基础上,通过微调(fine-tuning)使其适应特定任务,如文本分类、命名实体识别(NER)、机器翻译等。调优的目的是利用预训练模型已经学到的通用语言知识,将其转化为特定任务的专用知识,以提升模型在特定任务上的性能。

  • Transformer 模型调优的核心在于以下几个方面:
  1.     数据准备:将任务数据转换为适合模型输入的格式。
  2.     模型选择与加载:选择适合任务的预训练模型并加载。
  3.     超参数设置:设置训练过程中的各种超参数,如学习率、批处理大小等。
  4.     训练与评估:使用训练数据微调模型,并使用验证数据评估模型性能。
  5.     优化与测试:根据评估结果调整模型和超参数,最终在测试数据上测试模型。
  • 目前,Transformer 模型调优的实现方式主要包括以下几种:
  1.     全量微调(Full Fine-Tuning):对整个模型进行微调。适用于数据量较大、计算资源充足的场景。
  2.     部分微调(Partial Fine-Tuning):只微调部分层,如顶层几层或特定层。适用于计算资源有限或需要快速实验的场景。
  3.     冻结(Freezing)部分层:冻结预训练模型的前几层,只微调后几层。适用于需要保持模型原有特性的场景。
  4.     使用适应层(Adapters):在模型中引入适应层,只微调适应层的参数。适用于需要在多个任务之间快速切换的场景。
  5.     提示学习(Prompt Tuning):通过引入提示(prompts)来调整模型的输入格式,使得模型能够更好地适应特定任务。适用于少样本学习场景。

Transformer 模型调优是一项复杂且细致的工作,需要对数据、模型和训练过程有全面的理解。通过合理选择模型、设置超参数、进行训练和评估,可以使预训练模型在特定任务上达到最佳性能。掌握这些调优技巧,将帮助你在实际项目中充分发挥 Transformer 模型的强大能力。

在前面的介绍中,已经看到如何对Transformer进行微调,从而在各种任务上产生出色的结果。然而,在许多情况下,准确率(或你正在优化的任何指标)并不足够。如果你的最新模型太慢或太大而无法满足应用程序的商业要求,那么它就不会很有用。一个显而易见的替代方法是训练一个更快、更紧凑的模型,但模型容量的减少通常伴随着性能的下降。当你需要快速、紧凑且高度准确的模型时,你有什么技术可以选择?

在接下来的介绍中,我们将探讨四种互补技术,可用于加速你的Transformer模型的预测并减少内存使用:知识蒸馏、量化、剪枝和使用Open Neural Network Exchange(ONNX)格式和ONNX Runtime(ORT)进行图优化。我们还将看到如何将一些技术组合起来以产生显著的性能提升。例如,这是Roblox工程团队在他们的文章“How We Scaled Bert to Serve 1+Billion Daily Requests on CPUs”(https://oreil.ly/QdNIk)中采取的方法。他们发现将知识蒸馏和量化相结合,可以使他们的BERT分类器的延迟和吞吐量提高30倍以上!

为了阐明每种技术所带来的利弊,我们将以意图识别为案例研究。这是基于文本的助手的重要组成部分,低延时对于实时维持对话至关重要。在学习过程中,你将学习如何创建自定义训练器,进行高效的超参数搜索,并了解如何通过Hugging Face Transformers库实现尖端研究所需的要素。

三、利使

我们现在看到,通过知识蒸馏,我们可以将来自教师模型的信息迁移到较小的学生模型中,从而降低运行推理所需的计算和内存成本。量化采用了一种不同的方法,它并不是减少计算量,而是通过使用低精度数据类型[例如8位整数(INT8)]来表示权重和激活,而不是通常的32位浮点数(FP32),从而使计算更有效率。减少位数意味着结果模型需要更少的内存存储,并且像矩阵乘法这样的操作可以使用整数算术更快地执行。值得注意的是,这些性能提升可以在几乎不损失准确率的情况下实现!

1、浮

目前大多数Transformer模型都是使用浮点数(通常是FP32或FP16与FP32的组合)进行预训练和微调的,因为它们提供了适合非常不同范围的权重、激活和梯度的精度。像FP32这样的浮点数表示为32位的序列,这些位以符号、指数和有效数字的形式组成。符号确定数字是正的还是负的,而有效数字则对应于数量级,通过某个固定基数(通常是二进制或十进制)中的指数进行缩放。

例如,数字137.035可以通过以下算术运算表达为一个十进制浮点数:

137.035=(-1)0×1.370 35×102

其中1.37035是尾数,10的指数是2。通过指数,我们可以表示广泛的实数,并且小数或二进制点可以相对于有效数字放置在任何位置(因此称为“浮点数”)。

然而,当模型训练完成之后,我们只需要运行前向传递来进行推理,因此我们可以降低数据类型的精度,而不会对准确率产生太大影响。对于神经网络,我们通常使用定点格式表示低精度数据类型,其中实数表示为B位整数,它们由相同类型的所有变量共同缩放。例如,137.035可以表示为整数137 035,该整数缩放了1/1000。我们可以通过调整缩放因子来控制定点数的范围和查准率。

量化的基本思想是将每个张量中的浮点值f“离散化”,通过从范围[ff]映射到一个更小的范围[qq],然后在该范围线性分布所有值。从数学上讲,这种映射可以用以下方程式描述:

maxminmaxmin

f= \left ( \frac{f_{max-f_{min}}}{q_{max}-q_{min}} \right )\left ( q-Z \right )=S\left ( q-Z \right )

仿射映射只是神经网络的线性层中你熟悉的y=Ax+b映射的高级叫法。

其中缩放因子S是一个正浮点数,常数Zq类型相同,并称为零点,因为它对应于浮点值f=0的量化值。请注意,映射需要是仿射的(affine),这样当我们将定点解量化为浮点时,就可以得到浮点数 。转换过程如下图。

将浮点数量化为无符号8位整数(由Manas Sahni提供)

现在,Transformer(更普遍的包括深度神经网络)主要使用量化调优的一个主要原因是权重和激活函数通常取值于相对较小的范围内。这意味着我们不需要将所有可能的FP32数字的范围压缩到INT8表示的28=256个数字的范围内。为了说明这一点,我们从蒸馏模型中选出一个注意力权重矩阵,然后绘制其值的频率分布:

# 导入 Hugging Face 的 pipeline 方法,该方法可以快速构建 NLP 任务的管道
from transformers import pipeline
 
# 定义蒸馏后模型的检查点路径
distilled_ckpt = "transformersbook/distilbert-base-uncased-distilled-clinc"

# 使用新的检查点路径创建一个文本分类管道
pipe = pipeline("text-classification", model=distilled_ckpt)

# 导入matplotlib的pyplot模块,用于绘图  
import matplotlib.pyplot as plt  
  
# 假设pipe是一个已经加载并训练好的模型管道(如Transformers库中的Pipeline),这里获取模型的state_dict  
# state_dict是一个包含模型所有权重和偏置的字典  
state_dict = pipe.model.state_dict()  
  
# 从state_dict中获取特定层的权重  
# 这里获取的是DistilBERT模型中第一个Transformer层的attention机制中的输出线性层(out_lin)的权重  
# 注意:路径可能因模型架构的不同而有所变化  
weights = state_dict["distilbert.transformer.layer.0.attention.out_lin.weight"]  
  
# 将权重张量展平为一维数组,并转换为numpy数组以便matplotlib处理  
# flatten()方法用于展平张量,numpy()方法用于将PyTorch张量转换为numpy数组  
weights_flattened = weights.flatten().numpy()  
  
# 使用matplotlib的hist函数绘制权重的直方图  
# bins=250指定直方图的柱数,range=(-0.3,0.3)指定了x轴的范围  
# edgecolor="C0"设置了直方图柱子的边缘颜色为C0(matplotlib的默认颜色循环中的第一个颜色)  
plt.hist(weights_flattened, bins=250, range=(-0.3,0.3), edgecolor="C0")  

plt.savefig("images/attention_out_lin_weight.png", bbox_inches='tight')  # 保存图片到指定路径

# 显示图表  
plt.show()

运行结果:

正如我们所看到的,权重的值在接近于零的较小区间[-0.1,0.1]内分布。假设我们要将此张量量化为有符号的8位整数。在这种情况下,我们整数的可能值范围是[qq]=[-128,127]。零点与FP32的零点重合,并根据先前的公式计算比例因子:

maxmin

# 计算量化的零点(zero point),通常为0
zero_point = 0

# 计算量化的缩放因子(scale)
# 公式:scale = (weights.max() - weights.min()) / (127 - (-128))
# 其中,weights.max() 表示权重的最大值,weights.min() 表示权重的最小值
# 127 和 -128 分别是 8 位有符号整数的最大值和最小值
scale = (weights.max() - weights.min()) / (127 - (-128))

为了获得量化张量,我们只需反转映射q=f/S+Z,将值夹紧,将它们四舍五入到最近的整数,并使用Tensor.char()函数将结果表示为torch.int8数据类型:

# 量化过程:将权重除以缩放因子并加上零点,得到量化后的值
# .clamp(-128, 127) 将值限制在 8 位有符号整数的范围 [-128, 127]
# .round() 对值进行四舍五入
# .char() 将结果转换为 8 位有符号整数类型(char)
quantized_weights = (weights / scale + zero_point).clamp(-128, 127).round().char()
print(quantized_weights)

运行结果:

tensor([[ -5,  -8,   0,  ...,  -6,  -4,   8],
        [  8,   3,   1,  ...,  -4,   7,   0],
        [ -9,  -6,   5,  ...,   1,   5,  -3],
        ...,
        [  6,   0,  12,  ...,   0,   6,  -1],
        [  0,  -2, -12,  ...,  12,  -7, -13],
        [-13,  -1, -10,  ...,   8,   2,  -2]], dtype=torch.int8)

我们刚刚量化了第一个张量。在PyTorch中,我们可以使用quantize_per_tensor()函数和一个用于优化整数算术操作的量化数据类型torch.qint来简化转换过程:

# 从 PyTorch 导入 quantize_per_tensor 函数,用于对张量进行量化
from torch import quantize_per_tensor
import torch

# 定义量化的数据类型,这里使用 8 位有符号整数 (qint8)
dtype = torch.qint8

# 使用 quantize_per_tensor 函数将权重量化为 qint8 类型
# 参数包括原始权重张量 weights、缩放因子 scale、零点 zero_point 以及数据类型 dtype
quantized_weights = quantize_per_tensor(weights, scale, zero_point, dtype)

# 获取量化权重的整数表示,int_repr() 方法返回权重的整数值
quantized_weights_int = quantized_weights.int_repr()
print(quantized_weights_int)

运行结果:

tensor([[ -5,  -8,   0,  ...,  -6,  -4,   8],
        [  8,   3,   1,  ...,  -4,   7,   0],
        [ -9,  -6,   5,  ...,   1,   5,  -3],
        ...,
        [  6,   0,  12,  ...,   0,   6,  -1],
        [  0,  -2, -12,  ...,  12,  -7, -13],
        [-13,  -1, -10,  ...,   8,   2,  -2]], dtype=torch.int8)

如图非常清楚地表明了通过仅将某些权重值精确映射并舍入其余部分所引发的离散化。

# 导入 zoomed_inset_axes 和 mark_inset,用于创建缩放插图
from mpl_toolkits.axes_grid1.inset_locator import zoomed_inset_axes, mark_inset

#id weight-quantization
#alt Effect of quantization on a transformer's weights
#caption Effect of quantization on a transformer's weights

# 创建主图和轴对象
fig, ax = plt.subplots()

# 创建量化权重的直方图
# 使用 dequantize() 方法将量化权重转换回浮点数,并展平成 numpy 数组
# bins 设置为 250,range 设置为 (-0.3, 0.3),柱状边框颜色设置为 "C0"
ax.hist(quantized_weights.dequantize().flatten().numpy(), 
         bins=250, range=(-0.3,0.3), edgecolor="C0")

# 创建缩放插图,放大倍率设置为 5,位置设置为右上角
axins = zoomed_inset_axes(ax, 5, loc='upper right')

# 在缩放插图中创建量化权重的直方图
axins.hist(quantized_weights.dequantize().flatten().numpy(), 
         bins=250, range=(-0.3,0.3))

# 设置缩放插图的显示范围
x1, x2, y1, y2 = 0.05, 0.1, 500, 2500
axins.set_xlim(x1, x2)
axins.set_ylim(y1, y2)

# 隐藏缩放插图的 x 和 y 轴
axins.axes.xaxis.set_visible(False)
axins.axes.yaxis.set_visible(False)

# 使用 mark_inset 在主图和缩放插图之间绘制连接线
mark_inset(ax, axins, loc1=2, loc2=4, fc="none", ec="0.5")

plt.savefig("images/quantized_weights.png", bbox_inches='tight')  # 保存图片到指定路径

# 显示图像
plt.show()

运行结果:

量化对Transformer权重的影响

现在为了完善我们的分析,我们比较一下使用FP32和INT8值计算两个权重张量的乘积的时间。对于FP32张量,我们可以使用PyTorch的简便@操作符进行乘法计算:

# %%timeit 是一个 Jupyter Notebook 魔法命令,用于测量代码单元的执行时间
# 这里测量的是矩阵乘法 weights @ weights 的执行时间
# @ 运算符表示矩阵乘法
%%timeit 
weights @ weights


# UsageError: Line magic function `%%timeit` not found.


import timeit

# 定义矩阵乘法操作的函数
def matrix_multiplication():
    return weights @ weights

# 使用 timeit 测量矩阵乘法操作的执行时间
execution_time = timeit.timeit(matrix_multiplication, number=100)
print(f'Execution time over 100 runs: {execution_time:.6f} seconds')

运行结果:

Execution time over 100 runs: 0.304005 seconds

对于量化张量,我们需要使用QFunctional包装器类,以便我们可以使用特殊的torch.qint8数据类型执行操作:

# 从 PyTorch 导入 QFunctional 类,该类用于定义和执行量化操作
from torch.nn.quantized import QFunctional

# 创建 QFunctional 类的实例,用于执行量化操作
q_fn = QFunctional()

这个类支持各种基本的操作,比如加法,在我们的例子中,我们可以通过以下方式对我们量化张量的乘法进行计时:

# %%timeit 是一个 Jupyter Notebook 魔法命令,用于测量代码单元的执行时间
# 这里测量的是使用 QFunctional 的 mul 方法进行量化权重矩阵乘法的执行时间
%%timeit
q_fn.mul(quantized_weights, quantized_weights)


# UsageError: Line magic function `%%timeit` not found.


import timeit
from torch.nn.quantized import QFunctional

# 创建 QFunctional 类的实例,用于执行量化操作
q_fn = QFunctional()

# 定义量化矩阵乘法操作的函数
def quantized_matrix_multiplication():
    q_fn.mul(quantized_weights, quantized_weights)

# 使用 timeit 模块测量量化矩阵乘法操作的执行时间
# number 参数指定执行该函数的次数,这里设为 100 次
execution_time = timeit.timeit(quantized_matrix_multiplication, number=100)
print(f'Execution time over 100 runs: {execution_time:.6f} seconds')

运行结果:

Execution time over 100 runs: 0.086290 seconds

与我们的FP32计算相比,使用INT8张量的速度快了近100倍!通过使用专用后端有效地运行量化运算符,可以获得更大的收益。截至本书撰写时,PyTorch支持:

  • 需要支持AVX2或更高版本的x86 CPU
  • ARM CPU(通常用于移动设备/嵌入式设备)

由于INT8数字位数是FP32数字位数的四分之一,量化还将内存存储要求降低为FP32的四分之一。在我们的简单示例中,我们可以通过使用Tensor.storage()函数和Python的sys模块中的getsizeof()函数比较权重张量及其量化版本的底层存储大小来验证这一点:

# 导入 sys 模块,该模块提供与 Python 解释器相关的功能
import sys

# 计算并打印原始权重的存储大小
original_size = sys.getsizeof(weights.storage())

# 计算并打印量化后权重的存储大小
quantized_size = sys.getsizeof(quantized_weights.storage())

# 计算原始权重存储大小与量化后权重存储大小的比率
size_ratio = original_size / quantized_size

# 打印比率
print(f"Size ratio: {size_ratio}")

运行结果:

Size ratio: 3.999715196311114

对于一个完整的Transformer模型,在实际压缩比方面,取决于哪些层被量化(正如我们将在8.5节中看到的,通常只有线性层被量化)。

那么,量化会存在什么问题?将我们模型中所有计算的精度进行更改会在模型的计算图中的每个点引入小的扰动,这些扰动可能会被累积并影响模型的性能。有几种方法可以对模型进行量化,它们都有各自的优点和缺点。对于深度神经网络,通常有三种主要的量化方法:

当使用动态量化时,在训练期间不会发生任何更改,只有在推理过程中才进行调整。与我们将讨论的所有量化方法一样,在推理时间之前,模型的权重被转换为INT8。除了权重之外,模型的激活还被量化。这种方法是动态的,因为量化是即时进行的。这意味着所有矩阵乘法都可以使用高度优化的INT8函数计算。在所有讨论的量化方法中,动态量化是最简单的方法。但是,在使用动态量化时,激活会以浮点格式写入和读取到内存中。整数和浮点数之间的转换可能成为性能瓶颈。

与实时计算激活的量化不同,我们可以通过预先计算量化方案来避免将其转换为浮点数。静态量化通过在推理时间之前观察代表性数据样本上的激活模式来实现这一点。我们先计算好理想的量化方案,然后保存它。这使我们能够跳过INT8和FP32值之间的转换,以加速计算。但是,它需要访问良好的数据样本,并在整个流程中引入额外的步骤,因为现在我们需要在执行推理之前训练并确定量化方案。此外,静态量化并未能解决这样一个方面:训练和推理期间查准率之间的差异,这会导致模型的指标(例如准确率)下降。

在训练期间,通过对FP32值进行“伪”量化,可以有效地模拟量化的影响。在训练过程中,不使用INT8值,而是将FP32值舍入,以模仿量化的效果。这是在前向和后向传递期间完成的,并且在模型指标方面的性能优于静态量化和动态量化。

使用Transformer模型进行推理的主要瓶颈是这些模型中巨大数量的权重相关的计算量和内存带宽。因此,动态量化目前是自然语言处理中基于Transformer的模型的最佳方法。在较小的计算机视觉模型中,限制因素是激活的内存带宽,这就是为什么通常使用静态量化(或在性能下降太显著的情况下使用量化感知训练)。

在PyTorch中实现动态量化非常简单,只需要一行代码即可完成:

# 从 PyTorch 导入 quantize_dynamic 函数,该函数用于对模型进行动态量化
from torch.quantization import quantize_dynamic
# 从 transformers 库中导入 AutoTokenizer
from transformers import AutoTokenizer  
# 导入 AutoModelForSequenceClassification 类
from transformers import AutoModelForSequenceClassification  
# 导入 PyTorch 的神经网络模块
import torch.nn as nn  

# 定义模型的检查点(checkpoint)路径
model_ckpt = "transformersbook/distilbert-base-uncased-distilled-clinc"

# 使用 AutoTokenizer 从预训练模型中加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)

# 使用 AutoModelForSequenceClassification 从预训练模型中加载模型,并将其移动到 CPU
model = (AutoModelForSequenceClassification
         .from_pretrained(model_ckpt).to("cpu"))

# 对模型进行动态量化,将 nn.Linear 层转换为 8 位整数 (qint8) 类型
model_quantized = quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)

# 动态量化后的模型将比原始模型占用更少的内存,并且在推理时更快

在这里,我们将完整精度的模型传给quantize_dynamic(),并指定我们要对其中量化的PyTorch层类集。dtype参数指定目标精度,可以是fp16或qint8。一个好的做法是选择你可以容忍的最低精度,以实现评估指标。在本章中,我们将使用INT8,并且很快就会看到它对模型的准确性几乎没有影响。

四、基

现在我们的模型已经完成了量化,让我们将其传递到基准测试中并可视化结果:

如果没有和上一节的环境,可以先执行下面代码构建环境:

# 定义一个性能基准类,用于评估文本分类管道在不同方面的性能
class PerformanceBenchmark:
    def __init__(self, pipeline, dataset, optim_type="BERT baseline"):
        """
        初始化性能基准类
        
        参数:
        - pipeline: 文本分类管道
        - dataset: 数据集,用于评估性能
        - optim_type: 优化类型的描述,默认值为 "BERT baseline"
        """
        self.pipeline = pipeline
        self.dataset = dataset
        self.optim_type = optim_type
        
    def compute_accuracy(self):
        """
        计算模型在数据集上的准确性
        这个方法将在后面定义
        """
        pass    

    def compute_size(self):
        """
        计算模型的大小
        这个方法将在后面定义
        """
        pass

    def time_pipeline(self):
        """
        计算管道处理一个样本所需的时间
        这个方法将在后面定义
        """
        pass
    
    def run_benchmark(self):
        """
        运行基准测试,评估模型在不同指标上的性能
        
        返回:
        - metrics: 一个字典,包含模型的大小、处理时间和准确性等指标
        """
        metrics = {}
        
        # 计算模型大小并存储在metrics字典中
        metrics[self.optim_type] = self.compute_size()
        
        # 计算管道处理时间并更新metrics字典
        metrics[self.optim_type].update(self.time_pipeline())
        
        # 计算模型准确性并更新metrics字典
        metrics[self.optim_type].update(self.compute_accuracy())
        
        return metrics

def compute_accuracy(self):
    """
    重写 PerformanceBenchmark.compute_accuracy() 方法,计算模型在数据集上的准确性
    """
    preds, labels = [], []  # 初始化预测标签和真实标签的列表
    
    # 遍历数据集中的每个示例
    for example in self.dataset:
        # 使用管道对文本进行分类,获取预测标签
        pred = self.pipeline(example["text"])[0]["label"]
        
        # 获取示例的真实意图标签
        label = example["intent"]
        
        # 将预测标签从字符串转换为对应的索引,并添加到预测标签列表中
        preds.append(intents.str2int(pred))
        
        # 将真实标签添加到真实标签列表中
        labels.append(label)
    
    # 使用加载的准确度评价指标计算预测标签和真实标签之间的准确度
    accuracy = accuracy_score.compute(predictions=preds, references=labels)
    
    # 打印测试集上的准确度
    print(f"Accuracy on test set - {accuracy['accuracy']:.3f}")
    
    # 返回计算的准确度
    return accuracy

# 将 compute_accuracy 方法赋值给 PerformanceBenchmark 类的 compute_accuracy 方法
PerformanceBenchmark.compute_accuracy = compute_accuracy

import torch
from pathlib import Path

def compute_size(self):
    """
    重写 PerformanceBenchmark.compute_size() 方法,计算模型的大小
    """
    # 获取模型的状态字典,包含了模型的所有参数
    state_dict = self.pipeline.model.state_dict()
    
    # 定义一个临时文件路径,用于保存模型参数
    tmp_path = Path("model.pt")
    
    # 使用 torch.save 函数将模型的状态字典保存到临时文件中
    torch.save(state_dict, tmp_path)
    
    # 计算模型文件的大小(以兆字节为单位)
    size_mb = tmp_path.stat().st_size / (1024 * 1024)
    
    # 删除临时文件,释放磁盘空间
    tmp_path.unlink()
    
    # 打印模型大小
    print(f"Model size (MB) - {size_mb:.2f}")
    
    # 返回模型大小,以字典的形式
    return {"size_mb": size_mb}

# 将 compute_size 方法赋值给 PerformanceBenchmark 类的 compute_size 方法
PerformanceBenchmark.compute_size = compute_size

import numpy as np  # 导入 NumPy 库,用于数学运算

def time_pipeline(self, query="What is the pin number for my account?"):
    """
    重写 PerformanceBenchmark.time_pipeline() 方法,测量管道处理查询的延迟时间
    """
    latencies = []  # 初始化延迟时间列表
    
    # 预热阶段:执行管道处理查询操作,但不记录延迟时间,以确保 JIT 编译完成等
    for _ in range(10):
        _ = self.pipeline(query)
    
    # 正式测量阶段:重复执行管道处理查询操作,并记录每次的延迟时间
    for _ in range(100):
        start_time = perf_counter()  # 记录开始时间
        
        # 执行管道处理查询操作,并记录结果(这里我们暂时不使用结果,用 _ 代替)
        _ = self.pipeline(query)
        
        # 计算每次执行的延迟时间,并添加到延迟时间列表中
        latency = perf_counter() - start_time
        latencies.append(latency)
    
    # 计算延迟时间的统计数据:平均延迟时间和延迟时间的标准差
    time_avg_ms = 1000 * np.mean(latencies)  # 平均延迟时间,单位为毫秒
    time_std_ms = 1000 * np.std(latencies)   # 延迟时间的标准差,单位为毫秒
    
    # 打印计算出的平均延迟时间和标准差,并以字符串形式输出
    print(f"Average latency (ms) - {time_avg_ms:.2f} +- {time_std_ms:.2f}")
    
    # 返回延迟时间统计数据,以字典的形式
    return {"time_avg_ms": time_avg_ms, "time_std_ms": time_std_ms}

# 将 time_pipeline 方法赋值给 PerformanceBenchmark 类的 time_pipeline 方法
PerformanceBenchmark.time_pipeline = time_pipeline
# 从 datasets 库中导入 load_dataset 函数
from datasets import load_dataset

# 加载 CLINC OOS (Out of Scope) 数据集的 "plus" 版本
# CLINC OOS 数据集是一个用于意图分类的常见基准数据集
# "plus" 版本包含更多的意图类别和数据
clinc = load_dataset("clinc_oos", "plus")

# 打印数据集的结构和一些示例,以确认数据加载成功
print(clinc)
from time import perf_counter  # 从 time 模块中导入 perf_counter,用于测量时间

# 从 datasets 库中导入 load_metric 函数
from datasets import load_metric 

# 加载用于计算准确度的评价指标
# load_metric 函数会自动下载并缓存指定的评价指标
# "accuracy" 指定我们要加载的是准确度评价指标
accuracy_score = load_metric("accuracy",trust_remote_code=True)

# 从加载的数据集中获取意图标签的特征信息
# clinc["test"] 是数据集的测试集部分
# features["intent"] 获取意图标签的特征信息,包括意图的映射关系
intents = clinc["test"].features["intent"]

# 创建性能基准对象 pb,使用管道 pipe 和 CLINC OOS 数据集的测试集 clinc["test"]
pb = PerformanceBenchmark(pipe, clinc["test"])

# 运行基准测试,并获取性能指标
perf_metrics = pb.run_benchmark()

运行结果:

Model size (MB) - 132.39
Average latency (ms) - 13.41 +- 0.67
Accuracy on test set - 0.876
import pandas as pd  # 导入 pandas 库
import matplotlib.pyplot as plt  # 导入 matplotlib 库

def plot_metrics(perf_metrics, current_optim_type, save_path="performance_metrics.png"):
    """
    绘制性能指标的散点图,并突出显示当前优化类型的数据点。

    参数:
    - perf_metrics: 包含性能指标的字典
    - current_optim_type: 当前优化类型的名称
    - save_path: 图片保存路径
    """
    df = pd.DataFrame.from_dict(perf_metrics, orient='index')  # 将性能指标字典转换为 DataFrame

    # 遍历 DataFrame 中的每个优化类型
    for idx in df.index:
        df_opt = df.loc[idx]
        
        # 如果当前优化类型与索引相同,则添加一个虚线圆圈
        if idx == current_optim_type:
            plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100, 
                        alpha=0.5, s=df_opt["size_mb"], label=idx, 
                        marker='o')  # 标记当前优化类型的数据点
        else:
            plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100, 
                        s=df_opt["size_mb"], label=idx, alpha=0.5)
    
    # 调整图例的位置和大小
    legend = plt.legend(bbox_to_anchor=(1, 1))
    for handle in legend.get_lines():
        handle._sizes = [20]  # 设置图例标记的大小为 20 像素

    plt.ylim(80, 90)  # 设置 y 轴的范围
    xlim = int(perf_metrics["BERT baseline"]["time_avg_ms"] + 3)  # 使用最慢模型定义 x 轴范围
    plt.xlim(1, xlim)  # 设置 x 轴的范围
    plt.ylabel("Accuracy (%)")  # 设置 y 轴标签
    plt.xlabel("Average latency (ms)")  # 设置 x 轴标签
    plt.savefig(save_path, bbox_inches='tight')  # 保存图片到指定路径
    plt.show()  # 显示绘制的散点图


# 调用 plot_metrics 函数,绘制性能指标
# 参数包括性能指标 perf_metrics 和优化类型 optim_type
plot_metrics(perf_metrics, optim_type,'images/perf_metrics.png')

运行结果:

(请注意:数据有上一节的)

不错,量化模型几乎是蒸馏模型的一半大小,并且甚至获得了轻微的准确率提升!我们看看能否利用一个强大的框架——ONNX Runtime将我们的优化推到极限。

五、使ONNXONNX Runtime

有一个名为ONNX-ML的专门标准,旨在为传统的机器学习模型(例如随机森林)和框架(例如Scikit-learn)提供支持。

ONNX(https://onnx.ai)是一种开放标准,定义了一组共同的运算符和共同的文件格式,以在各种框架中表示深度学习模型,包括PyTorch和TensorFlow 。当将模型导出为ONNX格式时,这些运算符用于构建计算图(通常称为中间表示),表示数据通过神经网络的流程。下图是一个BERT-base模型的示意图,其中每个节点接收一些输入,应用像Add或Squeeze这样的操作,然后将输出馈送给下一组节点。

BERT-base模型ONNX图的一部分(通过Netron可视化)

通过使用标准化的运算符和数据类型公开图形,ONNX使得在不同框架之间进行切换变得容易。例如,可以将在PyTorch中训练的模型导出为ONNX格式,然后在TensorFlow中导入(反之亦然)。

其他流行的加速器包括NVIDIA的TensorRT(https://oreil.ly/HnNZx)和Apache TVM(https://oreil.ly/7KUyt)。

融合操作是指将一个运算符(通常是激活函数)合并到另一个运算符中,以便它们可以一起执行。例如,假设我们想要对矩阵积A×B应用激活函数f。通常需要将积的结果写回GPU内存,然后再计算激活函数。运算符融合允许我们在单个步骤中计算f(A×B)。常量折叠指的是在编译时评估常量表达式,而不是在运行时。

ONNX的真正优势在于与专用加速器(如ONNX Runtime或简称ORT)的结合使用 。ORT通过诸如运算符融合和常量折叠等技术提供了优化ONNX图形的工具 ,并定义了执行提供程序的接口,使你能够在不同类型的硬件上运行模型。这是一个强大的抽象。下图展示了ONNX和ORT生态系统的高级架构。

ONNX和ONNX Runtime生态系统的架构(由ONNX Runtime团队提供)

要看到ORT的实际效果,我们需要将我们的蒸馏模型转换为ONNX格式。Hugging Face Transformers库内置一个名为convert_graph_to_onnx.convert()的函数,它通过以下步骤简化了该过程:

  • 1.将模型初始化为Pipeline。
  • 2.通过管道运行占位符输入,以便ONNX可以记录计算图。
  • 3.定义动态轴以处理动态序列长度。
  • 4.使用网络参数保存图形。

要使用这个功能,我们首先需要为ONNX设置一些OpenMP(https://openmp.org)环境变量:

# 导入os模块,该模块提供了许多与操作系统交互的功能  
import os  
  
# 从psutil库导入cpu_count函数,用于获取系统的CPU数量  
# psutil是一个跨平台库(Windows、Linux/UNIX等),用于获取系统运行的进程和系统利用率(CPU、内存、磁盘、网络等)的信息  
from psutil import cpu_count  
  
# 设置环境变量OMP_NUM_THREADS的值为系统的CPU数量  
# 这通常用于控制并行计算时使用的线程数,比如在使用OpenMP进行并行计算时  
# 通过设置此变量,可以优化程序的性能,确保充分利用多核CPU  
os.environ["OMP_NUM_THREADS"] = f"{cpu_count()}"  
  
# 设置环境变量OMP_WAIT_POLICY的值为"ACTIVE"  
# 这个环境变量用于控制OpenMP程序中的线程等待策略  
# "ACTIVE"策略意味着当线程等待某个资源(如数据)时,它会积极地寻找其他工作来做,而不是简单地等待  
# 这有助于减少线程的空闲时间,进一步提高程序的并行效率  
os.environ["OMP_WAIT_POLICY"] = "ACTIVE"

OpenMP是一个为开发高度并行化应用程序而设计的API。OMP NUM THREADS环境变量设置了在ONNX Runtime中用于并行计算的线程数,而OMP_WAIT_POLICY=ACTIVE指定等待线程应处于活动状态(即使用CPU处理器周期)。

接下来,我们将蒸馏模型转换为ONNX格式。这里,我们需要指定convert()函数的参数pipeline_name="text-classification",以及model_ckpt参数和tokenizer参数来初始化pipeline。

# 从transformers库导入convert_graph_to_onnx模块中的convert函数  
# 这个函数用于将预训练的Transformer模型(如BERT, GPT等)从PyTorch或其他框架转换为ONNX格式  
from transformers.convert_graph_to_onnx import convert  
  
# 定义模型检查点(checkpoint)的路径  
# 这里使用的是"transformersbook/distilbert-base-uncased-distilled-clinc",这是一个预训练的DistilBERT模型,  
# 特别针对文本分类任务(如CLINC数据集)进行了蒸馏处理  
model_ckpt = "transformersbook/distilbert-base-uncased-distilled-clinc"  
  
# 定义ONNX模型保存的路径  
# 使用Path对象(来自pathlib模块,通常需要先导入:from pathlib import Path)来指定路径,这里假设已经做了导入  
# 路径设置为"onnx/model.onnx",意味着ONNX模型将被保存在当前目录下的onnx文件夹中,文件名为model.onnx  
onnx_model_path = Path("onnx/model.onnx")  
  
# 调用convert函数将模型转换为ONNX格式  
# 参数解释:  
# framework="pt" 指定原始模型框架为PyTorch  
# model=model_ckpt 指定模型的检查点路径  
# tokenizer=tokenizer 需要传入一个tokenizer对象(这里假设tokenizer已经被定义并传入),用于文本预处理  
# output=onnx_model_path 指定ONNX模型保存的路径  
# opset=12 指定ONNX的操作集版本为12,这会影响模型的兼容性和优化  
# pipeline_name="text-classification" 指定转换的模型是用于文本分类任务的,这有助于转换过程中优化和适配特定的任务  
convert(framework="pt", model=model_ckpt, tokenizer=tokenizer,   
        output=onnx_model_path, opset=12, pipeline_name="text-classification")

运行结果:

ONNX opset version set to: 12
Loading pipeline (model: transformersbook/distilbert-base-uncased-distilled-clinc, tokenizer: PreTrainedTokenizerFast(name_or_path='transformersbook/distilbert-base-uncased-distilled-clinc', vocab_size=30522, model_max_len=512, is_fast=True, padding_side='right', truncation_side='right', special_tokens={'unk_token': '[UNK]', 'sep_token': '[SEP]', 'pad_token': '[PAD]', 'cls_token': '[CLS]', 'mask_token': '[MASK]'}))
Using framework PyTorch: 2.2.1+cpu
Found input input_ids with shape: {0: 'batch', 1: 'sequence'}
Found input attention_mask with shape: {0: 'batch', 1: 'sequence'}
Found output output_0 with shape: {0: 'batch'}
Ensuring inputs are in correct order
head_mask is not present in the generated input list.
Generated inputs order: ['input_ids', 'attention_mask']

这里的参数opset=12用于指定ONNX运算符集的版本(ONNX使用运算符集来将不可变运算符规范分组)。

现在我们已经保存了模型,我们需要创建一个InferenceSession实例来将输入输送进模型中:

# 从 onnxruntime 导入 GraphOptimizationLevel、InferenceSession 和 SessionOptions
from onnxruntime import (GraphOptimizationLevel, InferenceSession, 
                         SessionOptions)

# 定义一个函数,用于创建指定提供者(例如 CPU)的 ONNX 模型推理会话
def create_model_for_provider(model_path, provider="CPUExecutionProvider"): 
    # 创建会话选项对象
    options = SessionOptions()
    
    # 设置会话选项中操作并行线程的数量,这里设置为 1
    options.intra_op_num_threads = 1
    
    # 设置图优化级别,ORT_ENABLE_ALL 表示启用所有优化
    options.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL
    
    # 创建推理会话,传入模型路径、会话选项和提供者(默认是 CPUExecutionProvider)
    session = InferenceSession(str(model_path), options, providers=[provider])
    
    # 禁用提供者回退机制
    session.disable_fallback()
    
    # 返回创建的推理会话
    return session

# 使用 create_model_for_provider 函数创建 ONNX 模型的推理会话
# onnx_model_path 是之前定义的 ONNX 模型保存路径
onnx_model = create_model_for_provider(onnx_model_path)

# 该行代码调用 create_model_for_provider 函数
# 参数 onnx_model_path 是保存的 ONNX 模型的路径
# 函数返回一个推理会话对象,并将其存储在 onnx_model 变量中

现在当我们调用onnx model.run()方法时,我们可以从ONNX模型中获取类logit。我们用测试集中的一个样本来测试一下这个方法。由于convert()方法的输出告诉我们ONNX模型只需要输入input_ids和attention_mask,我们需要从样本中删除label列:

# 从 CLINC 数据集中获取测试数据的第一条样本,并进行编码
# 这里假设 clinc_enc["test"] 已经是编码后的数据集
inputs = clinc_enc["test"][:1]

# 删除输入数据中的标签,因为在推理时不需要标签
del inputs["labels"]

# 使用 ONNX 模型进行推理,传入输入数据
# onnx_model.run(None, inputs) 执行推理,并返回输出结果
# 这里假设模型的输出是一个包含 logits 的列表,取第一个元素作为 logits_onnx
logits_onnx = onnx_model.run(None, inputs)[0]

# 打印 logits_onnx 的形状,检查输出的维度
logits_onnx.shape

运行结果:

(1, 151)

在我们有了logit之后,我们可以通过取最大值来轻松地获得预测标注:

# 导入 numpy 库,以便使用 np.argmax 函数
import numpy as np

# 使用 np.argmax 函数获取 logits_onnx 中最大值的索引
# logits_onnx 是 ONNX 模型输出的 logits
# np.argmax 返回 logits_onnx 数组中最大值的索引
predicted_label = np.argmax(logits_onnx)

# 打印预测的标签索引
print(predicted_label)

运行结果:

61

这确实与基准标注相符:

# 从 CLINC 编码测试数据集中获取第一条样本的标签
# 这里假设 clinc_enc["test"] 已经是编码后的数据集
first_sample_label = clinc_enc["test"][0]["labels"]

# 打印第一条样本的标签
print(first_sample_label)

运行结果:

61

ONNX模型与text-classification pipeline不兼容,因此我们将创建自己的类,模拟其核心行为:

# 从 scipy.special 导入 softmax 函数,用于计算概率分布
from scipy.special import softmax

# 定义 OnnxPipeline 类,用于封装 ONNX 模型和分词器,并实现推理功能
class OnnxPipeline:
    def __init__(self, model, tokenizer):
        # 初始化 OnnxPipeline 类的实例
        # model 是 ONNX 模型的推理会话
        # tokenizer 是用于将查询转换为模型输入格式的分词器
        self.model = model
        self.tokenizer = tokenizer
        
    def __call__(self, query):
        # 定义 __call__ 方法,使得 OnnxPipeline 实例可以像函数一样被调用
        # query 是输入的查询文本
        
        # 使用分词器将查询转换为模型输入的张量格式
        model_inputs = self.tokenizer(query, return_tensors="pt")
        
        # 将 PyTorch 张量转换为 NumPy 数组,并存储在字典中
        # 这一步是为了兼容 ONNX 模型的输入格式
        inputs_onnx = {k: v.cpu().detach().numpy() for k, v in model_inputs.items()}
        
        # 使用 ONNX 模型进行推理,获取 logits
        logits = self.model.run(None, inputs_onnx)[0][0, :]
        
        # 使用 softmax 函数计算 logits 的概率分布
        probs = softmax(logits)
        
        # 获取概率最大的索引,即预测的类别
        pred_idx = np.argmax(probs).item()
        
        # 返回包含预测标签和相应概率的列表
        # intents.int2str(pred_idx) 将预测的索引转换为标签字符串
        # probs[pred_idx] 是预测类别的概率
        return [{"label": intents.int2str(pred_idx), "score": probs[pred_idx]}]

我们可以用这个方法测试我们的简单查询,看看我们是否正确识别了car_rental的意图:

# 创建 OnnxPipeline 实例,传入 ONNX 模型和分词器
pipe = OnnxPipeline(onnx_model, tokenizer)

# 调用 pipe 实例处理查询文本,并返回预测结果
pipe(query)

# 该行代码调用 OnnxPipeline 实例 pipe,传入查询文本 query
# 实例将执行推理操作,返回预测标签和相应概率的列表

运行结果:

[{'label': 'car_rental', 'score': 0.7848334}]

我们的pipeline按照预期运作。下一步是为ONNX模型创建性能基准。在这里,我们可以重用PerformanceBenchmark类的工作,只需要重写compute_size()方法并保留compute_accuracy()和time_pipeline()方法。我们需要重写compute_size()方法的原因是我们不能依赖于state_dict和torch.save()来度量模型的大小,因为onnx_model技术上是一个ONNX InferenceSession对象,没有访问PyTorch nn.Module属性的权限。无论如何,其逻辑很简单,具体实现如下:

# 导入 PerformanceBenchmark 类,假设它是一个已经定义的类
from somewhere import PerformanceBenchmark

# 定义 OnnxPerformanceBenchmark 类,继承自 PerformanceBenchmark 类
class OnnxPerformanceBenchmark(PerformanceBenchmark):
    def __init__(self, *args, model_path, **kwargs):
        # 初始化方法,继承自父类,并添加 model_path 参数
        super().__init__(*args, **kwargs)
        self.model_path = model_path
        
    def compute_size(self):
        # 计算模型文件的大小(单位:MB)
        size_mb = Path(self.model_path).stat().st_size / (1024 * 1024)
        
        # 打印模型文件大小
        print(f"Model size (MB) - {size_mb:.2f}")
        
        # 返回模型大小的字典形式,用于性能评估
        return {"size_mb": size_mb}

有了我们的新基准测试,让我们看看将蒸馏模型转换为ONNX格式后的性能如何:

# 定义优化类型为 "Distillation + ORT"
optim_type = "Distillation + ORT"

# 创建 OnnxPerformanceBenchmark 实例,传入 OnnxPipeline 实例 pipe、CLINC 测试数据集 clinc["test"]、优化类型和 ONNX 模型路径
pb = OnnxPerformanceBenchmark(pipe, clinc["test"], optim_type, model_path="onnx/model.onnx")

# 执行性能评估,并将结果更新到 perf_metrics 中
perf_metrics.update(pb.run_benchmark())

# 该行代码调用 OnnxPerformanceBenchmark 实例 pb 的 run_benchmark 方法,开始性能评估过程
# 将评估结果更新到 perf_metrics 字典中,用于后续分析和比较

运行结果:

Model size (MB) - 255.88
Average latency (ms) - 21.02 +\- 0.55
Accuracy on test set - 0.868
# 调用 plot_metrics 函数,绘制性能指标图表
plot_metrics(perf_metrics, optim_type)

# 该行代码调用 plot_metrics 函数,传入性能指标字典 perf_metrics 和优化类型 optim_type
# 函数将生成并显示一个图表,展示不同优化类型下的性能指标比较

运行结果:

令人惊讶的是,将蒸馏模型转换为ONNX格式并使用ONNX Runtime使蒸馏模型(上图中的“Distillation”圆)的延迟得到了提升!让我们看看是否可以通过添加量化来获得更多性能。

与PyTorch类似,ORT提供了三种量化模型的方法:动态、静态和量化感知训练。就像我们在PyTorch中所做的那样,我们将对我们的蒸馏模型应用动态量化。在ORT中,量化是通过quantize_dynamic()函数应用的,该函数需要量化ONNX模型的一个路径、一个保存量化模型的目标路径,以及将权重减少的数据类型:

# 从 onnxruntime.quantization 中导入 quantize_dynamic 和 QuantType
from onnxruntime.quantization import quantize_dynamic, QuantType

# 定义原始 ONNX 模型的路径
model_input = "onnx/model.onnx"

# 定义量化后 ONNX 模型的保存路径
model_output = "onnx/model.quant.onnx"

# 使用 quantize_dynamic 函数对模型进行动态量化
# 参数 model_input 是原始 ONNX 模型的路径
# 参数 model_output 是量化后模型的保存路径
# weight_type=QuantType.QInt8 指定量化的类型为 QInt8,对权重进行量化
quantize_dynamic(model_input, model_output, weight_type=QuantType.QInt8)

# 该行代码调用 quantize_dynamic 函数,对原始 ONNX 模型进行动态量化,并保存为量化后的 ONNX 模型

现在模型已经量化了,我们将其传递到基准测试中:

# 使用 create_model_for_provider 函数创建量化后 ONNX 模型的推理会话
onnx_quantized_model = create_model_for_provider(model_output)

# 创建 OnnxPipeline 实例,使用量化后的 ONNX 模型和分词器
pipe = OnnxPipeline(onnx_quantized_model, tokenizer)

# 定义优化类型为 "Distillation + ORT (quantized)"
optim_type = "Distillation + ORT (quantized)"

# 创建 OnnxPerformanceBenchmark 实例,传入量化后的管道、CLINC 测试数据集、优化类型和量化后的模型路径
pb = OnnxPerformanceBenchmark(pipe, clinc["test"], optim_type, model_path=model_output)

# 执行性能评估,并将结果更新到 perf_metrics 中
perf_metrics.update(pb.run_benchmark())

# 该行代码调用 OnnxPerformanceBenchmark 实例 pb 的 run_benchmark 方法,开始量化后模型的性能评估过程
# 将评估结果更新到 perf_metrics 字典中,用于后续分析和比较

运行结果:

Model size (MB) - 64.20
Average latency (ms) - 9.24 +\- 0.29
Accuracy on test set - 0.877
# 调用 plot_metrics 函数,绘制性能指标图表
plot_metrics(perf_metrics, optim_type)

# 该行代码调用 plot_metrics 函数,传入性能指标字典 perf_metrics 和优化类型 optim_type
# 函数将生成并显示一个图表,展示不同优化类型下的性能指标比较

与从PyTorch量化获得的模型(上图中的“distillation+quantization”阴影圆)相比,ORT量化将模型大小和延迟都减少了约30%。其中一个原因是PyTorch仅优化nn.Linear模块,而ONNX还量化了嵌入层。从图中我们还可以看到,将ORT量化应用于我们的蒸馏模型与我们的BERT基线相比,提供了近三倍的增益!

至此我们对Transformer模型推理进行加速技术的分析就结束了。我们已经看到,可以通过量化等方法来降低表示的精度来减小模型大小。还有一种减小大小的策略是完全删除某些权重。

六、使使

到目前为止,我们已经看到知识蒸馏和权重量化非常有效,可以生成更快的推理模型,但在某些情况下可能还有对模型内存占用的严格限制。例如,如果我们的产品经理突然决定将我们的聊天机器人部署到移动设备上,那么我们需要尽可能地减少意图分类器所占的存储空间。我们看看如何通过识别和删除神经网络中最不重要的权重来减少模型参数的数量。

1、深

如下图所示,剪枝的主要思想是在训练过程中逐渐删除权重连接(可能还包括神经元),使得模型逐渐变得更加稀疏。由此得到的剪枝模型具有更少的非零参数,可以以紧凑的稀疏矩阵格式存储。剪枝也可以与量化结合使用以获得进一步的压缩效果。

剪枝前后的权重和神经元(由Song Han提供)

2、权

从数学上讲,大多数权重剪枝方法的工作方式是计算一个重要性分数矩阵S,然后根据重要性选择前k%的权重:

实际上,k是一个新的超参数,用于控制模型中稀疏性的程度,即零值权重的比例。k值越低,对应的矩阵越稀疏。我们可以根据这些分数定义一个掩码矩阵M,在前向传递过程中对某些输入xi进行Wij权重掩码,从而有效地创建激活ai的稀疏网络:

a_{i}= \sum_{k}^{}W_{ik}M_{ik}x_{k}

. Hassibi and D. Stork,“Second Order Derivatives for Network Pruning:Optimal Brain Surgeon,”Proceedings of the 5th International Conference on Neural Information Processing Systems(November 1992): 164-171,https://papers.nips.cc/paper/1992/hash/303ed4c69846ab36c2904d3ba8573050-Abstract.html.

正如“Optimal Brain Surgeon”论文中所讨论的那样,每种剪枝方法都有一系列需要考虑的问题:

  • 哪些权重应该被消除?
  • 剩余的权重应该如何调整以获得最佳性能?
  • 如何以计算效率高的方式进行网络剪枝?

这些问题的答案决定了得分矩阵S的计算方式,因此让我们首先看一下最早和最流行的剪枝方法之一:幅值剪枝。

S. Han et al., “Learning Both Weights and Connections for Efficient Neural Networks”(https://arxiv.org/abs/1506.02626),(2015).

正如其名称所示,幅值剪枝(magnitude pruning)根据权重幅度计算分数,S=(|W|)1,然后从M=Topk(S)得出掩码。在文献中,通常先训练模型学习哪些连接是重要的,然后剪枝最不重要的权重 ,最后重新训练稀疏模型并重复该过程直到达到所需的稀疏度。

ijj,jn

这种方法的一个缺点是计算成本高:在每个剪枝步骤中,我们需要将模型训练到收敛。因此,通常最好逐步增加初始稀疏度si(通常为零)到某个步数N后的最终值sf:

M. Zhu and S. Gupta, “To Prune, or Not to Prune: Exploring the Efficacy of Pruning for Model Compression”(https://arxiv.org/abs/1710.01878), (2017).

s_{t}=s_{f}+\left ( s_{i} -s_{f}\right )\left ( 1-\frac{t-t_{0}}{N\Delta t} \right )^{3},t\epsilon \left \{ t_{0}, t_{0}+\Delta t,\cdot \cdot \cdot ,t_{0}+N\Delta t\right \}

我们这里的思路是每隔Δt步更新二进制掩码M,以允许掩码权重在训练过程中重新激活并恢复可能由剪枝过程引入的准确率损失。如下图所示,立方因子意味着在早期阶段(冗余权重数量较多时)权重剪枝率最高,并逐渐减少。

用于剪枝的立方稀疏度调度器

. Sanh, T. Wolf, and A.M. Rush, “Movement Pruning:Adaptive Sparsity by Fine-Tuning”(https://arxiv.org/abs/2005.07683), (2020).

其中一个使用幅度剪枝的问题是,它真正是为纯监督学习设计的,其中每个权重的重要性与手头的任务直接相关。相比之下,在迁移学习中,权重的重要性主要由预训练阶段决定,因此幅度剪枝可能会删除对微调任务很重要的连接。最近,Hugging Face研究人员提出了一种自适应方法,称为运动剪枝,我们来看看 。

运动剪枝(movement pruning)的基本思想是在微调过程中逐渐移除权重,使得模型变得更加稀疏。关键的新颖之处是模型在微调过程中同时学习了权重和分数。因此,不像幅度剪枝那样直接从权重中派生分数,运动剪枝中的分数是任意的,并且可以通过梯度下降学习,就像神经网络中的其他任何参数一样。这意味着在反向传递过程中,我们也会跟踪损失函数L相对于分数Sij的梯度。

还有一个“软”版本的运动剪枝,它不是选择权重的前k%,而是使用全局阈值τ定义二进制掩码:M=(S>τ)。

一旦得到分数,使用M=Topk(S)二进制掩码生成就相对简单了 。

运动剪枝的直觉是,最重要的是保留那些从头开始变化最多的权重。换句话说,在微调过程中正权重增加(负权重减少),这相当于说,随着权重远离零,分数增加。如图8-12所示,这种行为与幅值剪枝不同,后者选择距离零最远的权重作为最重要的权重。

剪枝技术所移除权重的比较(左:幅值剪枝,右:运动剪枝)

这两种剪枝方法之间的差异在剩余权重的分布中也是明显的。如图8-13所示,大小剪枝产生了两个权重簇,而移动剪枝产生了一个更平滑的分布。

在本书编写时,Hugging Face Transformers库还没有开箱即用的剪枝方法。幸运的是,有一个名为Neural Networks Block Movementhttps://oreil.ly/aHEvD)的很不错的库实现了本书许多这些思想,所以在你的场景里面,如果有内存方面的限制,我们建议你看看这个库。

稀疏化剩余权重的分布情况,包括幅值剪枝(MaP)和运动剪枝(MvP)

将Transformer模型优化为在生产环境中部署涉及沿着两个维度进行压缩:延迟和内存占用。从微调模型开始,我们应用蒸馏、量化和通过ORT进行优化,显著减少了这两个方面的占用。特别是,我们发现ORT中的量化和转换在最小的工作量下获得了最大的收益。

尽管剪枝是减小Transformer模型存储大小的有效策略,但当前的硬件并未针对稀疏矩阵操作进行优化,这限制了这种技术的实用性。然而,这是一个活跃的研究领域,当本书出版时,许多这方面的局限性可能已经得到解决。

  • 23
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

仙魁XAN

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值