第 2 周 · Day 6:SFT、LoRA 与 QLoRA,以及第 2 周整合

Transformers and Large Language Models · Week 2 · Day 6。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

时长 7–8 小时:§1 监督微调(1.5h)· §2 为什么要参数高效(0.5h)· §3 LoRA(2h)· §4 QLoRA(1h)· §5 第 2 周整合与出关测试(2.5h)· §A–§B 穿插进行

对应正课 Lecture 4 的后半部分:监督微调(SFT)、参数高效微调(LoRA)。学习计划点名要会的:chat template、loss 只在 response 上算、数据质量远比数量重要;LoRA 的动机、rank 和 alpha 怎么选、注入哪些层、合并回原权重;QLoRA = 4-bit 基座 + LoRA 适配器 + paged optimizer。

配套代码 w2day6_checks.py(numpy):LoRA 的秩与截断 SVD 下限、三种初始化、合并与参数量、α/r 与 α/√r、全量 / LoRA / QLoRA 的显存账、loss 掩码与各种平均、打包与跨文档注意力、QLoRA 的合并陷阱

§B 参考实现(code/ 目录):w2d6_lora_sft.py(从零实现 LoRA 并在 mini-GPT 上对比全量微调;用 peft 在 Qwen2.5-0.5B 上做 LoRA / QLoRA 的 SFT,合并导出)。

今天的目标:能从一条对话数据写出 input_ids 和 labels;能推出 LoRA 的梯度、说清 B = 0 初始化和 α/r 缩放的理由;能算出 7B 全量 / LoRA / QLoRA 的显存;能说清 QLoRA 三件套各自解决什么;完成第 2 周的出关测试。


§0 今天的主线

预训练得到的是一个文本续写器(Day 1 §1.1):给它”法国的首都是”,它接”巴黎”;给它一个问题,它可能接着再编三个问题。要把它变成能对话、听指令的助手,最直接的办法是:拿”指令 → 好回答”的数据,继续做下一个 token 预测。这就是监督微调(SFT)。

SFT 本身没有新的数学:损失还是交叉熵。今天的难点都在工程细节上,而每个细节都会实打实地影响结果:数据怎么排成模型认识的格式(§1.2)、哪些 token 该算 loss(§1.3)、短样本怎么拼(§1.4)。

然后是成本。全量微调 7B 要约 100 GiB 显存(Day 4 §4.1),而 SFT 需要的改动其实很小。LoRA 只训练一个低秩的增量,QLoRA 再把冻结的底座压到 4 bit。今天会把这两者从第一性原理推一遍:为什么低秩够用、为什么 B 要初始化为 0、α 在干什么、底座为什么可以冻结甚至量化。

今天要用到的旧结论:16 字节/参数的显存账与”LoRA 省了什么,没省什么”(Week 0 Day 1 §9.1–9.3);交叉熵的 ignore_index=-100(Week 0 Day 5 §A.1);梯度累积里”平均的平均”(Day 4 §4.3);梯度检查点(Day 4 §4.2);NF4 与双重量化(Day 5 §4);BERT 的”预训练 + 微调”范式(Week 1 Day 6 §4.4);FFN 的键值记忆(Week 1 Day 2 §1.3)。


§1 监督微调(SFT)

1.1 SFT 改变了什么

动机:预训练模型已经”知道”很多东西,但不知道该以什么方式回应。问它”写一首关于秋天的诗”,续写器眼里这只是一段文本的开头,最自然的续写可能是”……的要求,请在周五前提交”。

直觉:SFT 教的主要是格式和行为:看到”用户说了一句话、然后轮到助手”这种结构时,产出一个有帮助、完整、会停下来的回答。这就是 LIMA(Zhou et al. 2023)提出的表面对齐假说(superficial alignment hypothesis):模型的知识和能力几乎全部来自预训练,对齐主要是学会”用哪种子分布说话”。他们只用 1000 条精心挑选的样本微调 65B 的 LLaMA,就得到了很有竞争力的助手。

意义与代价:

  • 数据质量远比数量重要(学习计划的要点)。LIMA 是一个例子;QLoRA 论文(Dettmers et al. 2023)里,9k 条的 OASST1 在聊天评测上胜过了 45 万条的 FLAN v2 子集。多样、准确、风格一致的几千条,往往胜过几十万条嘈杂的合成数据。
  • SFT 很难教会新知识。用模型预训练时没见过的事实去做 SFT,模型学得慢,而且会提高它一本正经地编造的倾向(Gekhman et al. 2024):它学到的是”对这类问题要自信地给出具体答案”,而不是答案本身。要注入新知识,靠继续预训练或检索(第 4 周的 RAG)。
  • 会遗忘。在窄领域数据上训久了,通用能力会下降。这是 §3 的 LoRA 在实践里受欢迎的原因之一:它”学得少,也忘得少”(§3.5)。

1.2 Chat template:把对话排成一串 token

模型只认识一串 token。多轮对话要用特殊 token标出每条消息的角色和边界。Qwen 系列用的是 ChatML:

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
2+3=?<|im_end|>
<|im_start|>assistant
5<|im_end|>

为什么要这么排:

  • 角色边界:模型要知道哪些话是用户说的、哪些是自己说的、什么时候轮到自己。
  • 结束符:<|im_end|> 是模型学会停下来的唯一途径。训练数据里回答后面没有它,模型就会一直写下去(常见症状:回答完又自己编一轮”用户”的问题)。
  • 生成时的提示:推理时把对话排好,末尾加上 <|im_start|>assistant\n(add_generation_prompt=True),模型接着写的就是助手的回答。

模板必须和训练时完全一致。换一个模型就要换它自己的模板(Llama 3 用 <|start_header_id|>…<|end_header_id|>,Gemma 用 <start_of_turn>)。多一个空格、少一个换行,都可能让指令模型的表现明显变差。所以永远用 tokenizer.apply_chat_template,不要手拼字符串(§A.4)。

base 模型还是 instruct 模型:学习计划的主项目在 base 模型上做 SFT,这样才看得到”从续写器变成助手”的完整变化。在 instruct 模型上继续 SFT 也很常见(做领域适配),但要沿用它原来的模板。

