Chapter 03

QLoRA:在 24GB 卡上微调 7B

📌 commit a106ff4c7061 4bit 量化加载 + LoRA = 单卡训大模型

QLoRA 是把 LoRA 从"省参数"扩展到"省显存"的关键一步—— base 模型以 4bit 量化加载(占 1/8 显存),只在 LoRA 上保留 bf16 梯度。 本章给一个能在 RTX 4090(24GB)上跑通的 Llama-7B 微调例子。

3.1QLoRA 关键三件套

组件角色来源
NF4 量化 把 base 权重压到 4bit(NormalFloat4,正态分布优化)bitsandbytes
双重量化 对 quantization constants 再量化一次,省 0.4 bit/parambitsandbytes
分页 optimizer optimizer state 超时换到 CPU,应对 spikebitsandbytes paged Adam

显存占比对照(Llama-7B):

fp32bf16QLoRA (NF4)
Base model 权重 28 GB 14 GB 3.7 GB
梯度 28 GB 14 GB ~30 MB(只有 LoRA)
Adam state 56 GB ~60 MB(只有 LoRA)
Activation ~8 GB ~8 GB ~8 GB
总计OOM OOM ~14 GB

3.2装额外依赖

pip install -U bitsandbytes accelerate
# bitsandbytes 在 Linux + CUDA 11.7+ 上工作良好
# macOS/Windows 用 bitsandbytes-foundation/bitsandbytes 的对应分支

验证:

python -c "import bitsandbytes as bnb; print(bnb.__version__)"

3.3最小代码(QLoRA + LoRA 训练)

import torch
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, TaskType
from transformers import (AutoModelForCausalLM, AutoTokenizer,
                          BitsAndBytesConfig, TrainingArguments, Trainer)
from datasets import load_dataset

MODEL = "meta-llama/Llama-2-7b-hf"

# 1) 4bit 量化配置
bnb_cfg = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",          # ★ NormalFloat4
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,     # ★ 双重量化
)

model = AutoModelForCausalLM.from_pretrained(MODEL, quantization_config=bnb_cfg,
                                             device_map="auto")
tok = AutoTokenizer.from_pretrained(MODEL)
tok.pad_token = tok.eos_token

# 2) 给量化模型做训练前处理(很关键)
model = prepare_model_for_kbit_training(model)

# 3) LoRA
peft_cfg = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16, lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
)
model = get_peft_model(model, peft_cfg)
model.print_trainable_parameters()

# 4) 数据
ds = load_dataset("databricks/databricks-dolly-15k", split="train[:1000]")
def to_text(x):
    p = f"### Instruction\n{x['instruction']}\n\n### Response\n{x['response']}"
    return tok(p, truncation=True, max_length=512)
ds = ds.map(to_text, remove_columns=ds.column_names)

# 5) 训
args = TrainingArguments(
    output_dir="./qlora_out",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    optim="paged_adamw_8bit",        # ★ 分页 8bit AdamW
    learning_rate=2e-4,
    num_train_epochs=1,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
)
Trainer(model=model, args=args, train_dataset=ds,
        data_collator=lambda d: tok.pad(d, return_tensors="pt")).train()

model.save_pretrained("./qlora_out/final")

3.4关键调用:prepare_model_for_kbit_training

这个函数在 src/peft/utils/other.py 实现,干 3 件事:

  1. 把所有非 4bit 的层(layernorm 等)升回 fp32——这些层数值范围小,fp32 才稳;
  2. gradient_checkpointing 并把 input embedding 设为 require_grad=True(gradient checkpointing 需要至少一个输入参数有梯度);
  3. 关闭 use_cache(与 gradient_checkpointing 不兼容)。

不调用这个函数的话 backward 经常 NaN。QLoRA 必须调

3.5NF4 是什么

普通的 INT4 量化把范围 [-1, 1] 等分成 16 个 bin。对正态分布权重,这浪费了 bin。NF4 的 16 个分位点是用标准正态分布的分位数函数反推的:

$$q_i = \text{quantile}\!\left(\tfrac{i+0.5}{16}\right),\;\; i=0,1,\ldots,15$$

