领域专用小型语言模型——针对生产环境进行量化

0 阅读29分钟

本章涵盖:

  • 训练和运行 LLM 时使用的精度格式
  • 将 LLM 量化为更低精度格式
  • 使用多种技术和库对 LLM 进行量化

在第 5 章中,你学习了 ONNX 的核心概念和能力。我只是简要提到了模型量化,但它对 LLM 推理性能至关重要,因此值得单独用一章来讲。这正是本章的重点。

6.1 Transformer 的精度格式

前面几章已经提到过几种用于 LLM 训练和推理的数值精度格式。现在是时候更仔细地看看它们,并介绍一些用于模型量化的额外格式。

在传统科学计算中,64 位浮点,也就是双精度,曾经是默认选择,因为它能够准确表示很大范围的数值。但 GPU 上的深度神经网络通常使用 32 位浮点,也就是单精度,因为 64 位操作并不必要,速度更慢,而且 GPU 硬件通常也不太充分支持。因此,FP32 成为了深度学习训练的标准格式。

在浮点数中,bit 是用于在计算机内存中存储数值的二进制位。bit 越多,精度越高,可表示范围也越广。一个浮点值由三部分组成:符号位、指数和尾数。在 32 位浮点中,1 bit 用于符号,8 bit 用于指数,23 bit 用于尾数,见图 6.1。

image.png

图 6.1 —— 32 位浮点数的表示

一个 32 位浮点数的值由如下公式给出:

image.png

图:32 位浮点数取值公式

在这个公式中:

image.png

图:尾数位说明

是尾数 bit,并且:

image.png

图:指数说明

相比 64 位操作,在 GPU 上训练深度神经网络时更偏好 32 位浮点操作,主要有两个原因:

  • 降低内存占用——它们使用的内存只有 64 位浮点的一半,使你可以训练更大的模型,并使用更大的 batch size。
  • 提升计算能力和速度——32 位浮点操作使用更少内存,因此 GPU 可以更快地执行它们,并减少训练时间。

我们能走得更远吗?混合精度训练已经成为一种常见方法,其中 16 位,也就是“半精度”,会临时用于浮点计算,见图 6.2。

image.png

图 6.2 —— 16 位浮点数的表示

FP16 使用 1 bit 表示符号,5 bit 表示指数,10 bit 表示尾数。FP16 的取值公式与 32 位格式相同,只是指数和尾数 bit 更少。

DL 模型通常对低精度算术具有较强鲁棒性。在大多数情况下,使用 32 位浮点而不是 64 位浮点带来的小幅精度损失,并不会明显影响预测性能,因此这种权衡是值得的。但转向 16 位精度可能更棘手。这就是为什么人们使用混合精度训练,而不是纯 16 位训练。使用混合精度时,只有部分参数和操作使用 16 位浮点,在训练过程中会在 16 位和 32 位之间切换。典型工作流是:将权重转换为低精度以加快计算,用低精度计算梯度,再将梯度转换回较高精度以保证数值稳定性,最后使用缩放后的梯度更新原始权重。

到目前为止,我们关注的是训练。那么推理呢?如果用 FP32 和 FP16 训练同一个 LLM,后者的内存使用大约是前者的一半,符合预期,但性能可能显著下降。使用混合精度,也就是 FP16 + FP32,内存使用高于纯 FP16,但仍低于纯 FP32,而性能相当,甚至通常略好。这听起来可能有些奇怪,但较低精度在训练中可能引入噪声,从而改善泛化并减少过拟合,进而可能提高准确率。

最近还引入了另一种浮点格式,并且使用越来越多:brain floating point,也就是 BF16。它由 Google 为 TPU 上的 ML/DL 开发。与 F16 相比,见图 6.3,它以较低精度为代价扩展了动态范围。

image.png

图 6.3 —— BF16 数字的表示

BF16 使用 1 bit 表示符号,8 bit 表示指数,7 bit 表示尾数。这种较低精度可能会影响某些计算的准确性,但在大多数深度学习应用中,它对模型性能的影响很小。使用 BF16 训练的模型,通常能提供与 FP16 混合精度训练相近的推理性能和内存使用。BF16 不仅由 TPU 支持,也由 NVIDIA Ampere GPU 支持。要检查你的 NVIDIA GPU 在 PyTorch 中是否支持这种格式,可以运行如下命令:

import torch

torch.cuda.is_bf16_supported()

如果支持 BF16,该方法返回 True;否则返回 False

为了进一步提升推理性能,我们可以不只降低浮点精度,还可以使用量化——也就是把 LLM 的权重从浮点转换为低 bit 整数,通常是 8 bit 或 4 bit。本章其余部分会解释 LLM 量化,并提供使用 Hugging Face Transformers 和其他开源库的完整代码示例。

6.2 8-bit 量化

