在上一篇 SFT(三):使用 TRL SFTTrainer 与 LoRA 微调对话模型 中,我已经知道:

  • Base Model 的参数保持冻结。
  • LoRA 在线性层旁边增加两个低秩矩阵 $A$ 和 $B$。
  • 反向传播只更新 LoRA Adapter。
  • 训练后保存的是 Adapter,而不是另一份完整 Base Model。

LoRA 已经大幅减少了可训练参数、梯度和优化器状态,但还有一个问题:

冻结不等于不占显存。Base Model 虽然不更新,前向传播时仍然要放在设备上。

如果一个 7B 模型以 BF16 保存,仅权重理论上就约占:

$$
7 \times 10^9 \times 2\text{ Byte}
\approx 14\text{ GB}
$$

再加上激活值、LoRA 参数、梯度、优化器状态和 CUDA 临时工作区,普通 LoRA 仍可能超出消费级显卡的容量。

QLoRA 解决的正是这部分问题:把冻结的 Base Model 以 4-bit 保存,同时继续用较高精度训练 LoRA Adapter。

QLoRA整体训练流程

1. QLoRA 到底是什么

QLoRA 的全称是 Quantized Low-Rank Adaptation

它不是一个替代 SFT 的新损失函数,也不是新的 Transformer 架构,而是一种更节省显存的参数高效微调方式:

SFT 数据与 Loss
        +
4-bit 冻结 Base Model
        +
高精度可训练 LoRA Adapter
        =
QLoRA SFT

论文中的核心描述可以压缩成一句话:

梯度穿过冻结的 4-bit 量化模型,但只流入 LoRA Adapter 并更新其参数。

这里有两个关键词:

  1. 穿过量化 Base Model。
  2. 更新量化 Base Model。

Base Model 仍然参与前向计算和梯度链式法则,只是其参数 requires_grad=False

2. QLoRA 与 SFT、LoRA、量化是什么关系

这几个术语处在不同层次。

名称 回答的问题
SFT 使用什么数据和监督信号训练
LoRA 更新哪些参数,如何低秩参数化
4-bit Quantization Base Model 权重如何压缩存储
QLoRA 如何组合 4-bit Base Model 与 LoRA 进行训练

因此下面的说法更准确:

我使用 Assistant-only SFT 作为训练目标,
并使用 QLoRA 作为参数与显存高效的训练方式。

只说“我用了 QLoRA”,并没有说明:

  • 数据是什么。
  • Loss 计算哪些 token。
  • Chat Template 是什么。
  • 训练了多少步。
  • 最终模型质量如何。

3. Full Fine-tuning、LoRA 与 QLoRA 对比

Full Fine-tuning、LoRA与QLoRA对比

3.1 Full Fine-tuning

Base Model:BF16 / FP16,可训练
梯度:所有参数
优化器状态:所有参数

它更新能力最完整,但训练显存通常最高。

3.2 LoRA

Base Model:通常 BF16 / FP16,冻结
LoRA:BF16 / FP32,可训练
梯度与优化器状态:仅 LoRA

它省掉了绝大部分梯度和优化器状态,却仍然要保存一份 16-bit Base Model。

3.3 QLoRA

Base Model:4-bit,冻结
LoRA:较高精度,可训练
梯度与优化器状态:仅 LoRA

它在 LoRA 的基础上,继续压缩占显存大头的冻结权重。

4. “4-bit 训练”是一个容易误导的简称

QLoRA 常被简称为“用 4-bit 训练模型”,但实际并不是所有张量都只有 4 bit。

至少要区分四种精度:

对象 常见精度 是否训练
Base Model 权重存储 NF4 4-bit
矩阵乘法计算 BF16 或 FP16 参与计算
LoRA 参数 BF16 / FP32
LoRA 优化器状态 8-bit 或 32-bit 随优化器而定

因此更准确的描述是:

Base Model 权重以 4-bit 形式存储,使用时分块反量化到计算 dtype;LoRA 分支保持可训练的浮点参数。

QLoRA 不是在一个只能表示 16 个值的空间里直接更新全部模型权重。

5. 为什么冻结的 Base Model 仍然消耗大量显存