1.3 只在回答上算 loss

标签怎么对齐(w2day6_checks.py 第 6 组):labels 和 input_ids 等长;在不想训练的位置填 −100(F.cross_entropy 的默认 ignore_index,Week 0 Day 5 §A.1)。模型内部把 logits[:, :-1] 和 labels[:, 1:] 对齐:位置 tt 的输出预测第 t+1t+1 个 token。

位置  输入 token          预测目标                  是否算 loss
  0   <|user|>            2+3                       —(标签 −100)
  1   2+3                 =?                        —
  2   =?                  <|assistant|>             —
  3   <|assistant|>       5                         ✓
  4   5                   <|end|>                   ✓

两个细节:提示的最后一个 token 预测回答的第一个 token,这一对要算;结束符要算,模型要学会停。

为什么要掩掉提示:

  1. 目标不对。我们要的是 p(回答∣提示)p(\text{回答}\mid\text{提示}),不是 p(提示)p(\text{提示})。在提示上算 loss,等于同时教模型”怎么当一个用户”。
  2. 梯度被稀释。第 6 组的 4 条样本里,按全部 token 平均时,60.3% 的梯度来自提示。系统提示和模板每条都一样,这部分梯度几乎是纯粹的浪费。
  3. 提示很长、回答很短的任务(阅读理解、分类、带长上下文的问答)里,这个比例还会高得多。

掩掉之后还有第二个选择:怎么平均。 第 6 组算了回答里每个 token 的权重:

样本提示回答全部 token 平均只平均回答 token先每条样本平均再平均
0200500.000760.001920.00500
1304000.000760.001920.00063
2500100.000760.001920.02500
360600.000760.001920.00417

按 token 平均,长回答的样本对梯度的贡献大;按样本平均,10 个 token 的回答里每个 token 的权重是 400 个 token 回答的 40 倍。两种都说得通,但你要知道自己在用哪一种。这和 Day 4 §4.3 梯度累积的”平均的平均”是同一个问题:一个批次先求平均、再在累积步之间求平均,就悄悄变成了”按微批次加权”。参考代码 sum_loss 的做法是:每个微批次求和,最后除以整个累积步的回答 token 总数,结果与一次性的大批次完全相同(自检第 5 项)。

多轮对话:每一轮助手的回答都要算 loss,用户的每一轮都掩掉。参考代码 build_example 逐条渲染模板、按字符串切段,每段单独分词:助手的内容和它的结束符算 loss,其余(包括 <|im_start|>assistant\n 这样的角色头)填 −100。

1.4 打包(packing)

SFT 的样本长短不一。第 7 组模拟了 4000 条样本(长度中位数 310,最长 2048),序列上限 2048,batch 8:

组 batch 的方式padding 占比
按到达顺序,补齐到 batch 内最长58.6%
先按长度排序再组 batch0.4%
打包进 2048 的窗口(850 个满窗口,每个平均 5.7 段)0%

按到达顺序补齐,一半以上的计算花在 padding 上。 两个办法:

  • 按长度分组(HF 的 group_by_length):几乎消灭 padding,代价是 batch 内的样本不再随机,一个 batch 里全是长样本或全是短样本,梯度噪声的结构变了。通常影响不大。
  • 打包:把多条样本首尾相接塞进一个固定长度的窗口。padding 为 0,但带来一个新问题:跨文档注意力。只用普通的因果掩码时,第 7 组打包后的注意力对里 67.8% 落在另一条样本上。

预训练里通常容忍跨文档注意力:文档之间有 EOS 分隔,模型学会了看到 EOS 就不再依赖前文。SFT 的样本短、彼此无关,更干净的做法是:

  1. 块对角的文档掩码:每条样本只能看到自己。FlashAttention 的变长接口(cu_seqlens)就是为这个设计的,没有额外计算量。
  2. 位置编号在每条样本开头重置为 0:否则第 2 段往后每一段的位置编号都从几百、上千开始(第 7 组平均每段约 360 个 token)。用可学习的绝对位置嵌入时,这等于让模型把新样本当成”长文档的后半截”;用 RoPE 并且已经做了文档掩码时,打分只依赖段内的相对距离(Week 1 Day 5 §2),偏移本身不改变结果,但重置后的 position_ids 正是 HF 用来找文档边界的信号:给打包后的序列传 position_ids、并用支持变长的注意力实现(flash_attention_2)时,它按位置归零的地方把文档切开,块对角掩码就是这样来的。

你需要知道到什么程度:能说清 padding 浪费、打包的收益、跨文档注意力的问题和两个修正。参考代码没有打包(每条样本单独补齐,逻辑最简单),数据量大时可以先用按长度分组。

1.5 SFT 的超参与常见问题

旋钮常见取值说明
学习率(全量)1×10−51\times10^{-5} – 2×10−52\times10^{-5}比预训练末期还小;太大会冲掉预训练的能力(Week 1 Day 6 §4.4 的 BERT 经验同理)
学习率(LoRA)1×10−41\times10^{-4} – 3×10−43\times10^{-4}比全量大一个数量级(§3.4 解释为什么)
epoch1–3小数据集上多了就过拟合:训练 loss 继续降,回答开始逐字复读训练集
最大长度覆盖 95% 以上的样本被截断的样本可能丢了结尾的结束符,模型会学到”不停”

常见问题:

  • 模型不停:训练数据里没有结束符,或者结束符被掩掉了;或者推理时 eos_token_id 没设成模板的结束符(Qwen 的 <|im_end|> 与分词器的 eos_token 不一定相同,§A.4)。
  • 回答复读提示:没做掩码,或掩码错位了一格。训练前打印一条样本,把 labels ≠ −100 的部分解码出来看一眼,这是最便宜的检查。
  • loss 很低但效果差:大概率是 loss 算在了模板和系统提示上(这些很好预测,把平均 loss 拉低了)。

§2 为什么要参数高效

