↓ Skip to main content
  1. Posts/

LoRA:为什么低秩更新足以微调大模型?

·2492 words·5 mins
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

上一篇从高斯消元推到了低秩分解,最后用 LoRA 举了个例子——\(\Delta W = BA\)。当时想清楚的是数学层面为什么两个小矩阵可以代替一个大矩阵,但 LoRA 本身没有展开。

顺着这条线,我还想搞清楚几件事:微调一个 7B 模型,真的需要更新全部参数吗?低秩约束到底省了什么、没省什么?推理的时候又该怎么处理这些额外的小矩阵?

全量微调的代价
#

Full Fine-Tuning 的做法很直接:在预训练权重 \(W_0\) 的基础上,用下游任务数据继续训练,每个权重矩阵都可以自由更新:

$$ W' = W_0 + \Delta W $$

\(\Delta W\) 和 \(W_0\) 一样大。一个 4096×4096 的权重矩阵,\(\Delta W\) 就有约 1678 万个自由参数。

更要命的是训练时的显存开销。之前算过,BF16 mixed precision + AdamW 配置下,每个可训练参数对应 16 bytes——参数本身、梯度、FP32 master weights 和 Adam 的两个矩估计。7B 模型的 Model States 加起来 112 GB,一张 80 GB 的 A100 连参数都放不下。

但预训练模型已经在海量数据上学到了通用的语言能力和知识表示。下游任务的适配——比如让模型学会某种回答格式,或者适应一个垂直领域的术语——真的需要在所有方向上都做调整吗?

用低秩矩阵参数化权重更新
#

LoRA 不动 \(W_0\),把更新量约束为低秩形式:

$$ \Delta W = BA $$

其中 \(B \in \mathbb{R}^{d \times r}\),\(A \in \mathbb{R}^{r \times d}\),\(r \ll d\)。

以 \(d = 4096\)、\(r = 16\) 为例:

  • 完整 \(\Delta W\):\(4096 \times 4096 = 16{,}777{,}216\) 个参数
  • LoRA 的 \(B + A\):\(4096 \times 16 + 16 \times 4096 = 131{,}072\) 个参数

可训练参数降低了 128 倍。

Full Fine-Tuning 与 LoRA 的参数更新对比

我在看这个公式的时候,有一个地方需要想清楚:LoRA 不是在压缩 \(W_0\),也不是把大模型变小。它限制的是权重更新 \(\Delta W\) 的自由度,不是基础模型的表达能力。

从子空间的角度看,\(A\) 把输入从 \(d\) 维投影到 \(r\) 维,\(B\) 再从 \(r\) 维映射回 \(d\) 维。这样构成的 \(\Delta W = BA\) 秩不超过 \(r\)——更新只能沿着 \(r\) 个方向调整权重,而不是在整个 \(d \times d\) 空间里自由移动。

但这不意味着整个 Transformer 的表达能力被限制在 \(r\) 维。每一层的每个目标矩阵(Q、K、V、O、FFN 等)都可以挂自己的 LoRA,各自选择不同的 \(r\) 维子空间。模型整体的更新是所有这些低秩增量的组合。

LoRA 能工作的前提假设是:预训练模型已经具备足够好的通用表示,下游任务的适配只需要在相对低维的子空间里做调整。这个假设在很多任务上经验性地成立——但它是一个经验观察,不是一个适用于所有场景的数学定理。

省掉的是什么,没省掉的又是什么
#

LoRA 冻结 \(W_0\),只训练 \(A\) 和 \(B\)。直觉上"参数少了 128 倍,训练就快 128 倍"——实际没有这回事。

显存组件Full Fine-TuningLoRA
基础权重 \(W_0\)存储,可训练存储,冻结
\(W_0\) 的梯度需要不需要
\(W_0\) 的 Optimizer States需要不需要
A、B 的权重 + 梯度 + States—需要(很小)
Activations需要基本不变
Forward 计算量全模型全模型 + 低秩分支

LoRA 真正省掉的是 \(W_0\) 的梯度和 Optimizer States。对于 7B 模型,这部分是 \(2 + 12 = 14\) bytes/param,合计 98 GB。A 和 B 参数量极小,它们的训练状态可以忽略。

但有几件事没有因此省掉:

Forward 仍然要跑完整的基础模型——输入要经过所有冻结层的矩阵乘法,这些 FLOPs 一个不少。Backward 通常也需要经过冻结层计算输入梯度——虽然 \(W_0\) 不更新,但梯度要传到前面的 LoRA 模块去。Activation Memory 也不会自动消失——反向传播需要的中间激活值跟哪些参数可训练没什么关系。

LoRA 主要降低的是可训练参数及其状态的显存开销,不是把整个训练的计算量按同比例缩减。

顺带一提,LoRA 和 ZeRO 优化的维度不同:ZeRO 通过分片分摊每张 GPU 的 Model States 负担,LoRA 通过减少可训练参数来降低训练状态总量。两者可以组合使用。

训练完把 Adapter 合并回去
#

LoRA 有一个很漂亮的设计:推理时可以把低秩更新合并进基础权重。

训练时的前向计算是:

$$ y = W_0 x + \frac{\alpha}{r} BA x $$

其中 \(\alpha\) 是一个缩放超参数,控制低秩更新的强度。因为矩阵乘法对加法满足分配律:

$$ y = \left(W_0 + \frac{\alpha}{r} BA\right) x $$

训练完成后,预先计算 \(W' = W_0 + \frac{\alpha}{r} BA\),把合并后的权重存下来,推理时就是一个普通的 Linear Layer——不需要保留 A 和 B,也没有额外的矩阵乘法。

训练时保持低秩分支,推理时合并为单个权重

训练时用低秩分支降低可训练参数的显存成本;推理时通过权重合并,恢复成普通的矩阵乘法。 不需要在推理路径上长期保留额外的 Kernel Launch 和计算开销。

实际部署有两种常见场景:

  • 单任务:直接 merge 权重,拿到一个标准模型,执行路径和普通模型完全一样。
  • 多任务:多个任务共享 Base Model,各自保留独立的 LoRA Adapter。切换任务只需要换一组 A、B,不用重新加载整个基础模型。代价是推理时要保留分支结构,或者做动态 merge/unmerge。

低秩更新不是万能的
#

几个需要清醒认识的边界。

Rank 的选择没有通用公式。 \(r\) 越大,允许的更新秩上限越高,但效果不保证单调提升。过大的 \(r\) 丢掉参数效率优势,过小的 \(r\) 可能限制有效适配。实践中需要在目标任务上调。

显存省不等于速度省。 LoRA 的显存节省很显著(Model States 的大头被砍掉了),但训练速度不会按同比例提升——Forward 和大部分 Backward 的计算量没有变化。

不是所有任务都适合低秩约束。 如果下游任务需要大幅改变模型的知识结构或表示方式,低秩更新可能不够用,全量微调仍然是更好的选择。不要把 LoRA 当成"几乎不损失精度的廉价替代"——不同任务的结果差异很大。

最后厘清一组概念:SFT(Supervised Fine-Tuning)是训练方式,LoRA 是参数高效微调技术,两者正交。LoRA SFT 就是用 LoRA 的方式做监督微调。如果想进一步压缩冻结的 Base Model 显存,QLoRA 通过量化 \(W_0\) 来实现,但那是另一个话题了。

写在最后
#

三个我觉得值得记住的点:

LoRA 限制的是权重更新空间,不是基础模型的表达空间。 原始模型的能力都在冻结的 \(W_0\) 里,低秩约束只作用在增量 \(\Delta W\) 上。

LoRA 减少的是训练状态的显存,不是模型的计算量。 省掉的是 \(W_0\) 的梯度和 Optimizer States;Forward/Backward 的 FLOPs 基本不变。

低秩线性结构让 Adapter 可以合并回基础权重。 训练时享受参数效率,推理时恢复成普通 Linear Layer——参数效率和执行效率兼得。

参考资料
#

Related

从高斯消元到矩阵的 Rank:重新理解主元、自由变量与低秩

·4743 words·10 mins
最近在看 LoRA(Low-Rank Adaptation)的时候,碰到了一个公式: $$ \Delta W = BA $$其中 \(B \in \mathbb{R}^{m \times r}\),\(A \in \mathbb{R}^{r \times n}\),中间那个维度 \(r\) 被称为 Rank。 我对 Rank 这个词并不陌生——以前在 ACM-ICPC 里写高斯消元的时候,算自由变量个数就用到了矩阵的秩。但当时的关注点全在怎么求解、怎么判断无解多解、怎么把实际问题转化成线性方程组。至于 Rank 本身代表什么数学结构、跟低秩分解有什么关系,说实话没有认真想过。

从投机解码到 Rand5 模拟 Rand7:跨越十年的拒绝采样直觉

上一篇理清了 Prefill 和 Decode 的因果链之后,我开始翻 Decode 阶段的加速方案。结论已经很清楚了:在单请求、小 Batch 的典型场景下,Decode 阶段是 memory-bound 的——每生成一个 token 就要把整个模型权重从显存搬一遍,计算单元大部分时间在等数据(Batch 较大或模型结构不同时,瓶颈也可能转向 compute-bound,但单次请求的延迟优化场景下 memory-bound 是常态)。

PagedAttention:为什么 KV Cache 需要自己的内存管理系统?

前一篇写完之后,KV Cache 在我脑子里的形象已经变了——它不再是一次 forward 里的临时中间量,而是一个跨多轮存活、随 Decode 持续增长、每个请求各自独立的状态。 但"知道它是有状态的"和"知道怎么管它"是两件事。当我开始想具体的内存管理问题的时候,发现事情比想象的复杂。

从一次 Forward 到持续生成:LLM Inference 为什么不一样?

最近在从传统模型 Serving 转向 LLM Inference,一个让我停下来想了很久的问题是:为什么 LLM 的推理系统看起来和我之前熟悉的那套完全不一样? 我之前做推荐模型和 CV 模型的部署,排序模型的 Serving 架构虽然在工程上也有不少复杂性(远程 embedding 读取、稀疏参数缓存等),但核心计算模式不难概括:一个请求进来,跑一次 forward,返回结果。LLM Serving 里冒出来一大堆新概念——推理要分成 Prefill 和 Decode 两个阶段,中间要维护 KV Cache,请求调度要做 Continuous Batching,显存管理要用 PagedAttention——我花了些时间才把它们背后的因果链串起来。