QLoRA:在 24GB 卡上微调 7B
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/param | bitsandbytes |
| 分页 optimizer | optimizer state 超时换到 CPU,应对 spike | bitsandbytes paged Adam |
显存占比对照(Llama-7B):
| 项 | fp32 | bf16 | QLoRA (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 件事:
- 把所有非 4bit 的层(layernorm 等)升回 fp32——这些层数值范围小,fp32 才稳;
- 开
gradient_checkpointing并把 input embedding 设为require_grad=True(gradient checkpointing 需要至少一个输入参数有梯度); - 关闭 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训练 + 推理性能
| 项 | QLoRA | FP16 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 LoRA | baseline | baseline + 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 / GPTQ | 2-4bit | 独立量化工具 | 推理 |
3.8推理 / merge 的特殊处理
QLoRA 训完后 base 是 4bit 量化的,不能直接 merge_and_unload(会拒绝)。两条路:
- 保持 4bit base + 挂 LoRA:推理快、显存省;
- 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.py 的 loftq_init):
- 对每个目标层的 fp16 权重做 SVD:
U, S, V = svd(W); - 把 top-r 截断当 LoRA 旁支:
A = sqrt(S[:r]) * V[:r],B = U[:, :r] * sqrt(S[:r]); - 剩下的近似当作 base:把 fp16 base 量化到 4bit;
- 迭代多次(
loftq_iter)让残差收敛。
实测:LoftQ 让 4bit QLoRA 训完精度接近 fp16 LoRA,把 QLoRA 跟全精度 LoRA 之间的 gap 缩到 1% 以内(论文数字)。
3.11这章你需要带走的
- QLoRA 三件套:NF4 量化 + 双重量化 + paged optimizer;
- 必须先调
prepare_model_for_kbit_training(model)再get_peft_model; - 选
nf4比fp4效果更好;compute_dtype 用 bf16; - 训练时 base 是 4bit,不能 merge;要 merge 先 dequantize 回 bf16;
- 单 RTX 4090 跑 Llama-7B QLoRA 显存 ~14 GB,速度 ~3.8k tok/s。