显存(Week 0 Day 1 §9.1、Day 4 §4.1):全量微调每个参数要 16 字节(bf16 权重与梯度、fp32 主权重、fp32 的 Adam m 与 v)。7B 模型光参数状态就是 100.4 GiB,一张 80GB 卡放不下。

存储与部署:每个任务存一份完整的 7B 权重(13.5 GB),十个任务就是 140 GB;服务时每个任务要单独加载一个模型。

遗忘:全量微调可以任意改动所有参数,在窄数据上最容易把通用能力改坏。

参数高效微调(PEFT) 的思路是:冻结底座,只训练很少的新参数。主要流派:

方法做法代价
Adapter(Houlsby et al. 2019)每层插入一个小的瓶颈 MLP推理时多了串行的层,有延迟
Prefix / Prompt tuning在输入或每层的 K/V 前面拼可学习的向量占用上下文长度;小模型上效果不稳定
LoRA(Hu et al. 2021)给权重矩阵加一个低秩增量 ΔW=BA\Delta W=BA可以合并回原权重,推理零额外开销

LoRA 胜出的关键就是最后一格:训练时省显存,部署时和原模型一模一样。


§3 LoRA

3.1 动机:微调需要的改动是低秩的

观察:Aghajanyan et al. 2020 发现,微调 RoBERTa 时,只在一个随机投影出来的几百维子空间里优化,就能达到全量微调九成的效果。他们称之为本征维度(intrinsic dimension)很低:预训练模型已经把”怎么做这个任务”的大部分结构准备好了,微调只需要沿着少数几个方向推一下。

LoRA 的假设(Hu et al. 2021):对一个权重矩阵 W0∈Rdout×dinW_0\in\mathbb R^{d_{out}\times d_{in}},微调需要的改动 ΔW\Delta W 的本征秩也很低。于是直接把它参数化成低秩:

ΔW=γBA,B∈Rdout×r, A∈Rr×din, r≪min⁡(din,dout).\Delta W=\gamma BA,\qquad B\in\mathbb R^{d_{out}\times r},\ A\in\mathbb R^{r\times d_{in}},\ r\ll\min(d_{in},d_{out}).

秩 rr 够不够,有一个精确的上限。 Eckart–Young 定理:所有秩 ≤r\le r 的矩阵里,离 ΔW∗\Delta W^*(Frobenius 范数下)最近的,就是保留前 rr 个奇异值的截断 SVD,误差是丢掉的奇异值平方和。所以 LoRA 能达到的最好相对误差是 ∑i>rσi2/∑iσi2\sum_{i>r}\sigma_i^2/\sum_i\sigma_i^2。

w2day6_checks.py 第 1 组:目标 ΔW∗\Delta W^* 是满秩的 128×128 矩阵,但奇异值按 e−i/16e^{-i/16} 衰减。LoRA 用 Adam 训练 1500 步:

秩 rr理论下限LoRA 实际(3 个种子)
10.88250.8802 ± 0.0027
40.60650.6060 ± 0.0022
160.13530.1335 ± 0.0008
320.01830.0179 ± 0.0001
640.00030.0004 ± 0.0001

(r≤32r\le32 时实际值比下限还略低一点:误差是在有限的 2048 个样本上算的,样本协方差不严格是单位阵,下限只在期望意义上成立。)

意义:LoRA 的训练基本贴着下限走,能学到多少完全由 ΔW∗\Delta W^* 的谱决定。风格、格式、指令遵循这类改动,谱衰减得快,小 rr 就够;学一门新语言、灌入大量新知识,谱就平,小 rr 会碰到这个下限。这也是 §3.5 “LoRA 学得少、忘得少”的根源。

3.2 形式、梯度与初始化

一层 LoRA 的前向(参考代码 LoRALinear):

h=W0x+γ B (Ax),u=Ax∈Rr.h=W_0x+\gamma\,B\,(Ax),\qquad u=Ax\in\mathbb R^r.

先乘 AA 再乘 BB:计算量是 r(din+dout)r(d_{in}+d_{out}),而先算 BABA 再乘 xx 要 dindoutrd_{in}d_{out}r。

梯度(Week 0 Day 1 §5.0 的五步法,记上游梯度 g=∂L/∂hg=\partial L/\partial h)。逐元素写出 hi=∑jW0,ijxj+γ∑kBikukh_i=\sum_jW_{0,ij}x_j+\gamma\sum_kB_{ik}u_k,uk=∑jAkjxju_k=\sum_jA_{kj}x_j:

∂L∂Bik=γ gi uk ⇒ ∂L∂B=γ g u⊤,∂L∂Akj=γ∑igiBik xj ⇒ ∂L∂A=γ (B⊤g) x⊤.\frac{\partial L}{\partial B_{ik}}=\gamma\,g_i\,u_k\ \Rightarrow\ \frac{\partial L}{\partial B}=\gamma\,g\,u^\top,\qquad \frac{\partial L}{\partial A_{kj}}=\gamma\sum_ig_iB_{ik}\,x_j\ \Rightarrow\ \frac{\partial L}{\partial A}=\gamma\,(B^\top g)\,x^\top.

对输入还有 ∂L/∂x=W0⊤g+γA⊤B⊤g\partial L/\partial x=W_0^\top g+\gamma A^\top B^\top g:梯度仍然要穿过冻结的 W0W_0 往下一层传。所以 LoRA 省的是 W0W_0 的梯度和优化器状态,不省激活(Week 0 Day 1 §9.2):反向传播要走完整个网络,每一层的激活都得存着。

初始化:为什么是 B=0B=0、AA 随机。 看上面两个梯度:

  • B=0B=0:起点 ΔW=0\Delta W=0,模型和原模型完全相同,微调从”什么都没改”开始。第 0 步 ∂L/∂A=γB⊤(⋅)=0\partial L/\partial A=\gamma B^\top(\cdot)=0,但 ∂L/∂B=γgu⊤≠0\partial L/\partial B=\gamma g u^\top\ne0;BB 走一步之后,AA 也有了梯度。
  • 两个都随机:起点就偏离原模型,一上来就在打乱预训练学到的东西。
  • 两个都为 0:两个梯度都是 0,而且永远是 0。这是一个鞍点,一步也走不动。

