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\),需要保存:

  1. \(W\) 本身;
  2. \(\nabla W\)
  3. Adam first moment;
  4. Adam second moment;
  5. 可能还有 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。

NoteDefinition: Parameter-Efficient Fine-Tuning

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

NoteDefinition: LoRA

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\}. \]

ImportantTheorem: LoRA Update Rank Bound

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\)

WarningPitfall: Nonzero LoRA Initialization Can Break the Base Model

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 更稳定。

WarningPitfall: Rank and Alpha Are Coupled

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_projv_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_projv_proj。所以 target modules 不是配置模板,而是模型结构的一部分。

WarningPitfall: Name Matching Can Be Over-Broad

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]))
ImportantFused QKV Is a Weight-Layout Contract

同一个“给 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。

TipPractical Rank Heuristic

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,适合多任务服务。

NoteDefinition: Adapter Merge

Adapter merge folds trained adapter weights into the base model weights so inference uses a single ordinary weight matrix.

merge 的风险:

  1. 多个 adapters 混合时要管理 scale;
  2. 量化 base model 上 merge 可能需要 dequantize/requantize;
  3. merge 后不容易区分 base 与 adapter 贡献;
  4. 如果要继续训练多个任务,保留 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
WarningPitfall: Quantized Merge Is Not Exact Round Trip

如果 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。

NoteDefinition: QLoRA

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 的三个关键点:

  1. NF4: NormalFloat4,适合近似正态分布的 pretrained weights;
  2. double quantization: 再量化 quantization constants,减少额外存储;
  3. 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。

WarningPitfall: Quantized Training Is Not the Same as Quantized Inference

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 训练前通常要做几件事:

  1. load base model in 4-bit;
  2. freeze quantized base weights;
  3. cast normalization layers to stable dtype;
  4. enable gradient checkpointing if activation memory is high;
  5. ensure input embeddings can receive gradients when needed;
  6. 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 不动。

WarningPitfall: QLoRA OOM Often Comes from Activations

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 上起点可能更好。

NoteDefinition: Quantization Residual

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
TipDiagnostic: First-Step Gradients

在常见初始化 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 省很多显存。但要小心:

  1. reference 必须没有训练中的 adapter;
  2. tokenizer/chat template 必须相同;
  3. dropout 应在 reference scoring 中关闭;
  4. 若 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。

WarningPitfall: Adapter Is Not Self-Contained

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 捕获。

ImportantServing Contract: Do Not Merge Per Request

不要在共享 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()
WarningPitfall: Successful Load Is Not Successful Deployment

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:

  1. 训练前,base 和 adapter 输出应完全相同或几乎相同;
  2. 训练若干 step 后,adapter 输出应改变;
  3. merge 后,merged 输出应与 unmerged adapter 输出接近;
  4. disable adapter 后,应回到 base 输出;
  5. 保存再加载 adapter 后,输出应复现。

这组检查能把 PEFT 的问题分成四类:训练变量、数值尺度、保存加载、部署 merge。

References