Press "Enter" to skip to content

介绍权重量化

使用8位量化减小大型语言模型的大小

介绍权重量化 四海 第1张

大型语言模型(LLMs)以其广泛的计算要求而闻名。通常,模型的大小通过将参数的数量(大小)乘以这些值的精度(数据类型)来计算。然而,为了节省内存,可以使用低精度数据类型存储权重,这个过程被称为量化。

文献中区分了两种主要的权重量化技术:

  • 后期训练量化(PTQ)是一种直接的技术,通过将已经训练好的模型的权重转换为低精度来实现,而不需要重新训练。尽管易于实现,但后期训练量化可能导致性能下降。
  • 量化感知训练(QAT)在预训练或微调阶段将权重转换过程纳入其中,从而提高模型性能。然而,量化感知训练在计算上是昂贵的,并需要具有代表性的训练数据。

本文重点介绍后期训练量化来降低参数的精度。为了直观理解,我们将应用简单和更复杂的技术来处理一个使用GPT-2模型的示例。

整个代码可以在Google Colab和GitHub上免费获取。

📚 浮点数表示的背景知识

数据类型的选择决定了所需的计算资源数量,影响模型的速度和效率。在深度学习应用中,平衡精度和计算性能成为一项重要的任务,因为较高的精度通常意味着更大的计算需求。

在各种数据类型中,浮点数在深度学习中被广泛使用,因为它们能够以高精度表示各种值。通常,浮点数使用n位来存储一个数值。这n位被进一步划分为三个不同的组成部分:

  1. 符号:符号位表示数的正负性。它使用一个位,其中0表示正数,1表示负数。
  2. 指数:指数是一段位表示底数(通常是二进制表示中的2)的乘幂。指数也可以是正数或负数,使得数能够表示非常大或非常小的值。
  3. 尾数/有效数字:剩余的位用于存储尾数,也称为有效数字。这表示数的有效数字。数的精度严重依赖于尾数的长度。

这种设计使得浮点数能够覆盖一定范围内的值,并具有不同级别的精度。该表示的公式如下:

介绍权重量化 四海 第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数字被量化因子除以,由于四舍五入导致了一定的精度损失。

介绍权重量化 四海 第4张

例如,假设我们有一个绝对最大值为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相比多了一个值)。首先,我们计算缩放因子和零点值:

介绍权重量化 四海 第5张

然后,我们可以使用这些变量来量化或反量化我们的权重:

介绍权重量化 四海 第6张

让我们举一个例子:我们有一个最大值为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之间的范围(稍后会详细说明)。

介绍权重量化 四海 第8张

两个图形非常相似,但是在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()通过以下三个关键步骤来进行矩阵乘法计算:

  1. 使用自定义阈值从输入隐藏状态X中提取包含异常特征的列。
  2. 使用FP16对异常特征进行矩阵乘法运算,使用INT8对非异常特征进行向量级量化(对隐藏状态X进行行级量化,对权重矩阵W进行列级量化)。
  3. 将非异常结果(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)。我们甚至可以像之前那样比较原始权重和量化权重的分布:

介绍权重量化 四海 第11张

在这种情况下,我们可以看到在-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).
Leave a Reply

Your email address will not be published. Required fields are marked *