LLM 模型微调:PEFT 与 QLoRA 技术总结
本文详细总结了 LLM 模型微调中的 PEFT 与 QLoRA 技术。内容涵盖 QLoRA 的提出背景、量化原理(包括 NF4、双重量化、分页优化器)、实验效果对比及代码实现。重点解释了如何通过 4 位量化在不牺牲性能的前提下大幅降低显存占用,使消费级 GPU 也能微调大模型。文中包含详细的配置参数说明、训练流程代码、推理脚本及权重合并方法,并对 LoRA 与其他微调技术的差异进行了分析,适合希望深入理解大模型高效微调的技术人员参考。

本文详细总结了 LLM 模型微调中的 PEFT 与 QLoRA 技术。内容涵盖 QLoRA 的提出背景、量化原理(包括 NF4、双重量化、分页优化器)、实验效果对比及代码实现。重点解释了如何通过 4 位量化在不牺牲性能的前提下大幅降低显存占用,使消费级 GPU 也能微调大模型。文中包含详细的配置参数说明、训练流程代码、推理脚本及权重合并方法,并对 LoRA 与其他微调技术的差异进行了分析,适合希望深入理解大模型高效微调的技术人员参考。

量化是指将模型的低精度表示,即把输入从存储更多信息的表征映射为存储较少信息表征的过程。例如将 FP32 的数据转化为 INT8,能够节省大量的内存。
全局量化方式存在问题:当输入中存在极大值或者离群值(outlier)时,一些较小的参数无法被精确表示,导致量化后的神经网络效果下降很多。为了缓解这个问题,作者采用了分块量化(Block-wise Quantization),即将输入划分为多个 block,每个 block 分别量化。
Block-wise k-bit Quantization(分块 k 位量化):在将 float32 量化为 int8 时,通常使用常数 c 缩小范围,但 outlier 会影响 c 的选取。Block-wise 方法通过每批次独立选择 c(本文中为 64 个数据)来解决此问题。
LoRA 微调是通过使用少量可训练参数来降低内存需求的方法,同时不更新完整模型中保持固定的参数。在随机梯度下降过程中,梯度通过固定的预训练模型权重传递到适配器,适配器被更新以优化损失函数。
对比 LoRA 和 QLoRA 的核心区别在于 QLoRA 引入了更激进的量化策略,使得在极低显存下也能进行大模型微调。
QLoRA 训练过程跟 LoRA 基本上是一致的,区别在于 QLoRA 模型是按照 NF4 保存的,训练时需要把参数反量化到 bf16 后进行训练。QLoRA 结合了低精度存储数据类型(NF4)+ 计算数据类型(BFloat16)。
QLoRA 主要使用了以下三项关键技术来实现高效 4bit 微调:
4-bit NormalFloat 量化是对 Quantile Quantization(分位量化)进行了改进,并结合 Block-wise Quantization,降低计算复杂度和误差。该策略基于分块的分位数量化,专门针对正态分布的权重进行了优化。
这是针对量化常数的二次量化。由于 BnB 的量化是块量化(block-wise),因此块级别的常数存储也会占用 GPU memory。对第一次量化后的那些常量再进行一次量化,减少存储空间。
具体而言,QLoRA 将每 64 个参数作为一个 block,即 block size=64,每个 block 计算一个 Scale。由于量化后的 Scale 通常以 FP32 存储,在 block 数众多的情况下,Scale 占用的显存也不可忽视。因此,QLoRA 对 Scale 进一步量化成 FP8,取 Double Quant 的 block size=256,因而进一步降低了显存消耗。
为防止梯度检查点所引起的内存波动导致的内存不足错误,使用 NVIDIA 统一内存特性。该特性可以在 GPU 偶尔 OOM 的情况下,进行 CPU 和 GPU 之间自动的页面切换,以实现无错误的 GPU 处理。使用此功能为优化器状态(Optimizer)分配分页内存,然后在 GPU 内存不足时将其自动卸载到 CPU 内存,并在优化器更新步骤需要时将其加载回 GPU 内存。
QLoRA 在所有全连接层都插入 LoRA Adapter,增加训练参数,弥补精度带来的性能损失,能匹配 16 位全参数微调的性能。在 LoRA 中,一般会选择在 query 和 value 的全连接层处插入 adapter。
作者提出的 4-bit NormalFloat 量化是对 Quantile Quantization 的改进。全局量化后参数整体的分布与原始的分布差别很大,例如出现极大 outlier 值,可能导致大部分参数都会量化到 0,这样效果就会下降很明显。
为了避免这种情况,作者采用分位量化(Quantile Quantization),可以先将输入参数从小到大进行排序再等分为 16 份,每一份映射一个值。这种分位量化方法量化出的参数就能保证分布尽可能与原始分布相差不大。
但是上述分位量化会额外引入明显的计算开销,因为每次有参数输入进来都需要对齐进行排序并等分。作者发现预训练的参数基本上都服从均值为 0 的正态分布,可以将其缩放到 [-1, 1] 的范围中。同时可以将正态分 N(0,1) 划分为 2^k + 1 份,并缩放到 [-1, 1] 的范围中。这样就能直接将参数映射到对应的分位,不用每次都对参数进行排序。
为处理零点问题,做了以下改进:分别将负数和整数部分划分 2^(k-1),参数 0 还是放在 0 原本的位置上。
在语言模型和零样本任务的评估中,4-bit NormalFloat 数据类型 (NF4) 表现出更好的性能,优于 4-bit 浮点数 (FP4)。研究采用了不同类型的量化 LLMs(OPT,BLOOM,Pythia,LLaMA)以及不同大小(125M 到 65B),比较 NF4、FP4 和 Int4 的性能,并发现 NF4 能够显著提升性能,双重量化可以减少内存占用同时不影响性能。
比较 RoBERTA 和 T5 模型在 GLUE 和 Super-NaturalInstructions 数据集上结果表明:无论是使用 16 位、8 位还是 4 位的适配器方法,都能够复制全 16 位微调的基准的性能。这说明,尽管量化过程中会存在性能损失,但通过适配器微调,完全可以恢复由于量化不准确而丢失这些性能。
测试 4 位 QLoRA 是否可以在 7B 到 65B 参数范围内匹配 16 位 LoRA。为此,在两个指令跟随数据集 Alpaca 和 FLAN v2 上对 LLaMA 7B 至 65B 进行了微调,并在 MMLU 基准测试上通过 5 点精度进行了评估。结果看到具有双重量化的 NF4 完全恢复了 16 位 LoRA 在 MMLU 性能。与 FP4 相比,QLoRA 性能落后于 16 位 BFloat16 LoRA 基准约 1 个百分点。这证实了 QLoRA 与 NF4 能够复制 16 位全微调和 16 位 LoRA 微调的性能,且 NF4 在量化精度方面优于 FP4。
使用 MMLU(Massively Multitask Language Understanding)基准来测量模型在一系列语言理解任务上的性能。报告 5-shot 测试准确率。还通过自动化和人工评估来测试生成语言能力。使用 nucleus 采样(p=0.9)和温度 0.7 进行所有测试。
基于 QLoRA 调优方法,作者对 OASST1 进行调优得到系列模型 Guanaco。基于自动化和人工评估,发现 QLoRA 优化的顶级模型 Guanaco 65B 是性能最好的开源聊天机器人模型,其性能与 ChatGPT 相比具有竞争力。与 GPT-4 相比,Guanaco 65B 和 33B 的预期获胜概率为 30%。在 Vicuna 基准相对于 ChatGPT 的结果显示,Guanaco 65B 是继 GPT-4 之后性能最好的型号,相对于 ChatGPT 实现了 99.3% 的性能。Guanaco 33B 比 Vicuna 13B 型号有更多的参数,但其权重仅使用 4 位精度,因此在 21 GB 和 26 GB 时的存储效率要高得多,比 Vicuna 13B 提高了三个百分点。此外,Guanaco 7B 以 5GB 的容量轻松安装在手机上,同时仍比 Alpaca 13B 高出近 20 个百分点。
PreTrainedModel.from_pretrained 调用了 bitsandbytes.py 的 replace_with_bnb_linear,将 nn.Linear 层替换成量化层。
随后需要加载模型权重,调用函数 _load_pretrained_model -> _load_state_dict_into_meta_model -> set_module_quantized_tensor_to_device。
set_module_quantized_tensor_to_device 在 bitsandbytes.py 中,用于将权重转化为 int4 量化权重,并更新量化层信息。
转化过程在 Params4bit.cuda 中实现,它调用了 bnb.functional.quantize_4bit 函数,这个函数再调用 lib 的一个函数 lib.cquantize_blockwise_fp16_nf4,所以核心计算过程都在 cuda 上完成。
最终,每个参数的量化交给 dQuantizeNF4 这个函数完成,而这个函数就是依照输入数值的区间赋值的。
from transformers import BitsAndBytesConfig
# 使用 bnb 加载 nf4 量化的模型
nf4_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16
)
model_nf4 = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=nf4_config
)
!pip install -q -U bitsandbytes
!pip install -q -U git+https://github.com/huggingface/transformers.git
!pip install -q -U git+https://github.com/huggingface/peft.git
!pip install -q -U git+https://github.com/huggingface/accelerate.git
!pip install -q -U datasets
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
model_id = "EleutherAI/gpt-neox-20b"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
# 加载 model 使用 qlora
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map={"": 0}
)
from peft import prepare_model_for_kbit_training
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)
def print_trainable_parameters(model):
"""
Prints the number of trainable parameters in the model.
"""
trainable_params = 0
all_param = 0
for _, param in model.named_parameters():
all_param += param.numel()
if param.requires_grad:
trainable_params += param.numel()
print(
f"trainable params: {trainable_params} || all params: {all_param} || trainable%: {100 * trainable_params / all_param}"
)
from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=8,
lora_alpha=32,
target_modules=["query_key_value"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, config)
print_trainable_parameters(model)
from datasets import load_dataset
data = load_dataset("Abirate/english_quotes")
data = data.map(lambda samples: tokenizer(samples["quote"]), batched=True)
import transformers
# needed for gpt-neo-x tokenizer
tokenizer.pad_token = tokenizer.eos_token
trainer = transformers.Trainer(
model=model,
train_dataset=data["train"],
args=transformers.TrainingArguments(
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
warmup_steps=2,
max_steps=10,
learning_rate=2e-4,
fp16=True,
logging_steps=1,
output_dir="outputs",
optim="paged_adamw_8bit"
),
data_collator=transformers.DataCollatorForLanguageModeling(tokenizer, mlm=False),
)
model.config.use_cache = False # silence the warnings. Please re-enable for inference!
trainer.train()
text = "Ask not what your country"
device = "cuda:0"
inputs = tokenizer(text, return_tensors="pt").to(device)
outputs = model.generate(**inputs, max_new_tokens=20)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
模型权重合并脚本:export_hf_checkpoint.py,将 lora 权重合并回原始权重。
import os
import torch
import transformers
from peft import PeftModel
from transformers import LlamaForCausalLM, LlamaTokenizer
BASE_MODEL = os.environ.get("BASE_MODEL", None)
LORA_MODEL = os.environ.get("LORA_MODEL", "tloen/alpaca-lora-7b")
HF_CHECKPOINT = os.environ.get("HF_CHECKPOINT", "./hf_ckpt")
assert (
BASE_MODEL
), "Please specify a value for BASE_MODEL environment variable, e.g. `export BASE_MODEL=decapoda-research/llama-7b-hf`"
tokenizer = LlamaTokenizer.from_pretrained(BASE_MODEL)
base_model = LlamaForCausalLM.from_pretrained(
BASE_MODEL,
torch_dtype=torch.bfloat16,
device_map={"": "cpu"},
)
first_weight = base_model.model.layers[0].self_attn.q_proj.weight
first_weight_old = first_weight.clone()
lora_model = PeftModel.from_pretrained(
base_model,
LORA_MODEL,
)
lora_weight = lora_model.base_model.model.model.layers[0].self_attn.q_proj.weight
assert torch.allclose(first_weight_old, first_weight)
# merge weights
for layer in lora_model.base_model.model.model.layers:
layer.self_attn.q_proj.merge_weights = True
layer.self_attn.v_proj.merge_weights = True
lora_model.train(False)
lora_model_sd = lora_model.state_dict()
deloreanized_sd = {
k.replace("base_model.model.", ""): v
for k, v in lora_model_sd.items()
if "lora" not in k
}
LlamaForCausalLM.save_pretrained(
base_model, HF_CHECKPOINT, state_dict=deloreanized_sd, max_shard_size="400MB"
)
推理脚本:inference.py
from transformers import AutoModelForCausalLM, LlamaTokenizer
import torch
model_id = "/data/nfs/guodong.li/pretrain/hf-llama-model/llama-7b"
merge_model_id = "/home/guodong.li/output/llama-7b-merge"
model = AutoModelForCausalLM.from_pretrained(
merge_model_id,
load_in_4bit=True,
device_map="auto"
)
tokenizer = LlamaTokenizer.from_pretrained(model_id)
device = torch.device("cuda:0")
text = "Hello, my name is "
inputs = tokenizer(text, return_tensors="pt").to(device)
outputs = model.generate(**inputs, max_new_tokens=20, do_sample=True, top_k=30, top_p=0.85)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
print("\n------------------------------------------------\nInput: ")
line = input()
while line:
inputs = tokenizer(line, return_tensors="pt").to(device)
outputs = model.generate(**inputs, max_new_tokens=20, do_sample=True, top_k=30, top_p=0.85)
print("Output: ", tokenizer.decode(outputs[0], skip_special_tokens=True))
print("\n------------------------------------------------\nInput: ")
line = input()
核心代码片段摘抄自 bitsandbytes 源码。
from scipy.stats import norm
import torch
def create_normal_map(offset=0.9677083, use_extra_value=True):
if use_extra_value:
# one more positive value, this is an asymmetric type
v1 = norm.ppf(torch.linspace(offset, 0.5, 9)[:-1]).tolist() # 正数部分
v2 = [0]*(256-15) ## we have 15 non-zero values in this data type
v3 = (-norm.ppf(torch.linspace(offset, 0.5, 8)[:-1])).tolist() #负数部分
v = v1 + v2 + v3
else:
v1 = norm.ppf(torch.linspace(offset, 0.5, 8)[:-1]).tolist()
v2 = [0]*(256-14) ## we have 14 non-zero values in this data type
v3 = (-norm.ppf(torch.linspace(offset, 0.5, 8)[:-1])).tolist()
v = v1 + v2 + v3
values = torch.Tensor(v)
values = values.sort().values
values /= values.max()
assert values.numel() == 256
return values
Q = create_normal_map()
函数 create_normal_map 有两个入参:offset 和 use_extra_value。其中 offset 的作用是确定分位数的始末值。use_extra_value 用来控制是使用对称量化还是非对称量化。 函数体内部有两个核心功能,其中 if…else…部分是用来计算分位数。其中 v1 计算正数部分,v3 计算负数部分。v2 直接将 0 映射到 0,并且根据要量化的单位计算 0 的个数。 源码是使用 NF4 来表示 8 比特的量化,如果是使用 4 比特的量化,我们将计算 v2 的 256 改成 16 就行。接下来最后几行用来将量化值归一化到 [-1,1] 。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online