在计算能力约束很紧,或者部署到小型设备的场景中,LLM 可能过大、过慢,不适合推理。仅使用降低精度,也就是 FP16,可能仍然无法让模型适配目标硬件。常见解决方案有两种:知识蒸馏和量化,二者都会在有限性能损失下减小模型大小。知识蒸馏会训练一个较小模型来模仿较大模型——这可以通过把知识从大模型转移到小模型来实现。对于硬件特定部署来说,量化通常是更好的选择;稳定量化方面的最新进展,也让它在许多情况下比蒸馏更可取。本书聚焦量化,用来简化 LLM 部署,并提升其在多种硬件上的性能。

量化会降低数值精度:降低 LLM 权重和激活的 bit 宽度,会缩小模型,并可能加快推理速度。通过使用低精度类型,例如 8 位整数,你可以以量化误差为代价减少内存占用。例如,在同样内存中,8-bit 值大约可以存放 32 位浮点数四倍数量的数字。目标是在精度、内存使用和速度之间取得平衡。

量化有两种类型:训练后量化,也就是 PTQ,在训练之后应用;以及量化感知训练,也就是 QAT,在训练期间进行。本书主要关注 PTQ,因为从预训练 LLM baseline 开始通常不需要完整训练——只需要微调、PEFT 或 RAG,就可以让它适配特定领域。

8-bit 量化在实践中如何工作?我们通过一个数值示例来走一遍。量化方案有好几种,最常见的是 zero-point 和 absmax。假设我们想把一个 FP32 tensor 转换为 INT8。使用 zero-point 量化时,我们假设输入分布是不对称的,因此用 INT8 值范围,也就是 255,除以输入最大值与最小值之间的差值进行缩放,公式中的 X 表示输入:

image.png

图:zero-point 量化 scale 计算公式

然后,缩放后的分布会被映射到 [-127, 128] 范围:

image.png

图:zero-point 映射公式

最后,我们使用计算出的 scale 和 zeropoint 值,对模型权重进行量化或反量化:

image.png

图:量化公式

image.png

图:反量化公式

来看一个 8-bit 量化示例:假设最小值是 -4.0,最大值是 4.1。scale 为 255 / (4.1 + 4.0) = 31.48,zero-point 为 -round(31.48 × -4.0) – 128 = -3。如果权重是 0.2,那么使用前面的量化公式,它的量化值是 3。反量化后得到 0.19——并不完全等于原始权重,因为量化不是无损的,但已经很接近。

使用 absmax 量化时,每个输入值会除以其 tensor 的绝对最大值,再乘以一个缩放因子 127,从而把值映射到 [-127, 127] 范围:

image.png

图:absmax 量化公式

反量化时,使用如下方程:

image.png

图:absmax 反量化公式

例如,使用前面 zero-point 示例中的同一个最大值 4.1,absmax 量化会把权重 0.2 映射为 round(127 × 0.2 / 4.1) = 6。使用 zero-point 量化时,你会得到另一个值。反量化会引入误差:6 × 4.1 / 127 = 0.19

回顾一下,LLM 量化有几个主要好处:

  • 减小模型大小
  • 提升速度和硬件兼容性,低精度算术在某些硬件加速器上可以运行得更快
  • 提高内存效率

它也有一些缺点:

  • 准确率权衡,较低精度可能影响模型性能
  • 实现挑战

至于这些缺点,不用担心——本书会帮助你克服它们。

6.2.1 动手实践 8-bit 量化

我们来看一个使用 PyTorch 和 Hugging Face Transformers 库,对 LLM 进行 8-bit 训练后量化,也就是 PTQ 的实践示例,暂时先不考虑 ONNX。我们会使用 GPT-2 small,但同样考虑也适用于其他 GPT-2 变体和基于 GPT 的 LLM。我们会以 32 位浮点加载预训练模型,并将其量化为 8 位整数。配套 Colab notebook 包含完整源代码。这个阶段不需要硬件加速。

像往常一样,从 Hugging Face Hub 加载 FP32 模型:

from transformers import AutoModelForCausalLM, AutoTokenizer

device = 'cpu'
model_id = 'openai-community/gpt2'
model = AutoModelForCausalLM.from_pretrained(model_id).to(device)
tokenizer = AutoTokenizer.from_pretrained(model_id)

在 FP32 下,该模型约为 548 MB。

我们可以把所有模型层转换为 INT8 格式,然后量化完整模型权重。先定义一个自定义函数,使用 absmax 技术完成这件事:

import torch

def absmax_quantize(X):
    scale = 127 / torch.max(torch.abs(X))

    X_quant = (scale * X).round()

    X_dequant = X_quant / scale

    return X_quant.to(torch.int8), X_dequant

正如 6.2 节前面所描述的,它执行三个步骤:计算 scale,量化为 INT8,再反量化回 FP32。对于给定输入,它会同时返回量化值和反量化值。下面用它来量化所有 GPT-2 模型权重:

import numpy as np
from copy import deepcopy

weights = [param.data.clone() for param in model.parameters()]

model_abs = deepcopy(model)

weights_abs = []
for param in model_abs.parameters():
    _, dequantized = absmax_quantize(param.data)
    param.data = dequantized
    weights_abs.append(dequantized)

我们创建了源模型的一个副本,并把 absmax_quantize 函数应用到其所有权重上,对它们进行量化。

接下来,验证这一过程产生的性能。一个有用的检查方式,是绘制量化前后模型权重分布,看看量化值与原始值有多接近。我们可以使用 matplotlib 库构建直方图,完整代码见 notebook。图 6.4 中,你可以看到 0 附近有一个尖峰,这说明这个量化过程在这种情况下损失相当大,因为反转这个过程无法重现精确的原始值。

image.png

图 6.4 —— 比较原始 GPT-2 模型权重与 absmax 量化后的权重

看看性能会受到怎样影响。首先定义一个函数,它接收 prompt,并使用 top-k sampling 生成文本。我们把这段代码放进函数中,因为本示例后面会复用它。

def generate_text(model, input_text, max_length=100):
    input_ids = tokenizer.encode(input_text, return_tensors='pt').to(device)
    output = model.generate(inputs=input_ids,
                            max_length=max_length,
                            do_sample=True,
                            top_k=30,
                            pad_token_id=tokenizer.eos_token_id,
                            attention_mask=input_ids.new_ones(
                                input_ids.shape
                            )
                )

    return tokenizer.decode(output[0], skip_special_tokens=True) 

我们会用这个函数分别让原始模型和 absmax 量化模型生成文本,限制为 100 个 token:

prompt = 'My favourite school subject is'
original_text = generate_text(model, prompt)
absmax_text = generate_text(model_abs, prompt)

原始模型生成如下内容:

My favourite school subject is to create beautiful images of people. 
Some are really good at it for this, but many are boring."

Chen said he has no particular reason to be ashamed at all.

"I love the fact that it's not really what you have thought up. 
It's been my opinion all this time in life that you really need 
to work on your work, to get your work done."

"I am very comfortable being a writer, and the fact

量化模型生成如下内容:

My favourite school subject is chess. But I don't know if I'm 
supposed to be fascinated by it myself. Maybe I can learn how 
to play it, but I can't learn how to play chess myself.

Because I'm interested in chess, I've got to learn how to play 
it first. I'm interested in learning about the psychology of chess. 
I'm interested in studying this subject because, let's face it, 
my wife and I have this weird fascination with what chess does

与其凭经验判断这两个输出哪个更合理,你也可以通过计算每个输出的 perplexity 来得到一个定量分数。Perplexity 是评估 LLM 的常见指标:它衡量模型在预测序列中下一个 token 时的不确定性。人们通常认为越低越好,但一个 perplexity 很高的句子仍然可能是正确的。

可以实现一个基础函数来计算 perplexity:

def calculate_perplexity(model, text, device):
    encodings = tokenizer(text, return_tensors='pt').to(device)

    input_ids = encodings.input_ids
    target_ids = input_ids.clone()

    with torch.no_grad():
        outputs = model(input_ids, labels=target_ids)

    neg_log_likelihood = outputs.loss

    perplexity = torch.exp(neg_log_likelihood)

    return perplexity 

这个函数需要三个参数:模型、文本 prompt,以及用于加载模型权重的设备。它会编码输入序列,准备模型输入,生成文本,计算 loss,并用它计算 perplexity 分数,然后返回给调用者。我们可以把这个函数应用到两个模型上,也就是原始模型和 absmax 量化模型,prompt 使用每个模型在前几步中生成的文本:

perplexity = calculate_perplexity(model, original_text, device)
perplexity_absmax = calculate_perplexity(model_abs, absmax_text, device)

测得的 perplexity 分数如下:

Original perplexity:  18.66
Absmax perplexity:    8.20

在这个示例中,量化模型的 perplexity 低于原始模型。但单次运行并不可靠;你应该多次重复实验,才能理解两个模型之间的差异。如果先创建两个可复用函数,这会更容易。

6.2.2 LLM.int8() 与量化

在前一个示例中,为了简单起见,我们对所有模型层应用了 per-tensor 量化。但量化可以在不同粒度上进行。一次性量化整个模型可能会严重损害性能,而对每个单独值进行量化又会产生过多开销。实践中,一个不错的折中方案是 vector-wise quantization,它会考虑 tensor 内部各行和各列之间的变化。对于领域专用模型来说,这通常已经足够,因为它们通常低于 70 亿参数。超过这个阈值后,你可能会在任何 LLM 层中看到 outlier feature。即使只有一个 outlier,也可能显著降低其他值的精度;而且你不能简单丢弃 outlier,否则会对模型性能造成不小影响。

