qinghuan
← 预训练与训练工程 / 全部笔记

单元 30 / 预训练与训练工程

P0 · 必会主线L2 · 进阶

LoRA、QLoRA 与量化:低成本适配模型

让基础权重保持冻结,只训练一个低秩增量:推导 LoRA 的形状、初始化和梯度,训练小适配器,再分清 QLoRA 额外的量化机制。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能计算LoRA参数量,核对零初始化首次梯度,训练低秩线性增量并验证合并输出。

学习理由、准备要求与知识点

为什么学它

可训练参数少了,基础模型仍要参与计算。看清低秩与量化各自的限制,才能选目标层、估算内存并验证权重合并。

先读这些内容

知识点清单

  • 低秩矩阵分解
  • A和B的形状
  • alpha/r缩放
  • 冻结基础权重
  • 零B初始化和梯度
  • 适配器合并条件
  • 量化存储与计算dtype
  • QLoRA的NF4与其他机制

运行前先确认Java17、PyTorch2.9.1 CPU;LoRA源码基线PEFT v0.17.1。实验训练原创线性层适配器;附带均匀量化误差演示,不是NF4或完整QLoRA,没有bitsandbytes/GPU运行。

01先弄懂它是什么

LoRA 的做法是保留原权重,再加一条只需训练少量参数的分支。普通线性层用 W 把长度 din 的输入 x 变成长度 dout 的输出,W 有 dout×din 个数。新增分支先用 A 把输入压到 r 维,再用 B 映回输出维,合起来算 Wx+sBAx;A 的形状是 [r,din],B 是 [dout,r],普通 LoRA 的 s=alpha/r。

例如 din=3、dout=2、r=1,新分支共需 3+2 个参数。一般就是 r(din+dout) 个,不必给 W 的每个位置都另设一个可训练改动;r 的选择限制了这条分支能表达的变化。

冻结基础权重 W,意味着训练时不计算它的参数梯度,也不更新它的优化器状态,但模型仍然要保存和使用 W。若更早的适配器需要梯度,反向信号还要通过基础层继续传回去,因此不能把整个基础网络直接放进 no_grad。LoRA 主要减少的是需要训练的状态;前向计算和中间激活不会按相同比例一起消失。

QLoRA 把两个办法放在一起:基础模型冻结,并用较少的位数编码保存;任务适配仍交给可训练的低秩分支。原论文还涉及 NF4 量化、对量化所需常数再压缩的双重量化,以及管理内存的分页优化器。这里的“四比特”说的是基础权重怎样存放,不等于所有乘法、适配器参数都用四比特计算。本文只把数值舍入到间距相同的网格上,演示这种离散化会带来误差;它没有实现 NF4,也没有验证完整 QLoRA 训练效果。

文中这几个词,先认一认

低秩增量
用两个较小矩阵 A、B 的乘积 BA 来表示权重的改动,相当于让新增分支先经过 r 维的中间层,再回到所需输出维度。改动矩阵的秩最多为 r,限制的是这次权重改动的结构,不是把原始输入样本永久变成 r 维。
缩放alpha
控制 LoRA 新增分支影响有多大的设置。普通 LoRA 使用 alpha/r 作为缩放,其他变体可能采用不同规则。
合并
把浮点增量 sBA 加进原权重,推理时用一个线性矩阵完成计算。合并前要明确数据类型和量化如何处理。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

常见线性 LoRA 会把 A 随机初始化,把 B 全设为零。这样初始 BA 为零,新增分支还没有改变基础层输出。第一次反向时,A 的梯度包含 B,通常为零;B 的梯度包含 A 和输入,可以非零。先更新 B 后,A 才能获得学习信号。若 A、B 都设为零,两边梯度都可能为零,分支就学不动。这个说明只针对普通线性分支和这里的初始化,不能推广到所有 PEFT 变体。

实验冻结一个二乘三基础矩阵,构造已知低秩目标增量,通过均方误差只更新A和B。先核对基础输出完全一致,再核对第一次梯度,最后训练并验证目标误差下降。训练结果只是这个合成线性问题可拟合的证据,不是模型已经获得通用指令能力。检查基础权重不变,比单看可训练参数数量更能发现误冻结。

合并等价来自线性代数:Wx+sB(Ax)等于(W+sBA)x。推理时关闭dropout,使用相同缩放与矩阵方向才能对照。浮点舍入会有小误差;量化基础权重需要明确反量化、合并、再量化的流程,重新量化又会引入新误差。训练过程中带dropout的分支不能简单用固定合并矩阵替代其随机行为。

