使用8位量化减小大型语言模型的大小
大型语言模型(LLMs)以其广泛的计算要求而闻名。通常,模型的大小通过将参数的数量(大小)乘以这些值的精度(数据类型)来计算。然而,为了节省内存,可以使用低精度数据类型存储权重,这个过程被称为量化。
文献中区分了两种主要的权重量化技术:
- 后期训练量化(PTQ)是一种直接的技术,通过将已经训练好的模型的权重转换为低精度来实现,而不需要重新训练。尽管易于实现,但后期训练量化可能导致性能下降。
- 量化感知训练(QAT)在预训练或微调阶段将权重转换过程纳入其中,从而提高模型性能。然而,量化感知训练在计算上是昂贵的,并需要具有代表性的训练数据。
本文重点介绍后期训练量化来降低参数的精度。为了直观理解,我们将应用简单和更复杂的技术来处理一个使用GPT-2模型的示例。
整个代码可以在Google Colab和GitHub上免费获取。
📚 浮点数表示的背景知识
数据类型的选择决定了所需的计算资源数量,影响模型的速度和效率。在深度学习应用中,平衡精度和计算性能成为一项重要的任务,因为较高的精度通常意味着更大的计算需求。
在各种数据类型中,浮点数在深度学习中被广泛使用,因为它们能够以高精度表示各种值。通常,浮点数使用n位来存储一个数值。这n位被进一步划分为三个不同的组成部分:
- 符号:符号位表示数的正负性。它使用一个位,其中0表示正数,1表示负数。
- 指数:指数是一段位表示底数(通常是二进制表示中的2)的乘幂。指数也可以是正数或负数,使得数能够表示非常大或非常小的值。
- 尾数/有效数字:剩余的位用于存储尾数,也称为有效数字。这表示数的有效数字。数的精度严重依赖于尾数的长度。
这种设计使得浮点数能够覆盖一定范围内的值,并具有不同级别的精度。该表示的公式如下:
为了更好地理解,让我们深入了解深度学习中最常用的几种数据类型:float32(FP32)、float16(FP16)和bfloat16(BF16):
- FP32使用32位来表示一个数:一位用于符号,八位用于指数,其余23位用于尾数。虽然它提供了高度的精度,但FP32的缺点是计算和内存占用高。
- FP16使用16位来存储一个数:一位用于符号,五位用于指数,十位用于尾数。尽管这使得它在内存上更高效并加速计算,但减少了范围和精度可能会引入数值不稳定性,可能影响模型的准确性。
- BF16也是一种16位格式,符号位占1位,指数占8位,尾数占7位。与FP16相比,BF16扩展了可表示的范围,从而减少了下溢和上溢的风险。尽管由于较少的尾数位数而导致精度降低,但BF16通常不会对模型性能产生显著影响,对于深度学习任务来说是一个有用的折中方案。

在机器学习术语中,FP32通常被称为“全精度”(4字节),而BF16和FP16被称为“半精度”(2字节)。但是,我们是否可以通过使用单个字节来存储权重来取得更好的结果?答案是INT8数据类型,它由一个能够存储256个不同值的8位表示组成。在下一节中,我们将看到如何将FP32权重转换为INT8格式。
🔰 朴素的8位量化
在本节中,我们将实现两种量化技术:一种使用绝对最大值(absmax)量化的对称量化技术,和一种使用零点量化的非对称量化技术。在两种情况下,目标是将FP32张量X(原始权重)映射到INT8张量X_quant(量化权重)。
使用absmax量化,原始数字被绝对最大值除以,并乘以一个缩放因子(127),以将输入映射到范围[-127, 127]。为了恢复原始的FP16值,INT8数字被量化因子除以,由于四舍五入导致了一定的精度损失。
例如,假设我们有一个绝对最大值为3.2。一个权重为0.1的值将会被量化为round(0.1 × 127/3.2) = 4。如果我们要反量化它,我们会得到4 × 3.2/127 = 0.1008,这意味着一个0.008的误差。以下是相应的Python实现:
import torchdef 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
使用zero-point量化,我们可以考虑非对称的输入分布,这在考虑ReLU函数的输出(仅为正值)时非常有用。首先,将输入值按照总值范围(255)除以最大值和最小值之差进行缩放。然后,将该分布通过零点进行移动,以将其映射到范围[-128, 127](与absmax相比多了一个值)。首先,我们计算缩放因子和零点值:
然后,我们可以使用这些变量来量化或反量化我们的权重:
让我们举一个例子:我们有一个最大值为3.2和最小值为-3.0。我们可以计算出缩放因子为255/(3.2 + 3.0) = 41.13,零点为-round(41.13 × -3.0) – 128 = 123 -128 = -5,所以我们先前的权重0.1将被量化为round(41.13 × 0.1 -5) = -1。这与使用absmax获得的先前值非常不同(4 vs. -1)。

