时长 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:] 对齐:位置 的输出预测第 个 token。
位置 输入 token 预测目标 是否算 loss
0 <|user|> 2+3 —(标签 −100)
1 2+3 =? —
2 =? <|assistant|> —
3 <|assistant|> 5 ✓
4 5 <|end|> ✓
两个细节:提示的最后一个 token 预测回答的第一个 token,这一对要算;结束符要算,模型要学会停。
为什么要掩掉提示:
- 目标不对。我们要的是 ,不是 。在提示上算 loss,等于同时教模型”怎么当一个用户”。
- 梯度被稀释。第 6 组的 4 条样本里,按全部 token 平均时,60.3% 的梯度来自提示。系统提示和模板每条都一样,这部分梯度几乎是纯粹的浪费。
- 提示很长、回答很短的任务(阅读理解、分类、带长上下文的问答)里,这个比例还会高得多。
掩掉之后还有第二个选择:怎么平均。 第 6 组算了回答里每个 token 的权重:
| 样本 | 提示 | 回答 | 全部 token 平均 | 只平均回答 token | 先每条样本平均再平均 |
|---|---|---|---|---|---|
| 0 | 200 | 50 | 0.00076 | 0.00192 | 0.00500 |
| 1 | 30 | 400 | 0.00076 | 0.00192 | 0.00063 |
| 2 | 500 | 10 | 0.00076 | 0.00192 | 0.02500 |
| 3 | 60 | 60 | 0.00076 | 0.00192 | 0.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% |
| 先按长度排序再组 batch | 0.4% |
| 打包进 2048 的窗口(850 个满窗口,每个平均 5.7 段) | 0% |
按到达顺序补齐,一半以上的计算花在 padding 上。 两个办法:
- 按长度分组(HF 的
group_by_length):几乎消灭 padding,代价是 batch 内的样本不再随机,一个 batch 里全是长样本或全是短样本,梯度噪声的结构变了。通常影响不大。 - 打包:把多条样本首尾相接塞进一个固定长度的窗口。padding 为 0,但带来一个新问题:跨文档注意力。只用普通的因果掩码时,第 7 组打包后的注意力对里 67.8% 落在另一条样本上。
预训练里通常容忍跨文档注意力:文档之间有 EOS 分隔,模型学会了看到 EOS 就不再依赖前文。SFT 的样本短、彼此无关,更干净的做法是:
- 块对角的文档掩码:每条样本只能看到自己。FlashAttention 的变长接口(
cu_seqlens)就是为这个设计的,没有额外计算量。 - 位置编号在每条样本开头重置为 0:否则第 2 段往后每一段的位置编号都从几百、上千开始(第 7 组平均每段约 360 个 token)。用可学习的绝对位置嵌入时,这等于让模型把新样本当成”长文档的后半截”;用 RoPE 并且已经做了文档掩码时,打分只依赖段内的相对距离(Week 1 Day 5 §2),偏移本身不改变结果,但重置后的
position_ids正是 HF 用来找文档边界的信号:给打包后的序列传position_ids、并用支持变长的注意力实现(flash_attention_2)时,它按位置归零的地方把文档切开,块对角掩码就是这样来的。
你需要知道到什么程度:能说清 padding 浪费、打包的收益、跨文档注意力的问题和两个修正。参考代码没有打包(每条样本单独补齐,逻辑最简单),数据量大时可以先用按长度分组。
1.5 SFT 的超参与常见问题
| 旋钮 | 常见取值 | 说明 |
|---|---|---|
| 学习率(全量) | – | 比预训练末期还小;太大会冲掉预训练的能力(Week 1 Day 6 §4.4 的 BERT 经验同理) |
| 学习率(LoRA) | – | 比全量大一个数量级(§3.4 解释为什么) |
| epoch | 1–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) | 给权重矩阵加一个低秩增量 | 可以合并回原权重,推理零额外开销 |
LoRA 胜出的关键就是最后一格:训练时省显存,部署时和原模型一模一样。
§3 LoRA
3.1 动机:微调需要的改动是低秩的
观察:Aghajanyan et al. 2020 发现,微调 RoBERTa 时,只在一个随机投影出来的几百维子空间里优化,就能达到全量微调九成的效果。他们称之为本征维度(intrinsic dimension)很低:预训练模型已经把”怎么做这个任务”的大部分结构准备好了,微调只需要沿着少数几个方向推一下。
LoRA 的假设(Hu et al. 2021):对一个权重矩阵 ,微调需要的改动 的本征秩也很低。于是直接把它参数化成低秩:
秩 够不够,有一个精确的上限。 Eckart–Young 定理:所有秩 的矩阵里,离 (Frobenius 范数下)最近的,就是保留前 个奇异值的截断 SVD,误差是丢掉的奇异值平方和。所以 LoRA 能达到的最好相对误差是 。
w2day6_checks.py 第 1 组:目标 是满秩的 128×128 矩阵,但奇异值按 衰减。LoRA 用 Adam 训练 1500 步:
| 秩 | 理论下限 | LoRA 实际(3 个种子) |
|---|---|---|
| 1 | 0.8825 | 0.8802 ± 0.0027 |
| 4 | 0.6065 | 0.6060 ± 0.0022 |
| 16 | 0.1353 | 0.1335 ± 0.0008 |
| 32 | 0.0183 | 0.0179 ± 0.0001 |
| 64 | 0.0003 | 0.0004 ± 0.0001 |
( 时实际值比下限还略低一点:误差是在有限的 2048 个样本上算的,样本协方差不严格是单位阵,下限只在期望意义上成立。)
意义:LoRA 的训练基本贴着下限走,能学到多少完全由 的谱决定。风格、格式、指令遵循这类改动,谱衰减得快,小 就够;学一门新语言、灌入大量新知识,谱就平,小 会碰到这个下限。这也是 §3.5 “LoRA 学得少、忘得少”的根源。
3.2 形式、梯度与初始化
一层 LoRA 的前向(参考代码 LoRALinear):
先乘 再乘 :计算量是 ,而先算 再乘 要 。
梯度(Week 0 Day 1 §5.0 的五步法,记上游梯度 )。逐元素写出 ,:
对输入还有 :梯度仍然要穿过冻结的 往下一层传。所以 LoRA 省的是 的梯度和优化器状态,不省激活(Week 0 Day 1 §9.2):反向传播要走完整个网络,每一层的激活都得存着。
初始化:为什么是 、 随机。 看上面两个梯度:
- :起点 ,模型和原模型完全相同,微调从”什么都没改”开始。第 0 步 ,但 ; 走一步之后, 也有了梯度。
- 两个都随机:起点就偏离原模型,一上来就在打乱预训练学到的东西。
- 两个都为 0:两个梯度都是 0,而且永远是 0。这是一个鞍点,一步也走不动。
第 2 组(,,训练 200 步):
| 初始化 | 起点输出与原模型的差 | 第 0 步 | 第 0 步 | 200 步后的相对误差 |
|---|---|---|---|---|
| 随机、 | 0 | 0 | 0.691 | 0.0002 |
| 都随机 | 9.50 | 102.3 | 30.9 | 0.0041 |
| 都为 0 | 0 | 0 | 0 | 0.0103(= 完全不微调) |
反过来”、 随机”也能起步(对称地,第 0 步只有 有梯度)。LoRA 论文和 peft 选的是 、 用 Kaiming 均匀分布。关键是恰好有一边为 0。
3.3 合并:推理零开销
训练完,把增量加回原权重:。之后这一层就是一个普通的线性层,结构、速度和原模型完全一样。第 3 组:旁路输出与合并后输出的最大差 ,减回去后与原权重的差 。
参数量:每个 的矩阵多 个参数。Llama-2-7B(32 层,,):
| 加在哪 | |||
|---|---|---|---|
| 只有 、 | 4.2M(0.062%) | 8.4M(0.124%) | 33.6M(0.498%) |
| 全部 7 个线性层 | 20.0M(0.297%) | 40.0M(0.593%) | 159.9M(2.373%) |
一个 的全线性层适配器约 80 MB(bf16),而完整模型是 13.5 GB。
不合并也有用处:同一个底座可以同时挂很多个适配器,按请求切换(多租户服务;vLLM 支持,§A.5)。代价是每次前向多两次小矩阵乘。
要注意的一点:bf16 下合并时 会再舍入一次(Day 4 §3.3:bf16 的间隔在 1 附近是 ),合并后的输出与”底座 + 旁路”不会逐位相同,但差距在舍入误差的量级。参考代码会打印这个差。
3.4 与 :缩放因子在干什么
LoRA 论文用 。为什么要除以 ? 看 Adam 的第一步。
Adam 的第一步(带偏差修正)对每个元素走的正好是 。 还没有梯度(§3.2),只有 在动。单个样本时 ,于是 。输出的变化:
项同号相加,变化量正比于 ;乘上 ,正好抵消。所以 让”第一步改动多大”与 无关:换 时不用重调学习率。这就是论文说的”调 约等于调学习率,所以我们固定 、不去调它”。
第 4 组(Adam,,学习率固定 ,相对误差越小越好):
| 步数 | 缩放 | ||||
|---|---|---|---|---|---|
| 1 | 0.9920 | 0.9913 | 0.9906 | 0.9904 | |
| 1 | 0.9859 | 0.9681 | 0.9294 | 0.8569 | |
| 20 | 0.9480 | 0.8596 | 0.7997 | 0.7797 | |
| 20 | 0.9478 | 0.7935 | 0.4071 | 0.0665 | |
| 100 | 0.8146 | 0.4654 | 0.1792 | 0.0844 | |
| 100 | 0.8167 | 0.4564 | 0.0498 | 0.0000 |
第 1 步: 下四个秩几乎一样(0.9920 到 0.9904),验证了上面的推导。代价也看得见:固定学习率时,大秩几乎不比小秩学得快(第 20 步 是 0.7997, 是 0.7797)。rsLoRA(Kalajdzievski 2023)把这称为大秩”用不上”,主张 ;在它下面,大秩的有效步长更大、学得快得多。
但这不等于 更强。 第 4 组 (b) 让每个(缩放, 秩)组合各自在 7 个学习率里挑最好的,训练 100 步:
| 缩放 | ||||
|---|---|---|---|---|
| 0.5993 @ 1e-2 | 0.1311 @ 3e-2 | 0.0005 @ 3e-2 | 0.0000 @ 3e-2 | |
| 0.6008 @ 1e-2 | 0.1317 @ 1e-2 | 0.0018 @ 1e-2 | 0.0000 @ 3e-3 |
各自重调学习率之后,差距基本消失。 缩放因子和学习率是耦合的: 下最优学习率随 略升, 下随 下降。选哪种缩放,决定的是”换 时学习率要怎么跟着改”。 这个玩具实验不能替代真实模型上的结论;rsLoRA 论文报告,在真实微调里用 时,加大 能继续带来收益。
回答学习计划的自测题”LoRA 的 rank 调大会怎样?alpha 的作用是什么?”
- 调大:表达能力上限提高(§3.1 的下限降低),参数量和优化器状态线性增长(§3.3),但只要 ,显存大头仍然是底座。用 时,固定学习率下大 的收益可能被缩放吃掉;用 rsLoRA 或者重调学习率才能看到。
- :一个乘在 上的固定系数。在 Adam 下它约等于 的学习率倍数。常见设定 或 (参考代码默认 、)。改 时要把它当成改学习率来看待。
- 为什么 LoRA 的学习率要比全量微调大一个数量级(§1.5): 从 0 出发, 又被 缩小,每个参数要走得更远才能产生同样大小的改动。
3.5 注入哪些层
- LoRA 原论文(GPT-3 175B):在固定的参数预算下,同时加在 、 上、每个用很小的 ,比只加一个矩阵、用大 更好。那时的默认是”只加 q、v”。
- QLoRA 论文:要追上 16 bit 全量微调的效果,必须加在全部线性层上;在这个前提下, 的大小反而不太敏感。现在 peft 的
target_modules="all-linear"是最常见的选择。 - MLP 占了大头:Llama 每层 MLP 的参数是注意力的约 2 倍,也是”知识”主要存放的地方(Week 1 Day 2 §1.3 的键值记忆)。只加注意力,等于放弃了大部分可调的方向。
LoRA 学得少、忘得少(Biderman et al. 2024):在代码和数学的继续预训练 / 指令微调上,LoRA 在目标任务上明显不如全量微调,但对原有能力的破坏也小得多。他们还测到,全量微调的 的秩比常用的 LoRA 配置高 10–100 倍。这正是 §3.1 下限的实际体现:任务需要的改动越”满秩”,LoRA 差得越多。参考代码 --mini 在 mini-GPT 上复现这个对比。
3.6 为什么底座可以冻结,甚至量化
学习计划的自测题:“为什么 LoRA 训练时基座权重可以冻结甚至量化,但训练效果还能接受?”
- 能力在底座里(§1.1):微调要的改动小而且低秩(§3.1),底座本身不需要动。
- LoRA 的梯度只需要”穿过”底座(§3.2):、 只依赖本层的输入 和上游梯度 ,而 是通过底座的前向和反向算出来的。底座冻结不妨碍这一点,底座是 4 bit 也不妨碍,只要计算时反量化回 bf16(§4.2)。
- 量化误差是一个固定的扰动,而且 LoRA 训练时看到的就是量化后的底座:适配器学到的是”在这个量化底座上完成任务需要的改动”,还能顺带补偿一部分量化误差(§4.3)。Day 5 §4.1:NF4 的权重相对误差约 0.09,对大模型的困惑度影响很小。QLoRA 论文的实验里,NF4 底座 + LoRA 在 MMLU 等评测上与 16 bit 的 LoRA、全量微调持平。
3.7 变体(知道名字和思路即可)
- LoRA+(Hayou et al. 2024): 和 用不同的学习率, 的大很多(论文建议约 16 倍)。理由和 §3.4 类似:两个矩阵在更新中的角色不对称。
- DoRA(Liu et al. 2024):把权重拆成”每列的长度”和”方向”,长度单独学,方向用 LoRA 改:。peft 里
use_dora=True。 - PiSSA:用 的前 个奇异向量初始化 、,冻结剩下的部分。起点仍然是原模型,但一开始就沿着”最重要”的方向调。
§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, | 100.4 GiB | 13.1 GiB | 3.8 GiB | 0.60 GiB(40M 参数) |
| Llama-2-7B, | 100.4 GiB | 14.9 GiB | 5.6 GiB | 2.38 GiB(160M) |
| LLaMA-65B, | 971.6 GiB | 124.4 GiB | 34.3 GiB | 2.98 GiB(200M) |
| LLaMA-65B, | 971.6 GiB | 133.4 GiB | 43.2 GiB | 11.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)再做矩阵乘:
反向时 ,又要反量化一次; 本身没有梯度(整数码值不可导,也不需要)。LoRA 的 、 用 bf16 存、bf16 算。
代价是速度:每次前向、反向(加上梯度检查点的重算)都要反量化。同样的模型,QLoRA 通常比 bf16 底座的 LoRA 慢,这是用时间换显存。参考代码 --sft 和 --sft --qlora 会分别打印吞吐和显存峰值,让你看到这个交换。
4.3 分页优化器,以及合并的陷阱
分页优化器:优化器状态放在 NVIDIA 的统一内存里。GPU 显存不够时(比如一个特别长的 batch 让激活突然变大),这部分内存被自动换页到 CPU 内存;优化器更新时再换回来。它不减少平均显存,只防止峰值 OOM;不发生换页时几乎没有开销。对 LoRA 来说优化器状态本来就小(7B、:40M 参数的 Adam 状态约 0.3 GiB),所以它主要在大模型、大 时起作用(65B、 时约 6 GiB)。
合并的陷阱(第 8 组):适配器是对着反量化后的 NF4 底座训练的。合并时有三种选择:
| 合并方式 | 与训练时模型的差 | 离目标 |
|---|---|---|
| 合并进反量化的 NF4 底座,存成 16 bit | 0(精确) | 0.00663 |
| 合并进原始 16 bit 底座 | 0.00845 | 0.00195 |
| 合并后再量化回 NF4 | 0.00211 | 0.00895 |
(参考: 本身只占目标输出能量的 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 从立项到上线,每一步在哪一节
| 阶段 | 要回答的问题 | 本周的工具 | 出处 |
|---|---|---|---|
| 定规模 | 给定算力,参数多大、数据多少? | ,,等算力曲线 | Day 1 §3.2 |
| 定规模(考虑推理) | 模型要服务很久,要不要更小、训更久? | 过度训练:Llama 3 8B 每参数 1875 个 token | Day 1 §4 |
| 选架构 | 稠密还是 MoE? | 总参数 vs 激活参数;负载均衡;专家并行 | Day 2 |
| 准备数据 | 从网页到训练语料 | 规则过滤、质量分类器、MinHash + LSH 去重、去污染、退火 | Day 4 §1 |
| 训得动 | 数值格式、显存、多卡 | BF16 + fp32 主权重;16 字节/参数 + 激活;ZeRO / TP / PP | Day 4 §3–5 |
| 训得稳 | loss spike 怎么办 | QK-norm、z-loss、跳过坏批次、回滚 | Day 4 §2 |
| 变成助手 | 格式与行为 | SFT:chat template、只在回答上算 loss、打包 | 今天 §1 |
| 便宜地微调 | 显存不够 | LoRA、QLoRA | 今天 §3–4 |
| 部署 | 显存与速度 | 权重量化:GPTQ / AWQ / NF4;roofline | Day 5 |
| 用起来 | 怎么生成、怎么提示 | 解码策略;ICL;CoT;自洽性 | Day 3 |
每一行你都应该能说出”为什么这么做、不这么做会怎样、用哪个数字来判断”。
5.2 贯穿一周的四条主线
算力账。 Week 1 Day 2 §5.2 的 → Day 1 §3.2 在 的约束下求最优 、 → 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 分钟,纸笔)
- 为什么现代 LLM 几乎全是 decoder-only?给出至少三个理由。
- 写出 Chinchilla 的参数化损失,推出给定 时的 。说明 Kaplan 与 Chinchilla 的结论为什么不同,以及过度训练在什么情况下是对的。
- Mixtral 8x7B 的总参数和激活参数大约各是多少?为什么推理成本不等于 56B?写出 Switch 负载均衡损失,说明它的梯度为什么会把 token 推离过载的专家。
- temperature = 0 和 top-p = 0 是一回事吗?beam search 为什么在开放生成上效果差?
- ICL 的三种机制解释各是什么?为什么示例的顺序会显著影响结果?
- CoT 为什么有效、为什么在小模型上失效?自洽性多付出了什么、换来了什么?投票在什么条件下能提高准确率?
- MinHash 为什么能估计 Jaccard 相似度?LSH 的 S 形曲线从哪来?
- BF16 为什么在训练里打败了 FP16? 为什么 BF16 训练仍然需要 fp32 主权重?
- 7B 全量微调、LoRA、QLoRA 各需要多少显存?ZeRO 的三个阶段各切了什么?流水线气泡公式是什么?
- 写出对称量化的公式;解释离群值为什么让大模型量化变难,以及 LLM.int8、SmoothQuant、GPTQ、AWQ 各自的思路。从分位点构造 NF4。
- 推出 LoRA 的两个梯度,据此解释 初始化;解释 缩放的理由,以及 rsLoRA 为什么主张 。
- SFT 为什么要掩掉提示的 loss?写出一条两轮对话的 labels。打包的收益与问题各是什么?
实战(60 分钟)
- 不看资料写出 top-k、top-p、min-p 与温度组合的采样函数,处理好顺序和边界(全部被过滤、温度为 0)。
- 写出 GPTQ 的逐列量化 + 误差补偿(numpy 即可),在相关输入上验证输出误差低于逐通道 RTN。
- 实现
LoRALinear(前向、合并、卸载),验证 时输出不变、合并前后输出一致。 - 给一条多轮对话写出
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);用 HFTrainer时设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 实操
-
跑通
w2day6_checks.py,8 组结论都看懂。第 4 组先自己预测 下第 1 步四个秩的结果,再看输出。 -
推导(1h):(a) LoRA 的两个梯度,据此说明三种初始化的结局;(b) Adam 第一步下 让改动量与 无关;(c) 7B 模型全量 / LoRA / QLoRA 的显存;(d) 一条两轮对话的 labels。
-
从零实现 LoRA(1h):不看参考代码写出
LoRALinear和add_lora,跑python code/w2d6_lora_sft.py --selftest对拍。 -
mini-GPT 上的对照(1h):
--mini,把 Tiny Shakespeare 的 mini-GPT 微调成”全大写的莎士比亚”,比较全量微调与不同秩、不同位置的 LoRA:学了多少(大写验证 loss)、忘了多少(原任务验证 loss)。 -
学习计划的第 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 底座 — — — -
读论文:LoRA 的第 4 节(方法)与第 7 节( 的秩分析);QLoRA 的第 3 节(三件套)与第 5 节(数据质量的实验)。
参考实现:code/w2d6_lora_sft.py。
出关标准
- 能从一条多轮对话写出 input_ids 和 labels,说清掩码为什么要做、平均方式有什么区别
- 能说清打包的收益、跨文档注意力的问题与修正
- 能推出 LoRA 的两个梯度,解释 初始化
- 能用 Eckart–Young 说明 LoRA 的能力上限,解释”学得少、忘得少”
- 能解释 的理由,回答”rank 调大会怎样、alpha 是什么”
- 能算出 7B 全量 / LoRA / QLoRA 的显存,说清 QLoRA 三件套各自解决什么
- 能说清 QLoRA 合并的三种选择与各自的问题
- 完成 LoRA SFT 主项目,填好 LoRA vs QLoRA 的对比表
- 通过 §5.3 的 Week 2 出关测试