先跑通一个最小例子

运行 experiment.py,在CPU拟合合成线性任务。最后几行只做均匀低位网格演示,不实现NF4,不加载预训练权重。

展开收起代码 · experiment.pypython · 39 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
import torch
torch.set_num_threads(1)
torch.manual_seed(30)
w = torch.randn(2, 3)
base_copy = w.clone()
a = torch.nn.Parameter(torch.randn(1, 3) * 0.1)
b = torch.nn.Parameter(torch.zeros(2, 1))
x = torch.randn(32, 3)
delta = torch.tensor([[0.4], [-0.2]]) @ torch.tensor([[1., -1., 0.5]])
target = x @ (w + delta).T
def forward():
    return x @ w.T + (x @ a.T) @ b.T
assert torch.equal(forward(), x @ w.T)
optimizer = torch.optim.Adam([a, b], lr=0.08)
initial = (forward() - target).square().mean()
initial.backward()
# 零B使首次A梯度为零,但B仍可获得梯度
assert torch.count_nonzero(a.grad) == 0
assert torch.count_nonzero(b.grad) > 0
assert w.grad is None
optimizer.zero_grad(set_to_none=True)
for _ in range(160):
    optimizer.zero_grad(set_to_none=True)
    loss = (forward() - target).square().mean()
    loss.backward()
    optimizer.step()
final = (forward() - target).square().mean().detach()
assert final < initial.detach() * 0.001
assert torch.equal(w, base_copy)
# 线性分支在无dropout且同精度下可以合并
merged = w + b @ a
assert torch.allclose(forward(), x @ merged.T, atol=1e-6)
# 对称均匀15级网格可装入4bit编码,但它不是NF4
values = torch.linspace(-1., 1., 101)
scale = values.abs().max() / 7
quant = (values / scale).round().clamp(-7, 7)
recovered = quant * scale
assert (values - recovered).abs().max() <= scale / 2 + 1e-6
print(f"8 LoRA and uniform quantization checks passed; loss {initial.item():.6f} -> {final.item():.6f}")

核对运行结果八项检查通过:基础权重不变,首次A梯度零、B非零,合成目标误差下降,合并结果一致。

正在加载流程图…

基础计算仍存在;适配器缩小的是更新子空间与相关优化器状态。
查看流程图源码(Mermaid)
flowchart LR
 X["输入x: din"] --> W["冻结W: dout乘din"]
 X --> A["可训练A: r乘din"]
 A --> B["可训练B: dout乘r"]
 B --> S["缩放alpha除r"]
 W --> Y["两分支相加"]
 S --> Y
 Y --> L["损失反向仅更新适配器"]

按顺序看,每一步做了什么

  1. 写清基础层输入输出维度、rank和缩放约定。
  2. 冻结W,随机初始化A并将B清零。
  3. 检查起点输出、首次梯度与基础权重不变。
  4. 训练后在推理模式下比较分支与合并结果,并单测量化误差。

03对照实现代码

PEFT 普通线性LoRA的参数和前向路径 (新标签页)↗

PEFT v0.17.1 · LoraLayer.update_layer;reset_lora_parameters;Linear.forward;get_delta_weight

下例为原创无偏置、无dropout的普通LoRA教学模块,不覆盖PEFT的量化、DoRA、rsLoRA或多适配器分支。

核心机制代码 · python
import torch
class TinyLoRA(torch.nn.Module):
    def __init__(self, weight, rank, alpha):
        super().__init__()
        # 基础权重保留为缓冲,明确不交给优化器
        self.register_buffer("weight", weight.clone())
        self.a = torch.nn.Parameter(torch.randn(rank, weight.shape[1]) * 0.01)
        # B清零保证增量初始为零,A保留非零学习通道
        self.b = torch.nn.Parameter(torch.zeros(weight.shape[0], rank))
        self.scale = alpha / rank
    def forward(self, x):
        # 行向量批次要使用矩阵转置,与列向量公式一致
        return x @ self.weight.T + self.scale * (x @ self.a.T) @ self.b.T

update_layer为普通线性层创建输入到r和r到输出的两个投影;默认缩放与rsLoRA分支不同,配置必须随适配器一起保存。

普通线性reset_lora_parameters将A按指定方式初始化,B置零;Embedding和特殊初始化路径不一定沿用相同布局。

未合并forward先运行基础层,再加入经过dropout、A、B与缩放的增量分支。