以 $N$ 个参数为例,只看权重存储的理论下限:

$$
M_{\text{BF16}} = 2N\text{ Byte}
$$

$$
M_{\text{INT8}} = 1N\text{ Byte}
$$

$$
M_{\text{4bit}} = 0.5N\text{ Byte}
$$

对 7B 模型:

存储形式 纯权重理论值
BF16 / FP16 约 14 GB
INT8 约 7 GB
4-bit 约 3.5 GB

实际显存会更高,因为还有:

  • 每个量化块的 scale 和量化元数据。
  • 激活值。
  • LoRA 参数、梯度和优化器状态。
  • Attention 与 Loss 的中间张量。
  • 内存碎片和 CUDA 工作区。

所以“7B 的 4-bit 权重约 3.5 GB”只是理解数量级,不能直接当成训练所需显存。

6. 先理解最普通的分块量化

NF4 不是普通的均匀 INT4,但先理解分块量化会更容易进入 QLoRA。

假设一个权重块为:

$$
W=[-1.20,-0.40,0.10,0.85]
$$

为了演示,先使用有符号 4-bit 均匀量化,整数范围取 $[-7,7]$。

6.1 计算缩放因子

$$
s=\frac{\max(|W|)}{7}=\frac{1.20}{7}\approx0.1714
$$

6.2 把浮点权重映射为整数编号

$$
q_i=\operatorname{clip}\left(\operatorname{round}\left(\frac{w_i}{s}\right),-7,7\right)
$$

得到:

$$
q=[-7,-2,1,5]
$$

6.3 反量化得到近似权重

$$
\hat w_i=sq_i
$$

$$
\hat W\approx[-1.200,-0.343,0.171,0.857]
$$

误差为:

$$
E=W-\hat W\approx[0,-0.057,-0.071,-0.007]
$$

分块量化与反量化计算

这个例子揭示了三个事实:

  1. 4 bit 保存的是每个权重的编号
  2. 想恢复近似浮点值,还需要保存 scale。
  3. 量化是有损的,$W$ 与 $\hat W$ 不完全相等。

注意:这个例子用于讲清基本过程,并不是 NF4 的实际编码表

7. 为什么要分块,而不是整层只用一个 scale

如果一个线性层中既有很小的权重,又有少量极端大值,整层共用一个 scale 会导致小权重挤在很少的量化档位中。

分块后,每一小组权重拥有自己的缩放统计量:

block 0: 64 个权重 -> scale_0
block 1: 64 个权重 -> scale_1
block 2: 64 个权重 -> scale_2
...

这样通常能降低量化误差,但也需要额外保存更多 scale。

这正好引出 Double Quantization:scale 本身也会占空间。

8. NF4 是什么

NF4 是 QLoRA 论文提出的 4-bit NormalFloat 数据类型。

预训练神经网络的权重通常近似集中在 0 附近,并向两侧逐渐变少。若 16 个表示值等距离分布,会把相同数量的档位分给稀疏尾部和密集中心区域。

NF4 的思路是:

根据近似正态分布设计非均匀量化档位,使每个区间承载更接近的概率质量。

NF4非均匀量化直觉

因此 NF4 的档位在 0 附近更密,在分布尾部更疏。

在 Hugging Face 中对应:

bnb_4bit_quant_type="nf4"

官方文档建议在训练 4-bit Base Model 时使用 NF4。

9. NF4 与 FP4 不要混为一谈

bitsandbytes 常见选项包括:

bnb_4bit_quant_type="nf4"

或:

bnb_4bit_quant_type="fp4"

两者都使用 4-bit 编码,但量化表示方式不同。

对于 QLoRA 训练,通常从 NF4 开始,因为它针对近似正态分布的权重设计。不要因为名字中都有“4”就认为两者完全相同。

10. Double Quantization 是什么

分块量化需要给每个 block 保存一个 scale。如果模型很大,scale 的数量也很多。

Double Quantization 又称 Nested Quantization,其思想是:

第一次量化:量化 Base Model 权重
第二次量化:继续量化第一次量化所需的 scale / constant

Double Quantization流程

在 Hugging Face 中开启:

bnb_4bit_use_double_quant=True

当前 Transformers 文档给出的说明是,它还能额外节省约 0.4 bit / parameter

需要注意:

  • 它不是“把 4 bit 再除以 2,变成 2 bit 权重”。
  • 第二次量化针对的是量化常数,而不是重新量化每个原始权重。
  • 这会引入额外元数据与反量化步骤,但换来更低的平均存储开销。

11. QLoRA 前向传播究竟怎样计算

对一个加入 LoRA 的线性层,普通 LoRA 可以写成:

$$
y=xW^T+\frac{\alpha}{r}xA^TB^T
$$

QLoRA 中保存的 Base Model 权重是量化后的 $W_q$ 和量化状态 $S$。计算时先近似反量化:

$$
\hat W=\operatorname{dequant}(W_q,S)
$$

再执行:

$$
y=x\hat W^T+\frac{\alpha}{r}xA^TB^T
$$

这里:

  • $W_q$:4-bit 存储的 Base Model 权重。
  • $S$:scale 等量化状态。
  • $\hat W$:用于本次计算的近似浮点权重。
  • $A,B$:可训练 LoRA 参数。

QLoRA前向与反向传播

12. 反向传播会不会更新 4-bit 权重

不会。

设损失为 $\mathcal L$,QLoRA 的训练目标是计算:

$$
\frac{\partial \mathcal L}{\partial A},\qquad
\frac{\partial \mathcal L}{\partial B}
$$

但不对 Base Model 执行参数更新:

$$
\frac{\partial \mathcal L}{\partial W_q}
\quad\text{不用于更新 }W_q
$$

梯度仍要经过 Base Model 的运算回传到较早位置的 LoRA 分支,所以“冻结”不能理解为“反向传播绕过整个 Base Model”。

这也是为什么 QLoRA 仍然需要激活值,且序列长度会显著影响显存。

13. LoRA 参数为什么不能也简单存成 4 bit 训练

训练参数需要表示细小的连续更新。

如果直接用极低精度保存和更新 LoRA 参数:

  • 小梯度可能舍入为 0。
  • 更新噪声可能变大。
  • 优化过程更容易不稳定。

QLoRA 的关键平衡是:

占绝大多数、又被冻结的 Base 权重 -> 4-bit
数量较少、需要学习的 LoRA 参数       -> 较高精度

因此它同时获得较低存储开销与可训练性。

14. Paged Optimizer 解决什么问题

训练显存并不是恒定的。

某些 step 可能因为长序列、临时张量或梯度检查点重计算产生显存峰值。QLoRA 论文使用 Paged Optimizer,借助统一内存分页思想在显存压力突然升高时转移部分优化器数据,降低 OOM 峰值风险。

在 Transformers 中可以配置:

optim="paged_adamw_8bit"

它同时表达两件事:

  • paged:支持分页管理。
  • 8bit:以 8-bit 形式保存大部分 AdamW 优化器状态。

不过 QLoRA 中优化器只管理 LoRA 等可训练参数,因此它节省的不是 4-bit Base Model 的优化器状态,因为 Base Model 原本就没有优化器状态。

15. QLoRA 到底省了哪些显存

QLoRA训练显存组成

QLoRA 主要降低:

  1. Base Model 权重存储:16-bit 降到约 4-bit 加元数据。
  2. LoRA 优化器状态:可选择 8-bit Optimizer。
  3. 显存峰值风险:Paged Optimizer 可缓解部分瞬时峰值。

QLoRA 没有自动消除:

  • 激活值。
  • Attention 中间张量。
  • Logits 和 Loss 中间张量。
  • LoRA 梯度。
  • 数据 batch。
  • CUDA 上下文与工作区。

所以长度从 512 增加到 4096 时,即使 Base Model 仍是 4-bit,训练仍可能 OOM。

16. QLoRA 不是把磁盘文件简单压缩成四分之一

量化模型的实际大小不严格等于 BF16 的四分之一,原因包括:

  • 分块 scale。
  • Double Quantization 状态。
  • 未量化模块。
  • Token Embedding 或输出层的处理差异。
  • 配置和序列化元数据。