Python的实现非常简单:
def zeropoint_quantize(X): # 计算值范围(分母)
x_range = torch.max(X) - torch.min(X)
x_range = 1 if x_range == 0 else x_range
# 计算比例尺
scale = 255 / x_range
# 零点偏移
zeropoint = (-scale * torch.min(X) - 128).round()
# 缩放和四舍五入输入值
X_quant = torch.clip((X * scale + zeropoint).round(), -128, 127)
# 反量化
X_dequant = (X_quant - zeropoint) / scale
return X_quant.to(torch.int8), X_dequant
不需要依赖完整的示例,我们可以使用transformers
库在实际模型上使用这两个函数。
我们首先加载GPT-2模型和分词器。这是一个非常小的模型,我们可能不想进行量化,但对于本教程来说足够好。首先,我们要观察模型的大小,以便后面进行比较,并评估8位量化带来的内存节省。
!pip install -q bitsandbytes>=0.39.0!pip install -q git+https://github.com/huggingface/accelerate.git!pip install -q git+https://github.com/huggingface/transformers.git
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
torch.manual_seed(0)
# 设置设备为CPU
device = 'cpu'
# 加载模型和分词器
model_id = 'gpt2'
model = AutoModelForCausalLM.from_pretrained(model_id).to(device)
tokenizer = AutoTokenizer.from_pretrained(model_id)
# 打印模型大小
print(f"模型大小:{model.get_memory_footprint():,} 字节")
模型大小:510,342,192 字节
GPT-2模型的大小约为487MB(FP32格式)。下一步是使用零点和绝对最大值量化对权重进行量化。在下面的示例中,我们对GPT-2的第一个注意力层应用这些技术,以查看结果。
# 提取第一层的权重
weights = model.transformer.h[0].attn.c_attn.weight.data
print("原始权重:")
print(weights)
# 使用绝对最大值量化对层进行量化
weights_abs_quant, _ = absmax_quantize(weights)
print("\n绝对最大值量化后的权重:")
print(weights_abs_quant)
# 使用零点量化对层进行量化
weights_zp_quant, _ = zeropoint_quantize(weights)
print("\n零点量化后的权重:")
print(weights_zp_quant)
原始值(FP32)和量化值(INT8)之间的差异是明显的,但是absmax和零点权重之间的差异更加微妙。在这种情况下,输入看起来偏移了一个值-1。这表明该层的权重分布相当对称。
我们可以通过将GPT-2中的每个层(线性层、注意力层等)量化并创建两个新模型:model_abs和model_zp来比较这些技术。确切地说,我们实际上将原始权重替换为解量化的权重。这有两个好处:它允许我们1/比较权重的分布(相同的尺度),2/实际运行模型。
实际上,PyTorch默认情况下不允许INT8矩阵乘法。在实际情况中,我们将解量化它们以运行模型(例如使用FP16),但将它们存储为INT8。在下一节中,我们将使用bitsandbytes库来解决此问题。
import numpy as npfrom 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)# 创建用于量化的模型model_zp = deepcopy(model)# 量化所有模型权重weights_zp = []for param in model_zp.parameters(): _, dequantized = zeropoint_quantize(param.data) param.data = dequantized weights_zp.append(dequantized)
现在我们已经量化了我们的模型,我们想要检查这个过程的影响。直观上,我们要确保量化后的权重与原始权重接近。一个可视化的方法是绘制解量化和原始权重的分布。如果量化是有损的,它将大大改变权重分布。
下图显示了这个比较,其中蓝色直方图表示原始(FP32)权重,红色直方图表示解量化(从INT8)权重。请注意,由于绝对值非常高的异常值,我们只显示-2到2之间的范围(稍后会详细说明)。
两个图形非常相似,但是在0附近有一个令人惊讶的峰值。这个峰值显示我们的量化是相当有损的,因为反转过程不会输出原始值。这对于absmax模型尤其如此,它在0附近显示了一个较低的谷和一个较高的峰。
让我们比较原始模型和量化模型的性能。为此,我们定义了一个generate_text()函数来生成50个使用top-k采样的标记。
def generate_text(model, input_text, max_length=50): 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)# 使用原始和量化模型生成文本original_text = generate_text(model, "I have a dream")absmax_text = generate_text(model_abs, "I have a dream")zp_text = generate_text(model_zp, "I have a dream")print(f"原始模型:\n{original_text}")print("-" * 50)print(f"Absmax模型:\n{absmax_text}")print("-" * 50)print(f"Zeropoint模型:\n{zp_text}")
原始模型:我有一个梦想,我相信我将来能实现。我爱我的母亲,有一次有人告诉我我的家庭甚至都不那么坚强。然后我得到了
Absmax模型:我有一个梦想,要找出她头发的起源。她喜欢它。但你无法诚实地说她的头发是怎么做的。她一定是疯了。我们找到了一张发型的照片,发
Zeropoint模型:我有一个梦想,要在美国创造两个全职工作——一个是给有心理健康问题的人,一个是给没有心理疾病的人,或者至少有药物滥用史的人,以
不必试图确定一个输出是否比其他输出更有意义,我们可以通过计算每个输出的困惑度来量化它。这是一种常用的评估语言模型的度量标准,它衡量模型在预测序列中下一个标记的不确定性。在这种比较中,我们通常假设分数越低,模型越好。实际上,困惑度高的句子也可能是正确的。
我们使用一个简单的函数来实现它,因为它不需要考虑上下文窗口的长度等细节,因为我们的句子很短。
def calculate_perplexity(model, text): # 对文本进行编码 encodings = tokenizer(text, return_tensors='pt').to(device) # 定义 input_ids 和 target_ids 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 # 困惑度计算 ppl = torch.exp(neg_log_likelihood) return pplppl = calculate_perplexity(model, original_text)ppl_abs = calculate_perplexity(model_abs, absmax_text)ppl_zp = calculate_perplexity(model_zp, absmax_text)print(f"原始困惑度: {ppl.item():.2f}")print(f"Absmax 困惑度: {ppl_abs.item():.2f}")print(f"Zeropoint 困惑度: {ppl_zp.item():.2f}")
我们可以看到原始模型的困惑度稍低于其他两个模型。单个实验并不是非常可靠,但我们可以多次重复这个过程来观察每个模型之间的差异。理论上,零点量化应该稍微优于absmax,但计算成本也更高。
在这个例子中,我们将量化技术应用于整个层(每个张量为基础)。然而,我们也可以在不同的粒度级别上应用它:从整个模型到单个值。一次性量化整个模型会严重降低性能,而量化单个值会带来很大的开销。实际上,我们通常更喜欢向量级量化,它考虑了同一张量中行和列的值的变化性。
然而,即使是向量级量化也无法解决异常特征的问题。异常特征是在模型达到一定规模(>6.7B参数)时,在所有变换器层中出现的极端值(负或正)。这是一个问题,因为一个异常值可以降低所有其他值的精度。但是丢弃这些异常特征并不是一个选项,因为它会严重降低模型的性能。
🔢 使用LLM.int8()进行8位量化
由Dettmers等人于2022年引入的LLM.int8()是解决异常特征问题的方法。它依赖于一种向量级(absmax)量化方案,并引入了混合精度量化。这意味着异常特征以FP16格式进行处理以保持精度,而其他值以INT8格式进行处理。由于异常特征约占值的0.1%,这有效地将LLM的内存占用减少了近2倍。

