本文面向正在学习大模型微调的同学,从全参数微调的显存问题出发,推导 LoRA 的低秩更新公式,并使用 PyTorch 手写一个可以运行和验证的 LoRALinear。

1. 为什么需要 LoRA?

大语言模型中的线性层可以写成:

y=W0x y=W_0x y=W0​x

其中,W0W_0W0​ 是预训练得到的权重。全参数微调会更新模型中的全部参数,并为这些参数保存梯度和优化器状态。对于数十亿参数的模型,这会带来很高的显存和存储成本。

以一个 4096×40964096\times40964096×4096 的线性层为例,其权重参数量为:

4096×4096=16,777,216 4096\times4096=16,777,216 4096×4096=16,777,216

如果模型中有几十层类似结构,全参数微调的成本会快速增长。

LoRA(Low-Rank Adaptation)的核心做法是:

  1. 冻结预训练权重 W0W_0W0​;
  2. 不直接训练完整的权重增量 ΔW\Delta WΔW;
  3. 用两个较小的矩阵表示这个增量;
  4. 训练时只更新这两个小矩阵。

2. LoRA 的核心公式

微调后的权重可以写成:

W′=W0+ΔW W'=W_0+\Delta W W′=W0​+ΔW

LoRA 假设具体任务所需的权重变化具有较低的内在秩,因此把增量分解为:

ΔW=BA \Delta W=BA ΔW=BA

假设原线性层权重为:

W0∈Rdout×din W_0\in\mathbb{R}^{d_{out}\times d_{in}} W0​∈Rdout​×din​

两个 LoRA 矩阵的形状分别是:

A∈Rr×din A\in\mathbb{R}^{r\times d_{in}} A∈Rr×din​

B∈Rdout×r B\in\mathbb{R}^{d_{out}\times r} B∈Rdout​×r

其中 rrr 是人为设置的低秩维度,并且通常满足:

r≪din,dout r\ll d_{in},d_{out} r≪din​,dout​

实际前向传播常写为:

y=W0x+αrBAx y=W_0x+\frac{\alpha}{r}BAx y=W0​x+rα​BAx

其中 α\alphaα 是 LoRA 的缩放超参数。

需要注意的是:rrr 不是“矩阵中有几个秩”,而是低秩分支的中间维度,同时也是 BABABA 的最大秩上限:

rank⁡(BA)≤r \operatorname{rank}(BA)\le r rank(BA)≤r

3. LoRA 为什么能节省参数?

原矩阵的参数量为:

doutdin d_{out}d_{in} dout​din​

LoRA 新增的参数量为:

rdin+doutr=r(din+dout) rd_{in}+d_{out}r=r(d_{in}+d_{out}) rdin​+dout​r=r(din​+dout​)

仍以 4096×40964096\times40964096×4096 的线性层为例,设置 r=16r=16r=16:

16×4096+4096×16=131,072 16\times4096+4096\times16=131,072 16×4096+4096×16=131,072

新增参数占原矩阵的比例为:

131,07216,777,216≈0.78125% \frac{131,072}{16,777,216}\approx0.78125\% 16,777,216131,072​≈0.78125%

这意味着该线性层只需训练不到 1% 的参数。

不过,这只是单个方阵的比例。整个模型的可训练参数比例还取决于 LoRA 覆盖了哪些层、Rank 的大小以及模型结构。

4. 数据在 LoRA 分支中如何流动?

假设输入形状为:

x∈RB×din x\in\mathbb{R}^{B\times d_{in}} x∈RB×din​

LoRA 分支进行两次映射:

[B,din]→A[B,r]→B[B,dout] [B,d_{in}] \xrightarrow{A} [B,r] \xrightarrow{B} [B,d_{out}] [B,din​]A​[B,r]B​[B,dout​]

也就是:

输入维度 d_in
      ↓ A
低秩维度 r
      ↓ B
输出维度 d_out

完整线性层存在两条路径:

输入 x
 ├── 冻结的基础层 W₀ ─────────┐
 └── Dropout → A → B → 缩放 ──┤
                               ↓
                              相加

5. 使用 PyTorch 手写 LoRALinear

下面实现一个包装现有 nn.Linear 的 LoRA 层。

import math

import torch
import torch.nn as nn
import torch.nn.functional as F


class LoRALinear(nn.Module):
    def __init__(
        self,
        base_layer: nn.Linear,
        rank: int = 8,
        alpha: float = 16.0,
        dropout: float = 0.0,
    ):
        super().__init__()

        if rank <= 0:
            raise ValueError("rank 必须大于 0")

        self.base_layer = base_layer
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank

        in_features = base_layer.in_features
        out_features = base_layer.out_features

        # A: [rank, in_features]
        self.lora_A = nn.Parameter(
            torch.empty(rank, in_features)
        )

        # B: [out_features, rank]
        self.lora_B = nn.Parameter(
            torch.zeros(out_features, rank)
        )

        self.lora_dropout = nn.Dropout(dropout)

        # 冻结预训练线性层
        for parameter in self.base_layer.parameters():
            parameter.requires_grad = False

        # A 随机初始化,B 保持为 0
        nn.init.kaiming_uniform_(
            self.lora_A,
            a=math.sqrt(5),
        )

    def forward(self, x):
        base_output = self.base_layer(x)

        lora_hidden = F.linear(
            self.lora_dropout(x),
            self.lora_A,
        )

        lora_output = F.linear(
            lora_hidden,
            self.lora_B,
        )

        return base_output + self.scaling * lora_output