第 2 组(d=64d=64,r=8r=8,训练 200 步):

初始化起点输出与原模型的差第 0 步 ∥∂L/∂A∥\lVert\partial L/\partial A\rVert第 0 步 ∥∂L/∂B∥\lVert\partial L/\partial B\rVert200 步后的相对误差
AA 随机、B=0B=0000.6910.0002
都随机9.50102.330.90.0041
都为 00000.0103(= 完全不微调)

反过来”A=0A=0、BB 随机”也能起步(对称地,第 0 步只有 AA 有梯度)。LoRA 论文和 peft 选的是 B=0B=0、AA 用 Kaiming 均匀分布。关键是恰好有一边为 0。

3.3 合并:推理零开销

训练完,把增量加回原权重:W′=W0+γBAW'=W_0+\gamma BA。之后这一层就是一个普通的线性层,结构、速度和原模型完全一样。第 3 组:旁路输出与合并后输出的最大差 5×10−155\times10^{-15},减回去后与原权重的差 3×10−173\times10^{-17}。

参数量:每个 dout×dind_{out}\times d_{in} 的矩阵多 r(din+dout)r(d_{in}+d_{out}) 个参数。Llama-2-7B(32 层,d=4096d=4096,dff=11008d_{ff}=11008):

加在哪r=8r=8r=16r=16r=64r=64
只有 WqW_q、WvW_v4.2M(0.062%)8.4M(0.124%)33.6M(0.498%)
全部 7 个线性层20.0M(0.297%)40.0M(0.593%)159.9M(2.373%)

一个 r=16r=16 的全线性层适配器约 80 MB(bf16),而完整模型是 13.5 GB。

不合并也有用处:同一个底座可以同时挂很多个适配器,按请求切换(多租户服务;vLLM 支持,§A.5)。代价是每次前向多两次小矩阵乘。

要注意的一点:bf16 下合并时 W0+γBAW_0+\gamma BA 会再舍入一次(Day 4 §3.3:bf16 的间隔在 1 附近是 2−72^{-7}),合并后的输出与”底座 + 旁路”不会逐位相同,但差距在舍入误差的量级。参考代码会打印这个差。

3.4 rr 与 α\alpha:缩放因子在干什么

LoRA 论文用 γ=α/r\gamma=\alpha/r。为什么要除以 rr? 看 Adam 的第一步。

Adam 的第一步(带偏差修正)对每个元素走的正好是 −lr⋅sign(梯度)-\text{lr}\cdot\text{sign}(\text{梯度})。AA 还没有梯度(§3.2),只有 BB 在动。单个样本时 ∂L/∂Bik=γgiuk\partial L/\partial B_{ik}=\gamma g_iu_k,于是 ΔBik=−lr⋅sign(gi) sign(uk)\Delta B_{ik}=-\text{lr}\cdot\text{sign}(g_i)\,\text{sign}(u_k)。输出的变化:

(ΔB u)i=∑k=1rΔBikuk=−lr⋅sign(gi)∑k=1r∣uk∣ ∝ r.(\Delta B\,u)_i=\sum_{k=1}^r\Delta B_{ik}u_k=-\text{lr}\cdot\text{sign}(g_i)\sum_{k=1}^r|u_k|\ \propto\ r.

rr 项同号相加,变化量正比于 rr;乘上 γ=α/r\gamma=\alpha/r,正好抵消。所以 α/r\alpha/r 让”第一步改动多大”与 rr 无关:换 rr 时不用重调学习率。这就是论文说的”调 α\alpha 约等于调学习率,所以我们固定 α\alpha、不去调它”。

第 4 组(Adam,α=16\alpha=16,学习率固定 5×10−45\times10^{-4},相对误差越小越好):

步数缩放r=4r=4r=16r=16r=64r=64r=256r=256
1α/r\alpha/r0.99200.99130.99060.9904
1α/r\alpha/\sqrt r0.98590.96810.92940.8569
20α/r\alpha/r0.94800.85960.79970.7797
20α/r\alpha/\sqrt r0.94780.79350.40710.0665
100α/r\alpha/r0.81460.46540.17920.0844
100α/r\alpha/\sqrt r0.81670.45640.04980.0000

第 1 步:α/r\alpha/r 下四个秩几乎一样(0.9920 到 0.9904),验证了上面的推导。代价也看得见:固定学习率时,大秩几乎不比小秩学得快(第 20 步 r=64r=64 是 0.7997,r=256r=256 是 0.7797)。rsLoRA(Kalajdzievski 2023)把这称为大秩”用不上”,主张 γ=α/r\gamma=\alpha/\sqrt r;在它下面,大秩的有效步长更大、学得快得多。

但这不等于 α/r\alpha/\sqrt r 更强。 第 4 组 (b) 让每个(缩放, 秩)组合各自在 7 个学习率里挑最好的,训练 100 步:

缩放r=4r=4r=16r=16r=64r=64r=256r=256
α/r\alpha/r0.5993 @ 1e-20.1311 @ 3e-20.0005 @ 3e-20.0000 @ 3e-2
α/r\alpha/\sqrt r0.6008 @ 1e-20.1317 @ 1e-20.0018 @ 1e-20.0000 @ 3e-3

各自重调学习率之后,差距基本消失。 缩放因子和学习率是耦合的:α/r\alpha/r 下最优学习率随 rr 略升,α/r\alpha/\sqrt r 下随 rr 下降。选哪种缩放,决定的是”换 rr 时学习率要怎么跟着改”。 这个玩具实验不能替代真实模型上的结论;rsLoRA 论文报告,在真实微调里用 α/r\alpha/\sqrt r 时,加大 rr 能继续带来收益。