LLM.int8()通过以下三个关键步骤来进行矩阵乘法计算:
- 使用自定义阈值从输入隐藏状态X中提取包含异常特征的列。
- 使用FP16对异常特征进行矩阵乘法运算,使用INT8对非异常特征进行向量级量化(对隐藏状态X进行行级量化,对权重矩阵W进行列级量化)。
- 将非异常结果(INT8转为FP16)进行反量化,并将其添加到异常结果中,以获得完整的FP16结果。

这种方法是必要的,因为8位精度是有限的,并且在对具有大值的向量进行量化时可能导致重大错误。这些错误在通过多个层传播时也会放大。
由于将bitsandbytes
库集成到Hugging Face生态系统中,我们可以轻松使用这种技术。当加载模型时,我们只需要指定load_in_8bit=True
(它还需要一个GPU)。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')model_int8 = AutoModelForCausalLM.from_pretrained(model_id, device_map='auto', load_in_8bit=True, )print(f"模型大小:{model_int8.get_memory_footprint():,} 字节")
模型大小:176,527,896 字节
通过这行额外的代码,模型现在几乎缩小了三倍(168MB vs. 487MB)。我们甚至可以像之前那样比较原始权重和量化权重的分布:
在这种情况下,我们可以看到在-2、-1、0、1、2等处有尖峰。这些值对应于以INT8格式存储的参数(非异常值)。您可以通过使用model_int8.parameters()
打印模型的权重进行验证。
我们还可以使用这个量化模型生成文本,并将其与原始模型进行比较。
# 使用量化模型生成文本text_int8 = generate_text(model_int8, "我有一个梦想")print(f"原始模型:\n{original_text}")print("-" * 50)print(f"LLM.int8() 模型:\n{text_int8}")
原始模型:我有一个梦想,这是一个我相信我将来会实现的梦想。我爱我的母亲,曾经有人告诉我,我的家庭甚至都不强大。然后我得到了--------------------------------------------------LLM.int8() 模型:我有一个梦想。我不知道会发生什么,但我要去寻找一些对的东西。我很久没有想过了,但我必须努力获得那个东西
再次强调,很难判断什么是最好的输出,但我们可以依靠困惑度指标给出一个(近似)答案。
print(f"困惑度(原始): {ppl.item():.2f}")ppl = calculate_perplexity(model_int8, text_int8)print(f"困惑度(LLM.int8()): {ppl.item():.2f}")
困惑度(原始): 15.53困惑度(LLM.int8()): 7.93
在这种情况下,量化模型的困惑度是原始模型的两倍低。一般来说,情况并非如此,但这表明这种量化技术非常有竞争力。实际上,LLM.int8()的作者表明性能降级非常低,可以忽略不计(小于1%)。然而,它在计算方面有额外的代价:对于大型模型,LLM.int8()大约比原始模型慢20%。
结论
本文概述了最流行的权重量化技术。我们首先了解了浮点表示,然后介绍了两种8位量化的技术:absmax和零点量化。然而,它们在处理异常值时的局限性导致了LLM.int8()的出现,该技术还能保持模型的性能。这种方法突显了在权重量化领域取得的进展,揭示了正确处理异常值的重要性。
展望未来,我们的下一篇文章将深入探讨GPTQ权重量化技术。这种技术由Frantar等人引入,仅使用4位,并在权重量化领域取得了重大进展。我们将提供一个全面的指南,介绍如何使用AutoGPTQ库实现GPTQ。
如果您对LLMs的更多技术内容感兴趣,请在Twitter上关注我@maximelabonne。
参考资料
- T. Dettmers, M. Lewis, Y. Belkada, and L. Zettlemoyer, LLM.int8(): 8位矩阵乘法在大规模Transformer中的应用. 2022.
- Y. Beldaka, and T. Dettmers, 8位矩阵乘法的简介, Hugging Face Blog (2022).
- A. Gholami, S. Kim, Z. Dong, Z. Yao, M. W. Mahoney, and K. Keutzer, 用于高效神经网络推断的量化方法综述. 2021.
- H. Wu, P. Judd, X. Zhang, M. Isaev, and P. Micikevicius, 深度学习推断的整数量化: 原理与实证评估. 2020.
- Lilian Weng, 大型Transformer模型推断的优化, Lil’Log (2023).
- Kamil Czarnogorski, 本地化大型语言模型, Int8 (2023).