F.linear(x, weight) 实际执行的是:

x⋅weightT x\cdot weight^T x⋅weightT

所以代码中的形状变化为:

x:            [batch, d_in]
lora_A:       [r, d_in]
lora_hidden:  [batch, r]
lora_B:       [d_out, r]
lora_output:  [batch, d_out]

6. 为什么 A 随机初始化、B 零初始化?

初始化时令:

B=0 B=0 B=0

因此:

BA=0 BA=0 BA=0

LoRA 层刚插入模型时:

W′=W0 W'=W_0 W′=W0​

也就是说,加入 LoRA 不会在训练开始前突然改变基础模型的输出。

可以使用下面的代码进行验证:

torch.manual_seed(42)

base = nn.Linear(4, 3)
lora = LoRALinear(
    base_layer=base,
    rank=2,
    alpha=4,
)

x = torch.randn(5, 4)

base_output = base(x)
lora_output = lora(x)

print(torch.allclose(base_output, lora_output, atol=1e-6))

预期输出:

True

如果 A、B 都初始化为零,两者会出现不利的梯度耦合。常见做法是让一侧随机初始化、另一侧初始化为零。

由于初始时 B=0B=0B=0,第一次反向传播时,A 的梯度可能为零;B 更新后,后续训练步骤中 A 就能获得有效梯度。

7. 验证只有 LoRA 参数参与训练

for name, parameter in lora.named_parameters():
    print(
        name,
        tuple(parameter.shape),
        parameter.requires_grad,
    )

预期结果类似:

lora_A             (2, 4) True
lora_B             (3, 2) True
base_layer.weight  (3, 4) False
base_layer.bias    (3,)   False

统计可训练参数:

trainable = sum(
    p.numel()
    for p in lora.parameters()
    if p.requires_grad
)

total = sum(
    p.numel()
    for p in lora.parameters()
)

print("可训练参数量:", trainable)
print("总参数量:", total)
print("可训练比例:", trainable / total)

8. Rank 和 Alpha 应该怎么选?

Rank rrr

Rank 决定低秩分支的表达能力和参数量。

  • rrr 小:参数少、速度快,但可能欠拟合;
  • rrr 大:表达能力强,但显存和训练成本增加;
  • Rank 增大不代表效果一定等比例提升。

常见实验起点:

r = 8、16、32

项目中最好不要只使用一个 Rank,而应进行消融实验。

Alpha α\alphaα

LoRA 的实际缩放系数为:

αr \frac{\alpha}{r} rα​

例如:

r = 16, alpha = 16 → scaling = 1
r = 16, alpha = 32 → scaling = 2

常见起点是 alpha=r 或 alpha=2r,但最终应该以验证集结果为准。

9. LoRA 应该加到哪些层?

Transformer 注意力模块常见的投影层包括:

q_proj
k_proj
v_proj
o_proj

MLP 中常见:

gate_proj
up_proj
down_proj

低成本配置可以从 q_proj 和 v_proj 开始;更充分的适配通常会覆盖 Attention 和 MLP 中的多个线性层。

适合项目的消融设计:

方案 A:q_proj + v_proj
方案 B:q_proj + k_proj + v_proj + o_proj
方案 C:Attention 四个投影 + MLP 三个投影

比较三种方案的:

  • 可训练参数量;
  • 验证集 Loss;
  • 下游任务指标;
  • 训练时间和显存。

10. 如何合并 LoRA 权重?

训练完成后,可以把增量合并进基础权重:

Wmerged=W0+αrBA W_{merged}=W_0+\frac{\alpha}{r}BA Wmerged​=W0​+rα​BA

代码如下:

@torch.no_grad()
def merge_lora_weights(lora_layer: LoRALinear):
    delta_weight = (
        lora_layer.lora_B
        @ lora_layer.lora_A
    )

    lora_layer.base_layer.weight.add_(
        lora_layer.scaling * delta_weight
    )

其中:

$$
[d_{out},r]\times[r,d_{in}]

[d_{out},d_{in}]
$$

合并后,不应再额外执行 LoRA 分支,否则相同增量会被计算两次。

部署时有两种方式:

  1. 保留“基础模型 + Adapter”,便于快速切换不同任务;
  2. 合并权重,减少额外分支并得到独立模型。

11. LoRA 的局限

LoRA 减少的是可训练参数、梯度和优化器状态,但基础模型权重仍然需要加载到显存中。

例如 7B 模型使用 FP16 权重时,仅权重理论上就需要:

7×109×2 bytes≈14 GB 7\times10^9\times2\text{ bytes}\approx14\text{ GB} 7×109×2 bytes≈14 GB

再加上激活、临时张量和运行开销,普通消费级显卡仍可能比较紧张。这也是 QLoRA 出现的原因:将冻结的基础模型量化为 4-bit,同时继续用较高精度训练 LoRA 参数。

Logo

更多推荐