回答学习计划的自测题”LoRA 的 rank 调大会怎样?alpha 的作用是什么?”

  • rr 调大:表达能力上限提高(§3.1 的下限降低),参数量和优化器状态线性增长(§3.3),但只要 r≪dr\ll d,显存大头仍然是底座。用 α/r\alpha/r 时,固定学习率下大 rr 的收益可能被缩放吃掉;用 rsLoRA 或者重调学习率才能看到。
  • α\alpha:一个乘在 ΔW\Delta W 上的固定系数。在 Adam 下它约等于 ΔW\Delta W 的学习率倍数。常见设定 α=2r\alpha=2r 或 α=r\alpha=r(参考代码默认 r=16r=16、α=32\alpha=32)。改 α\alpha 时要把它当成改学习率来看待。
  • 为什么 LoRA 的学习率要比全量微调大一个数量级(§1.5):BB 从 0 出发,ΔW=γBA\Delta W=\gamma BA 又被 γ\gamma 缩小,每个参数要走得更远才能产生同样大小的改动。

3.5 注入哪些层

  • LoRA 原论文(GPT-3 175B):在固定的参数预算下,同时加在 WqW_q、WvW_v 上、每个用很小的 rr,比只加一个矩阵、用大 rr 更好。那时的默认是”只加 q、v”。
  • QLoRA 论文:要追上 16 bit 全量微调的效果,必须加在全部线性层上;在这个前提下,rr 的大小反而不太敏感。现在 peft 的 target_modules="all-linear" 是最常见的选择。
  • MLP 占了大头:Llama 每层 MLP 的参数是注意力的约 2 倍,也是”知识”主要存放的地方(Week 1 Day 2 §1.3 的键值记忆)。只加注意力,等于放弃了大部分可调的方向。

LoRA 学得少、忘得少(Biderman et al. 2024):在代码和数学的继续预训练 / 指令微调上,LoRA 在目标任务上明显不如全量微调,但对原有能力的破坏也小得多。他们还测到,全量微调的 ΔW\Delta W 的秩比常用的 LoRA 配置高 10–100 倍。这正是 §3.1 下限的实际体现:任务需要的改动越”满秩”,LoRA 差得越多。参考代码 --mini 在 mini-GPT 上复现这个对比。

3.6 为什么底座可以冻结,甚至量化

学习计划的自测题:“为什么 LoRA 训练时基座权重可以冻结甚至量化,但训练效果还能接受?”

  1. 能力在底座里(§1.1):微调要的改动小而且低秩(§3.1),底座本身不需要动。
  2. LoRA 的梯度只需要”穿过”底座(§3.2):∂L/∂A\partial L/\partial A、∂L/∂B\partial L/\partial B 只依赖本层的输入 xx 和上游梯度 gg,而 gg 是通过底座的前向和反向算出来的。底座冻结不妨碍这一点,底座是 4 bit 也不妨碍,只要计算时反量化回 bf16(§4.2)。
  3. 量化误差是一个固定的扰动,而且 LoRA 训练时看到的就是量化后的底座:适配器学到的是”在这个量化底座上完成任务需要的改动”,还能顺带补偿一部分量化误差(§4.3)。Day 5 §4.1:NF4 的权重相对误差约 0.09,对大模型的困惑度影响很小。QLoRA 论文的实验里,NF4 底座 + LoRA 在 MMLU 等评测上与 16 bit 的 LoRA、全量微调持平。

3.7 变体(知道名字和思路即可)

  • LoRA+(Hayou et al. 2024):AA 和 BB 用不同的学习率,BB 的大很多(论文建议约 16 倍)。理由和 §3.4 类似:两个矩阵在更新中的角色不对称。
  • DoRA(Liu et al. 2024):把权重拆成”每列的长度”和”方向”,长度单独学,方向用 LoRA 改:W′=m⊙W0+BA∥W0+BA∥cW'=m\odot\frac{W_0+BA}{\lVert W_0+BA\rVert_c}。peft 里 use_dora=True。
  • PiSSA:用 W0W_0 的前 rr 个奇异向量初始化 AA、BB,冻结剩下的部分。起点仍然是原模型,但一开始就沿着”最重要”的方向调。

§4 QLoRA

4.1 三件套与显存账

QLoRA(Dettmers et al. 2023)= 4-bit NF4 底座 + LoRA 适配器 + 分页优化器,其中底座还用了双重量化。每一件解决的问题:

组件解决什么出处
NF4底座从 2 字节/参数压到约 0.5 字节/参数,精度损失比均匀 INT4 小Day 5 §4.1
双重量化absmax 常数的开销从 0.5 bit/参数降到 0.127 bit/参数Day 5 §4.3
LoRA只有适配器需要梯度和优化器状态§3
分页优化器显存峰值时把优化器状态临时挪到 CPU 内存,避免 OOM§4.3

显存账(第 5 组;可训练参数按 16 字节算,LoRA 加在全部线性层,不含激活):

模型全量微调LoRA(bf16 底座)QLoRA(NF4 底座)其中 LoRA 部分
Llama-2-7B,r=16r=16100.4 GiB13.1 GiB3.8 GiB0.60 GiB(40M 参数)
Llama-2-7B,r=64r=64100.4 GiB14.9 GiB5.6 GiB2.38 GiB(160M)
LLaMA-65B,r=16r=16971.6 GiB124.4 GiB34.3 GiB2.98 GiB(200M)
LLaMA-65B,r=64r=64971.6 GiB133.4 GiB43.2 GiB11.91 GiB(800M)

65B 的 NF4 底座是 31.3 GiB。这就是论文”单张 48GB 卡微调 65B”的来源;还要给激活留地方,所以同时开梯度检查点,显存峰值时靠分页优化器兜底。

回答学习计划的自测题”全量微调一个 7B 模型(BF16 + AdamW)大约需要多少显存?LoRA 呢?QLoRA 呢?”

  • 全量:参数状态约 100 GiB(16 字节/参数)。再加激活:序列 4096、batch 1 时,FlashAttention 下约 17 GiB,再开梯度检查点约 1.5 GiB(Day 4 §4.1–4.2)。单张 80GB 卡放不下,要 ZeRO / FSDP 切到多卡(Day 4 §5.2)。
  • LoRA:底座 bf16 约 12.6 GiB + 适配器状态不到 1 GiB,约 13 GiB + 激活。一张 24GB 卡可以(开梯度检查点)。
  • QLoRA:底座约 3.2 GiB + 适配器,约 4 GiB + 激活。一张 16GB 甚至 8GB 的卡可以(短序列、梯度检查点)。
  • 激活不随 LoRA 减少(§3.2),长序列时它会成为主角。

