SFT(四):QLoRA的4-bit量化与LoRA微调原理
在上一篇 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。
1. QLoRA 到底是什么
QLoRA 的全称是 Quantized Low-Rank Adaptation。
它不是一个替代 SFT 的新损失函数,也不是新的 Transformer 架构,而是一种更节省显存的参数高效微调方式:
SFT 数据与 Loss
+
4-bit 冻结 Base Model
+
高精度可训练 LoRA Adapter
=
QLoRA SFT
论文中的核心描述可以压缩成一句话:
梯度穿过冻结的 4-bit 量化模型,但只流入 LoRA Adapter 并更新其参数。
这里有两个关键词:
- 穿过量化 Base Model。
- 不更新量化 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 对比
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]
$$
这个例子揭示了三个事实:
- 4 bit 保存的是每个权重的编号。
- 想恢复近似浮点值,还需要保存 scale。
- 量化是有损的,$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 的档位在 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
在 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 参数。
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 主要降低:
- Base Model 权重存储:16-bit 降到约 4-bit 加元数据。
- LoRA 优化器状态:可选择 8-bit Optimizer。
- 显存峰值风险: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 中需要哪些组件
本篇使用:
| 库 | 职责 |
|---|---|
| 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__)"
transformers、trl、peft 和 bitsandbytes 都会持续更新。文章中的代码按当前官方接口编写,真实项目应锁定经过验证的一组版本。
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_proj、v_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_A 或 lora_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_dtype 与 bnb_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 时应该按什么顺序排查
可以按下面顺序处理:
- 减小
per_device_train_batch_size。 - 减小
max_length。 - 开启 Gradient Checkpointing。
- 增加 Gradient Accumulation 保持有效 Batch Size。
- 确认 Base Model 确实以 4-bit 加载。
- 检查是否意外让 Base Model 参数可训练。
- 减小 LoRA rank 或缩小目标层范围。
- 检查评估和保存阶段是否出现峰值。
- 再考虑 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
这解释了两个现象:
- QLoRA 能显著节省显存。
- 实际总显存不会严格变成 LoRA 的四分之一。
42. 完整代码
本文完整示例位于:
本地运行:
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 缓解优化器状态与峰值压力
真正需要记住的不是四个参数名,而是下面四句话:
- QLoRA 是量化 Base Model 与 LoRA 的组合,不是新的 SFT Loss。
- 4-bit 主要描述 Base Model 权重的存储,不代表所有计算都在 4 bit 中进行。
- 梯度经过量化模型的计算路径,但只更新高精度 Adapter。
- QLoRA 大幅压缩权重显存,却不会消除激活值、序列长度和数据质量问题。
理解这些之后,BitsAndBytesConfig 就不再是一组需要背诵的开关,而是每一步数学与显存取舍在 Hugging Face 中的具体入口。