结果是每个 bin 在大概率密度区域更密、低密度区域更疏,重建误差比 INT4 小约 1.5×(原论文 Table 2)。

双重量化

NF4 量化时每个 block(默认 64 个元素)需要一个 fp32 的 absmax 缩放因子,存储成本 32/64 = 0.5 bit/param。双重量化把这些 absmax 自己再 8bit 量化一次,开销降到 0.125 bit/param——一台 7B 模型省 ~150 MB。

3.6训练 + 推理性能

QLoRAFP16 LoRA全参微调
显存(7B / RTX 4090) ~14 GB ~22 GB OOM
训练速度 (tok/s) ~3,800 ~5,400
推理速度 (tok/s) ~25 (4bit)~50 (bf16)~50
效果(MMLU 上) ~99% of LoRAbaselinebaseline + 0.5pp

结论:QLoRA 显存省 35-40%,速度损失约 30%,效果损失 ≤ 1%。显存吃紧场景的首选

3.7QLoRA 与 fp4 / int8 的关系

方法位宽用法典型场景
QLoRA NF4 4bit load_in_4bit + nf4 训练(推荐)
QLoRA FP4 4bit load_in_4bit + fp4 稍快但效果次于 NF4
LLM.int8() 8bit load_in_8bit 推理为主,训练也可以
HQQ / AWQ / GPTQ2-4bit独立量化工具 推理

3.8推理 / merge 的特殊处理

QLoRA 训完后 base 是 4bit 量化的,不能直接 merge_and_unload(会拒绝)。两条路:

  1. 保持 4bit base + 挂 LoRA:推理快、显存省;
  2. dequantize 回 bf16 再 merge:用 vLLM / TGI 部署:
    base = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16)
    model = PeftModel.from_pretrained(base, "./qlora_out/final")
    model = model.merge_and_unload()
    model.save_pretrained("./merged_bf16")

3.9QLoRA 在 PEFT 内的真实实现锚

"4bit base + LoRA"这个组合在 PEFT 里靠的是 src/peft/tuners/lora/bnb.py。两个真实的类:

位置负责
Linear8bitLt lora/bnb.py:35–100 bnb.nn.Linear8bitLt,加 LoRA 旁支;merge 时 dequantize_bnb_weight() + delta + 再量化
Linear4bit lora/bnb.py:100+ bnb.nn.Linear4bit(NF4 / FP4);同上 merge 路径

触发:LoraModel 在扫描 base.named_modules() 时(lora/model.py:51–61 的 dispatch),检测到 base_layer 是 bnb.nn.Linear8bitLt / Linear4bit自动用上面这两个 LoRA wrapper 而不是普通 LoraLinear用户不写任何 if-bnb 分支——这就是"get_peft_model 一行套上 QLoRA"的真相。

3.10LoftQ 初始化:4bit base + LoRA 不掉精度

QLoRA 4bit base 直接初始化 LoRA 时,B = 0 → 一开始 forward 等价于 4bit base,数值已经掉精度LoftQ(2310.08659)的解法:用 SVD 把"4bit base 与 fp16 base 的差"近似分解成 LoRA 旁支,让 base + LoRA ≈ 原 fp16 base

在 PEFT 里启用:

from peft import LoraConfig, LoftQConfig

cfg = LoraConfig(
    r=16,
    target_modules="all-linear",
    init_lora_weights="loftq",                    # ★ 触发 LoftQ
    loftq_config=LoftQConfig(loftq_bits=4),       # 量化位数
)

真实路径(utils/loftq_utils.pyloftq_init):

  1. 对每个目标层的 fp16 权重做 SVD:U, S, V = svd(W)
  2. 把 top-r 截断当 LoRA 旁支:A = sqrt(S[:r]) * V[:r]B = U[:, :r] * sqrt(S[:r])
  3. 剩下的近似当作 base:把 fp16 base 量化到 4bit;
  4. 迭代多次(loftq_iter)让残差收敛。

实测:LoftQ 让 4bit QLoRA 训完精度接近 fp16 LoRA,把 QLoRA 跟全精度 LoRA 之间的 gap 缩到 1% 以内(论文数字)。

3.11这章你需要带走的