同样,“显存降为四分之一”也不是稳定承诺,因为激活值等部分不会按权重位宽同比下降。

17. 在 Hugging Face 中需要哪些组件

Hugging Face实现QLoRA的组件关系

本篇使用:

职责
Transformers Tokenizer、Base Model、BitsAndBytesConfig、训练参数
bitsandbytes NF4 线性层、4-bit 量化、分页/8-bit Optimizer
PEFT LoraConfig、Adapter 保存加载
TRL SFTTrainer、对话 SFT 与 Assistant-only Loss
Datasets 构建训练集和验证集

数据与 Loss 仍沿用 SFT 系列已经学过的逻辑。

QLoRA 只改变参数加载与更新方式,不会替我修复错误的 Chat Template 或标签范围。

18. 安装依赖与记录环境

pip install -U torch transformers datasets accelerate trl peft bitsandbytes

记录版本:

python -c "import torch, transformers, datasets, trl, peft, bitsandbytes; print(torch.__version__); print(transformers.__version__); print(datasets.__version__); print(trl.__version__); print(peft.__version__); print(bitsandbytes.__version__)"

transformerstrlpeftbitsandbytes 都会持续更新。文章中的代码按当前官方接口编写,真实项目应锁定经过验证的一组版本。

19. 先检查硬件与 BF16 支持

import torch

print("CUDA:", torch.cuda.is_available())

if torch.cuda.is_available():
    print("GPU:", torch.cuda.get_device_name(0))
    print("BF16:", torch.cuda.is_bf16_supported())

本篇完整示例为了让行为可预测,要求 CUDA GPU。

当前 bitsandbytes 已在扩展 NVIDIA CUDA 之外的后端支持,但不同平台、版本和算子的成熟度会变化。运行前应检查最新硬件兼容表,而不是照搬多年前“只支持某个平台”的结论。

20. 选择计算 dtype

compute_dtype = (
    torch.bfloat16
    if torch.cuda.is_bf16_supported()
    else torch.float16
)

这里选择的是计算 dtype,不是 Base Model 的权重存储 dtype。

BF16 与 FP16 都占 16 bit,但数值范围不同。支持 BF16 的 GPU 上通常优先使用 BF16;旧设备则回退到 FP16。

不要在不支持 BF16 的硬件上只改:

bf16=True

配置必须与设备能力一致。

21. 配置 BitsAndBytesConfig

from transformers import BitsAndBytesConfig

quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=compute_dtype,
)

逐项理解:

21.1 load_in_4bit=True

加载模型时将支持的线性层替换为 4-bit 量化实现。

它不是在训练结束后才压缩模型。

21.2 bnb_4bit_quant_type="nf4"

指定使用 NF4 编码 Base Model 权重。

21.3 bnb_4bit_use_double_quant=True

继续压缩第一次量化产生的量化常数。

21.4 bnb_4bit_compute_dtype=compute_dtype

指定矩阵乘法等运算使用 BF16 或 FP16。

再次强调:

storage dtype != compute dtype

22. 配置 LoRA

from peft import LoraConfig

peft_config = LoraConfig(
    task_type="CAUSAL_LM",
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    target_modules="all-linear",
)

上一篇已经推导过 LoRA:

$$
W’=W+\Delta W,
\qquad
\Delta W=\frac{\alpha}{r}BA
$$

QLoRA 没有改变这条公式,只是 $W$ 来自量化 Base Model 的近似反量化结果。

22.1 为什么使用 all-linear

target_modules="all-linear" 让 PEFT 尽量把 LoRA 加入模型的线性层,避免为每一种架构手写 q_projv_proj 等名称。

它不是永远最优的超参数,但适合构建结构无关、容易检查的 QLoRA 基线。训练后仍应打印实际命中的模块。

23. 当前 TRL 可以直接组合量化与 PEFT

当前 SFTTrainer 支持同时接收:

quantization_config=quantization_config
peft_config=peft_config

model 传入模型仓库 ID 时,Trainer 可以完成:

根据 model id 加载 Causal LM
-> 以 BitsAndBytesConfig 量化
-> 为 k-bit 训练做准备
-> 根据 LoraConfig 包装 PEFT Model
-> 执行 SFT