那应该怎么办?幸运的是,没有必要自定义实现——LLM.int8() 已经解决了这个问题。它最早在 2022 年的一篇论文中提出,可以在没有准确率损失的情况下,为 LLM 启用 INT8 矩阵乘法。它在保留全精度性能的同时,大约把推理内存减半。它使用带混合精度的 absmax 量化方案:outlier feature 使用 FP16 处理以保持准确率,其余值使用 INT8。这个方法几乎可以把 LLM 的内存占用降低两倍。

LLM.int8() 通过三步执行矩阵乘法:

  1. 使用自定义阈值,从输入 hidden state 中提取带 outlier feature 的列。outlier feature 是少量权重或激活,其数值幅度远大于分布中的其他值。
  2. 使用 vector-wise quantization,对 outlier 使用 FP16 相乘,对其余部分使用 INT8 相乘。
  3. 将非 outlier 结果从 INT8 反量化为 FP16,并与 outlier 结果相加,生成完整的 FP16 结果。

还有一个好消息:LLM.int8() 已经集成到 Hugging Face Transformers API 中。它需要 Accelerate 和 bitsandbytes 库;bitsandbytes 实现 LLM.int8() 量化,并且只在 GPU 上运行。

配套 Colab notebook 包含本节完整源代码,并且需要 GPU 加速。这些步骤与 6.2.1 节相似,因此这里只讨论主要部分。

要使用这种策略加载一个量化后的 GPT-2 small 模型,可以在 Transformers auto class 中设置 load_in_8bit=True

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

model_id = 'openai-community/gpt2'
model_int8 = AutoModelForCausalLM.from_pretrained(model_id,
                                             device_map='auto',
                                             load_in_8bit=True,
                                             )

这个模型大约 176 MB,比 FP32 版本小得多。和 6.2.1 节一样,我们可以绘制原始模型与量化模型的权重分布,看看它们有多接近,见图 6.5。

image.png

图 6.5 —— 比较原始 GPT-2 模型权重与 LLM.int8() 量化后的权重

这里我们看到某些值附近出现尖峰。它们对应的是以 INT8 格式存储的参数,因此不是 outlier。我们可以用两个模型运行推理:

prompt = 'My favourite school subject is'
original_text = generate_text(model, prompt)
text_int8 = generate_text(model_int8, prompt)

在这次运行中,原始模型生成如下内容:

My favourite school subject is to study music. The more I 
studied music, the more intrigued I became. It was a fun hobby.

Did you ever consider making your own version of the school piano?

No, but I have tried several different combinations of different 
pianos. In fact, I was quite surprised when I did find that a good 
piece of school music was actually in my hands. I found it quite 
easy to do it with a simple piano.

The school piano

量化模型生成如下内容:

My favourite school subject is football. My favourite football 
team is Everton. So I'll see you later and do some footballing.
'They looked at each other. 'Do you have your name on your phone?'

'Yes.'

'But you aren't talking?'

这些运行的 perplexity 如下:

Original Perplexity:   11.25
LLM.int8() perplexity: 15.07

在这个例子中,原始模型的 perplexity 更低,但并不总是如此。和前一个例子一样,单次 perplexity 运行并不能告诉你太多关于量化模型性能的信息——你需要多次重复。此外要注意,LLM.int8() 量化通常造成的性能下降小于 absmax,但它会增加计算开销,尤其是在更大模型上。

6.3 使用 ONNX 进行 8-bit 量化

我们来探索基于 ONNX 的 8-bit PTQ,使用 bert-base-uncased 模型。相同步骤适用于任何其他基于 Transformer 的语言模型。

Hugging Face API 支持三种 LLM 优化方式,文献中通常称为 low-level、mid-level 和 high-level。我们逐一来看。

low-level 方法使用 PyTorch 原生的 torch.onnx 模块,第 5.5 和 5.6 节代码示例已经使用过它。它提供 export 方法,可以将模型 checkpoint 转换为 ONNX 图。它被认为是 low-level,是因为它需要在 PyTorch API 中进行更多手动配置并编写更多样板代码。虽然它是模型转换和优化的可靠选择,但还有其他量化选项。

mid-level 方法使用 Transformers 的 transformers.onnx 包。我们还是使用 bert-base-uncased 模型,和 5.5 节一样,看看转换和优化过程。首先,从 Hugging Face Hub 加载模型和 tokenizer:

from transformers import AutoTokenizer, AutoModelForSequenceClassification

model_id = 'google-bert/bert-base-uncased'
model = AutoModelForSequenceClassification.from_pretrained(model_id)
tokenizer = AutoTokenizer.from_pretrained(model_id)

接下来,通过 transformer.onnx.FeaturesManager 类加载其配置,并构建 ONNX 配置:

from transformers.onnx import FeaturesManager
from transformers import AutoConfig