4.2 计算流:存 4 bit,算 16 bit

NF4 只是存储格式。前向时每一层临时把权重反量化成 bf16(bnb_4bit_compute_dtype,Day 5 §A.1)再做矩阵乘:

Wbf16=dequant(WNF4),h=Wbf16 x+γB(Ax).W_{bf16}=\text{dequant}(W_{NF4}),\qquad h=W_{bf16}\,x+\gamma B(Ax).

反向时 ∂L/∂x=(Wbf16+γBA)⊤g\partial L/\partial x=(W_{bf16}+\gamma BA)^\top g,又要反量化一次;WNF4W_{NF4} 本身没有梯度(整数码值不可导,也不需要)。LoRA 的 AA、BB 用 bf16 存、bf16 算。

代价是速度:每次前向、反向(加上梯度检查点的重算)都要反量化。同样的模型,QLoRA 通常比 bf16 底座的 LoRA 慢,这是用时间换显存。参考代码 --sft 和 --sft --qlora 会分别打印吞吐和显存峰值,让你看到这个交换。

4.3 分页优化器,以及合并的陷阱

分页优化器:优化器状态放在 NVIDIA 的统一内存里。GPU 显存不够时(比如一个特别长的 batch 让激活突然变大),这部分内存被自动换页到 CPU 内存;优化器更新时再换回来。它不减少平均显存,只防止峰值 OOM;不发生换页时几乎没有开销。对 LoRA 来说优化器状态本来就小(7B、r=16r=16:40M 参数的 Adam 状态约 0.3 GiB),所以它主要在大模型、大 rr 时起作用(65B、r=64r=64 时约 6 GiB)。

合并的陷阱(第 8 组):适配器是对着反量化后的 NF4 底座训练的。合并时有三种选择:

合并方式与训练时模型的差离目标 W+ΔW∗W+\Delta W^*
合并进反量化的 NF4 底座,存成 16 bit0(精确)0.00663
合并进原始 16 bit 底座0.008450.00195
合并后再量化回 NF40.002110.00895

(参考:ΔW∗\Delta W^* 本身只占目标输出能量的 0.0002,NF4 量化误差占 0.0084。)

  • 第 1 行和训练时的模型完全一样。
  • 第 2 行是常见做法:把适配器加到原始 bf16 权重上。得到的模型和训练时差了约”一个 NF4 量化误差”,比 LoRA 学到的改动本身大几十倍。这个例子里它离目标反而更近(适配器主要学的是任务,底座换回了更准的权重),但适配器里也有一部分在补偿量化误差,换了底座这部分就变成了噪声。它是一个没被评估过的新模型,合并后要重新评估。 参考代码 --sft --qlora 最后会比较两者的测试 loss。
  • 第 3 行最差:再量化引入的误差比 LoRA 的改动大一个数量级,改动被淹没了。想要 4 bit 部署,就保留”NF4 底座 + 16 bit 适配器”不合并,或者合并成 16 bit 之后再用 GPTQ / AWQ 做一次正经的量化(Day 5 §3),并重新评估。

§5 第 2 周整合

5.1 全景图:一个 LLM 从立项到上线,每一步在哪一节

阶段要回答的问题本周的工具出处
定规模给定算力,参数多大、数据多少?L=E+A/Nα+B/DβL=E+A/N^\alpha+B/D^\beta,C≈6NDC\approx6ND,等算力曲线Day 1 §3.2
定规模(考虑推理)模型要服务很久,要不要更小、训更久?过度训练:Llama 3 8B 每参数 1875 个 tokenDay 1 §4
选架构稠密还是 MoE?总参数 vs 激活参数;负载均衡;专家并行Day 2
准备数据从网页到训练语料规则过滤、质量分类器、MinHash + LSH 去重、去污染、退火Day 4 §1
训得动数值格式、显存、多卡BF16 + fp32 主权重;16 字节/参数 + 激活;ZeRO / TP / PPDay 4 §3–5
训得稳loss spike 怎么办QK-norm、z-loss、跳过坏批次、回滚Day 4 §2
变成助手格式与行为SFT:chat template、只在回答上算 loss、打包今天 §1
便宜地微调显存不够LoRA、QLoRA今天 §3–4
部署显存与速度权重量化:GPTQ / AWQ / NF4;rooflineDay 5
用起来怎么生成、怎么提示解码策略;ICL;CoT;自洽性Day 3

每一行你都应该能说出”为什么这么做、不这么做会怎样、用哪个数字来判断”。

5.2 贯穿一周的四条主线

算力账。 Week 1 Day 2 §5.2 的 6ND6ND → Day 1 §3.2 在 C=6NDC=6ND 的约束下求最优 N∗N^*、D∗D^* → Day 1 §4.1 把推理算力也算进来,得到过度训练 → Day 2 §2.2 MoE 按激活参数算 FLOPs,总参数只占显存 → Day 3 §5.3 自洽性用推理算力换准确率。先问”算力花在哪里”,大部分设计选择就有了答案。

字节账。 Day 4 §4.1 的 16 字节/参数 → Day 4 §5.2 ZeRO 把冗余的字节切掉 → Day 5 §5 解码时读一遍权重的字节数决定速度上限,所以 4 bit 能加速 → Day 2 §2.3 MoE 解码时要读多少专家取决于 batch → 今天 §4.1 LoRA 底座 2 字节、QLoRA 约 0.5 字节。显存和解码速度都是字节问题。

误差可以搬走、可以补偿,但不会消失。 Day 5 §3.2 SmoothQuant 把激活的量化难度搬到权重上 → Day 5 §3.3 GPTQ 把每一列的量化误差分摊给还没量化的列 → Day 5 §3.4 AWQ 放大重要通道,让误差落到不重要的地方 → Day 5 §6 QAT 让其他参数学会补偿 → 今天 §4.3 QLoRA 的适配器顺带补偿了量化误差,所以换底座合并要重新评估。每一个”几乎无损”的方法,都要问一句:误差去哪了。