代码变短了,但每一步仍应被检查。

24. 手动加载时为什么有 prepare_model_for_kbit_training

如果不让 SFTTrainer 负责模型初始化,而是自己加载模型,PEFT 官方流程会调用:

from peft import prepare_model_for_kbit_training

model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    quantization_config=quantization_config,
)
model = prepare_model_for_kbit_training(model)

这个函数用于把量化模型整理为适合 k-bit Adapter 训练的状态,例如冻结 Base Model 参数,并对部分数值敏感模块进行必要处理。

不要把它理解为“开始训练”;它只是训练前准备。

25. 构造对话数据

沿用前文的结构化格式:

from datasets import Dataset

train_dataset = Dataset.from_list([
    {
        "messages": [
            {"role": "system", "content": "你是一名耐心的机器学习助教。"},
            {"role": "user", "content": "什么是过拟合?"},
            {
                "role": "assistant",
                "content": "过拟合是模型在训练数据上表现很好,却难以推广到新样本。",
            },
        ]
    }
])

量化不会改变数据协议。

如果 messages、Chat Template 或 Assistant Mask 错了,QLoRA 只会更省显存地训练错误目标。

26. 配置 SFTConfig

from trl import SFTConfig

training_args = SFTConfig(
    output_dir="outputs/qwen3-0.6b-qlora",
    max_length=512,
    assistant_only_loss=True,
    eos_token="<|im_end|>",

    per_device_train_batch_size=1,
    gradient_accumulation_steps=8,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},

    learning_rate=2e-4,
    num_train_epochs=3,
    warmup_ratio=0.05,
    lr_scheduler_type="cosine",
    optim="paged_adamw_8bit",
    max_grad_norm=0.3,

    bf16=(compute_dtype == torch.bfloat16),
    fp16=(compute_dtype == torch.float16),
    eval_strategy="epoch",
    save_strategy="epoch",
    report_to="none",
    use_cache=False,
)

26.1 为什么微批次设为 1

显存不足时先减少每次真正送入 GPU 的样本数:

per_device_train_batch_size=1

再用梯度累积增大有效 Batch Size:

$$
B_{\text{effective}}
=B_{\text{device}}
\times G_{\text{accumulation}}
\times N_{\text{devices}}
$$

单卡示例中:

$$
1\times8\times1=8
$$

26.2 为什么开启 Gradient Checkpointing

它不保存所有中间激活,而是在反向传播时重新计算一部分前向过程:

更低激活显存 <-> 更多计算时间

QLoRA 主要压缩权重,Gradient Checkpointing 主要压缩激活保存,两者解决不同问题。

26.3 为什么 use_cache=False

KV Cache 主要服务于自回归生成。

训练时还要反向传播,KV Cache 通常没有生成阶段的意义,并可能与 Gradient Checkpointing 冲突。因此训练配置关闭,推理时再使用。

27. 创建 SFTTrainer

from trl import SFTTrainer

trainer = SFTTrainer(
    model="Qwen/Qwen3-0.6B-Base",
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    processing_class=tokenizer,
    quantization_config=quantization_config,
    peft_config=peft_config,
)

这几行连接了三条独立主线:

SFT 主线:messages -> Chat Template -> labels -> loss
量化主线:Base Model -> NF4 -> Double Quantization
PEFT 主线:Linear -> LoRA A/B -> 只更新 Adapter

28. 如何确认加载的真是 4-bit 模型

不能只因为配置中写了 load_in_4bit=True 就停止检查。

linear4bit = [
    name
    for name, module in trainer.model.named_modules()
    if module.__class__.__name__ == "Linear4bit"
]

print("4-bit Linear layers:", len(linear4bit))
print(linear4bit[:8])

还可以检查:

print(trainer.model)
print(trainer.model.get_memory_footprint())

如果量化层数量为 0,应先排查版本、硬件后端、模型支持与加载路径,而不是继续训练。

29. 如何确认只有 LoRA 在训练

trainer.model.print_trainable_parameters()

或手动统计:

trainable = sum(
    p.numel()
    for p in trainer.model.parameters()
    if p.requires_grad
)
total = sum(p.numel() for p in trainer.model.parameters())

print("trainable:", trainable)
print("ratio:", 100 * trainable / total, "%")

再查看具体名称:

for name, parameter in trainer.model.named_parameters():
    if parameter.requires_grad:
        print(name, parameter.shape, parameter.dtype)

正常情况下,大部分可训练名称会带有 lora_Alora_B

30. 启动训练并记录峰值显存

torch.cuda.reset_peak_memory_stats()

trainer.train()

peak_gib = torch.cuda.max_memory_allocated() / 1024**3
print(f"peak allocated VRAM: {peak_gib:.2f} GiB")

比较 LoRA 与 QLoRA 时必须保持尽量相同的:

  • Base Model。
  • 序列长度。
  • 微批次。
  • 梯度累积。
  • Gradient Checkpointing。
  • LoRA rank 与目标层。
  • 数据和训练步数。

否则显存差异不能只归因于量化。

31. 保存的仍然主要是 LoRA Adapter

ADAPTER_DIR = "outputs/qwen3-0.6b-qlora/final-adapter"

trainer.save_model(ADAPTER_DIR)
tokenizer.save_pretrained(ADAPTER_DIR)

目录中通常会看到:

adapter_model.safetensors
adapter_config.json
tokenizer files
chat template related files

QLoRA 不会自动把完整 Base Model 权重复制进 Adapter。

推理仍需要:

原始 Base Model
+ 相同量化配置
+ QLoRA Adapter
+ 匹配的 Tokenizer / Chat Template

32. 重新加载 QLoRA Adapter

from peft import PeftModel
from transformers import AutoModelForCausalLM

base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    quantization_config=quantization_config,
    device_map={"": 0},
)

model = PeftModel.from_pretrained(
    base_model,
    ADAPTER_DIR,
)
model.eval()

这里的顺序很重要:

先加载与训练一致的 Base Model
-> 以 4-bit 放入设备
-> 再挂载 Adapter

如果 Base Model ID 或 revision 不一致,Adapter 的效果和兼容性都可能出问题。

33. 使用 Chat Template 推理

messages = [
    {"role": "system", "content": "你是一名耐心的机器学习助教。"},
    {"role": "user", "content": "请用生活中的例子解释过拟合。"},
]

input_ids = tokenizer.apply_chat_template(
    messages,
    tokenize=True,
    add_generation_prompt=True,
    return_tensors="pt",
    enable_thinking=False,
)

device = model.get_input_embeddings().weight.device
input_ids = input_ids.to(device)

训练时完整样本已经包含 Assistant 回答,所以不添加空的 Assistant 开头;推理时要让模型开始生成 Assistant 内容,因此:

add_generation_prompt=True

QLoRA 没有改变这一规则。

34. 配置 GenerationConfig

from transformers import GenerationConfig

im_end_id = tokenizer.convert_tokens_to_ids("<|im_end|>")

generation_config = GenerationConfig(
    max_new_tokens=128,
    do_sample=True,
    temperature=0.7,
    top_p=0.9,
    repetition_penalty=1.05,
    pad_token_id=tokenizer.pad_token_id,
    eos_token_id=im_end_id,
)

with torch.inference_mode():
    output_ids = model.generate(
        input_ids=input_ids,
        generation_config=generation_config,
    )

只解码新生成部分:

new_tokens = output_ids[0, input_ids.shape[-1]:]
answer = tokenizer.decode(new_tokens, skip_special_tokens=True)
print(answer)

GenerationConfig 控制生成策略,不参与 QLoRA 的训练 Loss。

35. QLoRA Adapter 可以合并吗

可以讨论合并,但要理解合并对象。

训练时实际有效权重近似为:

$$
W_{\text{effective}}=\hat W+\frac{\alpha}{r}BA
$$

若希望导出一个普通浮点合并模型,更稳妥的流程是:

重新加载未量化的 Base Model
-> 加载 Adapter
-> merge_and_unload()
-> 保存浮点合并模型

示例:

merge_base = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    dtype=torch.bfloat16,
    device_map="cpu",
)