feature = "sequence-classification"
model_kind, model_onnx_config = 
➥FeaturesManager.check_supported_model_or_raise(
                        model, 
                        feature=feature)
onnx_config = model_onnx_config(model.config)

转换过程可以如下进行:

from pathlib import Path
from transformers.onnx import export

onnx_inputs, onnx_outputs = export(
        preprocessor=tokenizer,
        model=model,
        config=onnx_config,
        opset=14,
        output=Path("bert_uncased.onnx")
)

和原生 PyTorch 包一样,Transformers 库也包含 export 方法。与 5.5 节中的示例相比,它需要的配置设置少得多。

high-level 方法使用 Optimum,这是 Hugging Face Transformers 的一个扩展,提供用于在目标硬件上高效训练和运行 LLM 的优化工具。Optimum 包含几个专用包;其中一个支持通过 ONNX Runtime 进行图优化和量化,以加速训练和推理。你不需要安装所有包——如果只需要 ONNX Runtime 支持,安装对应包即可,跳过其他部分:

pip install optimum[onnxruntime]

使用 bert-base-uncased 模型时,可以用 Optimum 一行代码将模型转换为 ONNX 格式,也就是图:

from optimum.onnxruntime import ORTModelForSequenceClassification

model_id = 'bert-base-uncased'
model = ORTModelForSequenceClassification.from_pretrained(model_id,
                                                    from_transformers=True)

Optimum for ONNX 提供了多个 ORTModelForXxx 类,每个类都专门面向一个下游任务。在本例中,我们使用 ORTModelForSequenceClassification 处理序列分类任务。Optimum 在幕后使用 transformers.onnx 包,透明地保持代码高层抽象。注意,Optimum 尚未支持所有 Transformer 架构,第 7 章会展示一个常见但不受支持的 LLM,因此请查看 Hugging Face Hub 上最新支持模型列表。对于不受支持的模型,你可以直接使用 transformers.onnx,或者实现自定义 ONNX 配置,本书后面会介绍。

无论使用哪种技术,一旦你把 LLM 转换为 ONNX,8-bit 量化过程都是相同的。下载或保存到磁盘后,可以使用 ONNX Runtime 量化 API 对转换后的模型进行动态量化。示例如下:

from onnxruntime.quantization import quantize_dynamic, QuantType

onnx_path = 'bert_uncased_model.onnx'
onnx_quantized_path = 'bert_uncased_quantized_model.onnx'
quantized_model = quantize_dynamic(onnx_path, onnx_quantized_path, 
                                   weight_type=QuantType.QInt8)

完整精度转换后的 ONNX 模型是 417.91 MB,转换前为 440 MB;量化后模型是 105.22 MB。

下面这个端到端示例会使用 ONNX 执行 8-bit 量化,并评估其性能——这是一个最佳情况,因为该领域专用模型架构受到 Optimum 支持。配套 Colab notebook 包含完整代码。这个示例只需要 Optimum,用于 ONNX Runtime,以及 Hugging Face 的 Evaluate 库。

因为本节聚焦推理,我们会使用 Hugging Face Hub 上一个已经从 distilbert-base-uncased 微调好的模型:distilbert-base-uncased-finetuned-banking77。该模型基于 PolyAI/banking77 数据集进行文本分类微调,该数据集也在 Hugging Face Hub 上,包含带有对应 intent 标注的在线银行查询。该数据集由 13,083 条客服查询组成,共标注 77 个 intent,并有两个字段:text,也就是查询;以及 label,也就是 intent 标签。

首先从 Hub 下载微调后的 FP32 模型及其 tokenizer:

from optimum.onnxruntime import ORTModelForSequenceClassification
from transformers import AutoTokenizer

model_id="optimum/distilbert-base-uncased-finetuned-banking77"
model = ORTModelForSequenceClassification.from_pretrained(model_id,
                                                        export=True)
tokenizer = AutoTokenizer.from_pretrained(model_id)

我们使用 Optimum 的 ORTModelForSequenceClassification 下载模型,并自动将其转换为 ONNX 格式。我们仍然使用 Transformers 的 AutoTokenizer 下载 baseline 模型的 tokenizer。下载完成后,将二者保存到磁盘,这一步仅量化时需要:

from pathlib import Path

onnx_path = Path("onnx")
model.save_pretrained(onnx_path)
tokenizer.save_pretrained(onnx_path)

由于模型由 Optimum 下载,我们可以使用 Transformers pipeline 运行推理,并确认模型可用。Transformers pipeline 受到该库支持,不同于原生 ONNX Runtime:

from transformers import pipeline

vanilla_clf = pipeline("text-classification", model=model,
➥tokenizer=tokenizer)
vanilla_clf("Could you assist me in checking my card validity?") 

现在需要使用动态量化,将模型量化到 8-bit 精度。不同于静态量化,动态量化只在 CPU 上运行,而不是 GPU。这不是问题,因为量化离线进行,而且即使没有硬件加速,推理仍然很快,后面会看到。

