AdaLoRA 与 DoRA:两条改进 LoRA 的路径
LoRA 训得多了大家会发现两个问题:(1) 不同 layer 对 rank 的需求不一样、固定 r 浪费; (2) LoRA 的更新方向和模长耦合在一起,不够灵活。 AdaLoRA 和 DoRA 各自解决一个,本章拆开讲。
7.1问题:固定 rank 的浪费
LLaMA-7B 有 32 个 transformer block × 7 个 Linear = 224 个目标 layer。它们的"信息复杂度"差异很大:
- 底层 (layer 0-4) 主要学语法和 token embedding,新信息少;
- 中层 (layer 5-20) 学语义,需要的容量最大;
- 顶层 (layer 28-31) 主要学输出格式,需求小。
统一 r=16 → 底层和顶层的 rank 大多没学到什么。这是 AdaLoRA 想解决的问题。
7.2AdaLoRA 的核心思想
AdaLoRA 把 LoRA 的 $BA$ 显式写成 SVD 形式:
$$\Delta W = P\,\Lambda\,Q,\quad \Lambda = \text{diag}(\sigma_1,\sigma_2,\ldots,\sigma_r)$$
$P, Q$ 是低秩矩阵、$\Lambda$ 是对角线(奇异值)。训练中按"敏感度"动态把不重要的奇异值剪掉,并用预算控制总秩:
$$\sum_{l, h} r_l^h \;\le\; r_\text{total}$$
不同 layer / head 上的 $r$ 在训练过程中此消彼长。
7.3用 AdaLoRA
from peft import AdaLoraConfig, get_peft_model, TaskType
cfg = AdaLoraConfig(
task_type=TaskType.CAUSAL_LM,
# —— 容量 ——
init_r=12, # 初始 r(每个 layer 都从这开始)
target_r=8, # 训练结束时平均 r(小于 init_r)
# —— 调度 ——
tinit=200, # 前 200 步不剪(warmup)
tfinal=1000, # 1000 步后停止剪
deltaT=10, # 每 10 步检查一次哪些奇异值要剪
# —— 正则 ——
orth_reg_weight=0.5, # 强制 P, Q 正交的正则强度
# —— 普通 LoRA 字段 ——
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
lora_alpha=32,
lora_dropout=0.05,
)
model = get_peft_model(model, cfg)
实现:src/peft/tuners/adalora/。关键文件:
layer.py::AdaLoraLayer:在 LoRA 基础上加lora_E(奇异值 diag);model.py::RankAllocator:训练时分配 rank 预算。
7.4训练循环要加一行
AdaLoRA 的 rank 分配需要在每个 backward 之后通知一下:
from peft import AdaLoraModel
for step, batch in enumerate(dl):
loss = model(**batch).loss
loss.backward()
optimizer.step()
# ★ 关键这行:告诉 rank allocator 更新内部状态
model.base_model.update_and_allocate(step)
optimizer.zero_grad()
看 AdaLoraModel.update_and_allocate:
- step < tinit:什么都不做;
- tinit ≤ step ≤ tfinal:按梯度统计计算"敏感度",剪重要性最低的奇异值;
- step > tfinal:固定下来不再剪。
7.5AdaLoRA 的实战效果
| 设置 | trainable% | GLUE 平均 | 训练时间 |
|---|---|---|---|
| LoRA r=8 | 0.30% | 85.6 | 1.0× |
| LoRA r=16 | 0.60% | 86.4 | 1.1× |
| AdaLoRA target_r=8 | 0.30% | 86.3 | 1.3× |
| AdaLoRA target_r=16 | 0.60% | 87.1 | 1.4× |
同等参数预算下 AdaLoRA 比 LoRA 稳定提 0.5-1pp,代价是训练时间多 30%。
7.6DoRA 的核心:拆开 magnitude 和 direction
分析全参微调时发现:权重的方向变化和模长变化是分离的。LoRA 只是加一个低秩 $\Delta W$,这个分解被忽略了。DoRA 显式拆:
$$W = m \cdot \frac{V}{\lVert V \rVert_c}, \quad V \in \mathbb{R}^{d_{\text{out}}\times d_{\text{in}}},\; m \in \mathbb{R}^{1\times d_{\text{in}}}$$
其中 $\lVert \cdot \rVert_c$ 是逐列范数。微调时:
$$V' = W_0 + (\alpha/r) BA, \quad m\;\text{独立可学}$$
$$W' = m \cdot V' / \lVert V' \rVert_c$$
m 是单独可训的 vector(每列一个标量)。方向用 LoRA 学,模长用 m 学。
7.7用 DoRA(一行开关)
cfg = LoraConfig(
r=16, lora_alpha=32,
target_modules="all-linear",
use_dora=True, # ★ 仅此一行
lora_dropout=0.05,
)
实现:src/peft/tuners/lora/dora.py::DoraLinearLayer,把额外的 magnitude vector 注册成 nn.Parameter。
7.8DoRA 的代价
| 项 | LoRA | DoRA |
|---|---|---|
| trainable params | $2dr$ | $2dr + d$ |
| 新增 m 占比 | 0 | ~0.5% of LoRA |
| forward 计算 | 1× linear | 1× linear + 1× column-norm |
| 训练时间 | 1.0× | ~1.3× |
| 效果(MMLU / GLUE) | baseline | +1-2pp |
结论:显存不紧、又想多 1-2% 效果,就开 use_dora=True。
7.9AdaLoRA vs DoRA 怎么选
| 需求 | 选 |
|---|---|
| 想用更少参数达到 LoRA 同等效果 | AdaLoRA |
| 不在乎参数量,想刷点 1-2% | DoRA |
| 训练数据 ≥ 100k 条、长跑 | AdaLoRA(动态分配收益大) |
| 训练数据 < 10k、想快 | DoRA(不动 rank,少调度开销) |
| 同时上 QLoRA | DoRA(AdaLoRA + QLoRA 配合差) |
| 同时上 multi-adapter | DoRA |
7.10能不能两个都开
不能直接组合——AdaLoRA 的 SVD 拆分和 DoRA 的 magnitude-direction 拆分相互冲突。如果实在想要"两者结合"的效果,可以:
- 先 AdaLoRA 训一遍,拿到每层最优 r;
- 用这些 r 配置一份新 LoRA + use_dora=True 再训。
7.11AdaLoRA 的 RankAllocator 三阶段
AdaLoRA 的关键调度逻辑在 tuners/adalora/layer.py:350+ 的 RankAllocator。它把训练分三阶段,由 tinit / tfinal / total_step 三个字段切分:
| 阶段 | step 范围 | 做什么 |
|---|---|---|
| Init | 0 → tinit | warm-up:所有层都用 init_r 训练,先稳一稳 |
| Reduction | tinit → total_step - tfinal | 每 deltaT 步重新分配预算:用 EMA 估每个奇异分量重要度,剪低分量 |
| Final | total_step - tfinal → total_step | 固定 reduction 后的 rank pattern,正常 fine-tune |
关键字段(AdaLoraConfig, tuners/adalora/config.py:24–109):
| 字段 | 默认 | 含义 |
|---|---|---|
target_r | 8 | 所有层平均的目标 rank |
init_r | 12 | 每层启动时的 rank(> target_r,给 reduction 留空间) |
tinit | 0 | warm-up 步数 |
tfinal | 0 | final stage 步数 |
deltaT | 1 | 多少步分配一次 rank |
beta1 / beta2 | 0.85 / 0.85 | importance / uncertainty 的 EMA 系数 |
orth_reg_weight | 0.5 | SVD 形式 LoRA 的正交正则系数 |
total_step | 必填 | 总训练步数(用于决定三阶段切分) |
"为什么是 SVD-based?" → AdaLoRA 把 LoRA 写成 BPA 三段,中间 P 是对角的奇异值;剪 rank 等于把 P 上小的对角项置 0。这是 AdaLoRA 与普通 LoRA 在 layer 类上的最大差异(SVDLinear at adalora/layer.py:99)。
7.12DoRA 的 cache 装饰器与 magnitude vector
DoRA 在 PEFT 的实现非常紧凑(tuners/lora/dora.py,~160 行)。核心两点:
① DoraLinearLayer(line 62–160)
# dora.py:62 简化版
class DoraLinearLayer(nn.Module):
def __init__(self, fan_in_fan_out):
super().__init__()
self.weight = None # ★ magnitude vector m,跟 base 列数一样长
def update_layer(self, base_weight, lora_A, lora_B, scaling):
# 用 base 权重的列范数初始化 magnitude
self.weight = nn.Parameter(torch.linalg.norm(base_weight, dim=1))
含义:每个 base 列向量学一个 magnitude scalar;方向部分仍然由 lora_A B 提供。整个 DoRA = m * (W + s·BA) / ||W + s·BA||。
② cache_decorator(line 27–59)
DoRA forward 时要算 ||W + s·BA||,这个范数很贵。cache_decorator 给它加了一层 cache:eval 模式下范数被缓存,下次直接读;进入 train 模式时自动 invalidate。
| 方法 | cache 行为 |
|---|---|
get_weight_norm | L2 norm of W + scaling * BA,列方向 |
get_lora_weight | 用 eye matrix 算 BA(绕开 FSDP 不能直接乘的问题) |
这是 DoRA推理时比 LoRA 慢 30%但 eval 完后能 cache 加速的原因。merge 路径:先 dequant base(如果量化)→ 算 W + s·BA → 用 m / ||...|| 缩放 → 写回。
③ 与量化的兼容性
use_dora=True 跟所有量化 backend都兼容,包括 bnb / gptq / aqlm / awq / hqq / eetq / torchao。但每个 backend 的 DoRA forward 路径都要自己实现 dequant + norm 计算,因此不同 backend 上 DoRA 速度差异较大(bnb 4bit + DoRA 通常是最慢组合)。
7.13这章你需要带走的
- AdaLoRA = LoRA + 动态分配 rank:解决"不同 layer 需要的容量不同"问题;
- AdaLoRA 训练循环要加一行
update_and_allocate(step); - DoRA = LoRA + 单独学 magnitude vector:把方向和模长分离;
- DoRA 用一行
use_dora=True启用,参数量多约 0.5%; - 两者不能同时开,但可以两阶段先用 AdaLoRA 找 r 再用 DoRA 训。