merge_model = PeftModel.from_pretrained(
    merge_base,
    ADAPTER_DIR,
)

merged = merge_model.merge_and_unload()
merged.save_pretrained(
    "outputs/qwen3-0.6b-qlora/merged-model",
    safe_serialization=True,
)
tokenizer.save_pretrained(
    "outputs/qwen3-0.6b-qlora/merged-model"
)

这一步需要足够的 CPU 内存或 GPU 显存容纳未量化模型。合并后文件也会明显变大。

如果部署系统支持 PEFT Adapter,保留 Base + Adapter 往往更灵活。

36. QLoRA 与“量化后推理”有什么区别

两者都可能加载 4-bit Base Model,但目的不同。

36.1 量化推理

4-bit Base Model
-> generate()
-> 不进行 backward

36.2 QLoRA 训练

4-bit frozen Base Model
+ trainable LoRA
-> forward
-> SFT loss
-> backward through Base computations
-> update LoRA

只执行:

load_in_4bit=True

并不会自动变成 QLoRA。还必须有可训练 Adapter 和训练过程。

37. QLoRA 与 QAT 不要混淆

QAT 是 Quantization-Aware Training,通常会在训练中模拟量化误差,让待部署模型适应低精度表示。

QLoRA 的核心路径是:

  • Base Model 先被量化并冻结。
  • 不直接更新 Base Model 的量化权重。
  • 训练额外的 LoRA Adapter。

因此 QLoRA 不是标准意义上的“对全部模型权重执行 QAT”。

38. 常见错误

38.1 把 torch_dtypebnb_4bit_compute_dtype 当成一回事

一个控制普通浮点模块或加载 dtype,一个明确控制 4-bit 算子的计算 dtype。要结合模型和库版本理解,不能只看某一个参数。

38.2 认为量化参数可以像普通 FP32 Parameter 一样直接更新

QLoRA 冻结 4-bit Base Model,只训练 Adapter。

38.3 忘记安装 bitsandbytes

只有 Transformers 和 PEFT 不足以使用这里的 NF4 路径。

38.4 硬件不支持却强行设置 BF16

先检查 torch.cuda.is_bf16_supported()

38.5 使用 device_map="auto" 训练后又直接扩展到分布式

推理的自动切分与训练的设备放置不是同一问题。单卡示例跑通后,再根据 Accelerate、FSDP 或 TRL 当前文档设计多卡方案。

38.6 只看 nvidia-smi 的某一个瞬间

应该记录峰值显存、训练吞吐和完整配置。

38.7 只量化了模型,却没有挂载 LoRA

那只是量化加载,不是 QLoRA 训练。

38.8 Adapter 保存后删除了 Base Model 信息

至少记录准确的模型 ID、revision、Tokenizer 和量化配置。

38.9 把 4-bit 权重直接 merge 后当作无损浮点模型

量化本身已经产生近似误差。需要明确合并来源、输出 dtype,并重新评估模型。

38.10 因为更省显存就盲目增加序列长度

激活值和 Attention 开销仍然会增长。先测量,再扩大 max_length

38.11 用少量演示数据宣称模型已经学会领域知识

本篇数据只验证流程,不构成可靠模型训练实验。

38.12 不验证 Assistant-only Mask

QLoRA 与 Loss Mask 是正交问题。量化正确不代表监督目标正确。

39. OOM 时应该按什么顺序排查

可以按下面顺序处理:

  1. 减小 per_device_train_batch_size
  2. 减小 max_length
  3. 开启 Gradient Checkpointing。
  4. 增加 Gradient Accumulation 保持有效 Batch Size。
  5. 确认 Base Model 确实以 4-bit 加载。
  6. 检查是否意外让 Base Model 参数可训练。
  7. 减小 LoRA rank 或缩小目标层范围。
  8. 检查评估和保存阶段是否出现峰值。
  9. 再考虑 CPU Offload、多卡或更复杂的并行策略。

不要一次修改所有设置,否则很难知道真正解决问题的是哪一项。

40. 如何做一个可信的 LoRA 与 QLoRA 对比实验

至少记录:

模型与量化