我们会创建一个 ORTQuantizer 实例,这是 Optimum 用于对 Hub 上共享模型进行 ONNX Runtime 量化的类,并定义量化配置。然后把它应用到原始模型上:

from optimum.onnxruntime import ORTQuantizer
from optimum.onnxruntime.configuration import AutoQuantizationConfig

dynamic_quantizer = ORTQuantizer.from_pretrained(model)
dqconfig = AutoQuantizationConfig.avx512_vnni(is_static=False,
                                              per_channel=False)

model_quantized_path = dynamic_quantizer.quantize(
    save_dir=onnx_path,
    quantization_config=dqconfig,
)

量化完成后,可以比较模型大小:原始文件为 255.76 MB,量化版本为 64.33 MB。

你可以像原始模型一样,使用 Transformers pipeline 对量化模型运行推理:

quantized_model_name = "model_quantized.onnx"
model = ORTModelForSequenceClassification.from_pretrained(onnx_path,
                                            file_name=quantized_model_name)
tokenizer = AutoTokenizer.from_pretrained(onnx_path)

q8_clf = pipeline("text-classification",model=model, tokenizer=tokenizer)
q8_clf("Could you assist me in checking my card validity?")

接下来,我们会在 PolyAI/banking77 测试集上评估模型性能,看看量化如何影响准确率。可以使用 Hugging Face 的高层 Evaluate API 来完成:为具体任务创建 evaluator,本例中是文本分类;加载测试集;调用 evaluator 的 compute 方法,并指定 pipeline、测试集,包括哪些字段是输入和标签,以及要计算的指标,这里是 accuracy,notebook 中的代码还会计算其他指标:

from evaluate import evaluator
from datasets import load_dataset

dataset_id="PolyAI/banking77"
eval = evaluator("text-classification")
eval_dataset = load_dataset(dataset_id, split="test")

results = eval.compute(
    model_or_pipeline=q8_clf,
    data=eval_dataset,
    metric="accuracy",
    input_column="text",
    label_column="label",
    label_mapping=model.config.label2id,
    strategy="simple",
) 

根据 Hugging Face Hub,原始模型准确率为 92.5%。运行前面代码后,量化模型得到 92.34%,也就是 FP32 baseline 的 99.82%,损失可以忽略。但延迟呢?我们可以写一个快速粗略的 benchmark 函数,对两个版本进行比较,第 7 章和第 10 章会展示更好的 ONNX benchmark 方法:

def measure_latency(payload_prompt, pipe):
    latencies = []

    for _ in range(10):
        _ = pipe(payload_prompt)
    for _ in range(300):
        start_time = perf_counter()
        _ =  pipe(payload_prompt)
        latency = perf_counter() - start_time
        latencies.append(latency)

    time_avg_ms = 1000 * np.mean(latencies)
    time_std_ms = 1000 * np.std(latencies)
    time_p95_ms = 1000 * np.percentile(latencies,95)

    return f"P95 latency (ms) - {time_p95_ms}; Average latency (ms)
➥ - {time_avg_ms:.2f} +- {time_std_ms:.2f};", time_p95_ms

这个 pipeline 会先运行一个短暂 warmup,也就是若干初始推理 pass,用来稳定 cache 和编译影响;随后 benchmark 固定步数,并报告平均延迟和标准差。使用一个 142-token prompt 在原始模型和量化模型 pipeline 上运行,可以看到量化模型带来 1.86 倍推理加速。在性能相近的情况下,量化显著降低了延迟。

6.4 4-bit 量化

上一节中,你看到了 LLM 从单精度量化到 8-bit 整数格式的实践示例。现在我们进一步推进,把模型量化到更低精度,例如 4-bit 整数,以减小模型大小。

6.4.1 使用 GPTQ 进行 4-bit 量化

在 8-bit 量化被证明可以在准确率损失很小的情况下缩小 LLM 之后,新的 4-bit 整数量化技术出现了。第一个是 GPTQ,它在 2022 年的论文《GPTQ: Accurate Post-Training Quantization for Generative Pre-Trained Transformers》中提出。GPTQ 是一种基于近似二阶信息的一次性权重量化方法,既准确又高效。它可以把基于 GPT 的模型降低到每个权重 3 或 4 bit,并且相较未压缩 baseline 准确率损失可以忽略。该论文报告称,即使是 175B 参数模型,在量化后也可以在单块 NVIDIA A100 GPU 上运行推理;该方法在更小模型和较弱 GPU 上也可以取得强结果。论文介绍了数学和方法论,但你不需要掌握它们才能使用 GPTQ,因为已经有一个可靠的 Python 实现,并集成进主流 LLM 库。事实上的标准实现是 AutoGPTQ 包。如果你想了解低层实现细节,可以查阅相关仓库;AutoGPTQ 也集成进 Hugging Face Transformers、Optimum 和 PEFT 库。本章中,我们会使用原始实现,以便更容易看清 4-bit 量化如何工作,高层 API 会隐藏许多细节。

