Chapter 07

AdaLoRA 与 DoRA:两条改进 LoRA 的路径

📌 commit a106ff4c7061 动态分配 rank vs 拆 magnitude / direction

LoRA 训得多了大家会发现两个问题:(1) 不同 layer 对 rank 的需求不一样、固定 r 浪费; (2) LoRA 的更新方向和模长耦合在一起,不够灵活。 AdaLoRA 和 DoRA 各自解决一个,本章拆开讲。

7.1问题:固定 rank 的浪费

LLaMA-7B 有 32 个 transformer block × 7 个 Linear = 224 个目标 layer。它们的"信息复杂度"差异很大:

统一 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/。关键文件:

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

  1. step < tinit:什么都不做;
  2. tinit ≤ step ≤ tfinal:按梯度统计计算"敏感度",剪重要性最低的奇异值;
  3. 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=80.30%86.3 1.3×
AdaLoRA target_r=160.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 的代价

LoRADoRA
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 拆分相互冲突。如果实在想要"两者结合"的效果,可以:

  1. 先 AdaLoRA 训一遍,拿到每层最优 r;
  2. 用这些 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.85importance / 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这章你需要带走的