微调一个十亿参数级的模型,全参数训练需要数倍于权重的显存、数小时到数天的算力。LoRA 用一个低秩假设把这件事压缩了一到两个数量级:训练中权重的更新量是低秩的,所以只需要训练两个小矩阵。这篇文章把低秩假设的依据、实现的坑、QLoRA 的显存压榨手法完整讲清,并给出可运行的参考实现。
一、低秩假设:为什么"更新量"可以很小
预训练权重的信息已经完备,领域微调改变的是一小部分行为------这种改变在数学上表现为权重的增量 ΔW 的奇异值衰减很快:能量集中在前几十个奇异方向。LoRA 的赌注就是冻结 W,用低秩积近似 ΔW:
W' = W + ΔW, ΔW ≈ B·A, 其中 B∈R^{d×r}, A∈R^{r×k}, r ≪ min(d,k)
参数量对比一目了然:d=k=4096、r=16 时,原来 1678 万个参数的增量被压成 13 万个------压缩比就是 d/r,约 256 倍。可训练参数少了,优化器状态(AdamW 每参数两份动量)与梯度同样等比缩小,显存的大头节省其实来自这里,而不只是权重本身。前向与反向的计算形式为:
python
class LinearWithLoRA(nn.Module):
def __init__(self, base: nn.Linear, r=16, alpha=32, dropout=0.05):
super().__init__()
self.base = base
for p in self.base.parameters(): # 冻结原权重
p.requires_grad_(False)
d_out, d_in = base.weight.shape
self.A = nn.Parameter(torch.randn(r, d_in) * (1 / math.sqrt(r)))
self.B = nn.Parameter(torch.zeros(d_out, r)) # B 初始化为 0: 开局恒等
self.scale = alpha / r # 缩放: r 小则 alpha/r 补偿
self.drop = nn.Dropout(dropout)
def forward(self, x):
return self.base(x) + (self.drop(x) @ self.A.T) @ self.B.T * self.scale
三个细节是实作易错点。B 初始化为零 而 A 随机:开局 ΔW=0,微调从预训练模型的精确行为出发平滑过渡;若两者都随机,开局就破坏原模型,小学习率下也能恢复但浪费步数。缩放系数 alpha/r :论文原设计是让改 r 时行为可比,实践上 alpha 设为 r 的 1--2 倍是常见区间,它实际调节的是 LoRA 分支相对主干的影响强度。挂载位置:对注意力层挂 LoRA(q、v 优先,然后 k、o)是性价比最高的选择,FFN 层加挂对任务理解类提升有限,但对风格模仿类任务有帮助------挂得越多可训练参数越多,不是越多越好。
二、QLoRA:把基座压到 4 比特
QLoRA 把显存账本推到极限:基座权重量化到 4 比特(NF4 格式)冻结存储,LoRA 分支保持 fp16/bf16 可训练。前向时 4 比特权重反量化到 bf16 参与计算,梯度只流进 LoRA 分支。NF4(NormalFloat4)是信息论上对正态分布权重最优的 4 比特格式------神经网络权重经归一化后近似正态,NF4 的量化格点按正态分位数均匀放置,比均匀 int4 的量化误差更小。加上双重量化(量化常数本身再量化)与分页优化器,65B 模型的微调可以被压进单张 48GB 卡。
python
def dequantize_nf4(w_q: torch.Tensor, blocksize=64):
"""NF4 反量化: 每 64 个权重共享一组缩放常数"""
n_code = 16 # NF4 码本 16 个格点
codes = w_q.view(torch.uint8) & 0x0F
out = NF4_CODEBOOK[codes] # 查表得归一化值
scales = w_absmax.reshape(-1, blocksize) # 块级缩放
return (out.reshape(-1, blocksize) * scales).reshape(w_q.shape)
值得强调的实践结论:4 比特基座 + 高精度 LoRA 的组合,在常见下游任务上与 16 比特全参微调的效果差距通常在噪声范围内 ------这是 QLoRA 论文最重要的实证,也是它改变行业成本结构的原因。但要区分两件事:QLoRA 省的是训练 显存;量化后的基座推理同样受益,而 LoRA 分支可与基座合并(W += B·A·scale)也可以保持分离(一个基座热插拔多个任务适配器,服务端按请求切换,这是多租户微调的标准架构)。
三、训练循环与超参的实操谱系
LoRA 的训练循环就是标准的监督微调循环,特殊之处只在参数过滤与合并:
python
def train_lora(model, dataloader, steps=1000, lr=1e-4, r=16):
# 只把 LoRA 参数交给优化器------这一行决定了显存账单
params = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.AdamW(params, lr=lr)
print(f"trainable: {sum(p.numel() for p in params)/1e6:.1f}M params")
for step, batch in enumerate(dataloader):
if step >= steps: break
with torch.autocast("cuda", dtype=torch.bfloat16):
loss = model(**batch).loss
loss.backward()
torch.nn.utils.clip_grad_norm_(params, 1.0)
opt.step(); opt.zero_grad(set_to_none=True)
def merge_lora(model): # 部署合并: 消除推理时的额外矩阵乘
for m in model.modules():
if isinstance(m, LinearWithLoRA):
m.base.weight.data += (m.B @ m.A) * m.scale
超参有一个与直觉相反的谱系:r 不是越大越好 。r 从 8 到 64 在多数任务上提升边际,超过 64 之后往往只剩过拟合风险与训练时间;学习率要显著高于全参微调(1e-4 到 2e-4 对 1e-5 量级),因为可训练参数少、梯度信噪比反而高;数据量上千条高质量样本通常就够,"数据质量大于数量"在 LoRA 场景被反复验证。最后一条忠告:LoRA 改变的是行为风格与领域适配,它不能把新知识可靠地写进模型------需要模型记住新事实的任务,靠微调会得到"看过但复述不准"的结果,正确的工具是检索增强。选对工具,LoRA 是性价比之王;选错工具,它是把错误任务做得更流畅。