本节中,你将学习如何使用 AutoGPTQ。配套 Colab notebook 提供了示例完整源代码。除了 AutoGPTQ,我们还会使用 Hugging Face Transformers 库,对 GPT-2 XL 模型应用 4-bit 量化。GPT-2 XL 有 15 亿参数。我们会在单块 NVIDIA Tesla T4 GPU 上以硬件加速运行。

安装 AutoGPTQ 后,具体步骤取决于你的 PyTorch 和 CUDA 驱动版本,可以查看包文档,然后开始实现模型量化代码。首先,从 Hugging Face Hub 下载 GPT-2 XL tokenizer:

from transformers import AutoTokenizer

model_id = "gpt2-xl"
tokenizer = AutoTokenizer.from_pretrained(model_id)

在加载未量化模型之前,需要使用 AutoGPTQ 的 BaseQuantizeConfig 类定义一个基础 4-bit 量化配置:

from auto_gptq import BaseQuantizeConfig

quantize_config = BaseQuantizeConfig(
    bits=4, 
    group_size=128,
    desc_act=False,
)

现在可以下载模型:

from auto_gptq import AutoGPTQForCausalLM

model = AutoGPTQForCausalLM.from_pretrained(model_id, quantize_config)

我们可以从模型配置中获得最大序列长度:

model_config = model.config.to_dict()
seq_len_keys = ["max_position_embeddings", "seq_length", "n_positions"]
if any(k in model_config for k in seq_len_keys):
    for key in seq_len_keys:
        if key in model_config:
            model.seqlen = model_config[key]
            break
else:
    print(
    "The model's sequence length cannot be retrieved from 
its configuration. It will then be set to 2048."
    )
    model.seqlen = 2048

为了执行一次性权重量化,我们需要一些样本序列。我们会使用 Hugging Face Hub 上 wikitext 数据集中的一些样本,也就是 4.1.4 节中使用过的数据集:

import random
import torch
from datasets import load_dataset

seed = 0
random.seed(seed)
np.random.seed(seed)
torch.random.manual_seed(seed)

traindata = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
trainenc = tokenizer("\n\n".join(traindata["text"]), return_tensors="pt")

nsamples = 128
seqlen = model.seqlen
traindataset = []
for _ in range(nsamples):
    i = random.randint(0, trainenc.input_ids.shape[1] - seqlen - 1)
    j = i + seqlen
    inp = trainenc.input_ids[:, i:j]
    attention_mask = torch.ones_like(inp)
    traindataset.append({"input_ids": inp, "attention_mask":
   ➥attention_mask})

前面的代码片段从 wikitext 训练集中构建了一个包含 128 个样本的小数据集。量化质量取决于转换所使用的样本:更多样本可能改善结果。使用多少样本取决于你的用例、模型和数据——没有一种通用设置。

现在可以开始量化过程。它会在我们使用的硬件上花费几分钟,但没关系,因为它是离线运行的:

model.quantize(traindataset, use_triton=False)

在这个场景中,一旦训练样本准备好,量化一个 LLM 只需要一行代码。我们将 use_triton 设为 False,因为这里使用 CUDA,而不是 NVIDIA Triton 语言和编译器。

量化完成后,把 4-bit 模型保存到磁盘:

quantized_model_dir = "gpt2-xl-4bit"
model.save_quantized(quantized_model_dir)

你也可以选择上传到 Hugging Face Hub 进行共享。原始模型为 6.43 GB;4-bit 量化版本为 1.02 GB。

模型现在可以用于推理。从保存位置加载它:

from auto_gptq import AutoGPTQForCausalLM

model = AutoGPTQForCausalLM.from_quantized(quantized_model_dir, 
                                           device="cuda:0",
                                           ➥use_triton=False)

目前,AutoGPTQ 只支持把量化模型加载到 CPU 或单个 GPU;不支持多 GPU 设置。你可以通过调用模型的 generate 方法来运行推理:

prompt = "Auto-GPTQ is"
output = tokenizer.decode(
    model.generate(**tokenizer(prompt, 
    ➥return_tensors="pt").to("cuda:0"))[0])
print(output)

同样的权重分析和模型评估考虑,也就是 6.2 节中的 perplexity 计算,以及 6.3 节中的推理 benchmark,也适用于这里。

6.4.2 使用 ggml 进行 4-bit 量化

GPTQ 并不是获得 LLM 4-bit 训练后量化的唯一方式。另一个选项是 ggml,这是一个开源 C 库,支持多种量化级别,包括 8-bit、5-bit 和 4-bit。使用 ggml 量化的模型通常保存为 GGML 二进制文件格式。虽然它针对 Apple Silicon 做了优化,但 ggml 量化模型也可以在 x86、ppc64 和 Android 上运行,x86 使用 AVX/AVX2 intrinsic,ppc64 使用 VSX intrinsic。ggml 速度快的部分原因是它没有第三方依赖,并且运行时零内存分配。

