欢迎光临
我们一直在努力

QLoRA实战_4bit量化LoRA省显存微调方案

image.png

文章目录

    • 1. 先把问题讲清楚:LoRA 之后为什么还会显存不够
    • 2. QLoRA 的一句话版本
    • 3. 先算显存:全参、LoRA、QLoRA到底差在哪里
    • 4. 4bit 量化到底在做什么
    • 5. 为什么 QLoRA 使用 NF4,而不是普通 int4
    • 6. Double Quantization:连 scale 也要省
    • 7. Paged Optimizer:解决的是显存峰值,不是权重大小
    • 8. 一个最小“伪QLoRA”实验:冻结量化基座,只训练LoRA
    • 9. 真实训练配置骨架怎么读
    • 10. target_modules 该怎么选
    • 11. QLoRA 的训练参数怎么调
    • 12. OOM 排查:按这个顺序来
    • 13. 什么时候不要用 QLoRA
    • 14. 评估:不能只看“训完了”
    • 15. 常见误区
    • 16. 总结
    • 大模型视角
    • 下一篇

摘要:QLoRA(Quantized LoRA)解决的是一个很现实的问题:LoRA 已经把“需要训练的参数”降下来了,但基础模型权重本身仍然要占显存。QLoRA 的做法是把冻结的基础模型权重以 4bit 形式加载,只训练 LoRA adapter,从而让开发者在更小显存上完成指令微调。本文会先算清训练显存到底花在哪里,再解释 4bit 量化、NF4、double quantization、paged optimizer 各自解决什么问题,并给出一个可运行的“伪 QLoRA”小实验和一份真实 PEFT 配置骨架,帮助你知道什么时候该用 QLoRA、什么时候不该用。

  • 前置知识:专栏二第 49 篇 SFT、第 50 篇 LoRA;专栏一第 18 篇混合精度、第 4 篇矩阵运算
  • 阅读时间:45-60 分钟
  • 代码环境:Python 3.10+;概念实验使用 PyTorch 2.x;真实训练配置需要 transformers、peft、bitsandbytes
  • 读完目标:能解释 QLoRA 为什么省显存,能看懂 load_in_4bit、nf4、double_quant、bnb_4bit_compute_dtype,能按清单排查 QLoRA 训练 OOM

1. 先把问题讲清楚:LoRA 之后为什么还会显存不够

上一篇 LoRA 已经讲过:完整微调会更新大模型的所有权重,而 LoRA 只训练两个低秩矩阵

A

A

A 和

B

B

B:

W

′

=

W

+

α

r

B

A

W' = W + \\frac{\\alpha}{r}BA

W′=W+rα​BA

这里

W

W

W 是基础模型原始权重,训练时冻结;

A

A

A 和

B

B

B 是很小的 LoRA adapter,训练时更新。这个设计已经省掉了大量梯度和优化器状态。

但 LoRA 没有解决另一个问题:基础模型权重虽然不训练,仍然要常驻显存。 一个 7B 模型如果用 FP16/BF16 存权重,仅权重本身就大约需要 13GB 左右空间;训练时还要放激活值、LoRA 参数、优化器状态、CUDA 缓存和临时张量。于是你会看到一种常见情况:

模型能加载,推理也能跑;
一开始做 SFT,就 OOM。

QLoRA 的切入点很直接:既然基础权重不更新,能不能用更低精度存起来?这就是 4bit 量化的作用。


2. QLoRA 的一句话版本

QLoRA 可以先理解成:

冻结的 4bit 基础模型 + 可训练的 LoRA adapter + 更高精度的计算通路

