QLoRA 深挖:量化集成怎么落地
第 3 章给了能跑通的最小 QLoRA 例子,本章把"为什么 PEFT 能同时支持 5 种量化"讲清楚——
关键是 src/peft/tuners/lora/ 下针对每种量化方案各写一份 LoraLinear 兼容层。
6.1量化感知 LoRA 的核心问题
普通 LoRA 在 nn.Linear 上挂 BA:
$$y = W_0 x + (\alpha/r)\, BA x$$
但 base 现在是量化层(bnb.nn.Linear4bit、AwqLinear 等),$W_0$ 存的是 int4/int8 + scale。挂 LoRA 要解决:
- forward 时仍能正确算 $W_0 x$(量化层自家的 kernel 会处理);
- LoRA 部分必须用 fp16/bf16(量化值不能反向传梯度);
- 不能直接
merge_and_unload(merge 后 $W_0 + \Delta W$ 不再是合法量化值)。
PEFT 的解决方案:对每种量化方案各写一份兼容 LoraLinear,统一接口、不同实现。
6.25 种量化兼容层
| 量化方案 | 文件 | 对应 base layer |
|---|---|---|
| bitsandbytes 8bit / 4bit | tuners/lora/bnb.py | Linear8bitLt / Linear4bit |
| AWQ | tuners/lora/awq.py | WQLinear_GEMM |
| GPTQ / AutoGPTQ | tuners/lora/gptq.py | QuantLinear |
| HQQ | tuners/lora/hqq.py | HQQLinear |
| TorchAO | tuners/lora/torchao.py | quantized tensor |
| Intel Neural Compressor | tuners/lora/inc.py | INC quant module |
| EETQ | tuners/lora/eetq.py | EETQ Linear |
每种实现的骨架几乎相同,看一眼 bnb 4bit 版(简化):
# src/peft/tuners/lora/bnb.py
class Linear4bit(torch.nn.Module, LoraLayer):
def __init__(self, base_layer, adapter_name, r=0, lora_alpha=1, ...):
super().__init__()
LoraLayer.__init__(self, base_layer)
self.update_layer(adapter_name, r, lora_alpha, ...)
def forward(self, x):
if self.disable_adapters:
return self.base_layer(x)
result = self.base_layer(x) # 量化层的 kernel
for active_adapter in self.active_adapters:
lora_A = self.lora_A[active_adapter]
lora_B = self.lora_B[active_adapter]
scaling = self.scaling[active_adapter]
# LoRA 必须升到 bf16/fp16 算
compute_dtype = lora_A.weight.dtype
x_in_compute = x.to(compute_dtype)
result = result + lora_B(lora_A(x_in_compute)) * scaling
return result
def merge(self, ...):
raise NotImplementedError("4bit 不支持 in-place merge")
6.3prepare_model_for_kbit_training 详解
看 src/peft/utils/other.py::prepare_model_for_kbit_training 全貌:
def prepare_model_for_kbit_training(model, use_gradient_checkpointing=True,
gradient_checkpointing_kwargs=None):
# 1. 把所有 layernorm 升回 fp32
for name, param in model.named_parameters():
param.requires_grad = False # ★ 冻结一切
if param.ndim == 1:
param.data = param.data.to(torch.float32) # ★ 升精度
# 2. embedding 的 input 必须 requires_grad,否则 gradient checkpointing 失效
if use_gradient_checkpointing:
if hasattr(model, "enable_input_require_grads"):
model.enable_input_require_grads()
else:
def make_inputs_require_grad(module, input, output):
output.requires_grad_(True)
model.get_input_embeddings().register_forward_hook(make_inputs_require_grad)
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs or {})
return model
三件事:
- 冻结一切 base 参数,等
get_peft_model再 unfreeze LoRA; - 把 LayerNorm 升回 fp32,避免 4bit 训练数值爆炸(数值范围小但敏感);
- 启用 gradient_checkpointing 并 hook input embedding 让其有梯度。
6.4常见 BitsAndBytesConfig 推荐
# 推荐配方(QLoRA 论文同款)
BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
| 字段 | 取值 | 推荐 |
|---|---|---|
| quant_type | nf4 / fp4 | nf4(效果更好) |
| compute_dtype | bf16 / fp16 | bf16(数值范围更稳) |
| use_double_quant | True / False | True(多省 0.4 bit) |
| quant_storage | uint8 / bf16 | uint8(默认,更省) |
6.5LoftQ:让 QLoRA 长跑更稳
NF4 量化本身有重建误差 $\epsilon = W_0 - Q(W_0)$。LoftQ 的思路:让 LoRA 的初始 $\Delta W$ 等于 $\epsilon$,从一开始就补偿量化误差。
$$BA \;\approx\; \arg\min_{B,A} \lVert W_0 - Q(W_0) - BA \rVert_F^2 \;\;\text{(top-r SVD)}$$
实现:src/peft/utils/loftq_utils.py。启用方式:
cfg = LoraConfig(
...,
init_lora_weights="loftq",
loftq_config=LoftQConfig(loftq_bits=4, loftq_iter=1),
)
实际收益:QLoRA 长训(≥ 1k step)时 loss 比默认初始化低 5-15%。
6.6PiSSA:用 base 权重的主成分初始化
PiSSA 把 $W_0 = U \Sigma V^\top$ 做 SVD,把 top-r 的奇异向量当成 $BA$ 的初始化,剩下的 $W_0 - BA$ 当成 base:
$$W_0 \to W_\text{res} + BA_\text{init},\quad BA_\text{init} = U_{:,1:r}\Sigma_{1:r,1:r} V_{:,1:r}^\top$$
训练时只更新 BA,相当于从一开始就学最重要的方向,收敛速度比 LoRA 快 2-3×。开关:
LoraConfig(..., init_lora_weights="pissa")
# 或省内存版(不用全 SVD):
LoraConfig(..., init_lora_weights="pissa_niter_4")
6.7QLoRA 推理三选一
| 方案 | 速度 | 显存 | 适合 |
|---|---|---|---|
| 挂 LoRA 推理(4bit base + bf16 LoRA) | 慢 30% | 最省 | 显存吃紧 |
| merge 回 bf16 推理 | 正常 | 全量 bf16 | 需要快速生成 |
| merge 后 GPTQ/AWQ 重新量化 | 最快 | 4bit 部署 | vLLM/TGI 生产 |
6.8常见错误
| 现象 | 原因 | 处理 |
|---|---|---|
RuntimeError: expected scalar type Float but found Half |
没调 prepare_model_for_kbit_training | 加上即可 |
| 4bit 训练 loss 抖动 / 偶 NaN | compute_dtype 选了 fp16 | 换 bf16 |
| merge_and_unload 报错 | 4bit base 不支持 merge | 先 dequantize 回 bf16 |
| save 后体积变成 14GB | 是不是把整个模型保了 | 用 model.save_pretrained() 而非 PyTorch torch.save |
| load 推理时 LoRA 没生效 | compute_dtype 不一致 | load 时跟训练保持同 dtype |
6.99 个量化 backend 全表
PEFT 不只对接 bnb。src/peft/tuners/lora/ 下一字排开有 9 个量化 backend 文件,每个一个 dispatch 函数。这张表是"我用 X 量化模型能不能加 LoRA"的查表:
| backend | 位置 | 支持 merge? | 典型场景 |
|---|---|---|---|
bnb 8-bit | lora/bnb.py:35 Linear8bitLt | ✅(dequant → merge → 再量化) | 8bit QLoRA 基础 |
bnb 4-bit | lora/bnb.py:100+ Linear4bit | ✅ | NF4 QLoRA(最常用) |
gptq | lora/gptq.py:26–80 GPTQLoraLinear | ❌(comment 注明 "merging is not supported") | 挂 GPTQ 模型上加 LoRA 训练 |
awq | lora/awq.py:26–80 AwqLoraLinear | ❌ | AWQ 模型 + LoRA |
aqlm | lora/aqlm.py | ❌ | AQLM 2-bit 模型 |
hqq | lora/hqq.py:30–100 HqqLoraLinear | ✅(dequant → merge → re-quantize via HQQLinear.quantize) | HQQ 1-8 bit |
eetq | lora/eetq.py | ❌ | EETQ 8bit 训练 |
inc | lora/inc.py | — | Intel Neural Compressor |
te | lora/te.py | — | NVIDIA Transformer Engine |
torchao | lora/torchao.py | ✅(2025-Q1 新增) | PyTorch 官方量化栈 |
"为什么 GPTQ / AWQ / AQLM / EETQ 不能 merge?" → 它们都用 packed int4 存储(权重压成 int32 块),merge 后没法还原回 packed 格式。要 merge 必须 dequantize 回 fp16 再量化。bnb 和 HQQ 因为存的是 quantization parameters + int4 raw blocks,可以 dequant → 加 delta → 重新 quantize。
6.10dispatch 路由的真实顺序
"我加载了一个 GPTQ 模型 + 调 get_peft_model,PEFT 怎么知道用 GPTQLoraLinear 而不是普通 LoraLinear?" 看 lora/model.py:51–61:
# lora/model.py 51-61,简化版
from .aqlm import dispatch_aqlm
from .awq import dispatch_awq
from .gptq import dispatch_gptq
from .hqq import dispatch_hqq
from .eetq import dispatch_eetq
from .torchao import dispatch_torchao
from .te import dispatch_transformer_engine
from .tp_layer import dispatch_megatron
from .bnb import dispatch_bnb_8bit, dispatch_bnb_4bit
from .inc import dispatch_inc
每个 dispatcher 形如:
def dispatch_gptq(target, adapter_name, **kwargs):
if isinstance(target, GPTQQuantLinear):
return GPTQLoraLinear(target, adapter_name, ...)
return None
LoraModel 创建 wrapper 时按一个固定顺序依次试每个 dispatcher,第一个返回非 None 的就用它;都不命中才退到默认 LoraLinear。这种"chain of responsibility"让加新 backend 不需要改 LoraModel 核心代码。
6.11这章你需要带走的
- QLoRA 在 PEFT 里 = 量化兼容 LoraLinear 自动选择;
prepare_model_for_kbit_training是 4bit/8bit 训练前的必调函数;- nf4 + bf16 compute_dtype + double_quant 是默认推荐配方;
- 长训用 LoftQ 初始化,快收敛用 PiSSA;
- 4bit base 不能 in-place merge,要 merge 先 dequantize 回 bf16;
- PEFT 支持 bnb / AWQ / GPTQ / HQQ / TorchAO / EETQ 多家量化,接口统一。