get_delta_weight使用B乘A形成增量,并处理权重方向与dtype;合并后仍要做数值及任务评测。

代码先调用谁,再调用谁

  1. 配置目标层并建立A、B与缩放
  2. 初始化适配器后forward叠加增量
  3. 推理合并通过get_delta_weight生成权重增量

04动手写一个例子

低秩分支的矩阵向量计算与合并对照

给定W[dout][din]、A[r][din]、B[dout][r]、x[din]与alpha,计算Wx+(alpha/r)BAx;拒绝不匹配和非有限输入。

输入 1
W为2维单位矩阵,A=[[1,2]],B=[[3],[4]],x=[1,1],alpha=1
输出 1
[10,13]

先验证矩阵的矩形形状和每个数值,避免维度偶然相同掩盖转置错误。

先计算中间向量Ax,长度只有r;再乘B得到低秩输出,避免为单次向量计算显式构建完整BA。

基础输出与缩放增量逐元素相加。与显式合并矩阵做测试,能同时核对顺序、缩放和维度。

为什么这样写是对的完成第k个低秩通道后,每个输出已累加恰好前k个B列对应的A投影贡献;基础分支保持不变。

时间 O(dout·din+r·din+dout·r)额外空间 O(r+dout)

分别计算W乘x、A乘x与B乘中间向量;不创建dout乘din的增量矩阵。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    static void matrix(double[][] m,int rows,int cols){
        if(m.length!=rows)throw new IllegalArgumentException("rows");
        for(double[] row:m){
            if(row==null || row.length!=cols)throw new IllegalArgumentException("cols");
            for(double value:row)if(!Double.isFinite(value))throw new IllegalArgumentException("value");
        }
    }
    static double[] apply(double[][] w,double[][] a,double[][] b,double[] x,double alpha){
        int in=x.length,out=w.length,r=a.length;
        if(in==0 || out==0 || r==0 || !Double.isFinite(alpha))throw new IllegalArgumentException();
        matrix(w,out,in);matrix(a,r,in);matrix(b,out,r);
        for(double value:x)if(!Double.isFinite(value))throw new IllegalArgumentException();
        double[] mid=new double[r],y=new double[out];
        // 先计算低维投影,不显式构造完整增量矩阵
        for(int k=0;k<r;k++)for(int j=0;j<in;j++)mid[k]+=a[k][j]*x[j];
        for(int i=0;i<out;i++){
            // 基础权重在计算中只读,不会被适配器修改
            for(int j=0;j<in;j++)y[i]+=w[i][j]*x[j];
            for(int k=0;k<r;k++)y[i]+=(alpha/r)*b[i][k]*mid[k];
            // 有限输入仍可能算术溢出,结果必须再检查
            if(!Double.isFinite(y[i]))throw new ArithmeticException("overflow");
        }
        return y;
    }
    static void check(boolean ok){if(!ok)throw new AssertionError();}
    public static void main(String[] args){
        double[][] w={{1,0},{0,1}},a={{1,2}},b={{3},{4}};
        check(Arrays.equals(apply(w,a,b,new double[]{1,1},1),new double[]{10,13}));
        check(Arrays.equals(apply(w,a,b,new double[]{1,1},0),new double[]{1,1}));
        check(Arrays.equals(apply(w,a,new double[][]{{0},{0}},new double[]{1,1},1),new double[]{1,1}));
        double[][] merged={{4,6},{4,9}};
        check(Arrays.equals(apply(merged,a,new double[][]{{0},{0}},new double[]{1,1},1),
                            apply(w,a,b,new double[]{1,1},1)));
        try{apply(w,a,new double[][]{{1,2},{3,4}},new double[]{1,1},1);throw new AssertionError();}
        catch(IllegalArgumentException expected){}
        try{apply(w,a,b,new double[]{Double.NaN,1},1);throw new AssertionError();}
        catch(IllegalArgumentException expected){}
        System.out.println("6 low-rank update checks passed");
    }
}

这些特殊情况也要试一试

  • rank、输入和输出维度都必须为正。
  • alpha为零只保留基础分支,但仍验证输入协议。
  • 有限数字累加也可能溢出,结果非有限时显式失败。
进阶选读 · 多个适配器与量化合并

场景同一基础模型挂多个任务适配器,希望减少推理分支又保持任务隔离。

做法保留适配器与基础版本配对信息,在浮点参考上验证各分支之和;如果再量化,重新测量输出误差和任务指标,不把不同rank的缩放直接忽略。

