大模型的低成本应用--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 的优势

  1. 极低的显存需求: 这是 QLoRA 最大的优势。它使得在单个消费级或专业级 GPU(如 NVIDIA RTX 3090, A100 等)上微调数百亿甚至千亿参数的大模型成为可能。

  2. 高性能: 尽管将模型量化到 4 位,但通过 NF4 量化和解量化机制,它在微调后的性能可以媲美使用 16 位全量微调或标准 LoRA 的结果。

  3. 速度: 虽然量化/解量化操作会引入少量计算开销,但由于模型权重更小,数据传输更快,整体微调速度仍然非常高效。

总而言之,QLoRA 是一种突破性的微调技术,它通过极致的 4 位量化和内存优化手段,成功地将 LLM 微调的门槛降到了前所未有的低水平,极大地推动了个人和小团队在 LLM 领域的探索和应用。

QLoRA 显存占用精确计算与分析

以使用ms-swift对Qwen3-14b进行QLoRA 训练为例:

显存主要由三部分组成:模型权重优化器状态(训练参数)和激活值/梯度(运行时)

1. 基础模型权重显存占用 (Base Model)

这是 QLoRA 节省显存的核心。Qwen3-14B 模型的参数量约为 14*10^9

项目

参数量 (P)

存储精度

每参数字节数

显存占用 (VRAM)

计算公式

Qwen3-14B 权重

14B

NF4 + DQ

≈0.506

≈7.08GB

14B×4.05 bits/8 bits/byte

  • 示例配置:quant_bits=4bnb_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=8target_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=1

    • gradient_accumulation_steps=16

    • max_length=2048

    • torch_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 个状态:

  1. 一阶矩(First Moment, m): 梯度的指数移动平均。

  2. 二阶矩(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_ranktarget_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 矩阵的维度,这是参数量的主要控制因子

  • 对于一个维度为 W_out*W_in 的原始权重矩阵,LoRA 增加的参数数量为:W_out​×r+r×W_in​。

  • 示例配置: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_moduleslora_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("请先运行训练代码,然后再演示模型保存和加载")
posted @ 2025-11-06 11:03  ycfenxi  阅读(1)  评论(0)    收藏  举报