更细一点,训练时通常是:

  • 以 4bit 形式加载基础模型权重;
  • 基础权重冻结,不保存它们的梯度和优化器状态;
  • 在注意力投影、FFN 投影等模块上注入 LoRA;
  • 前向计算时,量化权重会按实现需要反量化到计算 dtype;
  • 反向传播时,梯度主要更新 LoRA 参数;
  • 最终保存的是 LoRA adapter,而不是完整新模型。
  • 注意一个容易误解的点:QLoRA 不是“整个训练都在 4bit 里算”。4bit 主要是存储格式;实际矩阵计算通常会使用 BF16/FP16/FP32 这类计算 dtype。存储 dtype 和计算 dtype 要分开理解。 qlora-dataflow.png


    3. 先算显存:全参、LoRA、QLoRA到底差在哪里

    训练显存不是一个数字,而是多项开销相加。先用一个粗略估算建立数量级直觉。

    对于一个参数量为

    N

    N

    N 的模型,权重存储开销约为:

    weight memory

    =

    N

    ×

    bits

    8

    \\text{weight memory} = N \\times \\frac{\\text{bits}}{8}

    weight memory=N×8bits​

    这只是权重,不包括激活、梯度、优化器状态和框架缓存。下面用代码估算不同位宽下的权重大小。

    # Python 3.10+,无额外依赖

    def weight_memory_gb(params_billion, bits_per_param):
    params = params_billion * 1_000_000_000
    bytes_used = params * bits_per_param / 8
    return bytes_used / 1024 ** 3

    for params in [7, 13, 34]:
    print(f"\\n{params}B 模型权重粗略大小:")
    for bits in [16, 8, 4]:
    print(f" {bits:2d}bit: {weight_memory_gb(params, bits):6.2f} GB")

    你会看到:从 16bit 到 4bit,理论权重存储降为四分之一。对 7B 模型来说,这就是从十几 GB 降到几 GB 的量级。 qlora-memory-compare.png

    但为什么我一直说“理论”?因为真实 QLoRA 还需要额外保存:

    • 量化 scale / zero point / codebook 等元数据;
    • LoRA adapter 参数;
    • LoRA 的梯度和优化器状态;
    • 前向反向所需激活值;
    • CUDA kernel 临时 buffer;
    • 长上下文带来的额外中间张量。

    所以 QLoRA 能显著省显存,但不能让训练显存只等于“4bit 权重文件大小”。


    4. 4bit 量化到底在做什么

    量化的核心是:用更少的离散值近似原来的连续权重。为了先理解,不急着讲 NF4,我们先看最普通的线性 4bit 量化。

    假设一组权重在区间

    [

    w

    m

    i

    n

    ,

    w

    m

    a

    x

    ]

    [w_{min}, w_{max}]

    [wmin​,wmax​] 内。4bit 一共能表示 16 个等级。我们可以把连续权重映射到 0 到 15 的整数,再反量化回近似浮点数。

    公式可以写成:

    q

    =

    round

    (

    w

    −

    w

    m

    i

    n

    s

    )

    q = \\text{round}\\left(\\frac{w – w_{min}}{s}\\right)

    q=round(sw−wmin​​)

    w

    ^

    =

    q

    ⋅

    s

    +

    w

    m

    i

    n

    \\hat{w} = q \\cdot s + w_{min}

    w^=q⋅s+wmin​

    其中

    s

    s

    s 是量化步长:

    s

    =

    w

    m

    a

    x

    −

    w

    m

    i

    n

    15

    s = \\frac{w_{max} – w_{min}}{15}

    s=15wmax​−wmin​​

    大白话解释:把原来的数轴切成 15 个间隔,每个权重找到离自己最近的刻度。反量化后得到的

    w

    ^

    \\hat{w}

    w^ 和原始

    w

    w

    w 会有误差,这个误差就是量化损失。

    下面写一个最小量化/反量化实验:

    # Python 3.10+ / torch >= 2.0
    import torch

    def quantize_4bit_linear(w):
    w_min = w.min()
    w_max = w.max()
    scale = (w_max – w_min) / 15
    q = torch.round((w – w_min) / scale).clamp(0, 15).to(torch.uint8)
    w_hat = q.float() * scale + w_min
    return q, w_hat, scale

    torch.manual_seed(0)
    w = torch.randn(1024)
    q, w_hat, scale = quantize_4bit_linear(w)

    mse = torch.mean((w – w_hat) ** 2).item()
    print("原始权重前5个:", w[:5])
    print("反量化前5个:", w_hat[:5])
    print("量化MSE:", mse)
    print("4bit整数前5个:", q[:5])

    这段代码不是 QLoRA 的真实 NF4 实现,但它能帮你看懂量化的基本逻辑:低 bit 存储会节省空间,同时引入近似误差。


    5. 为什么 QLoRA 使用 NF4,而不是普通 int4

    普通线性 int4 的刻度是均匀的:每个等级之间距离相同。但神经网络权重通常不是均匀分布,很多权重集中在 0 附近,极端大值较少。

    NF4(NormalFloat 4-bit)的想法是:既然预训练权重常常接近正态分布,就用更适合正态分布的 4bit 表示点。这样 16 个可表示值不会均匀撒在数轴上,而是更照顾常见权重所在的区域。

    可以用一个直觉对比:

    普通 int4:尺子刻度均匀,0附近和极端值附近一样稀疏。
    NF4:刻度按权重分布设计,常见区域更精细,少见区域更粗。

    为什么这有用?因为如果大量权重集中在 0 附近,那在 0 附近提供更合适的表示点,整体量化误差可能更小。 nf4-vs-int4.png

    这里要注意事实边界:NF4 的具体码本和实现细节由 bitsandbytes 等库处理,写业务代码时通常不需要手写 NF4。你需要知道的是:

    • bnb_4bit_quant_type="nf4" 表示使用 NF4 量化类型;
    • NF4 主要服务于冻结基础权重的低误差存储;
    • 它不能保证没有质量损失,最终仍要评估。

    6. Double Quantization:连 scale 也要省

    量化不只保存 4bit 权重,还要保存反量化所需的 scale 等元数据。为了降低误差,量化通常按 block 分组:每一小块权重有自己的 scale。

    假设每 64 个权重一组,每组保存一个 16bit scale。对于大模型,这些 scale 加起来也会占空间。Double quantization 的思路是:再把这些 scale 也量化一次,减少元数据开销。

    我们用代码算一个简单账。下面不追求和真实实现逐字节一致,只看量级关系。

    # Python 3.10+,无额外依赖

    def quant_metadata_mb(num_params, group_size=64, scale_bits=16):
    groups = (num_params + group_size – 1) // group_size
    bytes_used = groups * scale_bits / 8
    return bytes_used / 1024 ** 2

    params = 7_000_000_000
    for group in [32, 64, 128]:
    mb = quant_metadata_mb(params, group_size=group, scale_bits=16)
    print(f"group={group:3d}, 16bit scale 元数据约 {mb:8.1f} MB")

    你会发现:即使每组只存一个 scale,大模型上也不是完全可以忽略。Double quantization 的价值就在于进一步压缩这些量化元数据,让 4bit 方案更接近理论节省。


    7. Paged Optimizer:解决的是显存峰值,不是权重大小

    训练时显存不是平滑曲线。某些 step 会因为长样本、激活、临时 buffer、优化器状态访问而出现峰值。Paged optimizer 试图通过分页管理优化器状态,缓解突然的显存峰值。

    它解决的问题更像是:

    平时显存够,但某些 batch 或某些更新时刻突然 OOM。

    不是:

    模型基础权重太大,根本加载不进去。

    所以排查 OOM 时不要把 paged optimizer 当万能药。如果是 max_seq_length 太长、micro batch 太大、没有开启 4bit 加载,paged optimizer 也救不了根本问题。


    8. 一个最小“伪QLoRA”实验:冻结量化基座,只训练LoRA

    真实 QLoRA 依赖 bitsandbytes 的 4bit kernel。为了让你不安装复杂依赖也能理解机制,我们写一个教学版实验:

    • 先生成一个基础线性层权重

      W

      W

      W;

    • 把

      W

      W

      W 做 4bit 伪量化并冻结;

    • 额外训练 LoRA 矩阵

      A

      A

      A 和

      B

      B

      B;

    • 让 LoRA 学习一个目标增量,观察 loss 是否下降。

    这不是生产级 QLoRA,只是机制演示。

    # Python 3.10+ / torch >= 2.0
    import torch
    import torch.nn as nn
    import torch.optim as optim

    class FakeQuantLinear(nn.Module):
    def __init__(self, weight):
    super().__init__()
    q, w_hat, _ = quantize_4bit_linear(weight)
    # 教学版:直接保存反量化后的近似权重,并冻结
    self.register_buffer("weight", w_hat)
    self.register_buffer("qweight", q)

    def forward(self, x):
    return x @ self.weight.t()

    class QLoRALinearToy(nn.Module):
    def __init__(self, base_weight, r=4, alpha=8):
    super().__init__()
    self.base = FakeQuantLinear(base_weight)
    self.r = r
    self.scale = alpha / r
    d_out, d_in = base_weight.shape
    self.A = nn.Parameter(torch.randn(r, d_in) * 0.01)
    self.B = nn.Parameter(torch.zeros(d_out, r))

    def forward(self, x):
    base_out = self.base(x)
    lora_out = (x @ self.A.t() @ self.B.t()) * self.scale
    return base_out + lora_out

    torch.manual_seed(42)
    d_in, d_out = 32, 16
    base_w = torch.randn(d_out, d_in) * 0.2
    true_delta = torch.randn(d_out, d_in) * 0.03
    true_w = base_w + true_delta

    x = torch.randn(512, d_in)
    y = x @ true_w.t()

    model = QLoRALinearToy(base_w, r=4, alpha=8)
    opt = optim.AdamW([model.A, model.B], lr=5e-2)

    for step in range(101):
    pred = model(x)
    loss = ((pred – y) ** 2).mean()
    opt.zero_grad()
    loss.backward()
    opt.step()
    if step % 20 == 0:
    print(step, float(loss))

    print("base weight requires_grad:", model.base.weight.requires_grad)
    print("A requires_grad:", model.A.requires_grad)
    print("B requires_grad:", model.B.requires_grad)

    这个实验应该看到 loss 逐步下降,同时基础权重不参与梯度更新。它说明 QLoRA 的训练直觉:量化基座提供原模型能力,LoRA 分支学习任务所需的补偿。

    这里也能看出 QLoRA 的边界:如果 4bit 量化误差太大,或者任务需要的变化不是低秩 adapter 能表达的,训练效果就会受限。


    9. 真实训练配置骨架怎么读

    真实项目里,你通常不会手写 QLoRA 层,而是用 transformers + bitsandbytes + peft。下面是一份配置骨架,用来说明关键参数。它不是完整训练脚本,但足够帮助你读懂项目配置。

    # Python 3.10+
    # pip install transformers peft bitsandbytes accelerate

    import torch
    from transformers import AutoModelForCausalLM, BitsAndBytesConfig
    from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

    model_name = "your-base-model"

    bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    )

    model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    )

    model = prepare_model_for_kbit_training(model)

    lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    task_type="CAUSAL_LM",
    )

    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()

    逐项解释关键参数:

    • load_in_4bit=True:以 4bit 形式加载基础权重;
    • bnb_4bit_quant_type="nf4":使用 NF4 量化类型;
    • bnb_4bit_use_double_quant=True:开启 double quantization,进一步压缩量化元数据;
    • bnb_4bit_compute_dtype=torch.bfloat16:矩阵计算使用 BF16;如果硬件不支持 BF16,可能需要 FP16;
    • prepare_model_for_kbit_training:为 k-bit 训练做必要准备,例如处理某些层的 dtype 和梯度;
    • r:LoRA rank,控制 adapter 容量;
    • lora_alpha:LoRA 缩放强度;
    • lora_dropout:LoRA 分支 dropout,小数据时可缓解过拟合;
    • target_modules:在哪些线性层上加 LoRA。不同模型命名不同,必须按模型结构检查。

    如果你只记住一件事,就是:QLoRA 省显存主要靠 4bit 冻结基座;任务适配能力主要靠 LoRA 的 target modules、rank、数据质量和训练配置。


    10. target_modules 该怎么选

    很多教程会直接写:

    target_modules=["q_proj", "v_proj"]

    这能跑,但不一定最好。不同选择代表不同取舍:

    注入位置影响成本
    q_proj, v_proj 常见轻量配置,主要影响注意力读写 低
    q_proj, k_proj, v_proj, o_proj 更完整影响注意力模块 中
    再加 gate_proj, up_proj, down_proj 同时适配 FFN,表达能力更强 更高
    embedding / lm_head 特殊任务可能有用,但风险和成本更高 视情况

    选择时不要只看别人配置。建议顺序是:

  • 先用常见 attention 配置跑 baseline;
  • 如果任务需要明显风格/领域表达,再加入 FFN 投影;
  • 如果数据很少,不要一开始就把所有模块都加上;
  • 每次只改一个变量,用同一评估集比较。

  • 11. QLoRA 的训练参数怎么调

    一个稳妥的起点:

    {
    "max_seq_length": 2048,
    "per_device_train_batch_size": 1,
    "gradient_accumulation_steps": 16,
    "learning_rate": 0.0002,
    "num_train_epochs": 1,
    "warmup_ratio": 0.03,
    "lr_scheduler_type": "cosine",
    "gradient_checkpointing": true,
    "optim": "paged_adamw_8bit",
    "bf16": true,
    "logging_steps": 10,
    "save_steps": 200,
    "eval_steps": 200
    }

    这些不是通用最优值,只是起点。调参时优先关注:

    • 数据量小:减少 epoch,增加 dropout,避免 rank 太大;
    • 格式任务:重点看格式合法率,不只看 loss;
    • 长文本任务:逐步增加 max_seq_length,不要一开始拉满;
    • 显存紧张:先降 max_seq_length 和 micro batch,再考虑降 rank;
    • 训练不稳定:检查学习率、warmup、异常样本和 mixed precision。

    12. OOM 排查:按这个顺序来

    QLoRA OOM 最容易让人误判。很多人第一反应是“4bit 不是很省显存吗?”但 OOM 往往不是权重本身,而是激活值或配置。

    建议按下面顺序排查:

    1. 确认真的 load_in_4bit=True,而不是只设置了 LoRA。
    2. 打印 trainable parameters,确认没有意外训练全参数。
    3. 降低 max_seq_length,例如 4096 -> 2048 -> 1024。
    4. 把 per_device_train_batch_size 降到 1。
    5. 用 gradient_accumulation_steps 保持全局 batch。
    6. 开启 gradient_checkpointing。
    7. 减少 target_modules,先只训 attention 投影。
    8. 降低 LoRA rank,例如 64 -> 32 -> 16 -> 8。
    9. 检查数据里是否有超长样本没有截断。
    10. 检查评估和保存时是否临时占用额外显存。

    经验上,max_seq_length 和 micro batch 是最常见的 OOM 来源。长上下文 SFT 即使使用 QLoRA,也会因为激活值变大而吃显存。


    13. 什么时候不要用 QLoRA

    QLoRA 是低显存利器,但不是默认最优。

    不建议优先用 QLoRA 的情况:

    • 你有足够显存,BF16 LoRA 更简单、更快、更少兼容问题;
    • 任务对数值精度非常敏感,4bit 量化损失不可接受;
    • 训练速度比显存更重要,而当前 4bit kernel 在你的硬件上不快;
    • 你需要全参数微调或深度持续预训练;
    • 部署环境不支持对应量化格式;
    • 你的主要问题其实是数据质量差,而不是显存不够。

    选择 QLoRA 的合理理由应该是:显存是主要瓶颈,并且 LoRA 级别的任务适配足够满足需求。


    14. 评估:不能只看“训完了”

    QLoRA 训练后至少要比较四个版本:

    base model
    base model + prompt/RAG
    LoRA/QLoRA adapter
    旧线上模型或旧 adapter

    评估指标包括:

    • 目标任务准确率;
    • 格式合法率;
    • 长文本稳定性;
    • 幻觉和无证据拒答;
    • 通用能力回归;
    • 推理延迟和显存;
    • 量化后是否出现重复、乱码、退化。

    特别注意:QLoRA 训练阶段用了 4bit,不代表部署时一定也用同样方式。你可以保存 adapter,在推理时选择 4bit base + adapter,也可以在条件允许时合并到更高精度 base 上评估。部署方案也要重新测。


    15. 常见误区

    误区一:QLoRA 等于 LoRA 加一个参数。 不只是参数。它涉及 4bit 加载、量化类型、计算 dtype、double quantization、paged optimizer 和 k-bit training 准备。

    误区二:4bit 训练一定和 BF16 一样好。 4bit 是近似存储,会带来量化误差。很多任务表现很好,但不能不评估。

    误区三:显存只看模型权重大小。 SFT 的激活值、序列长度、batch、梯度检查点和临时 buffer 都会影响显存。

    误区四:rank 越大越安全。 rank 大会增加参数、显存和过拟合风险。小数据任务常常不需要很大 rank。

    误区五:adapter 可以随便换基座。 QLoRA/LoRA adapter 必须匹配同一个基础模型、结构、tokenizer 和 target modules。


    16. 总结

    QLoRA 的核心价值,是把冻结基础模型权重压到 4bit 存储,同时只训练 LoRA adapter,让较小显存也能完成大模型 SFT。它的关键组件分别解决不同问题:NF4 降低 4bit 权重量化误差,double quantization 压缩量化元数据,paged optimizer 缓解优化器状态带来的显存峰值,LoRA adapter 承担任务适配。

    但 QLoRA 不是免费的能力提升。它用量化误差和实现复杂度换显存空间。真正做项目时,应先确认显存瓶颈,再控制 max_seq_length、batch、rank 和 target modules,用独立评估集验证质量。能用 BF16 LoRA 稳定解决的问题,不必强行 QLoRA;显存受限但任务适配明确时,QLoRA 是非常实用的方案。

    大模型视角

    从大模型工程角度看,QLoRA 体现了一个重要原则:训练成本不只取决于可训练参数量,也取决于冻结权重的存储方式和训练过程的中间状态。 LoRA 解决“训练哪些参数”,QLoRA 进一步解决“冻结参数怎么更便宜地放进显存”。理解这两个层次,你就能更准确地评估微调方案,而不是只看教程里说“单卡可训”。

    下一篇

    下一篇会讲 DoRA、LoRA+、AdaLoRA、IA3 等 LoRA 变体。学完 QLoRA 后再看这些方法,要重点问:它们是在降低显存、提高表达能力、改善优化速度,还是更细地分配 adapter 容量?不同目标对应不同选择。

    赞(0)
    未经允许不得转载:171主机测评 » QLoRA实战_4bit量化LoRA省显存微调方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址