10. 参数高效微调(PEFT)
全参微调 70B 模型需要约 1.4 TB 显存,普通研究者无法承担。PEFT(Parameter-Efficient Fine-Tuning)通过只训练少量参数大幅降低需求,是消费级 GPU 玩转大模型的关键。本章重点讲 LoRA、QLoRA、DoRA,并简要梳理 Adapter、Prefix-Tuning、(IA)³ 等其他范式。
10.1 为什么需要 PEFT:显存账本
要理解 PEFT 的价值,先要算清楚全参微调一个 7B 模型需要多少显存。
10.1.1 显存四大块
训练时的显存占用主要由以下四部分构成:
| 类别 | 大小(按参数 N,BF16 + Adam) | 7B 模型实例 |
|---|---|---|
| 模型参数 | 14 GB | |
| 梯度 | 14 GB | |
| 优化器状态 (Adam: | 56 GB | |
| 激活值 (与 batch、seq、layer 相关) | 视设置 | 20-40 GB |
| 合计 | ~100-120 GB |
注:
- Adam 优化器需要保存 FP32 的 master weights、动量
、二阶动量 ,加起来每个参数 12 字节(实际上 master 4 + m 4 + v 4 = 12 字节,BF16 模型还要 2 字节,FP16 梯度也要 2 字节,加起来 16 字节)。简化估算用 bytes。 - 7B 全参微调的总显存约 112 GB——单张 80GB A100 / H100 都装不下。
- 70B 模型:约 1.12 TB,需要 14+ 张 A100 跑 ZeRO-3。
10.1.2 PEFT 的显存收益
PEFT 的核心:冻结绝大部分参数,只训练 0.1%-3% 的参数。这样:
- 模型参数仍占满显存(base 权重还在)。
- 梯度:只需要存可训练参数的梯度。
- 优化器状态:只需要存可训练参数的 Adam 状态。
举例,7B 模型 + LoRA r=16(全 linear):
| 类别 | 全参 | LoRA r=16 |
|---|---|---|
| 参数 | 14 GB | 14 GB(冻结)+ ~80 MB(LoRA) |
| 梯度 | 14 GB | ~80 MB |
| 优化器状态 | 56 GB | ~320 MB |
| 激活值 | 30 GB | 30 GB(同) |
| 合计 | ~114 GB | ~44 GB |
QLoRA 把 base 量化到 4-bit:base 参数从 14 GB → 4 GB,总显存可压到 ~16 GB,单张 RTX 3090(24 GB)即可微调 7B。
10.2 LoRA:Low-Rank Adaptation(Hu et al., 2021)
LoRA 是 PEFT 的事实标准。理解它需要从「权重更新的内在维度」假设出发。
10.2.1 核心数学推导
LoRA 假设:预训练权重在下游任务上的更新
冻结预训练权重
其中:
参数量从
前向传播变为:
引入 scaling 系数
其中
10.2.2 为什么这个分解有效?
LoRA 论文有个有趣的实证:把全参微调后的
直觉上:预训练已经把一般性的语言/世界知识压在权重里,下游任务的「调整」只是在这个高维空间里向特定方向偏移——这个偏移自然是低秩的。
10.2.3 初始化方案
或 Kaiming uniform 初始化。 初始化。 - 这样
,起始时模型行为与预训练完全一致。
为什么必须有一个矩阵置零?如果
10.2.4 PyTorch 实现(从零)
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class LoRALinear(nn.Module):
"""把一个 nn.Linear 替换为 LoRA 形式。"""
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16, dropout: float = 0.0):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False # 冻结 base
d_out, d_in = base.weight.shape
self.r = r
self.alpha = alpha
self.scaling = alpha / r
self.A = nn.Parameter(torch.empty(r, d_in))
self.B = nn.Parameter(torch.zeros(d_out, r))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
def forward(self, x: torch.Tensor) -> torch.Tensor:
base_out = self.base(x) # (..., d_out)
lora_out = self.dropout(x) @ self.A.T @ self.B.T # (..., d_out)
return base_out + lora_out * self.scaling
@torch.no_grad()
def merge(self) -> nn.Linear:
"""把 LoRA 融合回 base,返回普通 Linear(推理用)。"""
merged = nn.Linear(self.base.in_features, self.base.out_features,
bias=self.base.bias is not None)
merged.weight.copy_(self.base.weight + self.scaling * self.B @ self.A)
if self.base.bias is not None:
merged.bias.copy_(self.base.bias)
return merged调用:
def replace_linear_with_lora(model, target_names=("q_proj", "v_proj"), r=8, alpha=16):
for name, module in model.named_modules():
if any(t in name for t in target_names) and isinstance(module, nn.Linear):
parent_name, _, attr = name.rpartition(".")
parent = model.get_submodule(parent_name)
setattr(parent, attr, LoRALinear(module, r=r, alpha=alpha))
return model完整可运行代码请参考
/code/04_lora_from_scratch.py。
10.2.5 秩 的选择
| 任务复杂度 | 推荐 r | 备注 |
|---|---|---|
| 轻微风格调整 / 特定格式 | 4-8 | 0.05% 参数 |
| 一般 SFT(对话、问答) | 8-16 | 0.1-0.5% |
| 复杂任务(数学、代码、推理) | 32-64 | 0.5-1.5% |
| 重度 domain adaptation | 64-256 | 接近全参 |
经验:
不一定越大越好。论文实验显示 已能逼近 full FT, 收益快速递减。 - 不同模块用不同
(rank-allocation)通常没必要——固定 简单可靠。
10.2.6 的选择
- 默认
或 (即 scaling = 1 或 2)。 - rsLoRA(rank-stabilized LoRA, Kalajdzievski 2023)发现大 r 时标准
缩放过激进,建议改用 ,对 训练更稳定。
10.2.7 应用到哪些模块?
LoRA 论文最初建议只对 attention 的
- 应用到所有 linear(
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj)效果最好。 - 多增加的参数仍远小于全参,性价比高。
实战默认配置:
target_modules = [
"q_proj", "k_proj", "v_proj", "o_proj", # attention
"gate_proj", "up_proj", "down_proj", # MLP (LLaMA / Qwen / Mistral)
]10.2.8 推理时合并:零开销
LoRA 最大的工程优势:推理时可以把 LoRA 合并回 base。
合并后是普通 Linear,没有任何额外计算或显存开销。这与 Adapter(推理时仍需串行执行 adapter 模块)形成鲜明对比。
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-7B")
lora = PeftModel.from_pretrained(base, "my-lora-adapter")
merged = lora.merge_and_unload() # 返回普通 transformers 模型
merged.save_pretrained("qwen3-7b-merged")10.2.9 多任务部署:Adapter 切换
同一个 base + 多个 LoRA → 多任务切换部署,每个任务只占几十 MB:
┌── LoRA-medical (40 MB)
│
Base (14 GB) ────┼── LoRA-legal (40 MB)
│
└── LoRA-coder (40 MB)vLLM、SGLang 等推理引擎都原生支持 LoRA 热切换。这是 LoRA 在生产环境的另一个关键优势。
10.3 QLoRA(Dettmers et al., 2023)
QLoRA 把 LoRA 推到极致:65B 模型在单张 48GB GPU 上微调,性能匹配 16-bit 全参微调。它通过三个创新实现这一点。
10.3.1 创新 1:4-bit NormalFloat (NF4) 量化
观察:神经网络权重近似服从零均值正态分布
NF4 量化级别
实测对比:在 OPT、BLOOM、Pythia、LLaMA 上,NF4 比 FP4、Int4 都更优,Wikitext PPL 显著下降。
10.3.2 创新 2:Double Quantization
第一次量化(4-bit)需要每个 block(默认 64)一个 FP32 scale 常数,本身占
Double Quantization 把这些 32-bit scale 常数本身再量化为 8-bit float(block size 256),节省约 0.37 bits/param:
(相比简单方案
7B 模型:14 GB → 3.6 GB(节省 0.65 GB)。
10.3.3 创新 3:Paged Optimizers
利用 NVIDIA Unified Memory,遇到显存峰值(如长序列梯度检查点反向传播时)把 optimizer state 暂存到 CPU 内存,避免 OOM。这相当于自动 swap——透明、稳定。
实现上是 bitsandbytes 的 PagedAdamW8bit:
from bitsandbytes.optim import PagedAdamW8bit
optimizer = PagedAdamW8bit(model.parameters(), lr=2e-4)10.3.4 工作流
- 把 base model 量化为 NF4,冻结。
- 在每个 linear 上挂 LoRA adapter(FP16/BF16)。
- 前向:每次用到 NF4 权重时,临时反量化为 BF16 再做 matmul。
- 反向:梯度只流到 LoRA 的 A、B(base 权重无梯度)。
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-70b-hf",
quantization_config=bnb,
device_map="auto",
)
model = prepare_model_for_kbit_training(model)
lora_cfg = LoraConfig(
r=64,
lora_alpha=16,
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters()
# trainable params: 419,430,400 || all params: 35,318,964,224 || trainable%: 1.187QLoRA 训出的 Guanaco-65B 在 Vicuna 评测达到 ChatGPT 99.3% 的水平。
10.3.5 性能损失
QLoRA 论文做了大量对比:在 GLUE、MMLU、Vicuna-eval 上,QLoRA r=64 与 16-bit 全参微调几乎无差距(< 0.5%)。这一发现是 PEFT 历史性突破——4-bit 量化在精度损失上不再是瓶颈。
10.4 DoRA:Weight-Decomposed LoRA(Liu et al., ICML 2024 Oral)
10.4.1 动机:LoRA 与全参 FT 的更新模式不同
DoRA 作者通过权重分解分析(magnitude / direction)发现:
- 全参微调(FT):通常磁度(magnitude)和方向(direction)的变化呈负相关。
- LoRA:倾向于同方向变化(magnitude 与 direction 同时增减)。
这一差异可能是 LoRA 性能略低于 FT 的原因之一。
10.4.2 公式
把权重
其中:
是每列的 magnitude vector(长度向量)。 是 direction matrix。 表示按列求 L2 范数。
DoRA 对方向部分使用 LoRA 更新,magnitude 单独训练:
其中:
(用预训练权重作为 direction 初值)。 是 LoRA 更新(仍冻结 base )。 直接训练(额外参数仅 个 / 列)。
可训练参数 = LoRA
10.4.3 优势
更接近 FT 行为:magnitude 和 direction 解耦更新,能产生类似 FT 的负相关模式。
性能提升:在 LLaMA-7B/13B、LLaMA-2、LLaMA-3 上比 LoRA 平均高 1-3 点(commonsense reasoning、ARC、HellaSwag 等)。
训练稳定:低 rank 时(r=4, 8)相比 LoRA 更稳定。
推理零开销:与 LoRA 一样可合并:
可与 QLoRA 结合 → QDoRA。
10.4.4 PyTorch 实现
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoRALayer(nn.Module):
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False
d_out, d_in = base.weight.shape
self.r = r
self.scaling = alpha / r
# magnitude: 每列的 L2 范数,作为可训练参数
self.m = nn.Parameter(base.weight.norm(p=2, dim=0, keepdim=True))
# direction: LoRA
self.A = nn.Parameter(torch.empty(r, d_in))
self.B = nn.Parameter(torch.zeros(d_out, r))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 重建 direction
adapted = self.base.weight + self.scaling * (self.B @ self.A)
norm = adapted.norm(p=2, dim=0, keepdim=True) + 1e-6
weight = self.m * adapted / norm
return F.linear(x, weight, self.base.bias)注意:DoRA 计算量比 LoRA 多一次 norm,训练吞吐略低(约 80-90% LoRA 速度)。HuggingFace PEFT 的实现通过梯度优化把开销降到 ~10%。
PEFT 库使用 DoRA:
lora_cfg = LoraConfig(
r=16, lora_alpha=32,
use_dora=True, # 关键
target_modules=[...],
task_type="CAUSAL_LM",
)10.5 Adapter(Houlsby et al., ICML 2019)
PEFT 鼻祖,比 LoRA 早三年。在每层 Transformer 中插入两个瓶颈结构:
其中
┌─────────────────────┐
│ FFN (frozen) │
└──────────┬──────────┘
│
┌───▼───────────┐
│ down: d → m │
│ ↓ ReLU │
│ up: m → d │
└───┬───────────┘
│ residual
▼
output- 一般在 attention 之后和 FFN 之后各放一个。
- 仅训练 adapter + LayerNorm + classification head。
- 仅 3.6% 参数量即接近 full FT 性能(GLUE 基准)。
缺点:
- 推理时多了串行计算——adapter 不能合并回原权重,必须实际执行 down/up projection。
- 这导致推理延迟增加 5-15%。
变体:
- Pfeiffer adapter:仅在 FFN 后插入一个,参数减半。
- Parallel adapter(He et al., 2021):与原模块并行而非串行,可部分合并。
- AdapterFusion:训练多个领域 adapter 后用 attention 融合。
10.6 Prefix-Tuning(Li & Liang, ACL 2021)
冻结 LM,在每层注意力的 key/value 前面拼接一段可训练的「虚拟 token」(prefix):
其中
直觉:每个后续 token 在 self-attention 时可以「关注」prefix,相当于一段对模型生成行为的软指令——比离散 prompt 表达力更强。
10.6.1 重参数化技巧
直接训练
训练完后丢弃 MLP,只保留映射后的
10.6.2 性能
- 仅训练 0.1% 参数即接近 FT。
- 低数据场景 (< 1K 样本) 甚至超过 FT。
- 缺点:占用 context 长度(每层
个虚拟 token);多任务部署时 prefix 不能合并。
10.6.3 衍生方法
Prompt-Tuning(Lester et al., 2021):只在 input embedding 层加 soft prompt,更简单,但需 10B+ 模型才有效。
P-Tuning v2(Liu et al., 2022):每层都加(即 prefix-tuning 的回归),性能稳定。
(IA)³(Liu et al., 2022):用三个学习向量
缩放 K、V、FFN: 仅 0.01% 参数,极小但效果略低于 LoRA。
10.7 LoRA+ 与其他 LoRA 改进
2023-2024 年涌现了一批 LoRA 改进方法:
10.7.1 LoRA+(Hayou et al., 2024)
观察:LoRA 中
直观解释:
实现仅几行:
opt = torch.optim.AdamW([
{"params": [p for n, p in model.named_parameters() if "lora_A" in n], "lr": 1e-4},
{"params": [p for n, p in model.named_parameters() if "lora_B" in n], "lr": 1.6e-3},
])实测在数学、代码任务上 LoRA+ 比 LoRA 提升 1-2 点。
10.7.2 rsLoRA:Rank-Stabilized
把 scaling 改为
10.7.3 PiSSA
用 base 权重 SVD 的 Top-r 主成分初始化
10.7.4 VeRA
多个 LoRA 层共享同一个随机的
这些改进各有适用场景。对大多数项目,朴素 LoRA / DoRA + QLoRA 已经够用——不要过早优化。
10.8 PEFT 方法对比
| 方法 | 可训参数 % | 显存(vs FT) | 性能 | 推理延迟 | 备注 |
|---|---|---|---|---|---|
| Full FT | 100% | 100% | 上限基线 | 0 | 黄金标准 |
| Adapter (Houlsby) | 0.5-3% | ~50% | 接近 FT | +5-15% | 串行结构 |
| Prefix-Tuning | 0.1% | ~30% | 中 | 占 context | KV 拼接 |
| Prompt-Tuning | 0.01% | ~25% | 大模型才稳 | 占 context | embedding 层 |
| (IA)³ | 0.01% | ~25% | 略低 | 0 | 极小参数 |
| LoRA (r=16) | 0.1-1% | ~35% | 接近 FT | 0(合并后) | 主流 |
| QLoRA (r=64) | 同 LoRA + 4-bit base | ~12% | 接近 16-bit FT | 0 | 65B/48GB |
| DoRA | 比 LoRA 略多 m | ~38% | > LoRA | 0(合并后) | 新主流 |
| QDoRA | DoRA + 4-bit base | ~14% | 略 > QLoRA | 0 | 极致省显存 |
| LoRA+ | 同 LoRA | 同 LoRA | > LoRA | 0 | 改 LR |
| PiSSA | 同 LoRA | 同 LoRA | 收敛快 | 0 | 改初始化 |
10.8.1 实战选择决策树
┌─ 单卡显存 < 24 GB? ─── Yes ──→ QLoRA / QDoRA (r=32-64)
│ 如果还放不下 → 减小 batch / max_len
│
├─ 显存 24-80 GB? ─── Yes ──→ LoRA r=16-64 / DoRA r=16
│ 或 7B-13B 全参 SFT (注意激活值)
│
└─ 显存 > 80 GB (多卡) ── ┬─ 模型 < 13B → 全参 SFT
│
├─ 13B-70B → FSDP + LoRA / DoRA
│ 或 ZeRO-3 + 全参
│
└─ > 70B → QLoRA + FSDP经验法则:
- 显存充足、追求性能:DoRA r=64 > LoRA r=64 > LoRA r=16
- 显存极紧(消费级 24/48 GB):QLoRA / QDoRA
- 多任务部署:LoRA(adapter 切换)
- 研究 / 快速实验:LoRA r=8,
, dropout=0.05
10.9 完整 LoRA 训练样例
下面是一个完整可运行的 LoRA SFT 脚本(HuggingFace PEFT + TRL):
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
MODEL_ID = "Qwen/Qwen3-7B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)
# LoRA 配置
peft_cfg = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
use_dora=False, # 改 True 即 DoRA
)
model = get_peft_model(model, peft_cfg)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,656,360,448 || trainable%: 0.527
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft[:20000]")
config = SFTConfig(
output_dir="qwen3-7b-lora",
num_train_epochs=2,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
gradient_checkpointing=True,
learning_rate=2e-4, # LoRA 用更高 LR
lr_scheduler_type="cosine",
warmup_ratio=0.05,
bf16=True,
max_seq_length=2048,
assistant_only_loss=True,
logging_steps=10,
save_strategy="epoch",
optim="adamw_torch_fused",
)
trainer = SFTTrainer(model=model, args=config, train_dataset=ds, tokenizer=tokenizer)
trainer.train()
# 保存 adapter(仅 ~80 MB)
trainer.model.save_pretrained("qwen3-7b-lora/final")
# 合并并保存为完整模型(~14 GB)
merged = trainer.model.merge_and_unload()
merged.save_pretrained("qwen3-7b-merged")
tokenizer.save_pretrained("qwen3-7b-merged")QLoRA 版本只需把 from_pretrained 部分换成:
from transformers import BitsAndBytesConfig
from peft import prepare_model_for_kbit_training
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, quantization_config=bnb, device_map="auto",
)
model = prepare_model_for_kbit_training(model)
# LoRA r 可以更大(QLoRA 论文推荐 64)
peft_cfg.r = 64
peft_cfg.lora_alpha = 1610.10 本章小结
- PEFT 的核心:冻结大部分参数,只训练 0.1-3% 的「适配器」参数,把显存需求从 100 GB 降到 10-50 GB。
- LoRA 假设权重更新低秩:
, 初始化为 0 保证训练稳定起步。 - QLoRA 三大创新(NF4 + Double Quantization + Paged Optimizers)让 65B 模型能在单张 48 GB GPU 上微调。
- DoRA 把权重分解为 magnitude × direction,对方向用 LoRA,更接近全参 FT 的更新模式。
- 推理零开销是 LoRA/DoRA 相对 Adapter/Prefix 的关键优势——可合并回 base,部署无延迟。
- 实战默认配置:r=16,
, dropout=0.05, 全 linear 模块,bf16;显存紧时改 QLoRA。
思考题
LoRA 假设权重更新
是低秩的,这一假设在什么情况下会失效? 设计一个实验:训练同一个任务的 LoRA r=4, 16, 64, 256,比较收敛性能。如果 r=256 显著优于 r=16,意味着什么? QLoRA 论文声称 4-bit NF4 量化几乎无精度损失,但仅在 SFT 任务上验证。在 RLHF(PPO / DPO)阶段使用 QLoRA 会有什么风险? 提示:考虑梯度数值稳定性、reward signal 的细粒度。
如果你要在生产环境同时部署 50 个不同领域的 LoRA adapter(医疗、法律、金融、各种工具),你会如何设计推理架构? 比较以下方案的优劣:(a) 50 个独立合并的全模型;(b) 1 个 base + 50 个未合并 LoRA 的动态切换;(c) 用 S-LoRA / Punica 等 LoRA serving 框架批量服务。