Chapter 06

QLoRA 深挖:量化集成怎么落地

📌 commit a106ff4c7061 bnb / awq / gptq / hqq / torchao 五种量化的 LoRA 兼容层

第 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.Linear4bitAwqLinear 等),$W_0$ 存的是 int4/int8 + scale。挂 LoRA 要解决:

  1. forward 时仍能正确算 $W_0 x$(量化层自家的 kernel 会处理);
  2. LoRA 部分必须用 fp16/bf16(量化值不能反向传梯度);
  3. 不能直接 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

三件事:

  1. 冻结一切 base 参数,等 get_peft_model 再 unfreeze LoRA;
  2. 把 LayerNorm 升回 fp32,避免 4bit 训练数值爆炸(数值范围小但敏感);
  3. 启用 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.quantizeHQQ 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这章你需要带走的