ggml 也可以量化其他 Transformer 架构,例如图像和音频模型,但在本章以及下一章中,我们聚焦 LLM,以及如何在 CPU 上高效运行它们。GPTQ 推理针对 GPU 优化,而 ggml 推理针对 CPU 优化,这使它在许多环境中很实用,包括典型笔记本电脑和个人电脑。最新版本在推理时也支持有限 GPU offloading——目的不是加速推理,而是降低功耗并释放 CPU 资源。

后续章节会回到 ggml,介绍更高级的优化和量化技术;与 Hugging Face 的 Python 库不同,它需要更多低层编程知识。

ggml 官方 GitHub 仓库中提供了将一些最流行开源 LLM 转换为 GGML 格式的脚本,许多量化版本也已经可以从 Hugging Face Hub 下载。虽然 ggml 是用 C 写的,但你可以使用 Hugging Face Transformers 和 CTransformers,完全在 Python 中加载量化模型并运行推理。CTransformers 为通过 ggml 以 C/C++ 实现的 Transformer 模型提供 Python binding。

下面示例使用 Hugging Face Transformers pipeline API,对一个 GGML 转换模型运行推理:

from ctransformers import AutoModelForCausalLM
from transformers import pipeline, AutoTokenizer

model = AutoModelForCausalLM
➥.from_pretrained("TheBloke/Llama-2-7B-Chat-GGML", hf=True)
tokenizer = AutoTokenizer.from_pretrained("NousResearch/Llama-2-7b-chat-hf")

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
print(pipe("AI is going to", max_new_tokens=256))

你可以用 CTransformers 的 AutoModelForCausalLM 类,从 Hugging Face Hub 加载 ggml 量化模型。像往常一样,使用 Transformers 的 AutoTokenizer 从原始模型加载 tokenizer。然后可以通过 Transformers pipeline 运行推理,就像使用单精度或半精度预训练模型一样。

ggml 有两个上游开源项目:llama.cpp 和 whisper.cpp,二者都在活跃开发。Llama.cpp 最初是 Meta Llama 模型的 C/C++ 移植版本,但后来已经扩展到其他流行 LLM 和多模态架构。作为 llama.cpp 的一部分,引入了一种替代 GGML 的量化模型格式:GGUF,现在它是 llama.cpp 唯一支持的格式。与 GGML 相比,GGUF 更容易把量化模型作为单文件分发,不需要外部 JSON tokenizer 文件;并且它把超参数保存为 key-value metadata,使添加新字段时更容易保持与已有 GGUF 模型兼容。GGUF 正在迅速成为 C/C++ 量化模型事实上的标准。后续章节会展示多个 GGML 和 GGUF 格式量化示例。

本章和上一章介绍了 ONNX 框架的能力,重点关注 LLM 优化、量化和推理。接下来两章中,我们会把这些概念应用到真实项目中。

总结

  • 浮点精度会影响语言模型操作中的内存使用和计算速度。
  • 一个 32 位浮点数使用 1 个符号 bit、8 个指数 bit 和 23 个尾数 bit。
  • 16 位浮点会把内存需求降低一半,但可能降低性能。
  • 混合精度训练会在训练期间在 32 位和 16 位操作之间切换。
  • BF16 比 FP16 提供更宽的动态范围,但精度更低。
  • 量化会把模型权重从浮点转换为低 bit 整数。
  • 训练后量化在训练之后应用;量化感知训练发生在训练期间。
  • Zero-point 量化会按照总范围除以最大值和最小值之间差值来缩放输入。
  • Absmax 量化会把输入值除以其绝对最大值,然后乘以缩放因子。
  • LLM.int8() 使用混合精度量化:outlier 以 FP16 运行,其余部分以 INT8 运行。
  • Vector-wise quantization 会考虑 tensor 行和列之间的变化。
  • ONNX Runtime 提供三个层级的量化方式,从 low-level PyTorch API 到 high-level Optimum。
  • ONNX 动态量化可以在性能损失很小的情况下大幅减少模型大小。
  • GPTQ 可以以最小准确率损失为 GPT 模型启用 4-bit 量化。
  • AutoGPTQ 与 Hugging Face 库集成,可以简化 4-bit 量化。
  • GGML 库提供针对 CPU 优化的 8-bit、5-bit 和 4-bit 精度量化。
  • GGUF 格式允许把带嵌入 metadata 的量化模型作为单文件分发。
  • 量化可以把模型内存占用减少两到四倍,同时保持相近性能。
  • Perplexity 指标通过衡量预测不确定性,评估量化模型性能。