复杂度显式构建单个BA需要O(dout·din·r)计算与O(dout·din)增量空间;在线分支成本则随各rank之和增长。

好处与代价合并减少分支调用但损失快速切换便利,量化再合并可能增加误差且影响共享基础权重的复用。

05项目里怎么用

选择目标层应基于任务和容量,而不是只套一份层名列表。注意力投影、输出投影与前馈网络适配会有不同参数量和表达能力;层名需要在实际模型结构中核对。训练前打印可训练张量名称与数量,检查基础参数是否意外进入优化器,保存rank、alpha、dropout、目标层及基础模型版本。

LoRA 减少了需要训练的参数,也可能省下对应梯度和动量等优化器记录;但此时占内存最多的,可能变成了每层为反向计算保留的中间结果,也就是激活。长序列仍可能需要分批累积梯度,或少存中间结果、需要时重算。基础权重冻结后,前向计算和部分反向传递依然存在。比较内存时,还要分清权重的存储编码、运算时的数据类型 dtype、适配器的数据类型,并计入量化比例尺等附加信息,不能只算“参数个数乘四比特”。

评估需要覆盖基础能力保留、领域任务与格式遵循。rank增加并不必然提升泛化,低数据场景仍可能过拟合。部署合并前后先比较固定输入logits,再比较完整任务指标;保存未合并适配器便于回滚和排查。本文合成线性损失下降不能外推为真实语言模型质量或显存节省量。

容易踩的坑

  • A和B同时清零使普通分支失去学习信号。
  • 把基础网络整体no_grad,截断更早适配器的梯度。
  • 将简单均匀四比特量化称为NF4或完整QLoRA。
  • 合并时忘记alpha/r、转置或量化再编码误差。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
全参数微调更新空间最完整梯度与优化器状态较大资源充足且需要广泛适应
浮点LoRA实现和数值对照较容易基础权重容量仍存在低成本适配的首个基线
量化基础加LoRA进一步压缩基础存储内核依赖与量化误差更复杂已验证兼容性的容量受限训练

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么新增参数是r(din+dout)?

展开参考答案与推导

A有r乘din个元素,B有dout乘r个元素,两者相加;不含可选偏置。

02B为零时第一次A梯度为何通常为零?

展开参考答案与推导

链式法则中传回A的梯度经过B转置,零B使该项为零;B的梯度仍可依赖非零A和输入。

03四比特权重是否说明全训练都用四比特?

展开参考答案与推导

不是;存储编码与反量化后的计算dtype、适配器和优化器状态精度不同。

换个情况,还会处理吗

01适配器训练损失完全不变先查什么?

展开参考答案与推导

检查可训练参数是否进优化器、梯度是否被no_grad截断、是否A和B同时为零,以及损失是否有有效目标。

02合并后输出不同如何定位?

展开参考答案与推导

先关闭dropout,使用同dtype浮点权重,核对矩阵方向与缩放,再单独检查反量化和再量化误差。

03LoRA仍然OOM怎么判断原因?

展开参考答案与推导

分别统计基础权重、适配器状态、激活与临时峰值;如果激活主导,应检查长度、微批次和重计算策略。

写代码试一试

01计算适配器参数数。

输入 / 条件
din=4096,dout=4096,r=8,无偏置
输出 / 预期
65536

提示 把两个矩阵分别计数。

展开参考答案与推导

8×4096+4096×8=65536。

02计算低秩输出。

输入 / 条件
W=I,A=[1,2],B=[3,4]转置,x=[1,1],s=1
输出 / 预期
[10,13]

提示 先计算Ax。

展开参考答案与推导

Ax=3,BAx=[9,12],加基础输出[1,1]。

03给出均匀量化误差检查。

输入 / 条件
网格步长s,数值均在未饱和范围
输出 / 预期
舍入误差不大于s/2,容忍浮点误差

提示 最近网格点距离。

展开参考答案与推导

验证最大绝对误差小于s/2加数值容差;饱和截断时不能使用这个界。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能计算LoRA参数量,核对零初始化首次梯度,训练低秩线性增量并验证合并输出。

本篇重点加练