度量在测什么。 Day 1 §5 的”涌现”很大程度上是不连续指标造成的 → Day 3 §3.2 长上下文”能放进去”不等于”能用上”(迷失在中间)→ Day 3 §4.4 ICL 的准确率对示例顺序高度敏感 → Day 5 §A.3 困惑度只涨一点,数学和代码可能掉很多 → 今天 §1.5 SFT 的 loss 很低,可能只是模板很好预测。看到一个数字,先问它在测什么、在哪种条件下测的。

5.3 Week 2 出关测试(2.5h,闭卷)

理论(90 分钟,纸笔)

  1. 为什么现代 LLM 几乎全是 decoder-only?给出至少三个理由。
  2. 写出 Chinchilla 的参数化损失,推出给定 CC 时的 N∗∝Cβ/(α+β)N^*\propto C^{\beta/(\alpha+\beta)}。说明 Kaplan 与 Chinchilla 的结论为什么不同,以及过度训练在什么情况下是对的。
  3. Mixtral 8x7B 的总参数和激活参数大约各是多少?为什么推理成本不等于 56B?写出 Switch 负载均衡损失,说明它的梯度为什么会把 token 推离过载的专家。
  4. temperature = 0 和 top-p = 0 是一回事吗?beam search 为什么在开放生成上效果差?
  5. ICL 的三种机制解释各是什么?为什么示例的顺序会显著影响结果?
  6. CoT 为什么有效、为什么在小模型上失效?自洽性多付出了什么、换来了什么?投票在什么条件下能提高准确率?
  7. MinHash 为什么能估计 Jaccard 相似度?LSH 的 S 形曲线从哪来?
  8. BF16 为什么在训练里打败了 FP16? 为什么 BF16 训练仍然需要 fp32 主权重?
  9. 7B 全量微调、LoRA、QLoRA 各需要多少显存?ZeRO 的三个阶段各切了什么?流水线气泡公式是什么?
  10. 写出对称量化的公式;解释离群值为什么让大模型量化变难,以及 LLM.int8、SmoothQuant、GPTQ、AWQ 各自的思路。从分位点构造 NF4。
  11. 推出 LoRA 的两个梯度,据此解释 B=0B=0 初始化;解释 α/r\alpha/r 缩放的理由,以及 rsLoRA 为什么主张 α/r\alpha/\sqrt r。
  12. SFT 为什么要掩掉提示的 loss?写出一条两轮对话的 labels。打包的收益与问题各是什么?

实战(60 分钟)

  1. 不看资料写出 top-k、top-p、min-p 与温度组合的采样函数,处理好顺序和边界(全部被过滤、温度为 0)。
  2. 写出 GPTQ 的逐列量化 + 误差补偿(numpy 即可),在相关输入上验证输出误差低于逐通道 RTN。
  3. 实现 LoRALinear(前向、合并、卸载),验证 B=0B=0 时输出不变、合并前后输出一致。
  4. 给一条多轮对话写出 input_ids 和 labels(用任意一个 chat template),打印出 labels ≠ −100 的部分,确认只有助手的内容和结束符。

通过线:理论 12 题至少 10 题讲清推导过程和动机,实战 4 项全部通过。达不到的项回到对应的 Day 重读。

5.4 进入第 3 周之前

  • 保留 LoRA SFT 的模型和适配器(code/w2d6_out/)。第 3 周 Lecture 5 的 DPO 就在这个 SFT 模型上继续做,对比”只 SFT”和”SFT + DPO”的差异。
  • 保留 Day 3 的 GSM8K 评测脚本和结果。Lecture 6 的 GRPO 与测试时缩放会再用到它:同一批题目,看 RL 训练和多数投票各带来多少提升。
  • 把本周四个实验的结果整理成一页笔记:缩放律外推(Day 1)、MoE 负载(Day 2)、解码网格与 GSM8K(Day 3)、量化对比与 LoRA / QLoRA 对比(Day 5–6)。每个实验一张表加三句结论。
  • 第 3 周第 1 天是期中考试(2025 期中卷,闭卷、计时、手写)。它覆盖第 1–2 周的全部内容,上面的出关测试就是最好的准备。

§A SFT 与 LoRA:API 用法与边界

A.1 peft 的 LoraConfig

from peft import LoraConfig, get_peft_model
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
                 target_modules="all-linear",          # 或 ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
                 use_rslora=False,                     # True:缩放用 α/√r(§3.4)
                 use_dora=False,                       # True:DoRA(§3.7)
                 modules_to_save=None,                 # 需要全量训练的模块,例如新增 token 时的 embed_tokens、lm_head
                 task_type="CAUSAL_LM")
model = get_peft_model(model, cfg)
model.print_trainable_parameters()
  • target_modules 的名字随模型而变:Llama / Qwen 是 q_proj、down_proj 这类名字,GPT-2 是 c_attn、c_fc。不确定时打印 model.named_modules() 看一眼,或者用 "all-linear"(它会自动跳过输出层 lm_head)。
  • 新增了特殊 token(例如给 base 模型加上 chat template 的角色 token):要先 model.resize_token_embeddings(len(tok)),再把 embed_tokens、lm_head 放进 modules_to_save。否则新 token 的嵌入是随机的,而且永远不会被训练。Qwen 的分词器里 ChatML 的 token 本来就有,不需要这一步。
  • lora_dropout 只作用在旁路的输入上(参考代码 LoRALinear.forward),不影响底座。

A.2 梯度检查点与 LoRA

model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.enable_input_require_grads()
model.config.use_cache = False
  • enable_input_require_grads 解决什么:旧的(reentrant)检查点实现里,如果一段被检查点包住的计算的输入都不需要梯度,这段的输出也不会需要梯度,里面的 LoRA 参数就收不到梯度。底座的嵌入层冻结时,第一层的输入恰好不需要梯度。这个函数给嵌入层的输出挂一个钩子,让它 requires_grad=True。用 use_reentrant=False 时一般不需要,但加上无害(Day 4 §A.1 提到的就是这件事)。
  • 训练时关掉 KV Cache,生成时再打开(参考代码的 probe 就是这样做的)。