model id / revision
LoRA or QLoRA
quant type
double quant
compute dtype

训练

max_length
micro batch size
gradient accumulation
effective batch size
LoRA r / alpha / target modules
optimizer
learning rate
gradient checkpointing

结果

peak allocated VRAM
peak reserved VRAM
tokens per second
wall-clock time
eval loss
task metrics
generation samples

QLoRA 的价值不应只用“成功跑起来”判断,还要比较速度、显存与质量之间的交换。

41. 一个具体的显存思考例子

假设有一个 7B Base Model,先只估计权重:

BF16 LoRA

$$
7\text{B}\times16\text{ bit}=112\text{ Gbit}\approx14\text{ GB}
$$

理想 4-bit QLoRA

$$
7\text{B}\times4\text{ bit}=28\text{ Gbit}\approx3.5\text{ GB}
$$

理论差值约为:

$$
14-3.5=10.5\text{ GB}
$$

但假设训练时激活、Adapter、优化器、缓存和工作区还需要 6 GB,那么粗略总量可能是:

LoRA  : 14 + 6 = 20 GB
QLoRA : 3.5 + 量化元数据 + 6 GB

这解释了两个现象:

  1. QLoRA 能显著节省显存。
  2. 实际总显存不会严格变成 LoRA 的四分之一。

42. 完整代码

本文完整示例位于:

sft_qlora.py

本地运行:

python source/code/nlp-blog/sft_qlora.py

运行前请先把它当作一次环境验证:

能否识别 CUDA
-> 能否加载 NF4 Linear4bit
-> 能否构造 Assistant Mask
-> 可训练参数是否只有 LoRA
-> 能否完成一个训练 step
-> 能否保存并重新加载 Adapter
-> 能否生成回答

全部通过后,再替换为真实数据和更大的模型。

43. 从已经学过的 Hugging Face API 重新理解 QLoRA

现在可以把 QLoRA 映射回前面学过的内容:

Tokenizer

仍然负责把 Chat Template 的文本变成 input_ids,量化不会改变 token id。

Model / Forward

模型内部部分 Linear 被替换为 Linear4bit。Forward 时 Base 权重反量化计算,并与 LoRA 分支结果相加。

Config / Architecture

Transformer 主体架构不因 QLoRA 改变。量化配置决定权重如何加载,PEFT 配置决定在哪些模块增加 Adapter。

Generation / Sampling

训练完成后仍通过 generate() 自回归预测下一个 token,Temperature、Top-p 等规则不参与训练。

GenerationConfig

继续管理推理策略,不管理 NF4、LoRA rank 或 SFT Loss。

SFT

Loss 仍然是带 Label Mask 的 next-token cross entropy。QLoRA 只改变模型参数如何存储和更新。

44. 总结

QLoRA 可以用下面这条主线记忆:

Base Model 权重
-> 分块 NF4 4-bit 存储
-> Double Quantization 压缩量化常数
-> 前向时反量化到 BF16 / FP16 计算
-> 与高精度 LoRA 分支相加
-> SFT Loss 反向传播
-> 冻结 Base Model
-> 只更新 LoRA Adapter
-> Paged Optimizer 缓解优化器状态与峰值压力

真正需要记住的不是四个参数名,而是下面四句话:

  1. QLoRA 是量化 Base Model 与 LoRA 的组合,不是新的 SFT Loss。
  2. 4-bit 主要描述 Base Model 权重的存储,不代表所有计算都在 4 bit 中进行。
  3. 梯度经过量化模型的计算路径,但只更新高精度 Adapter。
  4. QLoRA 大幅压缩权重显存,却不会消除激活值、序列长度和数据质量问题。

理解这些之后,BitsAndBytesConfig 就不再是一组需要背诵的开关,而是每一步数学与显存取舍在 Hugging Face 中的具体入口。

参考资料

  1. QLoRA: Efficient Finetuning of Quantized LLMs
  2. Hugging Face Transformers:Bitsandbytes
  3. Hugging Face PEFT:Quantization
  4. Hugging Face TRL:PEFT Integration
  5. Hugging Face TRL:SFTTrainer
  6. Hugging Face bitsandbytes:Installation Guide