低秩参数化、缩放与合并定义了同一函数的不同表示,需要加强等价条件审计。

  • 低秩矩阵分解
  • alpha/r缩放
  • 适配器合并条件

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    输入维度 6、输出维度 4、rank=2、alpha=6。写出低秩前向各中间形状、参数量及缩放位置,给一个手算小输入。

    提交内容
    形状图及计算记录
    怎样算完成
    • 矩阵方向一致
    • 额外参数与基础参数分开

    练到的知识点低秩矩阵分解 · A和B的形状 · alpha/r缩放

  2. 作业 02原理问答

    比较 A、B 同时为零、只 B 为零、两者随机三种初始化下的初始输出与梯度条件;用符号表达说明判断所依赖的上游梯度。

    提交内容
    条件分析表
    怎样算完成
    • 冻结参数范围明确
    • 不假定上游梯度永不为零

    练到的知识点冻结基础权重 · 零B初始化和梯度

  3. 作业 03原理问答

    把 A 乘以非零常数 c、B 除以 c,分析前向增量与两因子梯度是否具有相同变化;明确缩放 alpha/r 保持不变。

    提交内容
    前向与梯度条件推导
    怎样算完成
    • 参数化与函数分开
    • c 的合法范围明确

    练到的知识点低秩矩阵分解 · alpha/r缩放

综合应用

  1. 作业 04编程实践

    实现 CPU 低秩线性层,在训练前后检查基础权重未变;保存适配器并重载,比较未合并与合并状态的输出。

    提交内容
    实现、保存文件与误差报告
    怎样算完成
    • 缩放配置随适配器保存
    • 合并前置条件完整

    练到的知识点A和B的形状 · alpha/r缩放 · 冻结基础权重 · 适配器合并条件

  2. 作业 05排错实验

    故意遗漏适配器参数注册、加载错误 alpha、重复合并三种错误,构造能够分别发现问题的测试。

    提交内容
    故障代码与检测记录
    怎样算完成
    • 各故障有独立证据
    • 加载后不仅检查形状

    练到的知识点零B初始化和梯度 · 适配器合并条件

  3. 作业 06面试场景

    评审“基础权重四比特,所以梯度、激活和优化器也各占四比特”的预算表;重建按状态分类的估算并列出未知项。

    提交内容
    预算修订及 dtype 表
    怎样算完成
    • 存储和计算类型分开
    • NF4 不等同任意均匀量化

    练到的知识点量化存储与计算dtype · QLoRA的NF4与其他机制

  4. 作业 07编程实践

    在 CPU 上训练两个不同 rank 的小适配器,再把各自增量转成稠密矩阵,比较秩、输出与合并结果;使用不超过 6×6 的权重。

    提交内容
    小矩阵实验与表示对照
    怎样算完成
    • 原权重和增量分别保存
    • 矩阵秩测定容差记录

    练到的知识点低秩矩阵分解 · 适配器合并条件

  5. 作业 08排错实验

    适配器保存文件缺少 alpha 或目标层标识。设计加载校验和故障样例,判断仅凭 A/B 形状能否安全合并。

    提交内容
    缺字段测试与加载契约
    怎样算完成
    • 身份和缩放均核验
    • 未知配置不静默猜测

    练到的知识点alpha/r缩放 · 适配器合并条件

  6. 作业 09设计评审

    要叠加两个同基础模型训练的适配器,自行定义叠加权重及是否保留独立参数。评审可合并性、rank 变化和任务质量需要的证据。

    提交内容
    组合方案及验收计划
    怎样算完成
    • 代数可合并与任务有效分开
    • 基础权重身份一致

    练到的知识点低秩矩阵分解 · 适配器合并条件

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    用 CPU 小矩阵比较两个 rank 和两种自定义量化粒度的拟合误差;明确该实验与完整 QLoRA 训练在机制及资源验证上的差距。

    提交内容
    受控实验与边界说明
    怎样算完成
    • rank 和量化因素分别控制
    • 未实现机制不宣称已验证

    练到的知识点低秩矩阵分解 · 量化存储与计算dtype · QLoRA的NF4与其他机制

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    “同 rank 的两组 LoRA 因子表达同一增量,就会按相同轨迹优化”是否成立?构造不同因子尺度但初始函数相同的 CPU 对照。

    提交内容
    等价初值及更新轨迹
    怎样算完成
    • 优化器设置一致
    • 函数等价与优化等价分别检验

    练到的知识点低秩矩阵分解 · alpha/r缩放

  2. 作业 12思维拓展 · 开放探究

    将多个低秩增量合成为一个更低 rank 近似,会引入什么可测误差?在小矩阵上分别评估权重误差和任务输出误差,不要求量化 GPU 工具链。

    提交内容
    近似实验与误差定义
    怎样算完成
    • 两类误差不混用
    • 近似 rank 和数据分布明确

    练到的知识点低秩矩阵分解 · 适配器合并条件

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