4.9 Parameter-Efficient Fine-Tuning: LoRA and QLoRA
Full fine-tuning 会更新模型的全部参数。对小模型这很自然;对 LLM,这意味着保存一份完整新权重、维护全部 optimizer states、反传全部参数,成本非常高。Parameter-Efficient Fine-Tuning (PEFT) 的核心问题是:
能不能冻结大部分 pretrained weights,只训练很少的 task-specific parameters,却获得接近 full fine-tuning 的效果?
LoRA 和 QLoRA 是最常用的答案之一。
Full Fine-Tuning Cost
假设一个线性层:
\[ y=xW^\top, \qquad W\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}}. \]
Full fine-tuning 直接更新 \(W\),需要保存:
- \(W\) 本身;
- \(\nabla W\);
- Adam first moment;
- Adam second moment;
- 可能还有 FP32 master weights。
如果 \(W\) 有 \(n\) 个参数,BF16 参数和梯度各 2 bytes,Adam states 各 4 bytes,至少是:
\[ 2n+2n+4n+4n=12n\text{ bytes}. \]
这还没算 activation。对数十亿参数模型,full fine-tuning 的显存瓶颈很快超过单卡能力。
更关键的是,full fine-tuning 会为每个任务保存一整个新模型。若 base model 有 \(N\) 参数,部署 \(K\) 个任务的 BF16 full checkpoints,权重存储大约是:
\[ K\cdot 2N\text{ bytes}. \]
以 7B 模型为例,一个任务约 14 GB,十个任务就是 140 GB。PEFT 的现实动机就是:共享一个 frozen base model,只保存每个任务很小的 adapter。
Parameter-efficient fine-tuning freezes most pretrained parameters and optimizes a much smaller set of task-specific parameters, usually adapters, prompts, prefixes, or low-rank updates.
LoRA: Low-Rank Update
LoRA freezes a pretrained weight matrix \(W_0\) and learns a low-rank update \[ \Delta W=BA, \qquad B\in\mathbb{R}^{d_{\text{out}}\times r}, \quad A\in\mathbb{R}^{r\times d_{\text{in}}}, \] where \(r\ll\min(d_{\text{in}},d_{\text{out}})\).
Forward pass:
\[ y=xW_0^\top+\frac{\alpha}{r}xA^\top B^\top. \]
其中 \(\alpha/r\) 是 scaling。直觉上,模型保留 pretrained function \(W_0\),只学习一个低秩 correction \(\Delta W\)。
如果 \(W\) 是 \(4096\times4096\),full update 参数量:
\[ 4096^2\approx16.8\text{M}. \]
LoRA rank \(r=16\) 时:
\[ r(d_{\text{in}}+d_{\text{out}}) = 16(4096+4096) = 131{,}072. \]
参数量约减少:
\[ \frac{16.8\text{M}}{0.131\text{M}}\approx128\times. \]
原矩阵 \(W\) 的参数量:
\[ d_{\text{out}}d_{\text{in}}. \]
LoRA 的两个矩阵参数量:
\[ d_{\text{out}}r+rd_{\text{in}} = r(d_{\text{out}}+d_{\text{in}}). \]
当 \(d_{\text{out}}\approx d_{\text{in}}=d\) 时,比例为:
\[ \frac{r(2d)}{d^2} = \frac{2r}{d}. \]
若 \(d=4096,r=16\),比例约为 \(0.78\%\)。
Why Low Rank Can Work
LoRA 的经验假设是:downstream adaptation 的有效更新 \(\Delta W\) 具有低 intrinsic rank。也就是说,任务不需要在所有参数方向上独立移动,只需要在少数重要方向上调整。
这不是严格保证。rank 太小会 underfit;rank 太大又接近 full update。工程上常见 rank:
| Scenario | Typical rank |
|---|---|
| small classification adapter | 4-16 |
| SFT for 7B model | 8-64 |
| style/domain adaptation | 8-32 |
| harder reasoning/code task | 32-128 |
rank 应该和数据量、任务复杂度、target modules 一起调。
LoRA 可以看成把 full update 空间
\[ \Delta W\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}} \]
限制到低秩集合:
\[ \mathcal{S}_r = \{\Delta W:\operatorname{rank}(\Delta W)\leq r\}. \]
For \(\Delta W=BA\) with \(B\in\mathbb{R}^{d_{\text{out}}\times r}\) and \(A\in\mathbb{R}^{r\times d_{\text{in}}}\), \[ \operatorname{rank}(\Delta W)\leq r. \]
矩阵乘积满足:
\[ \operatorname{rank}(BA) \leq \min(\operatorname{rank}(B),\operatorname{rank}(A)). \]
\(B\) 最多只有 \(r\) 列,\(A\) 最多只有 \(r\) 行,所以二者 rank 都不超过 \(r\)。因此 \(\operatorname{rank}(BA)\leq r\)。
这也是 PEFT 的本质:不是神奇地“免费微调”,而是把可训练更新限制在一个低维子空间里。
Initialization
LoRA 常见初始化:
\[ A\sim\mathcal{N}(0,\sigma^2), \qquad B=0. \]
这样初始时
\[ \Delta W=BA=0, \]
模型一开始等价于 base model,不会突然破坏 pretrained behavior。训练中 \(B\) 先被梯度推开,随后 \(A,B\) 共同学习低秩更新。
Gradient at Initialization
设 LoRA 层输出为:
\[ Y=XW_0^\top+sXA^\top B^\top, \qquad s=\frac{\alpha}{r}. \]
令上游梯度为:
\[ G=\frac{\partial\mathcal{L}}{\partial Y}. \]
则:
\[ \frac{\partial\mathcal{L}}{\partial B} = sG^\top XA^\top, \qquad \frac{\partial\mathcal{L}}{\partial A} = sB^\top G^\top X. \]
当 \(B=0\) 时:
\[ \frac{\partial\mathcal{L}}{\partial A}=0, \qquad \frac{\partial\mathcal{L}}{\partial B}=sG^\top XA^\top. \]
所以第一步主要更新 \(B\);一旦 \(B\) 非零,\(A\) 才开始收到梯度。这不是 bug,而是“初始函数完全等于 base model”的代价。
LoRA 分支为:
\[ Y_{\text{lora}}=sXA^\top B^\top. \]
对 \(B\):
\[ d\mathcal{L} = \operatorname{tr}(G^\top sXA^\top dB^\top) = \operatorname{tr}((sG^\top XA^\top)^\top dB), \]
所以 \(\partial\mathcal{L}/\partial B=sG^\top XA^\top\)。
对 \(A\):
\[ d\mathcal{L} = \operatorname{tr}(G^\top sX dA^\top B^\top) = \operatorname{tr}((sB^\top G^\top X)^\top dA), \]
所以 \(\partial\mathcal{L}/\partial A=sB^\top G^\top X\)。
If both LoRA factors are initialized with nonzero random values, the adapter changes the base model before training starts. Zero-initializing one factor preserves the initial base behavior.
Rank, Alpha, and Scaling
LoRA 的 effective weight 是:
\[ W_{\text{eff}} = W_0+\frac{\alpha}{r}BA. \]
\(r\) 控制可表达的 rank,\(\alpha\) 控制 update scale。若只增大 \(r\) 而不重新考虑 \(\alpha\),训练动态会变:每个 rank component 的缩放变小,但总自由度变大。
rsLoRA 常用缩放:
\[ s=\frac{\alpha}{\sqrt{r}}, \]
直觉是如果 \(BA\) 是 \(r\) 个 rank-1 component 的累加,随机情况下整体 norm 可能随 \(\sqrt{r}\) 增长,用 \(\sqrt{r}\) 而不是 \(r\) 缩放能让不同 rank 下的 update magnitude 更稳定。
Changing LoRA rank without reconsidering \(\alpha\) changes the effective update scale. Treat rank and alpha as a coupled hyperparameter pair.
LoRA factorization 还不是唯一的。任意可逆矩阵 \(R\in\mathbb{R}^{r\times r}\) 都满足:
\[ BA=(BR)(R^{-1}A). \]
所以监控 adapter 时,\(\|A\|_F\) 或 \(\|B\|_F\) 单独看不够,更有意义的是:
\[ \|\Delta W\|_F, \qquad \frac{\|\Delta W\|_F}{\|W_0\|_F}. \]
Where to Apply LoRA
Transformer 中可插 LoRA 的典型位置:
| Module | Effect |
|---|---|
| \(W_q\) | changes query subspace |
| \(W_k\) | changes key matching |
| \(W_v\) | changes value content written/read |
| \(W_o\) | changes attention output projection |
| MLP up/gate/down | changes token-wise computation |
| LM head | changes output vocabulary mapping |
常见轻量配置只对 q_proj、v_proj 加 LoRA;更强配置会覆盖 q,k,v,o 和 MLP projections。
工程上 target modules 写错很常见:
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"]不同模型命名不同:GPT-2 可能是 c_attn,LLaMA/Qwen 是 q_proj 这类拆分模块。配置必须和模型实现匹配。
Target Module Trade-offs
LoRA 插在哪里,本质上是在选择允许模型改变哪些函数:
| Target | What changes | Typical effect |
|---|---|---|
| \(W_q\) | how tokens ask questions | changes attention pattern |
| \(W_k\) | how tokens expose keys | changes matching geometry |
| \(W_v\) | what content is read | changes information written into attention |
| \(W_o\) | how heads are mixed back | changes attention output composition |
| MLP gate/up | which features activate | strong capacity for style/domain |
| MLP down | how features return to residual stream | strong but more parameters |
| LM head | vocabulary scoring | useful for new tokens or narrow tasks |
若一个 linear 层形状为 \(d_{\text{out}}\times d_{\text{in}}\),LoRA 参数量是:
\[ P_{\text{LoRA}} = r(d_{\text{out}}+d_{\text{in}}). \]
如果一个 LLaMA-like block 有 \(q,k,v,o\) 四个 \(d\times d\) projection,对它们都加 rank \(r\) LoRA,每层参数约为:
\[ 4\cdot 2rd=8rd. \]
若再加 MLP up/gate/down,假设 intermediate size 为 \(d_{\text{ff}}\):
\[ P_{\text{MLP LoRA}} = r(d_{\text{ff}}+d) +r(d_{\text{ff}}+d) +r(d+d_{\text{ff}}) = 3r(d_{\text{ff}}+d). \]
因此 MLP LoRA 通常比 attention-only LoRA 多不少参数,但也给 token-wise feature transformation 更大适配能力。
Discovering Target Modules Programmatically
不要凭记忆写 target_modules。不同模型家族命名差异很大:GPT-2 常把 QKV 合成 c_attn,LLaMA/Qwen 常拆成 q_proj/k_proj/v_proj/o_proj,有些实现还会把 MLP 写成 gate_proj/up_proj/down_proj。一个更稳的流程是先列出所有 linear modules:
import torch.nn as nn
def list_linear_modules(model):
rows = []
for name, module in model.named_modules():
if isinstance(module, nn.Linear):
rows.append((name, module.in_features, module.out_features))
return rows
for name, din, dout in list_linear_modules(model):
print(f"{name:80s} {din:6d} -> {dout:6d}")如果模型使用 fused QKV,可能看不到单独的 q_proj。这时 target module 可能是一个合并矩阵:
c_attn: d_model -> 3 * d_model
给 fused QKV 加 LoRA 会同时影响 Q/K/V,不能像拆分模型那样只调 q_proj 和 v_proj。所以 target modules 不是配置模板,而是模型结构的一部分。
Substring matching like "proj" may accidentally include projections you did not intend to train. Always print trainable parameter names after wrapping the model.
Fused QKV and Sliced LoRA Semantics
GPT-2 这类实现常把 query、key、value 合成一个 projection。若 hidden size 是 \(d\),fused QKV 权重可写成:
\[ W_{\text{qkv}} \in \mathbb{R}^{3d\times d}, \qquad [Q;K;V] = XW_{\text{qkv}}^\top . \]
对这个 fused matrix 直接加 LoRA 得到:
\[ \Delta W_{\text{qkv}} = BA, \qquad B\in\mathbb{R}^{3d\times r}, \quad A\in\mathbb{R}^{r\times d}. \]
这意味着 \(\Delta W_{\text{qkv}}\) 的三段 row 同时作用到 \(Q,K,V\):
\[ \Delta W_{\text{qkv}} = \begin{bmatrix} \Delta W_q\\ \Delta W_k\\ \Delta W_v \end{bmatrix}. \]
所以在 fused QKV 模型中,target_modules=["c_attn"] 不是“只训练 attention 里的 q/v”,而是允许 Q/K/V 三者共同变化。如果想模拟 LLaMA-style 的 q_proj + v_proj LoRA,需要显式做 sliced adapter:
\[ \Delta W_{\text{qkv}} = \begin{bmatrix} B_qA_q\\ 0\\ B_vA_v \end{bmatrix}, \qquad B_q,B_v\in\mathbb{R}^{d\times r}, \quad A_q,A_v\in\mathbb{R}^{r\times d}. \]
这时参数量是 \(4rd\),和分别给两个 \(d\times d\) projection 加 LoRA 一样。也可以共享一个 \(A\):
\[ \Delta W_{\text{qkv}} = \begin{bmatrix} B_qA\\ 0\\ B_vA \end{bmatrix}, \qquad P=rd+2dr=3rd, \]
但共享 \(A\) 会把 Q/V 的输入低维子空间绑定在一起,表达力更弱。它不是“免费等价”,而是一个额外结构假设。
一个最小 sliced fused-QKV LoRA 可以这样写:
import torch
from torch import nn
class QVFusedLora(nn.Module):
def __init__(self, base, hidden_size: int, rank: int, alpha: float):
super().__init__()
self.base = base
self.d = hidden_size
self.scale = alpha / rank
for p in self.base.parameters():
p.requires_grad_(False)
self.a_q = nn.Parameter(torch.empty(rank, hidden_size))
self.b_q = nn.Parameter(torch.zeros(hidden_size, rank))
self.a_v = nn.Parameter(torch.empty(rank, hidden_size))
self.b_v = nn.Parameter(torch.zeros(hidden_size, rank))
nn.init.kaiming_uniform_(self.a_q, a=5**0.5)
nn.init.kaiming_uniform_(self.a_v, a=5**0.5)
def delta_qkv(self):
zeros = torch.zeros_like(self.b_q @ self.a_q)
return torch.cat(
[self.b_q @ self.a_q, zeros, self.b_v @ self.a_v],
dim=0,
) * self.scale
def forward(self, x):
y = self.base(x)
delta = self.delta_qkv()
return y + x @ delta.T关键 smoke test 是确认 K 段没有被 adapter 改动:
with torch.no_grad():
delta = module.delta_qkv()
d = module.d
assert torch.equal(delta[d : 2 * d], torch.zeros_like(delta[d : 2 * d]))同一个“给 attention 加 LoRA”的想法,在 split QKV 和 fused QKV 代码里对应不同的矩阵切片。迁移 adapter 配置时必须先确认 checkpoint 的 row layout,否则可能把原本只想改 Q/V 的适配误写成 Q/K/V 全部变化。
一个 pragmatic selection recipe:
| Budget | Target modules | When |
|---|---|---|
| tiny | q_proj, v_proj |
style/task light adaptation |
| medium | q,k,v,o |
instruction tuning with attention-pattern changes |
| strong | attention + gate/up/down |
domain, code, reasoning, or format-heavy SFT |
| vocabulary shift | plus lm_head / embeddings |
new special tokens or narrow label space |
如果新增 special tokens,只训练 LoRA 可能不够,因为新增 token 的 embedding row 和 LM head row 需要学习。此时要显式让 embedding/LM head 的相关参数可训练,或者初始化后做额外 embedding tuning。
Layer-Wise Rank and Alpha
LoRA rank 不必全层相同。浅层更多处理 lexical/syntax,中层处理 composition,深层更接近 task/output behavior。工程上可以做 layer-wise 配置:
\[ r_\ell = \begin{cases} r_{\text{low}}, & \ell < L/3,\\ r_{\text{mid}}, & L/3\leq \ell < 2L/3,\\ r_{\text{high}}, & \ell\geq 2L/3. \end{cases} \]
一种常见直觉是:越靠后层给更高 rank,因为 instruction following、format 和 style 更多在高层 residual stream 中体现。但这不是定理,应该用验证集和 trainable budget 决定。
如果每层 hidden size 近似相同,attention-only LoRA 的总参数可以估算为:
\[ P_{\text{attn LoRA}} = \sum_{\ell=1}^{L} 8r_\ell d. \]
这给了一个预算反推 rank 的方法:
\[ \bar{r} \approx \frac{P_{\text{budget}}}{8Ld}. \]
例如 \(L=32,d=4096\),若 attention-only adapter budget 是 \(16\)M 参数,则平均 rank 约为:
\[ \bar{r} \approx \frac{16\times10^6}{8\cdot32\cdot4096} \approx15.3. \]
所以 rank 16 不是玄学,它对应一个明确的 trainable parameter budget。
Start from a parameter budget, decide target modules, then derive rank. Tuning rank without counting target-module parameters is mostly guesswork.
Merge and Inference
训练后可以把 LoRA merge 进 base weight:
\[ W_{\text{merged}} = W_0+\frac{\alpha}{r}BA. \]
merge 后推理不需要额外 LoRA 分支,没有额外 latency。未 merge 时,也可以动态加载多个 adapters,适合多任务服务。
Adapter merge folds trained adapter weights into the base model weights so inference uses a single ordinary weight matrix.
merge 的风险:
- 多个 adapters 混合时要管理 scale;
- 量化 base model 上 merge 可能需要 dequantize/requantize;
- merge 后不容易区分 base 与 adapter 贡献;
- 如果要继续训练多个任务,保留 adapter 更灵活。
Multi-Adapter Composition
如果同一个 base model 挂多个 adapters,可以写成:
\[ W_{\text{eff}} = W_0+\sum_{i=1}^{K}\lambda_i\Delta W_i. \]
这可以用于任务切换或风格混合,但不要把它想成完全线性的语义空间。两个 adapters 可能修改相近甚至相反的方向:
\[ \langle \Delta W_i,\Delta W_j\rangle_F = \operatorname{tr}(\Delta W_i^\top \Delta W_j). \]
若内积很大,说明两个 adapter 在相似方向上更新;若为负,混合可能互相抵消。服务中更稳的方案通常是 request 级别选择 adapter,而不是随意线性混合。
Merge Precision
若 base weight 是 4-bit 或 8-bit,merge 通常需要:
dequantize base -> add LoRA delta -> optionally requantize -> save merged model
这会引入新的量化误差。若追求质量,可以保留 unmerged adapter 分支;若追求低延迟,可以 merge 后重新量化并重新验证输出。
Merge State and Idempotence
merge 不是一个可以随便重复调用的纯函数。若令
\[ \Delta W=sBA, \qquad s=\alpha/r, \]
正确的一次 merge 是:
\[ W^{(1)} = W_0+\Delta W. \]
如果重复 merge 两次,会变成:
\[ W^{(2)} = W_0+2\Delta W, \]
这等价于把 adapter strength 翻倍,输出会悄悄漂移。unmerge 也必须和 merge 使用同一个 \(\Delta W\):
\[ (W_0+\Delta W)-\Delta W=W_0. \]
因此工程实现里需要一个 merged 状态位。它不是装饰性字段,而是防止重复加权重的 invariant。
import torch
@torch.no_grad()
def merge_lora(layer):
if layer.merged:
return
delta = layer.delta_weight().to(
device=layer.base.weight.device,
dtype=layer.base.weight.dtype,
)
layer.base.weight.add_(delta)
layer.merged = True
@torch.no_grad()
def unmerge_lora(layer):
if not layer.merged:
return
delta = layer.delta_weight().to(
device=layer.base.weight.device,
dtype=layer.base.weight.dtype,
)
layer.base.weight.sub_(delta)
layer.merged = False其中 delta_weight() 返回 shape 与 base weight 相同的 \(sBA\)。若 base linear 使用 PyTorch 约定 y = x @ W.T,则:
def delta_weight(layer):
return layer.scale * (layer.b @ layer.a)merge 的最小测试不是“调用不报错”,而是 idempotence 和 round trip:
@torch.no_grad()
def check_merge_round_trip(layer, atol=1e-5, rtol=1e-4):
base = layer.base.weight.detach().float().clone()
merge_lora(layer)
once = layer.base.weight.detach().float().clone()
merge_lora(layer)
twice = layer.base.weight.detach().float().clone()
assert torch.allclose(once, twice, atol=0.0, rtol=0.0)
unmerge_lora(layer)
restored = layer.base.weight.detach().float()
err = (restored - base).abs().max().item()
assert torch.allclose(restored, base, atol=atol, rtol=rtol), err如果 base 是 4-bit/8-bit,merge 常涉及 dequantize、add、requantize。此时 unmerge 不一定能精确回到原权重,因为量化已经改变了 base。量化部署更适合把 merged artifact 视为新模型,并重新做评测。
Adapter, Prefix Tuning, and Prompt Tuning
| Method | Trainable object | Inference cost | Intuition |
|---|---|---|---|
| Adapter | small MLP modules inserted between layers | extra modules | add task-specific computation |
| Prefix tuning | trainable KV-like prefix states | longer context/cache | steer attention with virtual prefix |
| Prompt tuning | trainable input embeddings | longer prompt | soft prompt in embedding space |
| LoRA | low-rank weight updates | none after merge | change weight directions cheaply |
LoRA 流行的原因是实现简单、训练稳定、merge 后无额外延迟,并且和现有 Transformer linear layers 对接自然。
Beyond LoRA: DoRA and Bias Tuning
LoRA 只学习 additive low-rank update。DoRA 的想法是把权重分成 direction 和 magnitude,对 direction 做 LoRA-like adaptation,同时单独学习 magnitude:
\[ W = m\frac{V}{\|V\|}, \qquad V=W_0+\Delta W_{\text{LoRA}}. \]
这样模型不仅能改变方向,也能调整输出通道尺度。它常被用来缓解低 rank adapter 表达力不足的问题。
另一类极轻量方法是 bias tuning 或 norm tuning,只训练 bias / LayerNorm / RMSNorm 参数。它更省,但 capacity 更弱。可以把几类 PEFT 放在同一条谱系上:
| Method | Trainable fraction | Capacity | Deployment |
|---|---|---|---|
| bias / norm tuning | tiny | low | easy |
| prompt / prefix | small | low-medium | adds tokens/cache |
| LoRA | small-medium | medium-high | mergeable |
| DoRA / richer adapters | medium | higher | slightly more complex |
| full fine-tuning | all | highest | expensive |
QLoRA
QLoRA 的目标更激进:base model 用 4-bit quantization 冻结,梯度穿过量化权重流向 LoRA adapters。
QLoRA freezes a 4-bit quantized pretrained model and trains LoRA adapters through it, reducing memory while preserving much of full-precision fine-tuning behavior.
QLoRA 的三个关键点:
- NF4: NormalFloat4,适合近似正态分布的 pretrained weights;
- double quantization: 再量化 quantization constants,减少额外存储;
- paged optimizers: 处理长序列/大 batch 导致的 optimizer memory spikes。
抽象 forward:
\[ y=x\operatorname{dequant}(W_q)^\top+\frac{\alpha}{r}xA^\top B^\top. \]
其中 \(W_q\) 是 4-bit quantized frozen weight,LoRA adapters 通常是 BF16/FP16 训练。
Quantization Blocks
4-bit quantization 通常按 block 做。对一个 block 权重 \(w\),保存 scale \(s\) 和 quantized code \(q\):
\[ w\approx s\cdot q. \]
若每个 block 都要保存 scale,scale 本身也占显存。double quantization 再把这些 scale 常数也量化,从而降低 metadata 成本。
NF4 Intuition
均匀 4-bit quantization 把数轴等距切成 16 个 bins。但 pretrained weights 常集中在 0 附近,尾部较少。NF4 使用更适合近似正态权重的 codebook,让 16 个 code points 更符合权重分布。
抽象地,对 block 权重做归一化:
\[ \tilde{w}_i=\frac{w_i}{s}, \qquad s=\max_i|w_i| \text{ or another block scale}. \]
然后选最近 code:
\[ q_i = \arg\min_{c_j\in\mathcal{C}_{\text{NF4}}} |\tilde{w}_i-c_j|. \]
反量化:
\[ \hat{w}_i=s\cdot c_{q_i}. \]
QLoRA 中 \(\hat{W}\) 参与 forward/backward,但 base quantized codes 不被 optimizer 更新;梯度流向 LoRA adapters。
QLoRA Memory Sketch
对 \(N\) 个 base 参数,4-bit 权重主体约为:
\[ 0.5N\text{ bytes}. \]
还要加 block scales、quantization metadata 和 LoRA/optimizer states。若 LoRA trainable 参数量为 \(P_{\text{lora}}\),AdamW states 约为:
\[ 12P_{\text{lora}}\text{ bytes}, \]
而不是 \(12N\)。这就是 QLoRA 的显存优势来源:base model 大但 frozen + quantized,optimizer 只服务很小的 adapter。
QLoRA trains adapters while the base weights are quantized and frozen. It does not update the quantized base weights. Full quantized training, where quantized weights themselves are updated, is a different and harder problem.
Paged Optimizers
长序列 SFT 中,activation 和 optimizer states 的峰值可能瞬间超过显存。Paged optimizers 借助 unified memory / paging 思路,把一部分 optimizer state 溢出到 CPU/host memory,缓解峰值 OOM。
这不是免费提速:page migration 会带来延迟。如果每一步都大量 paging,吞吐会明显下降。paged optimizer 更像安全阀,而不是加速器。
Preparing a Quantized Model for LoRA Training
QLoRA 训练前通常要做几件事:
- load base model in 4-bit;
- freeze quantized base weights;
- cast normalization layers to stable dtype;
- enable gradient checkpointing if activation memory is high;
- ensure input embeddings can receive gradients when needed;
- inject LoRA modules into quantized linear wrappers。
概念代码:
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
bnb_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
base = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_cfg,
device_map="auto",
)
base = prepare_model_for_kbit_training(base)
lora_cfg = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(base, lora_cfg)prepare_model_for_kbit_training 这类步骤不是装饰。它通常会处理 norm dtype、input requires grad、gradient checkpointing 兼容性等问题。若跳过,常见症状是训练不稳定、梯度为 None、显存异常或 loss 不动。
QLoRA greatly reduces parameter and optimizer memory, but long-sequence activations still scale with microbatch, sequence length, layers, and hidden size.
LoftQ-Style Initialization Intuition
普通 LoRA 初始化保证 \(\Delta W=0\),初始函数等于 base model。量化场景里还有另一个问题:量化 base weight \(\hat{W}\) 已经偏离 full-precision \(W\)。如果 LoRA 从零开始,训练初期要同时弥补 quantization error 和学习下游任务。
可以把目标写成:
\[ W \approx \hat{W} +BA. \]
若先用低秩分解拟合量化残差:
\[ R=W-\hat{W}, \qquad \min_{\operatorname{rank}(\Delta W)\leq r} \|R-\Delta W\|_F^2, \]
则最优 rank-\(r\) 近似由 SVD 给出:
\[ R\approx U_r\Sigma_rV_r^\top. \]
可以设置:
\[ B=U_r\Sigma_r^{1/2}, \qquad A=\Sigma_r^{1/2}V_r^\top. \]
这类 LoftQ-style initialization 的直觉是:adapter 先补一部分 quantization residual,再开始任务训练。代价是初始化更复杂、需要访问 full-precision 或校准权重信息;收益是在低 bit base 上起点可能更好。
The quantization residual is \(R=W-\hat{W}\), the difference between the original full-precision weight and its quantized reconstruction.
Minimal PEFT Pattern
概念代码:
from peft import LoraConfig, get_peft_model
cfg = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(base_model, cfg)
model.print_trainable_parameters()训练时要检查:
for name, p in model.named_parameters():
if p.requires_grad:
print(name, p.shape)如果 trainable parameters 意外很多,说明 base model 没 freeze;如果几乎没有,说明 target modules 没匹配上。
LoRA Dropout and Small Data
LoRA dropout 对 LoRA branch 的输入做 dropout:
\[ y=xW_0^\top+s\,\operatorname{Dropout}(x)A^\top B^\top. \]
它的作用不是让 base path 随机,而是让 adapter path 不要在小数据上过拟合。经验上:
| Data regime | LoRA dropout |
|---|---|
| very small style dataset | 0.05-0.1 |
| medium SFT | 0.0-0.05 |
| large high-quality SFT | often 0.0 |
| DPO/preference | often 0.0-0.05 |
dropout 太高会让 adapter 学得慢,尤其是 rank 已经很小时。一个检查方法是同时看 train loss 和 validation generation:若 train loss 降得很快但输出风格崩,可能需要 dropout、降低 alpha 或减少 target modules;若 loss 不动,dropout 可能只是雪上加霜。
Minimal LoRA Linear Layer
不用库也可以写出 LoRA 的核心:
from torch import nn
class LoraLinear(nn.Module):
def __init__(self, base: nn.Linear, rank: int, alpha: float):
super().__init__()
self.base = base
self.rank = rank
self.scale = alpha / rank
for p in self.base.parameters():
p.requires_grad_(False)
self.a = nn.Parameter(torch.empty(rank, base.in_features))
self.b = nn.Parameter(torch.zeros(base.out_features, rank))
nn.init.kaiming_uniform_(self.a, a=5**0.5)
def forward(self, x):
base_y = self.base(x)
lora_y = (x @ self.a.T) @ self.b.T
return base_y + self.scale * lora_y真实库还要处理 dropout、fan-in/fan-out、mixed precision、merge/unmerge、state dict 命名、多 adapter 和量化模块。但这段代码揭示了核心:冻结原矩阵,再旁路一个低秩矩阵乘法。
Trainable Parameter Audit
一个可靠的检查函数:
def audit_trainable(model):
total = 0
trainable = 0
rows = []
for name, p in model.named_parameters():
n = p.numel()
total += n
if p.requires_grad:
trainable += n
rows.append((name, tuple(p.shape), n, str(p.dtype)))
print(f"trainable={trainable:,} / total={total:,} ({trainable / total:.4%})")
for row in rows[:40]:
print(row)PEFT 训练前必须跑这类 audit。很多“LoRA 不收敛”其实是 base model 被意外冻错、target modules 没匹配、或者 LM head/new token embedding 没设为 trainable。
Optimizer Coverage and Update Diagnostics
requires_grad=True 只是 autograd 会算梯度,不保证 optimizer 一定会更新这些参数。PEFT 训练真正的变量集合是:
\[ \phi = \{A_{\ell,m},B_{\ell,m}\}_{(\ell,m)\in\mathcal{T}}, \]
其中 \(\mathcal{T}\) 是被选中的 target module 集合。optimizer 参数集合应该满足:
\[ \operatorname{Params}(\text{optimizer}) = \{p: p.\texttt{requires\_grad}=\texttt{True}\}. \]
若左边少了某些 adapter 参数,loss 可能完全不动;若右边多了 base 参数,训练会从 PEFT 变成意外 full fine-tuning。可以把 coverage 检查写成硬错误:
def optimizer_coverage_report(model, optimizer):
id_to_name = {id(p): name for name, p in model.named_parameters()}
trainable = {
id(p): name
for name, p in model.named_parameters()
if p.requires_grad
}
opt_ids = {
id(p)
for group in optimizer.param_groups
for p in group["params"]
}
missing = [name for pid, name in trainable.items() if pid not in opt_ids]
extra = [
id_to_name.get(pid, "<unnamed optimizer param>")
for pid in opt_ids
if pid not in trainable
]
if missing or extra:
raise RuntimeError({"missing": missing, "extra": extra})
return {
"trainable_params": sum(
p.numel() for p in model.parameters() if p.requires_grad
),
"optimizer_params": sum(
p.numel()
for group in optimizer.param_groups
for p in group["params"]
),
}adapter 的 weight decay 也要想清楚。对 LoRA 因子做普通 L2:
\[ \lambda(\|A\|_F^2+\|B\|_F^2) \]
不是直接惩罚 \(\|\Delta W\|_F^2\),因为
\[ (B,A) \mapsto (cB,A/c) \]
保持 \(BA\) 不变,却改变两个因子的范数分配。更准确地说,在 rank 足够时,约束 \(BA=\Delta W\) 下最小化
\[ \frac12(\|A\|_F^2+\|B\|_F^2) \]
对应的是对 \(\Delta W\) 的 nuclear norm 倾向,而不是 Frobenius norm 倾向。因此 LoRA factor decay 同时在做 scale balancing 和低秩矩阵正则化。实践中,instruction tuning 常用一个保守配置:LoRA A/B 不做 weight decay;若额外训练 lm_head、embedding 或 classifier head,再给这些普通权重单独分组。
from torch.optim import AdamW
def build_peft_optimizer(model, lr: float, head_lr: float | None = None):
adapter_params = []
head_params = []
unknown = []
for name, p in model.named_parameters():
if not p.requires_grad:
continue
if "lora_" in name or ".lora_A." in name or ".lora_B." in name:
adapter_params.append(p)
elif name.startswith(("lm_head.", "embed_tokens.")):
head_params.append(p)
else:
unknown.append(name)
if unknown:
raise RuntimeError(f"Unclassified trainable params: {unknown[:20]}")
groups = [{"params": adapter_params, "lr": lr, "weight_decay": 0.0}]
if head_params:
groups.append({
"params": head_params,
"lr": head_lr or lr,
"weight_decay": 0.01,
})
return AdamW(groups)训练中还应记录 adapter update ratio,而不是只看 loss:
\[ \rho_W = \frac{\|sBA\|_F}{\|W_0\|_F+\epsilon}. \]
若 \(\rho_W\) 长期接近 \(0\),adapter 没学起来;若突然很大,通常是 alpha、LR、梯度裁剪或数据异常。对 PEFT 库包装后的 layer,可以写一个诊断函数:
@torch.no_grad()
def peft_lora_update_ratios(model, adapter="default", eps=1e-12):
rows = []
for name, module in model.named_modules():
if not all(hasattr(module, attr) for attr in ("base_layer", "lora_A", "lora_B")):
continue
if adapter not in module.lora_A or adapter not in module.lora_B:
continue
a = module.lora_A[adapter].weight.float()
b = module.lora_B[adapter].weight.float()
scale = module.scaling[adapter]
delta = scale * (b @ a)
base = module.base_layer.weight.float()
rows.append((name, (delta.norm() / (base.norm() + eps)).item()))
return rows在常见初始化 B=0, A=random 下,第一步 B 有梯度而 A 没有梯度;第二步之后 A 才开始动。这不是 bug。真正的 bug 是所有 LoRA 参数长期 grad=None 或 optimizer coverage 检查失败。
LoRA With SFT and DPO
SFT + LoRA:
\[ \min_{\phi} - \sum_{t\in\mathcal{A}} \log \pi_{W_0+\Delta W_\phi}(y_t\mid x,y_{<t}). \]
DPO + LoRA:
\[ \min_\phi - \log\sigma \left( \beta \left[ \Delta\log\pi_{W_0+\Delta W_\phi} - \Delta\log\pi_{\text{ref}} \right] \right). \]
其中训练变量只有 adapter 参数 \(\phi=(A,B)\)。这让偏好优化可以在较小显存上运行,但也限制了 policy 能移动的子空间。
Reference Model and Adapter Sharing
DPO/RLHF 类训练常需要 policy model 和 reference model。若 base frozen,reference 可以共享 base weights,只禁用 policy adapter:
policy: base + adapter_phi
reference: base
这样比复制一份完整 reference model 省很多显存。但要小心:
- reference 必须没有训练中的 adapter;
- tokenizer/chat template 必须相同;
- dropout 应在 reference scoring 中关闭;
- 若 policy merge 了 adapter,reference 不能误用 merged weights。
Saving and Loading
PEFT checkpoint 通常只保存 adapter:
adapter_config.json
adapter_model.safetensors
它不是完整模型。加载时必须同时知道 base model identity:
\[ \text{adapter output} = f_{W_0+\Delta W_\phi}(x). \]
如果换了 base checkpoint,即使 architecture 相同,adapter 也可能失效,因为它学的是相对 \(W_0\) 的 correction。
A LoRA adapter usually depends on the exact base model, tokenizer, chat template, and target module naming used during training.
Adapter Manifest
一个严肃的 adapter checkpoint 应该带 manifest,而不只是权重文件:
{
"base_model": "Qwen/Qwen3-8B",
"base_revision": "commit-or-sha",
"tokenizer_hash": "sha256:...",
"chat_template_hash": "sha256:...",
"target_modules": ["q_proj", "k_proj", "v_proj", "o_proj"],
"rank": 16,
"alpha": 32,
"scaling": "alpha/r",
"lora_dropout": 0.05,
"trainable_params": 12345678,
"dtype": "bfloat16",
"peft_library_version": "..."
}Manifest 的价值是部署时能拒绝不兼容组合,而不是默默加载然后输出变差。服务端多 adapter 场景尤其需要:
| Field | Why |
|---|---|
| base revision | adapter learns correction relative to exact weights |
| tokenizer/template hash | role tokens and spans must match |
| target modules | architecture naming must match |
| rank/alpha/scaling | reconstruct adapter math |
| dtype/quantization | merge and serving precision |
| library version | state-dict naming and module wrappers can change |
Adapter Serving Isolation
服务端多 adapter 场景里,adapter 选择必须是 request state,而不是一个 worker 进程里的全局 mutable state。设第 \(i\) 个请求使用 adapter \(a_i\),同一个 decode batch 中正确的 logits 来自:
\[ z_i = f_{W_0+\Delta W_{a_i}}(x_i). \]
若实现里先调用 model.set_adapter(a_star),然后把混合 adapter 的 batch 一起 forward,就会错误地计算:
\[ \tilde{z}_i = f_{W_0+\Delta W_{a_\star}}(x_i). \]
在一层 linear 的局部近似下,这个错误项就是:
\[ e_i = h_i(\Delta W_{a_\star}-\Delta W_{a_i})^\top . \]
这不是小问题:一个请求可能被上一个请求残留的 adapter 改写风格、格式、甚至安全策略。连续 batching 会放大这个风险,因为一个 decode step 里天然混合很多请求。
一个安全的 request context 至少应带:
from dataclasses import dataclass
@dataclass(frozen=True)
class AdapterRequest:
request_id: str
base_id: str
adapter_id: str | None
tokenizer_hash: str
chat_template_hash: str
input_ids: list[int]调度器有两种实现路线。第一种是按 adapter 分组 forward,再把 logits scatter 回原请求顺序:
from collections import defaultdict
def grouped_decode_step(model, requests, build_batch):
groups = defaultdict(list)
for pos, req in enumerate(requests):
key = (
req.base_id,
req.adapter_id,
req.tokenizer_hash,
req.chat_template_hash,
)
groups[key].append((pos, req))
outputs = [None] * len(requests)
for (_base, adapter_id, _tok, _tmpl), items in groups.items():
model.set_adapter(adapter_id) if adapter_id else model.disable_adapter()
batch = build_batch([req for _pos, req in items])
logits = model(**batch).logits
for row, (pos, _req) in enumerate(items):
outputs[pos] = logits[row]
return outputs第二种是 kernel 或 wrapper 支持 per-sample adapter dispatch,即每个样本带 adapter_indices,在 LoRA branch 中按样本选择 \(A_{a_i},B_{a_i}\)。这种方式对吞吐更好,但实现复杂,需要确认 batch 内不同 adapter 的低秩矩阵 gather 不会破坏内存布局和 CUDA graph 捕获。
不要在共享 worker 上为每个请求临时 merge/unmerge adapter。merge 会修改 base weight,是跨请求共享状态;一旦并发、异常退出或重复 merge,就会污染后续请求。低延迟部署可以为热门 adapter 维护独立 merged replica,长尾 adapter 则走 unmerged 分组或 per-sample dispatch。
部署日志里应该记录 request 级 adapter 信息:
| Field | Meaning |
|---|---|
request_id |
trace single request |
base_id |
which frozen model served it |
adapter_id |
selected adapter or none |
adapter_revision |
exact checkpoint |
tokenizer_hash |
tokenizer compatibility |
chat_template_hash |
prompt format compatibility |
merge_policy |
merged replica / unmerged grouped / per-sample |
如果线上输出偶发串风格,第一步不是调 prompt,而是查同一个 decode batch 中是否混入不同 adapter 且使用了全局 set_adapter。
Merge / Unmerge Consistency Test
部署前应比较三条路径:
base + unmerged adapter
merged full model
saved-and-reloaded adapter
对固定 prompt,比较 logits:
\[ \max_i |z_i^{\text{unmerged}}-z_i^{\text{merged}}| \leq \epsilon. \]
如果 base 是 BF16 且 merge 不重新量化,\(\epsilon\) 应该很小;如果 merge 到 4-bit/8-bit 权重,误差会明显变大,需要重新做任务评测,而不是只看能不能加载。
def max_logit_diff(model_a, model_b, batch):
with torch.no_grad():
za = model_a(**batch).logits.float()
zb = model_b(**batch).logits.float()
return (za - zb).abs().max().item()An adapter can load without shape errors but still be wrong because the base revision, tokenizer, chat template, quantization, or merge policy differs from training.
Engineering Checklist
| Check | Why |
|---|---|
| target module names | 不同模型命名差异很大 |
| trainable parameter count | 检查是否误训全模型或没训到 |
| rank and alpha | 控制 capacity 和 update scale |
| LoRA dropout | 小数据防过拟合 |
| optimizer coverage | 确认所有 trainable adapter 参数都在 optimizer 中 |
| adapter update ratio | 检查 \(\|sBA\|/\|W_0\|\) 是否正常增长 |
| base dtype | BF16/FP16/4-bit 影响显存和稳定性 |
| merge policy | 部署时是否 merge adapter |
| merge idempotence | 防止重复 merge 或 unmerge 后无法恢复 |
| tokenizer/template | adapter 和训练格式绑定 |
| save format | 保存 adapter 还是 merged full model |
| request adapter id | 多 adapter 服务中防止跨请求串线 |
PEFT 的本质不是“省显存的小技巧”,而是把 adaptation 限制在一个低维可训练子空间中。它降低成本,也引入 capacity 和配置敏感性。
Debugging Patterns
| Symptom | Likely cause |
|---|---|
| trainable params near 100% | base model not frozen |
| trainable params near 0 | target module names did not match |
| trainable params exist but never update | optimizer missing adapter params |
| loss unchanged from base | rank too low, LR too low, adapter not active |
| loss drops but generation unchanged | adapter not loaded/merged at inference |
| output style changes too aggressively | alpha too high or too many target modules |
| OOM in QLoRA | activations, not adapter params, dominate memory |
| merged model worse than adapter model | quantized merge/requantization error |
| DPO reward gap unstable | reference accidentally includes adapter |
| one user’s style leaks into another request | global adapter state in continuous batching |
最小 sanity check:
- 训练前,base 和 adapter 输出应完全相同或几乎相同;
- 训练若干 step 后,adapter 输出应改变;
- merge 后,merged 输出应与 unmerged adapter 输出接近;
- disable adapter 后,应回到 base 输出;
- 保存再加载 adapter 后,输出应复现。
这组检查能把 PEFT 的问题分成四类:训练变量、数值尺度、保存加载、部署 merge。
References
- LoRA: Low-Rank Adaptation of Large Language Models, Hu et al.
- QLoRA: Efficient Finetuning of Quantized LLMs, Dettmers et al.