A.3 QLoRA 的加载

from peft import prepare_model_for_kbit_training
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
                         bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16)
model = AutoModelForCausalLM.from_pretrained(name, quantization_config=bnb, device_map={"": 0})
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True,
                                        gradient_checkpointing_kwargs={"use_reentrant": False})
  • prepare_model_for_kbit_training 把归一化层等小参数转成 fp32 以求稳定,开启梯度检查点,并做 §A.2 的输入梯度处理。
  • 分页优化器:自己写循环时用 bitsandbytes.optim.PagedAdamW(或 PagedAdamW8bit,优化器状态也量化成 8 bit);用 HF Trainer 时设 optim="paged_adamw_8bit" 或 "paged_adamw_32bit"。
  • 需要 CUDA(Day 5 §A.1)。Mac 上先跑 bf16 / fp32 底座的 LoRA。

A.4 chat template 与 trl 的 SFTTrainer

text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)   # 推理:末尾加助手的角色头
ids = tok(text, add_special_tokens=False).input_ids                                     # 模板里已经有特殊 token 了
  • 不要重复加 BOS:模板渲染出来的文本里通常已经包含 BOS(Llama 系),再用默认的 add_special_tokens=True 分词会多出一个。参考代码一律 add_special_tokens=False。
  • 停止符:base 模型分词器的 eos_token 往往不是模板的回合结束符。Qwen2.5 的 base 模型里 eos_token 是 <|endoftext|>,而 ChatML 的回合结束是 <|im_end|>。生成时要把两者都放进 eos_token_id(参考代码 end_ids),否则模型答完了还会继续写。
  • Qwen3 的模板会改写历史轮次(例如处理思考内容),不满足”每一步渲染结果是下一步的前缀”。参考代码遇到这种样本会跳过并计数;单轮样本不受影响。
  • 用 trl 的 SFTTrainer 时,训练前逐项确认三件事:(1) loss 是否只算在助手的回答上(这个开关的名字和默认值在不同版本里变过,以你安装版本的文档为准);(2) 是否开启了打包、打包时有没有做文档掩码(§1.4);(3) 它有没有自动对 messages 字段套用 chat template。最可靠的检查永远是:取一条处理后的样本,把 labels ≠ −100 的部分解码出来看。

A.5 合并、导出与部署

merged = model.merge_and_unload()                    # PeftModel → 普通的 transformers 模型
merged.save_pretrained(out_dir); tok.save_pretrained(out_dir)

# QLoRA:在 16 bit 底座上加载适配器再合并(§4.3:合并后要重新评估)
base = AutoModelForCausalLM.from_pretrained(name, torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, adapter_dir).merge_and_unload()
  • vLLM 可以直接加载合并后的目录:LLM(model=out_dir)。也可以不合并,用 LLM(model=base_name, enable_lora=True),生成时传 lora_request=LoRARequest("sft", 1, adapter_dir),同一个底座服务多个适配器(§3.3)。
  • 合并后的模型要重新跑一遍评测,尤其是 QLoRA 的适配器合并进 16 bit 底座时。
  • 只保存适配器时,加载要用同一个底座(同一个名字、同一个版本)。换了底座,适配器就不对了。

§B 实操

  1. 跑通 w2day6_checks.py,8 组结论都看懂。第 4 组先自己预测 α/r\alpha/r 下第 1 步四个秩的结果,再看输出。

  2. 推导(1h):(a) LoRA 的两个梯度,据此说明三种初始化的结局;(b) Adam 第一步下 α/r\alpha/r 让改动量与 rr 无关;(c) 7B 模型全量 / LoRA / QLoRA 的显存;(d) 一条两轮对话的 labels。

  3. 从零实现 LoRA(1h):不看参考代码写出 LoRALinear 和 add_lora,跑 python code/w2d6_lora_sft.py --selftest 对拍。

  4. mini-GPT 上的对照(1h):--mini,把 Tiny Shakespeare 的 mini-GPT 微调成”全大写的莎士比亚”,比较全量微调与不同秩、不同位置的 LoRA:学了多少(大写验证 loss)、忘了多少(原任务验证 loss)。

  5. 学习计划的第 2 周主项目(3h,需要 GPU 或耐心):

    • --sft:Qwen2.5-0.5B(或学习计划的 Qwen3-1.7B-Base、Llama-3.2-1B)在 no_robots 的 2000 条上做 LoRA SFT,记录 loss 曲线,对比微调前后的回答;
    • --sft --qlora --paged:同样的配置做 QLoRA,填下面这张表;
    • 合并导出,用 transformers 或 vLLM 加载合并后的模型做推理。
    显存峰值吞吐(tok/s)训练用时测试集回答 loss回答质量(主观)
    LoRA(bf16 底座)
    QLoRA(NF4 底座)
    QLoRA 合并进 16 bit 底座———
  6. 读论文:LoRA 的第 4 节(方法)与第 7 节(ΔW\Delta W 的秩分析);QLoRA 的第 3 节(三件套)与第 5 节(数据质量的实验)。

参考实现:code/w2d6_lora_sft.py。


出关标准

  • 能从一条多轮对话写出 input_ids 和 labels,说清掩码为什么要做、平均方式有什么区别
  • 能说清打包的收益、跨文档注意力的问题与修正
  • 能推出 LoRA 的两个梯度,解释 B=0B=0 初始化
  • 能用 Eckart–Young 说明 LoRA 的能力上限,解释”学得少、忘得少”
  • 能解释 α/r\alpha/r 的理由,回答”rank 调大会怎样、alpha 是什么”
  • 能算出 7B 全量 / LoRA / QLoRA 的显存,说清 QLoRA 三件套各自解决什么
  • 能说清 QLoRA 合并的三种选择与各自的问题
  • 完成 LoRA SFT 主项目,填好 LoRA vs QLoRA 的对比表
  • 通过 §5.3 的 Week 2 出关测试

疑问清单

← 返回课程目录