大模型的低成本应用--PEFT(参数高效微调) - 详解
QLoRA(量化低秩自适应) 是 LoRA (Low-Rank Adaptation) 的一种优化扩展,它属于参数高效微调(PEFT,Parameter-Efficient Fine-Tuning) 技术族。它的核心目标是大幅减少微调大型语言模型(LLM)所需的显存(GPU Memory),同时保持与全量微调相近的性能。
核心技术原理
QLoRA 在 LoRA 的基础上引入了 量化(Quantization) 的概念,主要包括以下几个关键创新点:
1. 4-bit NormalFloat (NF4) 量化
量化目标: QLoRA 将预训练模型(Pre-trained Model)的权重从标准的 16 位浮点数(FP16)或 32 位浮点数(FP32)量化到 4 位(4-bit)。
NF4: 它引入了一种新型的 4 位数据类型,NormalFloat (NF4),这是专门为正态分布权重设计的,能够在量化时提供信息论最优的下界。简单来说,它能更有效地利用 4 位来表示模型的权重,尽可能地减少精度损失。
2. 双量化(Double Quantization, DQ)
减少量化常数(Quantization Constants)的显存占用: 在量化过程中,为了将 4 位数据映射回原始的 16 位数据,需要存储一个称为“量化常数”的比例因子。
双量化就是对这些量化常数本身再次进行量化。这样可以进一步将每个参数的平均显存占用从 4.25 位降低到 4.05 位,虽然提升看起来不大,但在超大型模型中能节省可观的显存。
3. Paged Optimizers
解决 OOM(Out-Of-Memory)问题: 在微调过程中,优化器(如 Adam)的状态(例如梯度平方的移动平均值)会占用大量的显存。
Paged Optimizers 利用 NVIDIA 的统一内存(Unified Memory)功能,将优化器状态的内存页按需**分页(Paging)到 CPU 内存(RAM)和 GPU 显存之间,类似于操作系统中的虚拟内存管理。这能有效防止在梯度计算或优化器更新时出现显存溢出(OOM)**的问题。
QLoRA 的优势
极低的显存需求: 这是 QLoRA 最大的优势。它使得在单个消费级或专业级 GPU(如 NVIDIA RTX 3090, A100 等)上微调数百亿甚至千亿参数的大模型成为可能。
高性能: 尽管将模型量化到 4 位,但通过 NF4 量化和解量化机制,它在微调后的性能可以媲美使用 16 位全量微调或标准 LoRA 的结果。
速度: 虽然量化/解量化操作会引入少量计算开销,但由于模型权重更小,数据传输更快,整体微调速度仍然非常高效。
总而言之,QLoRA 是一种突破性的微调技术,它通过极致的 4 位量化和内存优化手段,成功地将 LLM 微调的门槛降到了前所未有的低水平,极大地推动了个人和小团队在 LLM 领域的探索和应用。
QLoRA 显存占用精确计算与分析
以使用ms-swift对Qwen3-14b进行QLoRA 训练为例:
显存主要由三部分组成:模型权重、优化器状态(训练参数)和激活值/梯度(运行时)。
1. 基础模型权重显存占用 (Base Model)
这是 QLoRA 节省显存的核心。Qwen3-14B 模型的参数量约为 。
项目 | 参数量 (P) | 存储精度 | 每参数字节数 | 显存占用 (VRAM) | 计算公式 |
Qwen3-14B 权重 | 14B | NF4 + DQ | ≈0.506 | ≈7.08GB | 14B×4.05 bits/8 bits/byte |
示例配置:
quant_bits=4和bnb_4bit_quant_type='nf4',启用了bnb_4bit_use_double_quant=True。计算: QLoRA 论文指出,启用双重量化后,每个参数的平均占用为 4.05 bits。
结论: 基础模型的存储显存约为 7.08 GB。
2. LoRA 适配器和优化器状态显存占用 (Trainable Parameters)
仅训练 LoRA 适配器 (A 和 B 矩阵)。
LoRA 参数量估算: LoRA 训练的参数量远小于总参数量。对于 14B 模型,LoRA 参数通常在 0.1% 到 1% 之间。
假设 LoRA 模块占总参数的 0.1%≈14M (百万)。
示例配置:
lora_rank=8,target_modules='all-linear'。
项目 | 训练参数量 (PLoRA) | 存储精度 | 每参数字节数 | 显存占用 (VRAM) | 说明 |
LoRA 权重 | ≈14M | BF16 | 2 字节 | ≈0.03GB | LoRA A,B 矩阵的存储 |
AdamW 优化器 | ≈14M | FP32 | 8 字节 | ≈0.11GB | LoRA 权重的 m,v 状态 (2×4 字节) |
梯度 | ≈14M | BF16 | 2 字节 | ≈0.03GB | 反向传播所需的 LoRA 梯度 |
小结: LoRA 模块及其训练状态的总占用远低于 1 GB,可以忽略不计。
3. 激活值和运行时显存占用 (Activation and Runtime)
这是显存占用的最大变数,取决于配置的训练参数。
参数:
per_device_train_batch_size=1gradient_accumulation_steps=16max_length=2048torch_dtype='bfloat16'(计算数据类型)
项目 | 估算值 | 说明 |
激活值 (Activations) | ≈5GB 到 7GB | 这是前向传播过程中,中间层输出 (Activation) 占用的显存。对于 max_length=2048 的 14B 模型,即使使用 Gradient Checkpointing (梯度检查点)(Swift 默认可能开启),也会占用大量显存。 |
计算所需的缓冲区 (BF16) | ≈1.5GB | 用于 LoRA 权重和基础模型权重的解量化、矩阵乘法等操作的 16 bit 缓冲区。 |
CUDA 核心/其他开销 | ≈1GB 到 2GB | 操作系统、CUDA 环境、PyTorch/Swift 库等自身占用的显存。 |
Paged Optimizers | 0 GB (GPU VRAM) | QLoRA 的 Paged Optimizers 将 LoRA 优化器状态页回 CPU 内存,从而使这部分占用在 GPU VRAM 上几乎为零 (仅存储其 4 bits 量化副本和页表)。 |
4. 总显存占用估算
将所有项目相加,估算的峰值显存占用:
总显存=模型权重+激活值+缓冲区/开销
总显存≈7.08 GB+(5 GB∼7 GB)+(1.5 GB∼2 GB)
总显存≈13.58 GB 到 16.08 GB
优化器状态显存分析:为什么 LoRA 能大幅节省
在深度学习的训练过程中,优化器状态(Optimizer States) 实际上是占用显存的大头。这与 LoRA/QLoRA 只训练极少数参数的设计是密切相关的。
1. 传统微调中的优化器状态(以 AdamW 为例)
当使用标准的 Adam 或 AdamW 优化器进行训练时,对于模型中的每个可训练参数 θ,都需要额外存储 2 个状态:
一阶矩(First Moment, m): 梯度的指数移动平均。
二阶矩(Second Moment, v): 梯度平方的指数移动平均。
如果模型权重 θ 以 FP16(2 字节)存储,那么 m 和 v 通常需要以更高的精度(如 FP32,4 字节)存储,以确保训练的稳定性。
存储项 | 存储精度 | 每参数字节数 |
模型参数 (θ) | FP16 | 2 字节 |
梯度 (∇θ) | FP16 | 2 字节 |
一阶矩 (m) | FP32 | 4 字节 |
二阶矩 (v) | FP32 | 4 字节 |
总计 | 12 字节/参数 |
对于一个 140 亿参数的模型,如果进行全量微调,仅优化器状态(m,v)就需要 14B×8 字节≈112 GB 显存!这是导致全量微调资源需求巨大的主要原因。
2. LoRA/QLoRA 如何改变游戏规则
LoRA 的核心思想是:将原始大模型冻结(不可训练),只训练新增的小型 LoRA 适配器。
因此,优化器只需要为这些新增的 LoRA 参数存储状态。
冻结的 14B 权重: 不需要存储梯度,不需要存储优化器状态 m 和 v。(节省了 112 GB)
可训练的 LoRA 权重: 只需要为 LoRA 矩阵 A 和 B 中的参数存储 m 和 v。
显存节省的关键在于:可训练参数量从 140 亿骤降至几千万。
LoRA 参数量与 lora_rank、target_modules 的关系
LoRA 训练参数的数量直接决定了优化器状态的显存占用,而这个数量完全由配置参数决定。
可训练参数量≈2×∑(原始权重矩阵维度)×LoRA_rank
1. target_modules (决定训练范围)
target_modules 决定了要将 LoRA 模块插入到基础模型的哪些层中。
示例配置:
target_modules='all-linear'含义: 这意味着 LoRA 模块将被添加到模型中所有的线性层(如 Qwen3-14B 中的 Q/K/V/O 投影层、FFN 层的 up/down 投影层等)。这最大化了 LoRA 的覆盖范围,但也最大化了 LoRA 参数量。
2. lora_rank (决定参数数量)
lora_rank(记为 r)决定了每个 LoRA 模块中 A 和 B 矩阵的维度,这是参数量的主要控制因子。
对于一个维度为
的原始权重矩阵,LoRA 增加的参数数量为:
×r+r×
。
示例配置:
lora_rank=8。这是一个相对较小的秩。如果使用 r=64,参数量会大幅增加。
3. 精确计算 LoRA 状态显存
假设 Qwen3-14B 的 LoRA 参数量估算为 PLoRA≈14M(百万)。
存储项 | LoRA 参数量 (PLoRA) | 存储精度 | 每参数字节数 | 显存占用 (VRAM) |
LoRA AdamW 状态 (m,v) | 14M | FP32 | 8 字节 | 14M×8≈0.11 GB |
可以看到,因为 LoRA 的参数量非常小,所以优化器状态的显存占用也极其小,约为 0.11 GB,几乎可以忽略不计。
4. QLoRA 的最终优化:Paged Optimizers
QLoRA 的创新点 是在 LoRA 的基础上更进一步:
尽管 LoRA 优化器状态已经很小(≈0.11 GB),但对于 33B 或 65B 这种更大模型,它们仍可能引起瞬时 OOM。
Paged Optimizers 会将这部分优化器状态 (m,v) 分页转移到 CPU 内存 (RAM),在需要更新时才调回 GPU。
因此,在您的 QLoRA 训练中,LoRA 优化器状态对 GPU 显存的实际占用(峰值) 几乎为零,实现了最大化的显存节省。
总结: 优化器状态显存和 LoRA 配置的关系是:target_modules 和 lora_rank 决定了可训练参数的总量,而这个总量相对于原始模型是如此之小,以至于优化器状态的显存占用从 112 GB 降到了 0.11 GB 级别,最终通过 QLoRA 的优化被转移或优化到几乎不占用 GPU VRAM。
ModelScope Notebook环境下MS-Swift模型QLoRA 示例
导入必要的库
# 导入必要的库
import os
import torch
from swift.llm import sft_main, TrainArguments
from swift.utils import get_logger, seed_everything
# 设置环境变量
os.environ['CUDA_VISIBLE_DEVICES'] = '0' # 使用第一张GPU
# 设置日志和随机种子
logger = get_logger()
seed_everything(42)
print(f"PyTorch版本: {torch.__version__}")
print(f"CUDA可用: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU设备: {torch.cuda.get_device_name(0)}")
print(f"GPU显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
使用sft_main函数进行QLoRA训练
def train_qlora_with_bnb():
"""使用sft_main函数进行QLoRA训练"""
# 配置训练参数
train_args = TrainArguments(
# === 模型配置 ===
model='/mnt/workspace/.cache/modelscope/models/Qwen/Qwen3-14B', # 基础模型
train_type='lora', # 使用LoRA训练
# === BNB量化配置 ===
quant_method='bnb', # 使用BitsAndBytes量化
quant_bits=4, # 4位量化
bnb_4bit_compute_dtype='bfloat16', # 计算数据类型
bnb_4bit_quant_type='nf4', # 量化类型:nf4 (NormalFloat4)
bnb_4bit_use_double_quant=True, # 启用双重量化
torch_dtype='bfloat16', # 模型数据类型
# === 数据集配置 ===
dataset=[
'AI-ModelScope/alpaca-gpt4-data-zh#500', # 中文数据
'AI-ModelScope/alpaca-gpt4-data-en#500', # 英文数据
'swift/self-cognition#500' # 自我认知数据
],
# === LoRA配置 ===
lora_rank=8, # LoRA秩
lora_alpha=32, # LoRA缩放因子
target_modules='all-linear', # 目标模块:所有线性层
# === 训练超参数 ===
num_train_epochs=1, # 训练轮数
per_device_train_batch_size=1, # 每设备训练批次大小
per_device_eval_batch_size=1, # 每设备评估批次大小
gradient_accumulation_steps=16, # 梯度累积步数
learning_rate=1e-4, # 学习率
warmup_ratio=0.05, # 预热比例
# === 其他配置 ===
max_length=2048, # 最大序列长度
output_dir='output_qlora_bnb', # 输出目录
logging_steps=5, # 日志记录步数
eval_steps=50, # 评估步数
save_steps=50, # 保存步数
save_total_limit=2, # 最大保存检查点数
dataloader_num_workers=4, # 数据加载器工作进程数
# === 模型信息 ===
system='You are a helpful assistant.', # 系统提示
model_author='swift', # 模型作者
model_name='swift-robot' # 模型名称
)
print("=== 开始QLoRA训练 ===")
print(f"模型: {train_args.model}")
print(f"量化方法: {train_args.quant_method}")
print(f"量化位数: {train_args.quant_bits}")
print(f"LoRA秩: {train_args.lora_rank}")
print(f"输出目录: {train_args.output_dir}")
# 开始训练
result = sft_main(train_args)
print("=== 训练完成 ===")
print(f"最后的检查点: {result['last_model_checkpoint']}")
print(f"最佳检查点: {result.get('best_model_checkpoint', 'N/A')}")
return result
# 执行训练
training_result = train_qlora_with_bnb()
训练后模型推理测试
def test_trained_model(checkpoint_path):
"""测试训练后的模型"""
from swift.llm import infer_main, InferArguments
print(f"=== 测试训练后的模型 ===")
print(f"检查点路径: {checkpoint_path}")
# 配置推理参数
infer_args = InferArguments(
ckpt_dir=checkpoint_path, # 检查点目录
load_data_args=True, # 加载训练时的数据参数
max_new_tokens=512, # 最大生成token数
temperature=0.7, # 温度参数
top_p=0.9, # top-p采样
stream=True # 流式输出
)
# 开始推理
infer_main(infer_args)
# 如果训练完成,可以测试模型
if 'training_result' in locals():
checkpoint_path = training_result['last_model_checkpoint']
print(f"\n准备测试模型,检查点路径: {checkpoint_path}")
test_trained_model(checkpoint_path) # 取消注释以运行推理测试
def save_and_load_model_demo(training_result):
"""演示如何保存和加载训练好的模型"""
print("=== 模型保存和加载演示 ===")
# 获取训练结果
model = training_result['model']
tokenizer = training_result['tokenizer']
output_dir = training_result['output_dir']
# === 保存模型 ===
print("\n1. 保存模型...")
# 保存LoRA权重
lora_save_path = f"{output_dir}/lora_weights"
model.save_pretrained(lora_save_path)
print(f"LoRA权重已保存到: {lora_save_path}")
# 保存分词器
tokenizer_save_path = f"{output_dir}/tokenizer"
tokenizer.save_pretrained(tokenizer_save_path)
print(f"分词器已保存到: {tokenizer_save_path}")
# === 加载模型 ===
print("\n2. 重新加载模型...")
from swift.llm import get_model_tokenizer
from swift.tuners import Swift
from transformers import BitsAndBytesConfig
# 重新配置量化
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_quant_type='nf4',
bnb_4bit_use_double_quant=True,
)
# 加载基础模型
base_model, base_tokenizer = get_model_tokenizer(
'Qwen/Qwen2.5-7B-Instruct',
torch_dtype=torch.bfloat16,
quantization_config=bnb_config
)
# 加载LoRA权重
loaded_model = Swift.from_pretrained(base_model, lora_save_path)
print("模型加载完成")
# === 验证模型 ===
print("\n3. 验证加载的模型...")
# 简单测试
test_query = "你好,请介绍一下你自己。"
# 使用原模型生成
print(f"测试问题: {test_query}")
from swift.llm import get_template
template = get_template(
base_model.model_meta.template,
base_tokenizer,
default_system='You are a helpful assistant.',
max_length=2048
)
template.set_mode('infer')
inputs = template.encode({'query': test_query})
input_ids = torch.tensor([inputs['input_ids']]).to(loaded_model.device)
attention_mask = torch.tensor([inputs['attention_mask']]).to(loaded_model.device)
loaded_model.eval()
with torch.no_grad():
generated_ids = loaded_model.generate(
input_ids=input_ids,
attention_mask=attention_mask,
max_new_tokens=128,
temperature=0.7,
do_sample=True,
pad_token_id=base_tokenizer.eos_token_id
)
response_ids = generated_ids[0][len(input_ids[0]):]
response = base_tokenizer.decode(response_ids, skip_special_tokens=True)
print(f"加载模型的回答: {response}")
print("\n✅ 模型保存和加载验证成功!")
return {
'loaded_model': loaded_model,
'tokenizer': base_tokenizer,
'template': template,
'lora_path': lora_save_path
}
# 如果训练完成,演示保存和加载
if 'training_result' in locals():
loaded_model_info = save_and_load_model_demo(training_result)
print(f"\n模型文件保存在: {loaded_model_info['lora_path']}")
else:
print("请先运行训练代码,然后再演示模型保存和加载")
浙公网安备 33010602011771号