【案例】Qwen 微调实战 · LoRA / QLoRA 与 DeepSpeed

主力机床一颗螺丝都不动,换活儿只换那套快换夹具——冻结原权重、只训低秩旁路,再把夹具图纸压成 4-bit 存起来。

30″30 秒看懂 LoRA 与 QLoRA

车间里有一台价值千万的主力机床,精度是几十年攒出来的。现在来了一批新活儿,要加工一种没做过的零件。

最笨的办法是把机床整个重新校一遍——拆下来、改导轨、重调主轴。新活儿是能干了,但老活儿的精度也一起被改没了,而且停机几个月。聪明的办法是:机床本体一颗螺丝都不动,另做一套快换夹具装上去。换活儿只换夹具,夹具很轻、很便宜、随时能摘。

再进一步:夹具图纸太占地方,那就压缩存放——压缩包不能直接上机,用的时候解开就是了。这就是 QLoRA 比 LoRA 多做的那一步。

图① 30 秒看懂:机床本体不动,只换夹具
图① 30 秒看懂:机床本体不动,只换夹具
车间里的东西对应的技术概念它到底是什么
主力机床本体预训练权重 W训练全程冻结,梯度不流过它,一个数都不改
快换夹具(两块板)低秩旁路 AB唯一被训练的东西。A·B 去逼近「本该对 W 做的那点改动」
夹具的厚度档位lora_rank(秩 r)决定夹具能有多复杂,也线性决定要训多少参数
夹具用多大力lora_alpha缩放系数,控制旁路输出在最终结果里占多大权重
图纸压缩存放4-bit 量化(NF4)QLoRA 的第一块。存 4-bit,算的时候反量化回高精度
压缩包目录再压一次双重量化第二次量化只作用在第一次产生的量化常数上
料架放不下先挪到隔壁分页优化显存不够时把优化器状态自动换出到主机内存,要用再取回
车间里多台机床分工DeepSpeed 并行与 ZeRO把料、把矩阵、把网络层分给不同的卡,再把该存的东西切开分摊
⛔ 整讲只有一条铁律 原矩阵 W 从头到尾不动。所有学到的东西都在旁路里,所有省下来的显存都来自「不用为 W 存梯度和优化器状态」。量化改的只是存放方式,不是计算方式——算的时候仍要反量化回高精度。谁要是说「QLoRA 是在 4-bit 上做矩阵乘法」,那是把存和算混为一谈了。
这一页和上一页的分工 上一页管立项:定任务、挑基座、写菜谱、备料、上菜单。这一页开始真正点火:夹具怎么设计、图纸怎么压、车间怎么分工、三份工艺卡(yaml)每一行是什么意思。上一页留下的 cutoff_len: 1024val_size: 0.1 这些数,在这一页会以训练参数的身份重新出现。

01概念:为什么不能整台机床重新校

全量微调的三个缺陷、LoRA 的应对思路,以及 LoRA 与 QLoRA 到底差在哪一步

1.1 全量微调的三个缺陷

全量微调(finetuning_type: full)就是把模型所有参数都放开来更新。它效果好、思路直接,但有三个绕不过去的缺陷:

01训练成本高

需要更新千亿甚至万亿级别的参数,消耗大量计算资源。而且为了调整海量参数时不跑偏,还需要准备相当规模的数据集——算力和数据两头都贵。

02训练时间长

全量微调的训练周期比较长,有些场合需要快速迭代,这个速度跟不上业务的节奏。等训完,需求可能已经变了。

03灾难性遗忘

全量微调要根据新任务调整和重构预训练模型的所有参数,有可能导致原有的知识表征被破坏——新任务上表现虽好,旧任务上急剧下降。这就是大模型的灾难性遗忘。

第三点是最致命的,也最符合直觉:把整台机床拆开重调,新活儿是能干了,几十年攒下来的老精度也一起改没了。前两点还能靠加钱、加时间解决,遗忘是加钱也买不回来的。

为什么遗忘叫「灾难性」而不是「有点退步」 因为它不是均匀地退一点点,而是某些能力整块塌掉。你拿抽取任务的几千条数据去全量微调,模型抽取变强了,但可能连正常对话都不会了——它被那几千条样本重新定义了「该说什么话」。样本越少、训得越久,塌得越厉害。

1.2 LoRA 的思路:不改机床,另做夹具

先想清楚全量微调在数学上做了什么:它把一个巨大的权重矩阵 W 改成了 W + ΔW。真正承载「新任务学到的东西」的,其实只是那个 ΔW

那么,为了避免灾难性遗忘,训练时就不要去动原始矩阵 W,而是单独训一个同样大小的矩阵 ΔW,推理时用 W + ΔW 代替原来的 W

问题是:ΔWW 一样大,训练的参数量一点都没少。遗忘解决了,成本和时间没解决。

这时候借一个机器学习里的老思路——矩阵分解。PCA 用过它,潜在语义分析(LSA)用过它,推荐系统的协同过滤也用过它。做法是:

⛔ LoRA 的核心一句话 不去真正训练那个巨大的 ΔW,而是训练两个小型矩阵 AB,使得 A·B ≈ ΔW只要这两个结果足够近似,在工程上就是可行的。如果秩 r 远小于 MN,参数量就能被极大压缩。

三个缺陷于是被一一化解:

缺陷LoRA 的应对为什么有效
训练成本高只训 AB可训练参数降到原来的万分之几,优化器状态跟着一起降——显存主要就省在这里
训练时间长反向传播只更新旁路要算梯度的参数少了几个数量级,单步更快;小数据集也能训得动
灾难性遗忘原矩阵 W 完全冻结原有知识表征在物理上不可能被破坏——梯度根本不流到它上面。夹具摘掉,机床还是原来那台
一个顺带的好处:适配器可插拔 旁路是独立于主干的一小包权重(通常几十 MB)。同一个基座可以挂不同的适配器应对不同任务,换任务只换这一小包,不用各存一份几十 GB 的完整模型。这就是「快换夹具」这个比喻最贴切的地方——夹具是一套一套的,机床只有一台

1.3 LoRA 与 QLoRA 差在哪一步

QLoRA(Quantized Low-Rank Adaptation)是 LoRA 的进一步扩展,结合了深度量化技术。它把预训练模型量化为低精度格式(如 4 位),并在训练过程中使用高精度格式(如 bf16)进行反量化。这样可以在保持模型精度的同时,进一步减少存储需求和计算复杂度。

维度全量微调LoRAQLoRA
原权重 W参与更新冻结,以 bf16/fp16 存放冻结,以 4-bit 存放
训练的是全部参数低秩旁路 AB低秩旁路 AB(与 LoRA 相同)
配置里的 finetuning_typefulllora仍然是 lora,靠 quantization_bit: 4 区分
显存(7B 实测)42.18GB20.17GB10.97GB
额外代价旁路只是近似,表达能力弱于全量在 LoRA 之上再叠一层量化误差,且反量化有额外计算开销
典型学习率1e-51e-41e-4
⚠️ QLoRA 不是「第三种微调类型」 配置里 finetuning_type 写的还是 lora。QLoRA = LoRA + 基座 4-bit 量化,差别只有 quantization_bitquantization_method 这两行。把它当成一种独立方法去找「qlora」这个选项,是会找不到的。同理,合并权重时 finetuning_type 也一律填 lora,无论当初是 LoRA 还是 QLoRA 训的。
三者怎么选 显存够、要极致效果、且不怕遗忘(比如做继续预训练)→ 全量。绝大多数业务微调 → LoRA,这是默认答案。单卡显存实在紧张(比如只有一张 12GB 的卡)→ QLoRA,拿一点精度换进门票。反过来,显存明明够却上 QLoRA,等于白白背上量化误差和反量化开销。

02原理:夹具怎么算、图纸怎么压、车间怎么分工

参数量推导、初始化的讲究、量化的两套公式、NF4 与双重量化、并行策略与 ZeRO

2.1 低秩分解:把一个胖矩阵换成两个瘦矩阵

假设要适配的权重矩阵 WM × N。全量微调要更新 M × N 个参数;LoRA 冻结 W,改为训练 AM × r)与 Br × N),用 A·B 去逼近 ΔW

图② 低秩旁路的参数量账:两个瘦矩阵代替一个胖矩阵
图② 低秩旁路的参数量账:两个瘦矩阵代替一个胖矩阵

把数代进去算一遍——取 M = 300000,N = 500000,r = 16

对象形状参数量说明
原矩阵 W300000 × 5000001500 亿全量微调要更新的量;LoRA 里它冻结不训
矩阵 A300000 × 16480 万M × r
矩阵 B16 × 500000800 万r × N
旁路合计1280 万480 万 + 800 万
占比0.008533%约为总参数量的 0.000085,即不到万分之一

这个账可以自己跑一遍验证,脚本在 03 节。这里先看两个由它直接推出来的结论:

1参数量随 r 线性增长

旁路参数量是 r × (M + N)r 翻倍,参数就翻倍。所以 lora_rank 不是越大越好——秩越高越接近全量微调,省显存的好处也就越少。

2省的其实是优化器状态

可训练参数少了几个数量级,AdamW 为每个可训练参数额外保存的两份动量也跟着少。显存降下来的主因在这里,而不是「模型变小了」——基座该占多少还占多少。

2.2 实际做法:每层都挂旁路,A 高斯、B 全零

做法有三条,前两条是结构,第三条是初始化,也是最容易被问倒的一条:

  • 每个 transformer 层都添加低秩旁路。 不是只在最后一层加,而是逐层加——配置里 lora_target: all 表示所有线性层都挂上。
  • 矩阵 A 高斯初始化。
  • 矩阵 B 初始化为全 0 矩阵。
⛔ 为什么不能两个都为 0,也不能两个都高斯 都为 0:模型学不动。 AB 全零时,B 的梯度正比于 AA 的梯度正比于 B,两边互相为零,梯度恒为 0,参数永远停在原地。
都高斯:一开始就把原模型搞坏了。 此时 A·B ≠ 0,训练还没开始,旁路就已经在往原输出上叠一堆随机噪声——第 0 步的模型就不是原模型了,相当于夹具还没调好就先把工件铣花了。
一高斯一全零,两个问题同时解决:B = 0 保证 A·B = 0,训练起点严格等于原模型A ≠ 0 保证 B 的梯度不为零,模型立刻能学。

2.3 量化的基本盘:对称与非对称

在讲 NF4 之前,先把量化本身讲清楚。常用的量化方法包括对称量化(symmetric quantization)和非对称量化(asymmetric quantization),它们都是线性映射(linear mapping)的不同形式。

对称量化

核心思想是将浮点数量化为整数,且量化后的分布是关于零对称的。以量化为 Int8 为例:

步骤公式说明
算缩放系数Scale = |R_max| / Q_max|R_max| 是原始浮点数范围内的最大绝对值,Q_max 是量化后整数的最大绝对值
量化Q = Round(R / Scale)R 是要量化的浮点数,Q 是量化后的整数值,要取整
裁剪Q = Clip(Q, -127, 127)控制不要超出范围。工程上一般舍弃 -128,正负对称好算
反量化R' = Q × ScaleR' 是反量化后恢复的近似浮点值——注意是近似,不是原值

非对称量化

并不是以零为中心对称的。它将浮点数范围中的最小值 β 和最大值 α 映射到量化范围的最小值和最大值,充分利用量化后的整数范围。实现上靠一个缩放因子(scale)和一个偏移量(zero-point):

步骤公式说明
算 ScaleScale = (R_max − R_min) / (Q_max − Q_min)用的是区间宽度之比,而不是最大绝对值之比
算 Zero-pointZero-point = Q_min − R_min / Scale结果要取整。它是「浮点 0 落在整数轴的哪个位置」
量化Q = Round(R / Scale + Zero-point)比对称量化多了一次平移
裁剪Q = Clip(Q, Q_min, Q_max)同样要夹在范围内
反量化R' = Scale × (Q − Zero-point)先减偏移再乘尺度,顺序不能反

量化到底换来了什么

假设有一个浮点输入矩阵 X_f 和一个浮点权重矩阵 W_f。先把两者做对称量化变成 X_qW_q,那么两个浮点矩阵的乘法就变成了两个整数矩阵的乘法,计算速度提升,代价是精度误差:

对称量化下的矩阵乘法
X_f · W_f = (s_x · X_q) @ (s_w · W_q) = s_x · s_w · (X_q @ W_q)

两个 s 是两个矩阵各自的缩放系数。整数矩阵乘完之后,再乘上两个标量就还原了尺度。

非对称量化多了 zero-point,展开之后会多出三项:

非对称量化下的矩阵乘法
X_f · W_f = ((X_q − Z_x) · s_x) @ ((W_q − Z_w) · s_w)
          = s_x · s_w · (X_q @ W_q − X_q @ Z_w − W_q @ Z_x + Z_x @ Z_w)
维度对称量化非对称量化
是否需要 zero-point不需要需要
整数范围利用率数据偏向一侧时会浪费半边充分利用整个整数范围
矩阵乘法展开项1 项,最简洁4 项,多出三项交叉修正
适合什么数据关于 0 大致对称的数据,比如神经网络权重明显偏向一侧的数据,比如 ReLU 之后的激活值
为什么权重量化偏爱对称 神经网络的权重本来就大致以 0 为中心对称分布,用非对称量化多背一个 zero-point、多算三项,换来的收益却很小。这也是下面 NF4 只做归一化、不做偏移的原因。

2.4 QLoRA 第一块:4 位标准浮点数量化(NF4)

QLoRA 的重点有三个。第一个是 4 位标准浮点数量化(4-bit NormalFloat Quantization),第二个是双重量化,第三个是分页优化。先说第一个。

图③ 4-bit 量化的三个手法:NF4、双重量化、分页优化
图③ 4-bit 量化的三个手法:NF4、双重量化、分页优化

关键前提是一句观察:对于神经网络,一般参数值都符合正态分布——0 附近的参数很多,远离 0 的参数很少。用前面那种均匀切分的线性量化是不太合适的:它在几乎没有数据的两端浪费了大量档位,而在数据最密集的 0 附近分辨率反而不够。

NF4 的做法是:将正态分布按照其累计概率密度分为 16 个区域(因为 4-bit 量化后的整数正好有 16 个),对每个区间求出起始点和终止点对应的 X 值。结果就是越靠近 0 越密集,越远离 0 越稀疏

走一遍完整流程

输入向量 X = [0.32, -1.76, 0.025, -1.22]

步骤动作结果
absmax(X)1.76
归一化 X / absmax(X)[0.1818, -1, 0.0142, -0.6932],全部落进 [-1, 1]
按 NF4 分位点舍入取最近的那个分位点,实际存下来的只有 0~15 的索引
反量化索引查表取回分位点,再乘回 absmax

NF4 的 16 个分位点:

NF4 分位点
NF4 = [-1.0000, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911,
        0.0796,  0.1609,  0.2461,  0.3379,  0.4407,  0.5626, 0.7230, 1.0000]

把相邻间隔算出来就能看见那句「靠近 0 更密」是真的:两端的间隔是 0.30380.2770,而中间几段只有 0.08 上下。

⚠️ 归一化这一步不能省 分位点全部落在 [-1, 1] 区间内,所以必须先用 absmax 把数据缩放到同一区间才能比。这也意味着每一块数据都会产生一个量化常数(就是那个 absmax),它必须原样存下来,否则反量化时无从还原尺度。这些常数本身也占空间——下一节的双重量化处理的正是它们。

2.5 QLoRA 第二块与第三块:双重量化与分页优化

双重量化(Double Quantization)

定义很直接:双重量化是量化「量化常数」以进一步减少内存的过程。将第一次量化的常数作为第二次量化的输入,进行第二级的量化,进一步减小所需的空间。

用比喻说 第一次量化把每一份夹具图纸都压成了小文件,但压完会多出一本索引册记录每份图纸的还原比例。图纸越分块、索引册越厚。双重量化就是把这本索引册自己也压一遍——图纸本身一个字节都不再动。所以块越小、常数越多,这一步省下来的就越可观。

分页优化(Paged Optimizer)

它用于管理大语言模型训练期间的内存使用。在训练具有数十亿个参数的大型模型时,GPU 内存不足是一个常见问题,分页优化器就是用来解决训练期间发生的这些内存峰值的。

机制是:NVIDIA 统一内存可实现 CPU 和 GPU 之间的自动页面到页面传输,以便在 GPU 显存不够的情况下仍能正常处理。具体来说,在 GPU 内存不足时,自动将其移出到 CPU 的 RAM 中,并在优化器更新步骤需要时取回到 GPU 显存中

⚠️ 分页优化治的是「峰值」,不是「平均」 它的价值在于让偶发的显存尖峰不至于直接崩掉训练,不是让你可以长期超配。如果每一步都在换进换出,PCIe 带宽会成为瓶颈,训练速度会显著下降——能跑完,但慢得不像话。看到训练巨慢又没报错时,先查是不是一直在分页。

2.6 DeepSpeed 的并行策略

DeepSpeed 是微软开发的开源库,专为大规模模型训练设计。它基于 PyTorch 构建,只需要简单修改即可迁移。它提供多种并行化策略,适应不同训练场景。

图④ 并行策略与 ZeRO 的三级显存分片
图④ 并行策略与 ZeRO 的三级显存分片

数据并行

把大型数据集分割成小块,在多个处理器上并行处理。在多个 GPU 的情况下,将模型分发到每个 GPU 上,每个 GPU 都保留一个完整的模型参数;把每个 batch 的样本平均分配到每个 GPU 上进行梯度计算,然后汇总每个 GPU 上的梯度,再将汇总梯度重新分发回每个 GPU,各自根据汇总梯度更新参数。

比喻:每台机床都装一整套模具,把料分给不同机床同时干。前提是每台机床都装得下整套模具——装不下就得换下面两种。

流水线并行

将模型划分为多个阶段,在不同处理器上并行处理这些阶段。假如网络有 6 层,把模型分成两组,分别放在 GPU0 与 GPU1 上。前向传播时数据从第一层依次往后算,第三层传到第四层时会有 GPU 之间的通信开销;反向传播同理。

同一节点上不同 GPU 的通信开销较少,多机多卡时涉及跨节点通信,开销会非常大

⛔ 朴素流水线的致命缺陷:GPU 利用率太低 当一个 GPU 在计算时,其他层的 GPU 都是闲置的。模型分成四组装到四块卡上,每个时间点只有一台设备在工作,GPU 利用率最高只有四分之一

谷歌提出的 GPipe 就是来修这个的。它引入一个超参数 chunks将每一个 mini-batch 数据划分到 chunks 个 micro-batch 上。以 chunks = 4 为例:

时段发生了什么在工作的 GPU 数
t0device0 计算第 1 个 micro-batch 在第一组网络上的前向1 台
t1device1 接着算第 1 个 micro-batch;同时 device0 开始算第 2 个 micro-batch2 台
t2依次往后推3 台
t3流水线填满4 台全部在工作

反向传播时则依次从 device3 至 device0 计算。DeepSpeed 通过 GPipe 优化流水线并行的实现,减少了处理器之间的通信开销

比喻:四台机床排成一条流水线,把一大批料拆成小批陆续投进去——第一批还在第二道工序时,第二批已经进了第一道工序,四台机床就都转起来了。

张量并行

张量并行(TP)也是模型并行的一种形式。如果流水线并行是按网络层切分,那张量并行就是进行矩阵切分,充分利用矩阵分块乘法的原理。

核心思想是:每个 GPU 仅处理矩阵的一部分,当算子需要整个矩阵的时候再进行矩阵聚合。无论横向切分还是竖向切分,都可以把切分后的矩阵放到不同 GPU 上计算,最后合并结果。

以 MLP 层为例,数学表达是 Y = GeLU(XA)Z = Dropout(YB)。并行化的做法是权重 A 矩阵竖着切,B 矩阵横向切分,最后合并

2.7 ZeRO:把该存的东西切开分摊

DeepSpeed 采用的内存优化技术叫 ZeRO(Zero Redundancy Optimizer,零冗余优化器)。它在 GPU 集群上以最佳系统吞吐量的三到五倍的速度训练具有 1000 亿个参数的模型。

它的思路是:通过在数据并行进程之间划分模型状态(参数、梯度、优化器状态)来消除内存冗余,而不是复制它们;同时在训练期间使用动态通信调度在设备之间共享必要的状态,以保持数据并行的计算粒度和通信量。

为什么能这么干?因为这些状态只会在对应算子计算时才被用到:optimizer states 只在最终做 update 时用到;数据并行中 gradients 只在最后做 AllReduce 和 update 时用到;参数 W 只在做 forward 和 backward 的那一刻用到。既然平时用不上,就没必要每张卡都各存一整份。

等级切分什么省了多少代价
Baseline禁用所有分片,仅使用 DDP每张卡各存一整套,最费通信最少
ZeRO-1优化器状态分片到每个数据并行进程(每个 GPU)优化器状态通常是大头(AdamW 每参数两份动量)update 时需要额外通信
ZeRO-2优化器状态 + 梯度在 ZeRO-1 基础上再省一份梯度梯度规约的通信模式变复杂
ZeRO-3优化器状态 + 梯度 + 模型参数最省,大到单卡放不下的模型也能训通信最频繁,参数要用时才从别的卡取回来

ZeRO-Offload

除此之外,DeepSpeed 还提供 ZeRO-Offload,能够同时利用 CPU 和 GPU 内存来训练大型模型,核心是将优化器状态和梯度卸载到 CPU 内存中。用户在使用带有单张 V100 GPU 的机器时,可以在不耗尽显存的情况下运行多达 130 亿个参数的模型,模型规模扩展至现有方法的 10 倍,并保持有竞争力的吞吐量。

选项在哪一级之上把什么下沉到 CPU 内存或硬盘
Optimizer OffloadZeRO-2 基础上梯度和优化器状态
Param OffloadZeRO-3 基础上模型参数
⚠️ Offload 和分页优化是两件事,别混 分页优化是 QLoRA 那一侧的能力,靠 NVIDIA 统一内存在显存不够时自动换出,治的是偶发峰值。ZeRO-Offload 是 DeepSpeed 的配置项,由你主动指定把哪些状态常驻 CPU。两者都会让训练变慢,都是「拿速度换能跑起来」,但触发方式和配置位置完全不同。

2.8 混合精度

混合精度训练是同时使用不同精度的浮点数进行训练的方法,通常结合单精度(FP32)和半精度(FP16)。使用混合精度可以显著减少内存占用和计算时间,同时降低能耗。

类型精度范围显存占用适用场景
FP165.96e-8 ~ 65504大多数 NVIDIA GPU
BF161.18e-38 ~ 3.39e38大模型训练
TF321.18e-19 ~ 3.40e38数学运算加速
为什么三份 yaml 都写 bf16: true 看「精度范围」那一列:FP16 的上限只有 65504,大模型训练中一旦某个中间值超过它就会溢出成 inf,loss 直接变 NaN——这正是 FP16 要配一整套 loss scaling(loss_scaleloss_scale_windowinitial_scale_power)的原因。BF16 的范围和 FP32 几乎一样(3.39e38),牺牲的是尾数精度而不是动态范围,不用调 loss scaling 就能稳定训练。代价是需要 Ampere 架构以上的卡。

03最小代码:把两笔账自己算一遍

参数量账与量化账都不需要 GPU,用标准库跑一遍,前面的推导就变成手感

3.1 十行看懂 LoRA 省在哪

整个低秩分解的账,剥掉打印之后就是三个乘法:

最小可运行版本
M, N, r = 300000, 500000, 16

full = M * N          # 原矩阵 W:全量微调要更新的参数量
a    = M * r          # 矩阵 A
b    = r * N          # 矩阵 B
side = a + b          # 旁路合计,这才是 LoRA 真正训练的东西

print(full, a, b, side)            # 150000000000 4800000 8000000 12800000
print("%.6f%%" % (side / full * 100))   # 0.008533%

三个数字就够说明问题:1500 亿 → 1280 万,占比 0.008533%,不到万分之一。

把它扩成能回答更多问题的版本,就是下面这份脚本——它还能按真实模型结构逐层累加,算出「挂满旁路之后可训练参数占全模型的百分之几」:

lora_param_calc.py —— LoRA 参数量与压缩比可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""lora_param_calc.py —— 算清楚 LoRA 到底省了多少参数。

回答三个问题:
  1. 一个 M×N 的权重矩阵,换成秩为 r 的旁路 A(M×r)·B(r×N) 之后,参数量从多少变成多少?
  2. 压缩比是多少?低秩旁路占原矩阵的百分之几?
  3. 按一个真实模型的所有目标层累加,可训练参数占全模型的比例是多少?

纯标准库,直接运行:
    python3 lora_param_calc.py                 # 跑内置的三个算例
    python3 lora_param_calc.py -M 300000 -N 500000 -r 16
    python3 lora_param_calc.py --model qwen7b -r 16
"""

import argparse


# ---------------------------------------------------------------------------
# 单个矩阵的账
# ---------------------------------------------------------------------------
def lora_params(m, n, r):
    """返回 (原矩阵参数量, A 的参数量, B 的参数量, 旁路合计)。

    原矩阵 W 是 M×N,全量微调要更新全部 M*N 个参数。
    LoRA 不动 W,只训练 A(M×r) 与 B(r×N),用 A·B 去逼近 ΔW。
    只要 r 远小于 M 和 N,M*r + r*N 就会远小于 M*N。
    """
    full = m * n
    a = m * r
    b = r * n
    return full, a, b, a + b


def fmt(x):
    """把大整数写成人能读的量级,避免一串零看花眼。"""
    units = [(1e12, "万亿"), (1e8, "亿"), (1e4, "万")]
    for scale, name in units:
        if x >= scale:
            v = x / scale
            return ("%.4g%s" % (v, name)).replace(".0000", "")
    return str(x)


def report_matrix(m, n, r, title=""):
    full, a, b, side = lora_params(m, n, r)
    ratio = side / full
    print("-" * 66)
    if title:
        print(title)
    print("  原矩阵 W:%d × %d = %s 个参数" % (m, n, fmt(full)))
    print("  矩阵 A  :%d × %d = %s" % (m, r, fmt(a)))
    print("  矩阵 B  :%d × %d = %s" % (r, n, fmt(b)))
    print("  旁路合计:%s" % fmt(side))
    print("  占原矩阵:%.6f%%  ≈ 1/%d" % (ratio * 100, round(1 / ratio)))
    return ratio


# ---------------------------------------------------------------------------
# 按真实模型结构累加
# ---------------------------------------------------------------------------
# 只记录算 LoRA 需要的几个形状;这些是公开的模型结构参数,不含任何权重。
MODELS = {
    "qwen7b": {
        "label": "7B 级别模型(28 层,hidden 3584,中间层 18944)",
        "layers": 28,
        "hidden": 3584,
        "inter": 18944,
        "kv_hidden": 512,        # 分组查询注意力,k/v 的输出维度比 q 小
        "total": 7_615_616_512,
    },
    "qwen1_8b": {
        "label": "1.8B 级别模型(24 层,hidden 2048,中间层 5504)",
        "layers": 24,
        "hidden": 2048,
        "inter": 5504,
        "kv_hidden": 2048,
        "total": 1_836_828_672,
    },
}


def target_matrices(cfg):
    """lora_target: all 时被挂上旁路的线性层形状清单(每层)。"""
    h, i, kv = cfg["hidden"], cfg["inter"], cfg["kv_hidden"]
    return [
        ("q_proj", h, h),
        ("k_proj", h, kv),
        ("v_proj", h, kv),
        ("o_proj", h, h),
        ("gate_proj", h, i),
        ("up_proj", h, i),
        ("down_proj", i, h),
    ]


def report_model(key, r):
    cfg = MODELS[key]
    print("=" * 66)
    print("模型:%s" % cfg["label"])
    print("LoRA 秩 r = %d,lora_target = all(每层 7 个线性层都挂旁路)" % r)
    print("=" * 66)

    per_layer_full = 0
    per_layer_side = 0
    print("  每一层的账:")
    for name, m, n in target_matrices(cfg):
        full, _, _, side = lora_params(m, n, r)
        per_layer_full += full
        per_layer_side += side
        print("    %-10s %6d × %-6d%-10s 旁路 %s"
              % (name, m, n, fmt(full), fmt(side)))

    layers = cfg["layers"]
    total_side = per_layer_side * layers
    total_full = cfg["total"]

    print("  单层合计:原 %s / 旁路 %s" % (fmt(per_layer_full), fmt(per_layer_side)))
    print("  全部 %d 层:可训练参数 %s" % (layers, fmt(total_side)))
    print("  全模型参数:%s" % fmt(total_full))
    print("  可训练占比:%.4f%%" % (total_side / total_full * 100))
    print()
    print("  读法:全量微调要更新 %s 个参数,LoRA 只更新 %s 个。"
          % (fmt(total_full), fmt(total_side)))
    print("  优化器状态(AdamW 每个可训练参数额外存两份动量)也按这个比例缩小,")
    print("  这才是显存真正降下来的原因 —— 不是模型变小了,是要存的中间量变少了。")


def main():
    ap = argparse.ArgumentParser(description="LoRA 参数量与压缩比计算")
    ap.add_argument("-M", type=int, help="原矩阵行数")
    ap.add_argument("-N", type=int, help="原矩阵列数")
    ap.add_argument("-r", type=int, default=16, help="LoRA 的秩,默认 16")
    ap.add_argument("--model", choices=sorted(MODELS), help="按真实模型结构累加")
    args = ap.parse_args()

    if args.model:
        report_model(args.model, args.r)
        return

    if args.M and args.N:
        report_matrix(args.M, args.N, args.r, "自定义矩阵")
        return

    # 默认算例:教科书里的那一组数
    print("=" * 66)
    print("算例一:M=300000,N=500000,r=16")
    print("=" * 66)
    report_matrix(300000, 500000, 16)

    print()
    print("=" * 66)
    print("算例二:同一个矩阵,r 取不同值时压缩比怎么变")
    print("=" * 66)
    print("  %-6s %-14s %-12s" % ("r", "旁路参数量", "占原矩阵"))
    for r in (1, 2, 4, 8, 16, 32, 64, 128):
        _, _, _, side = lora_params(300000, 500000, r)
        print("  %-6d %-14s %.6f%%" % (r, fmt(side), side / (300000 * 500000) * 100))
    print("  注意:旁路参数量随 r 线性增长,秩翻倍参数就翻倍。")
    print("  r 不是越大越好 —— 秩越高越接近全量微调,省显存的好处也就越少。")

    print()
    print("=" * 66)
    print("算例三:一个 7B 模型挂满旁路之后的真实占比")
    print("=" * 66)
    report_model("qwen7b", 16)


if __name__ == "__main__":
    main()
跑一遍:秩取不同值时压缩比怎么变
$ python3 lora_param_calc.py
  r      旁路参数量          占原矩阵
  1      80万            0.000533%
  2      160万           0.001067%
  4      320万           0.002133%
  8      640万           0.004267%
  16     1280万          0.008533%
  32     2560万          0.017067%
  64     5120万          0.034133%
  128    1.024亿         0.068267%
  注意:旁路参数量随 r 线性增长,秩翻倍参数就翻倍。
跑一遍:一个 7B 模型挂满旁路之后的真实占比
$ python3 lora_param_calc.py --model qwen7b -r 16
模型:7B 级别模型(28 层,hidden 3584,中间层 18944)
LoRA 秩 r = 16,lora_target = all(每层 7 个线性层都挂旁路)
  每一层的账:
    q_proj       3584 × 3584    原 1285万      旁路 11.47万
    k_proj       3584 × 512     原 183.5万     旁路 6.554万
    v_proj       3584 × 512     原 183.5万     旁路 6.554万
    o_proj       3584 × 3584    原 1285万      旁路 11.47万
    gate_proj    3584 × 18944   原 6790万      旁路 36.04万
    up_proj      3584 × 18944   原 6790万      旁路 36.04万
    down_proj   18944 × 3584    原 6790万      旁路 36.04万
  单层合计:原 2.33亿 / 旁路 144.2万
  全部 28 层:可训练参数 4037万
  全模型参数:76.16亿
  可训练占比:0.5301%
为什么这里是 0.53% 而不是 0.0085% 两个数算的不是一回事。0.008533%单个 300000×500000 的超大矩阵的压缩比;真实模型里的矩阵没那么大(最大的也就 3584×18944),而 M、N 越小,低秩分解的相对收益就越小——因为旁路参数量是 r×(M+N),原矩阵是 M×N,两者的比值随矩阵变小而变大。
所以别记「LoRA 永远省到万分之一」,要记公式:占比 = r×(M+N) / (M×N)。矩阵越大、秩越小,越划算。

3.2 十几行看懂 NF4 在干什么

量化那一套公式,写成代码只有三步:求 absmax、归一化、找最近的分位点。

最小可运行版本
NF4 = [-1.0000, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911,
        0.0796,  0.1609,  0.2461,  0.3379,  0.4407,  0.5626, 0.7230, 1.0000]

X = [0.32, -1.76, 0.025, -1.22]

absmax = max(abs(x) for x in X)            # 1.76 —— 这就是「量化常数」
normed = [x / absmax for x in X]           # [0.1818, -1.0, 0.0142, -0.6932]

idxs = [min(range(len(NF4)), key=lambda i: abs(v - NF4[i])) for v in normed]
print(idxs)                                 # [8, 0, 7, 1] —— 每个只占 4 bit

back = [NF4[i] * absmax for i in idxs]      # 反量化:查表取回,再乘回常数
print(["%.4f" % v for v in back])           # ['0.2832','-1.7600','0.1401','-1.2253']

注意最后一行的第三个数:原值 0.025,反量化回来变成 0.1401这就是量化误差,它不是 bug,是这笔交易的价码。

完整版脚本还演示了分块量化与双重量化,并把存储账按位算清楚:

nf4_quantize_demo.py —— NF4 归一化、分位点舍入、反量化误差与双重量化可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""nf4_quantize_demo.py —— 把 4-bit NormalFloat 量化算一遍给自己看。

演示四件事,全部用标准库,不需要 GPU、不需要 bitsandbytes:
  1. absmax 归一化:把一组权重按最大绝对值缩放到 [-1, 1]
  2. NF4 分位点舍入:按 16 个分位点取最近的那个,存下来的只是 0~15 的索引
  3. 反量化与误差:乘回 absmax,看看和原值差了多少
  4. 分块量化与双重量化:每块一个量化常数,再把这批常数自己量化一次

运行:
    python3 nf4_quantize_demo.py                 # 跑课堂上那个四元素例子 + 分块演示
    python3 nf4_quantize_demo.py --block 64 --n 512
"""

import argparse
import random


# ---------------------------------------------------------------------------
# NF4 的 16 个分位点
# ---------------------------------------------------------------------------
# 神经网络权重近似服从正态分布:0 附近很密,远离 0 很稀。
# 所以不按等距切,而是按正态分布的累计概率密度切成 16 个区域,
# 取每个区域的代表值。结果就是下面这串数 —— 靠近 0 的间隔明显更小。
NF4_LEVELS = [
    -1.0000, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911,
    0.0796, 0.1609, 0.2461, 0.3379, 0.4407, 0.5626, 0.7230, 1.0000,
]


def nearest_level(x, levels=NF4_LEVELS):
    """返回 (最近分位点的索引, 该分位点的值)。

    真实实现用查表或二分,这里直接线性找,胜在看得清楚在干什么。
    """
    best_i, best_v, best_d = 0, levels[0], abs(x - levels[0])
    for i, v in enumerate(levels):
        d = abs(x - v)
        if d < best_d:
            best_i, best_v, best_d = i, v, d
    return best_i, best_v


# ---------------------------------------------------------------------------
# 单块量化:absmax 归一化 → 分位点舍入 → 反量化
# ---------------------------------------------------------------------------
def quantize_block(xs):
    """把一个块量化成 (索引列表, 量化常数)。

    量化常数就是这一块的 absmax。它必须原样存下来,否则反量化时无从还原尺度——
    而这些常数本身也占空间,正是双重量化要处理的对象。
    """
    absmax = max(abs(x) for x in xs) or 1.0
    normed = [x / absmax for x in xs]
    idxs = [nearest_level(v)[0] for v in normed]
    return idxs, absmax


def dequantize_block(idxs, absmax, levels=NF4_LEVELS):
    """索引查表取回分位点,再乘回量化常数。"""
    return [levels[i] * absmax for i in idxs]


# ---------------------------------------------------------------------------
# 演示一:课堂上那个四元素例子
# ---------------------------------------------------------------------------
def demo_four():
    xs = [0.32, -1.76, 0.025, -1.22]
    print("=" * 68)
    print("演示一:四个数走完整个 NF4 流程")
    print("=" * 68)
    print("  原始输入 X = %s" % xs)

    absmax = max(abs(x) for x in xs)
    print("\n  第 1 步 求 absmax(X) = %.4g" % absmax)

    normed = [x / absmax for x in xs]
    print("  第 2 步 归一化 X / absmax(X):")
    print("         %s" % ["%.4f" % v for v in normed])
    print("         归一化之后所有值都落进 [-1, 1],才能和分位点比")

    print("\n  第 3 步 按 NF4 的 16 个分位点舍入:")
    print("    %-10s %-10s %-8s %-10s" % ("归一化值", "最近分位点", "索引", "存几位"))
    idxs = []
    for v in normed:
        i, lv = nearest_level(v)
        idxs.append(i)
        print("    %-10.4f %-10.4f %-8d %-10s" % (v, lv, i, "4 bit"))
    print("    实际存下来的只有索引:%s —— 每个 4 bit,一共 %d bit"
          % (idxs, 4 * len(idxs)))
    print("    外加一个量化常数 absmax = %.4g(这个是 FP32 或 FP16,不是 4 bit)" % absmax)

    back = dequantize_block(idxs, absmax)
    print("\n  第 4 步 反量化并看误差:")
    print("    %-10s %-12s %-12s %-10s" % ("原值", "反量化值", "绝对误差", "相对误差"))
    for o, b in zip(xs, back):
        err = abs(o - b)
        rel = err / abs(o) if o else 0.0
        print("    %-10.4f %-12.4f %-12.4f %.2f%%" % (o, b, err, rel * 100))

    print("\n  读法:绝对值大的数误差小,接近 0 的数相对误差反而大 ——")
    print("  因为分位点虽然在 0 附近更密,但归一化之后小数被压得更靠近 0。")
    print("  这就是量化的本质交易:拿精度换显存和速度。")


# ---------------------------------------------------------------------------
# 演示二:分块量化 + 双重量化
# ---------------------------------------------------------------------------
def demo_blocks(n, block, seed=7):
    print()
    print("=" * 68)
    print("演示二:分块量化与双重量化(%d 个权重,块大小 %d)" % (n, block))
    print("=" * 68)

    rnd = random.Random(seed)
    # 用正态分布造一批权重 —— NF4 的整个设计前提就是权重近似正态分布
    weights = [rnd.gauss(0, 0.05) for _ in range(n)]

    blocks = [weights[i:i + block] for i in range(0, len(weights), block)]
    all_idx, consts = [], []
    for blk in blocks:
        idxs, absmax = quantize_block(blk)
        all_idx.extend(idxs)
        consts.append(absmax)

    # 还原并统计误差
    recovered = []
    for blk_idx, c in zip(
            [all_idx[i:i + block] for i in range(0, len(all_idx), block)], consts):
        recovered.extend(dequantize_block(blk_idx, c))

    errs = [abs(a - b) for a, b in zip(weights, recovered)]
    mean_abs = sum(abs(w) for w in weights) / len(weights)
    print("  块数:%d,每块一个量化常数" % len(blocks))
    print("  平均绝对误差:%.6f(权重平均绝对值 %.6f,相对 %.2f%%)"
          % (sum(errs) / len(errs), mean_abs, sum(errs) / len(errs) / mean_abs * 100))
    print("  最大绝对误差:%.6f" % max(errs))

    # ---- 存储账:这才是量化的目的 ----
    print("\n  存储账(按位算):")
    fp16_bits = n * 16
    nf4_bits = n * 4
    const_bits_fp32 = len(consts) * 32
    print("    原始 FP16 权重            :%8d bit" % fp16_bits)
    print("    NF4 索引                  :%8d bit" % nf4_bits)
    print("    量化常数(FP32,每块一个):%8d bit" % const_bits_fp32)
    print("    NF4 合计                  :%8d bit  压缩到 %.1f%%"
          % (nf4_bits + const_bits_fp32,
             (nf4_bits + const_bits_fp32) / fp16_bits * 100))

    # ---- 双重量化:把这批常数自己再量化一次 ----
    print("\n  双重量化:把上面那批量化常数当成一组新数据,再量化一次")
    d_idxs, d_absmax = quantize_block(consts)
    d_back = dequantize_block(d_idxs, d_absmax)
    d_err = sum(abs(a - b) for a, b in zip(consts, d_back)) / len(consts)
    const_bits_after = len(consts) * 4 + 32     # 索引 4 bit 一个,外加一个总常数
    print("    常数个数:%d" % len(consts))
    print("    量化前占用:%d bit(FP32)" % const_bits_fp32)
    print("    量化后占用:%d bit(4 bit 索引 + 一个 FP32 总常数)" % const_bits_after)
    print("    常数本身的平均误差:%.6f" % d_err)
    print("    NF4 + 双重量化合计:%d bit  压缩到 %.1f%%"
          % (nf4_bits + const_bits_after,
             (nf4_bits + const_bits_after) / fp16_bits * 100))
    print("\n  读法:第二次量化只作用在第一次产生的量化常数上,权重本身不再动。")
    print("  块越小,常数越多,这一步省下来的就越可观。")


def main():
    ap = argparse.ArgumentParser(description="NF4 量化演示")
    ap.add_argument("--n", type=int, default=256, help="演示二里权重个数")
    ap.add_argument("--block", type=int, default=64, help="分块大小")
    args = ap.parse_args()

    print("NF4 的 16 个分位点:")
    print("  %s" % NF4_LEVELS)
    gaps = [round(NF4_LEVELS[i + 1] - NF4_LEVELS[i], 4) for i in range(len(NF4_LEVELS) - 1)]
    print("  相邻间隔:%s" % gaps)
    print("  可以看到靠近 0 的地方间隔更小 —— 权重密集的区域分得更细。")
    print()

    demo_four()
    demo_blocks(args.n, args.block)


if __name__ == "__main__":
    main()
跑一遍:误差与存储账
$ python3 nf4_quantize_demo.py
  第 4 步 反量化并看误差:
    原值         反量化值         绝对误差         相对误差
    0.3200     0.2832       0.0368       11.51%
    -1.7600    -1.7600      0.0000       0.00%
    0.0250     0.1401       0.1151       460.38%
    -1.2200    -1.2253      0.0053       0.44%

演示二:分块量化与双重量化(256 个权重,块大小 64)
  块数:4,每块一个量化常数
  平均绝对误差:0.004002(权重平均绝对值 0.040708,相对 9.83%)

  存储账(按位算):
    原始 FP16 权重            :    4096 bit
    NF4 索引                  :    1024 bit
    量化常数(FP32,每块一个):     128 bit
    NF4 合计                  :    1152 bit  压缩到 28.1%

  双重量化:把上面那批量化常数当成一组新数据,再量化一次
    量化前占用:128 bit(FP32)
    量化后占用:48 bit(4 bit 索引 + 一个 FP32 总常数)
    NF4 + 双重量化合计:1072 bit  压缩到 26.2%
输出里该注意的说明
绝对值最大的那个数误差为 0因为它就是 absmax,归一化后正好是 -1.0,而 -1.0 恰好是一个分位点。每块里至少有一个数是精确的
接近 0 的数相对误差最大分位点在 0 附近虽然更密,但归一化之后小数被压得更靠近 0,仍然落不进合适的档位
压缩到 28.1% 而不是 25%4 bit / 16 bit 理论上是 25%,多出来的 3.1% 正是量化常数占的。块越小常数越多,这部分越显眼
双重量化把它压到 26.2%只动常数、不动权重,就把开销从 128 bit 压到 48 bit。这就是第二次量化的全部意义
⚠️ 这两个脚本是原理演示,不是生产实现 nf4_quantize_demo.py 用线性查找找最近分位点,真实实现走查表或二分;块大小、常数精度也和 bitsandbytes 的实际参数不完全一致。它的作用是让你看清每一步在算什么,不要拿它去量化真实权重。真正干活的是配置里那行 quantization_method: bitsandbytes

04完整案例:三份工艺卡逐段讲

LLaMA-Factory 环境、全量 / LoRA / QLoRA 三份配置的每一段、显存实测、学习率量级与权重合并

4.1 训练工具:LLaMA-Factory

LLaMA-Factory 是一个简单易用且高效的大模型训练框架,支持上百种大模型的训练。它把「改代码」变成了「改一份 yaml」,这也是本节能逐段讲配置的前提。

特性覆盖范围
模型种类LLaMA、LLaVA、Mistral、Mixtral-MoE、Qwen、Yi、Gemma、Baichuan、ChatGLM、Phi 等
训练算法(增量)预训练、(多模态)指令监督微调、奖励模型训练、PPO、DPO、KTO、ORPO 等
运算精度16 比特全参数微调、冻结微调、LoRA 微调,以及基于 AQLM/AWQ/GPTQ/LLM.int8/HQQ/EETQ 的 2/3/4/5/6/8 比特 QLoRA 微调
优化算法GaLore、BAdam、DoRA、LongLoRA、LLaMA Pro、Mixture-of-Depths、LoRA+、LoftQ、PiSSA
加速算子FlashAttention-2、Unsloth
推理引擎Transformers、vLLM
实验面板LlamaBoard、TensorBoard、Wandb、MLflow 等
安装
git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
pip install -e ".[torch,metrics]" -i https://mirrors.aliyun.com/pypi/simple/

# 验证安装:出现版本横幅即为成功
llamafactory-cli version
两种用法,选一种 命令行llamafactory-cli train xxx.yaml)适合固化流程、便于版本管理、能进 CI,本节全程用它。Web 界面llamafactory-cli webui,默认 http://localhost:7860)适合第一次摸索参数,界面上每个选项对应的就是 yaml 里的一行。先用界面找手感,定型之后落成 yaml,是最省事的路径。

4.2 全量微调配置

先看最重的一份。它是基准线——后面两份都是在它的基础上做减法。

qwen2-7b-full-sft.yaml —— 全量微调,路径处留 TODO可复用模板
### 全量微调:所有参数都参与更新,显存占用最高
### 启动:FORCE_TORCHRUN=1 llamafactory-cli train qwen2-7b-full-sft.yaml

### model
# 基座模型的本地路径或在线模型 ID
model_name_or_path: TODO/换成你的基座模型路径
# 加载含自定义代码的模型时必须开启
trust_remote_code: true

### method
# 训练阶段:有监督微调
stage: sft
do_train: true
# 全参数微调
finetuning_type: full
# 全量微调几乎必须搭 DeepSpeed,否则单卡装不下优化器状态
deepspeed: TODO/换成 ds_config_zero3.json 的路径

### dataset
# 必须与 data/dataset_info.json 里登记的键名一致
dataset: TODO_你的数据集名
# 对话模板要与基座匹配,Qwen 系列填 qwen
template: qwen
# 输入序列最大 token 数,超出部分被截断
cutoff_len: 1024
# 数据集改动后置 true,强制重新预处理
overwrite_cache: true
# 预处理并行进程数,建议取 CPU 核心数的 50%~70%
preprocessing_num_workers: 16

### output
output_dir: saves/qwen2-7b/full/sft
logging_steps: 10
save_steps: 100
plot_loss: true
overwrite_output_dir: true

### train
# 每卡批次大小;实际总批次 = 此值 × gradient_accumulation_steps × 卡数
per_device_train_batch_size: 1
# 梯度累积步数,用来在小显存下模拟大批次
gradient_accumulation_steps: 16
# 全量微调动的是原始权重,学习率必须比 LoRA 小一个量级
learning_rate: 1.0e-5
num_train_epochs: 1.0
# 余弦退火
lr_scheduler_type: cosine
# 前 10% 的 step 用于线性预热,防止参数剧烈震荡
warmup_ratio: 0.1
# BF16 混合精度,需要 Ampere 架构以上的卡
bf16: true
# 分布式超时时间,单位毫秒
ddp_timeout: 180000000

### eval
# 从训练集里划 10% 做验证;它不是验收用的评测集
val_size: 0.1
per_device_eval_batch_size: 1
eval_strategy: steps
eval_steps: 500
段落关键行含义与注意
modelmodel_name_or_path基座模型的本地路径或在线 ID。trust_remote_code: true 是加载含自定义代码的模型(Qwen、ChatGLM 等)的前提
methodfinetuning_type: full全参数微调。可选值 full / freeze / lora
methoddeepspeed指向 DeepSpeed 配置文件。全量微调几乎必须配它,否则单卡装不下优化器状态
datasetdataset必须与 data/dataset_info.json 里登记的键名一致
datasettemplate: qwen对话模板要与基座架构匹配。填错不报错,但对话格式全乱
datasetcutoff_len: 1024输入序列最大 token 数,超出部分被截断——这就是数据侧要做长度过滤的原因
datasetpreprocessing_num_workers: 16预处理并行进程数,建议设为 CPU 核心数的 50%~70%
trainper_device_train_batch_size: 1
gradient_accumulation_steps: 16
这两行要合起来读:实际总 batch = 1 × 16 × 卡数。显存装不下大 batch 时,用梯度累积模拟大 batch——累积 16 步再更新一次参数,等效批次 16,显存却只按 1 算
trainlearning_rate: 1.0e-5全量微调的典型值。它在动原始权重,学习率必须小;见 4.6 的量级表
trainlr_scheduler_type: cosine余弦退火:学习率沿余弦曲线从峰值平滑降到接近 0,收尾阶段自动变细致
trainwarmup_ratio: 0.1前 10% 的 step 用于线性预热。刚开始就用峰值学习率会让参数剧烈震荡,预热是防震的
trainbf16: trueBF16 混合精度,需要 Ampere 架构以上的卡。为什么不用 FP16,见 2.8
evalval_size: 0.1从训练集划 10% 做验证。它不是验收用的评测集,训练中每轮都会看到它
启动训练
FORCE_TORCHRUN=1 llamafactory-cli train qwen2-7b-full-sft.yaml

FORCE_TORCHRUN=1 的作用是强制用 PyTorch 的 torchrun 来启动分布式训练,适用于多 GPU 环境,或者自动检测失败、需要显式控制分布式初始化的时候。

配套的 DeepSpeed 配置

两份都给了:ZeRO-2 适合大多数场景,ZeRO-3 用在单卡实在装不下的时候。

ds_config_zero2.json —— 切分优化器状态与梯度可复用模板
{
    "fp16": {
        "enabled": "auto",
        "loss_scale": 0,
        "loss_scale_window": 1000,
        "initial_scale_power": 16,
        "hysteresis": 2,
        "min_loss_scale": 1
    },
    "bf16": {
        "enabled": "auto"
    },
    "optimizer": {
        "type": "AdamW",
        "params": {
            "lr": "auto",
            "betas": "auto",
            "eps": "auto",
            "weight_decay": "auto"
        }
    },
    "scheduler": {
        "type": "WarmupLR",
        "params": {
            "warmup_min_lr": "auto",
            "warmup_max_lr": "auto",
            "warmup_num_steps": "auto"
        }
    },
    "zero_optimization": {
        "stage": 2,
        "offload_optimizer": {
            "device": "none",
            "pin_memory": true
        },
        "allgather_partitions": true,
        "allgather_bucket_size": 2e8,
        "overlap_comm": true,
        "reduce_scatter": true,
        "reduce_bucket_size": 2e8,
        "contiguous_gradients": true
    },
    "gradient_accumulation_steps": "auto",
    "gradient_clipping": "auto",
    "steps_per_print": 100,
    "train_batch_size": "auto",
    "train_micro_batch_size_per_gpu": "auto",
    "wall_clock_breakdown": false
}
ds_config_zero3.json —— 再切模型参数可复用模板
{
    "fp16": {
        "enabled": "auto",
        "loss_scale": 0,
        "loss_scale_window": 1000,
        "initial_scale_power": 16,
        "hysteresis": 2,
        "min_loss_scale": 1
    },
    "bf16": {
        "enabled": "auto"
    },
    "optimizer": {
        "type": "AdamW",
        "params": {
            "lr": "auto",
            "betas": "auto",
            "eps": "auto",
            "weight_decay": "auto"
        }
    },
    "scheduler": {
        "type": "WarmupLR",
        "params": {
            "warmup_min_lr": "auto",
            "warmup_max_lr": "auto",
            "warmup_num_steps": "auto"
        }
    },
    "zero_optimization": {
        "stage": 3,
        "offload_optimizer": {
            "device": "none",
            "pin_memory": true
        },
        "offload_param": {
            "device": "none",
            "pin_memory": true
        },
        "overlap_comm": true,
        "contiguous_gradients": true,
        "sub_group_size": 1e9,
        "reduce_bucket_size": "auto",
        "stage3_prefetch_bucket_size": "auto",
        "stage3_param_persistence_threshold": "auto",
        "stage3_max_live_parameters": 1e9,
        "stage3_max_reuse_distance": 1e9,
        "stage3_gather_16bit_weights_on_model_save": true
    },
    "gradient_accumulation_steps": "auto",
    "gradient_clipping": "auto",
    "steps_per_print": 100,
    "train_batch_size": "auto",
    "train_micro_batch_size_per_gpu": "auto",
    "wall_clock_breakdown": false
}
配置项说明
"auto" 这一串train_batch_sizegradient_accumulation_stepsgradient_clippinglrauto 表示由 LLaMA-Factory 从 yaml 里推导后填进来。所以这些值要在 yaml 改,不要在 json 里写死——两边写死且不一致时会直接报冲突
zero_optimization.stage23对应 2.7 讲的分片等级
offload_optimizer.device"none"默认不下沉。显存实在不够时改成 "cpu",会明显变慢
offload_param.device"none"(仅 ZeRO-3 有)把模型参数也下沉到 CPU,这是最后的手段
pin_memory: true使用锁页内存加速 CPU 与 GPU 之间的传输
fp16 那一整段loss_scaleFP16 的 loss scaling 配置。用 BF16 时这段不生效,但留着无害——enabled: "auto" 会自己判断
stage3_gather_16bit_weights_on_model_savetrueZeRO-3 下参数是分片的,保存时要先聚合成完整的 16 位权重。不开这个,存出来的 checkpoint 没法直接用

4.3 LoRA 配置

和全量那份比,method 段换了,train 段的学习率变了,其余几乎照抄

qwen2-7b-lora-sft.yaml —— LoRA 微调可复用模板
### LoRA 微调:冻结主干,只训练低秩旁路
### 启动:llamafactory-cli train qwen2-7b-lora-sft.yaml

### model
model_name_or_path: TODO/换成你的基座模型路径
trust_remote_code: true

### method
stage: sft
do_train: true
# 低秩适配
finetuning_type: lora
# 给哪些线性层挂旁路;all 表示所有线性层
lora_target: all
# 秩 r,决定旁路能学到的信息丰富程度,同时线性决定可训练参数量
lora_rank: 16
# 缩放系数,控制旁路输出的权重;常取与 rank 相等
lora_alpha: 16
# 旁路层的 dropout,正则化手段,防止模型过度依赖旁路
lora_dropout: 0.05

### dataset
dataset: TODO_你的数据集名
template: qwen
cutoff_len: 1024
overwrite_cache: true
preprocessing_num_workers: 16

### output
output_dir: saves/qwen2-7b/lora/sft
logging_steps: 100
save_steps: 100
plot_loss: true
overwrite_output_dir: true

### train
per_device_train_batch_size: 1
gradient_accumulation_steps: 16
# 旁路是随机初始化的新模块,学习率比全量微调大一个量级
learning_rate: 1.0e-4
num_train_epochs: 1.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
ddp_timeout: 180000000

### eval
val_size: 0.1
per_device_eval_batch_size: 1
eval_strategy: steps
eval_steps: 500
新增 / 变化的行含义
finetuning_typelorafull 换过来
lora_targetall所有线性层挂旁路。也可以只写某几个模块名,挂得越少越省,但效果也越弱
lora_rank16r,决定学到信息的丰富程度,同时线性决定可训练参数量
lora_alpha16缩放系数,决定旁路的重要程度。常取与 rank 相等
lora_dropout0.05随机丢弃旁路层权重的概率,是一种正则化手段,防止模型过度依赖旁路
learning_rate1.0e-4比全量微调大一个量级。典型范围 1e-4 ~ 5e-4
没有 deepspeedLoRA 单卡通常就能跑,不强制配 DeepSpeed
⛔ 为什么 LoRA 的学习率要比全量大十倍 全量微调动的是已经训练好的原始权重——它们已经在一个好位置上,学习率稍大就会把好不容易学到的东西冲掉,所以要 1e-5 这种量级慢慢挪。
LoRA 训的是刚刚随机初始化出来的新模块,它从零开始学,用 1e-5 会慢到几乎不动。一句话:改老东西要轻,学新东西要快。
启动训练
llamafactory-cli train qwen2-7b-lora-sft.yaml

4.4 QLoRA 配置

和 LoRA 那份比,只多了两行

qwen2-7b-qlora-sft.yaml —— QLoRA 微调可复用模板
### QLoRA 微调:把基座量化成 4-bit 之后再挂低秩旁路,显存最省
### 启动:llamafactory-cli train qwen2-7b-qlora-sft.yaml

### model
model_name_or_path: TODO/换成你的基座模型路径
trust_remote_code: true

### method
stage: sft
do_train: true
# 注意这里依然是 lora —— QLoRA 不是另一种微调类型,
# 它是「LoRA + 基座 4-bit 量化」,靠下面两行量化参数区分
finetuning_type: lora
lora_target: all
# 量化位数:4-bit
quantization_bit: 4
# 量化实现库;可选 bitsandbytes(4/8)、hqq(2/3/4/5/6/8)、eetq(8)
quantization_method: bitsandbytes
lora_rank: 16
lora_alpha: 16
lora_dropout: 0.05

### dataset
dataset: TODO_你的数据集名
template: qwen
cutoff_len: 1024
overwrite_cache: true
preprocessing_num_workers: 16

### output
output_dir: saves/qwen2-7b/qlora/sft
logging_steps: 100
save_steps: 100
plot_loss: true
overwrite_output_dir: true

### train
per_device_train_batch_size: 1
gradient_accumulation_steps: 16
learning_rate: 1.0e-4
num_train_epochs: 1.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
# 基座存成 4-bit,但计算时反量化成 bf16 —— 精度靠这一行保住
bf16: true
ddp_timeout: 180000000

### eval
val_size: 0.1
per_device_eval_batch_size: 1
eval_strategy: steps
eval_steps: 500
多出来的行含义
quantization_bit4基座量化成 4-bit。注意量化的是冻结的主干,旁路仍是高精度
quantization_methodbitsandbytes量化实现库。可选 bitsandbytes(4/8 位)、hqq(2/3/4/5/6/8 位)、eetq(8 位)
finetuning_type仍然是 lora再说一次:没有叫 qlora 的选项
bf16: true这一行在 QLoRA 里格外重要:基座以 4-bit 存放,计算时反量化成 bf16。精度就靠它兜住
启动训练
llamafactory-cli train qwen2-7b-qlora-sft.yaml

4.5 三种方式的显存实测

使用上述训练配置,各个方法实测的显存占用如下:

训练方式显存占用相对全量省在哪
全量参数训练42.18GB100%基准线。模型权重 + 梯度 + 优化器状态全都是满的
LoRA 训练20.17GB约 48%主干冻结 → 不存主干的梯度和优化器状态。主干权重本身照样占着
QLoRA 训练10.97GB约 26%在 LoRA 基础上把主干权重也压成 4-bit,最后这块大头也削下去了
⚠️ 这三个数字是配置相关的,不是常数 原文说得很清楚:训练中的显存占用与训练参数配置息息相关,可根据自身实际需求进行设置。换一个 cutoff_len、换一个 per_device_train_batch_size、开不开梯度检查点,数字都会变。这三个数该记的是比例关系和省在哪一层,不是绝对值。
从 42GB 到 20GB 到 11GB,是三刀砍在不同地方 第一刀(全量 → LoRA)砍的是优化器状态和梯度,因为可训练参数少了几个数量级。第二刀(LoRA → QLoRA)砍的是冻结的主干权重本身,靠 4-bit 存放。注意这两刀砍的不是同一块肉——这也解释了为什么 LoRA 只降到一半左右:主干权重那一大块它动不了。

4.6 学习率量级怎么选

学习率是最影响整体效果的一个参数。按量级记,比背具体数字有用:

量级典型用途说明
0.1一般只用于探索没人会真的用它来训练,只是用来快速看看 loss 曲线的形状
0.01从头训练的标准模型初始学习率指的是从随机初始化开始训练的场景,不是微调
0.001已经快接近优化目标时做细致调整介于从头训练和微调之间
0.0001模型接近收敛时做微调就是 1e-4LoRA / QLoRA 用的就是这一档
0.00005预训练阶段的最后微调5e-5,比 1e-4 更保守

全量微调用的 1e-5 比这张表的最后一档还要小一半——因为它直接改原始权重,是所有场景里最需要轻手轻脚的那一种。

⚠️ 一个典型的错配 「1500 条电气自动化数据集,用 0.01 的学习率训了 10 轮,为什么效果不好?」——两处都错了。学习率错了两个量级0.01 是从头训练的档位,拿来微调会直接把预训练学到的东西冲垮。轮数也过多:数据集只有 1500 条却训 10 轮,模型会把这 1500 条背下来,典型过拟合。数据太小时正确的做法是减少轮数 + 做数据增强,而不是反复刷同一批数据。
其余几个参数的一句话记法 截断长度决定样本能有多长(配合数据侧的长度过滤)· 训练轮数:特性简单时 1 轮即可,数据集太小不要选太多轮 · 批处理大小根据显存调整 · 批处理大小 × 梯度累计决定梯度更新的频率 · 预热步数建议使用,防止参数剧烈震荡 · LoRA 的秩是学到信息的丰富程度 · 缩放系数是旁路的重要程度 · 随机丢弃防止模型过度依赖旁路。

4.7 训练完之后:合并权重

如果采用 LoRA 或者 QLoRA 进行训练,脚本只保存对应的 LoRA 权重,需要合并权重才能进行推理。全量参数训练无需执行此步骤。

合并
llamafactory-cli export qwen2-7b-merge-lora.yaml
参数说明
model_name_or_path预训练模型的名称或路径
adapter_name_or_path训练出来的适配器路径,要与微调时的 output_dir 对应
template模型模板。合并 Qwen2 模型权重,务必设为 qwen
finetuning_type无论 LoRA 还是 QLoRA 训练,合并时均为 lora
export_dir导出路径
export_size最大导出模型文件大小(分片用)
export_device导出设备
export_legacy_format是否使用旧格式导出
⛔ 合并时不要用量化模型 合并 LoRA 适配器时,不要使用量化后的模型,也不要带 quantization_bit。原因回到铁律:合并是把 A·B 加回到原始的 W 上,这一步需要高精度的 W。在 4-bit 的主干上做加法,等于把量化误差永久焊进权重里。
用车间的说法:把夹具的形状永久刻回机床之前,得先把机床恢复到全精度的原始状态——拿着压缩过的图纸直接刻,刻出来的就是失真的。

05骨架模板:拿去改就能用

四份工艺卡 + 两份车间配置,把 TODO 换成你的路径就能开训

5.1 六份文件各管什么

文件什么时候用改哪里
qwen2-7b-lora-sft.yaml默认从这份开始。绝大多数业务微调两处 TODO:基座路径、数据集名
qwen2-7b-qlora-sft.yaml单卡显存紧张(12GB 级别)同上。多的两行 quantization_* 不用动
qwen2-7b-full-sft.yaml显存充足、要极致效果、不怕遗忘三处 TODO:基座路径、DeepSpeed 配置路径、数据集名
qwen2-7b-merge-lora.yamlLoRA / QLoRA 训完之后两处 TODO:基座路径、适配器路径
ds_config_zero2.json全量微调的默认搭档通常一行都不用改
ds_config_zero3.jsonZeRO-2 仍然装不下时实在不够就把 offload_*.device 改成 "cpu"

5.2 合并权重的模板

训练产出的只是那一小包旁路权重,要拿去推理、量化或部署,先把它加回主干导出成一个完整模型。

qwen2-7b-merge-lora.yaml —— 合并适配器并导出完整模型可复用模板
### 合并低秩旁路权重:把训练出来的适配器加回主干,导出成一个完整模型
### 启动:llamafactory-cli export qwen2-7b-merge-lora.yaml
### 注意:合并时不要使用量化模型,也不要带 quantization_bit

### model
# 原始的、未量化的基座模型
model_name_or_path: TODO/换成你的基座模型路径
# 训练产出的适配器目录,要与微调配置里的 output_dir 对应
adapter_name_or_path: TODO/换成你的适配器输出路径
# Qwen2 系列一律填 qwen
template: qwen
# 无论当初是 LoRA 还是 QLoRA 训的,这里都填 lora
finetuning_type: lora

### export
# 合并后完整模型的导出目录
export_dir: models/qwen2-7b-sft-lora-merged
# 单个权重文件的最大体积,单位 GB,超出则自动分片
export_size: 2
# 导出时用哪个设备做合并计算
export_device: cpu
# 是否使用旧版权重格式
export_legacy_format: false
✅ 从模板到跑起来,五步 数据集按上一页的流程备好并注册进 data/dataset_info.json · 复制 qwen2-7b-lora-sft.yaml,把两处 TODO 换成真实路径与数据集名 · 先用几百条样本、1 轮试跑,确认能跑完不报错 · 换回完整数据正式训练,盯 logging_steps 打出来的 loss 和 eval_steps 的验证结果 · qwen2-7b-merge-lora.yaml 合并权重导出。
⚠️ 第 ③ 步不要跳 先小样本试跑一遍,是为了在几分钟内暴露那些「配置写错但不报错」的问题:数据集名对不上、template 填错、路径拼错、显存装不下。跳过这一步直接开整轮训练,同样的错误要等几小时才会浮出来——而 ddp_timeout: 180000000 差不多是 50 小时,它不会替你早点失败。

5.3 三份训练配置的差异速查

三份 yaml 有八成内容是一样的,真正的差别只有下面这几行。改配置时对着这张表看,比逐行读三遍快:

配置行全量LoRAQLoRA
finetuning_typefullloralora
deepspeed需要不需要不需要
lora_targetallall
lora_rank / lora_alpha16 / 1616 / 16
lora_dropout0.050.05
quantization_bit4
quantization_methodbitsandbytes
learning_rate1.0e-51.0e-41.0e-4
output_dir.../full/sft.../lora/sft.../qlora/sft
logging_steps10100100
启动命令FORCE_TORCHRUN=1 开头直接 llamafactory-cli train直接 llamafactory-cli train
训完要不要合并不用
output_dir 一定要分开写 三份配置的输出目录各不相同,这不是凑数——overwrite_output_dir: true直接覆盖已有目录。三份 yaml 共用一个 output_dir 时,后跑的那次会把前一次的 checkpoint 整个抹掉,而且不会问你。

06易错点汇总

按「概念 / LoRA / 量化 / DeepSpeed / 配置 / 合并」六类归并,多数错误不会报错,只会悄悄变差

⚠️ 一、概念层面

  • 以为 QLoRA 是「在 4-bit 上做矩阵乘法」。 量化改的只是存放方式。计算时必须反量化回高精度(配置里那行 bf16: true 就是干这个的)。存和算是两件事。
  • 去配置里找 finetuning_type: qlora 没有这个选项。QLoRA = LoRA + 基座 4-bit 量化,finetuning_type 写的还是 lora,靠 quantization_bit: 4 区分。
  • 以为 LoRA 之所以省显存是因为「模型变小了」。 基座该占多少还占多少。省的是梯度和优化器状态——AdamW 为每个可训练参数额外存两份动量,可训练参数少了几个数量级,这部分就跟着塌下去了。
  • 显存明明够却上 QLoRA。 白白背上量化误差和反量化的计算开销。显存够就用 LoRA,这是默认答案。
  • 把灾难性遗忘理解成「有点退步」。 它是某些能力整块塌掉:抽取变强了,但可能连正常对话都不会了。样本越少、训得越久,塌得越厉害。

⚠️ 二、LoRA 原理与参数

  • 把 A 和 B 都初始化为 0。 两边梯度互相为零,梯度恒为 0,参数永远停在原地——训练日志上 loss 一动不动,很多人会以为是学习率太小。
  • 把 A 和 B 都做高斯初始化。 此时 A·B ≠ 0训练第 0 步的模型就已经不是原模型了,旁路在往原输出上叠随机噪声。正确做法是 A 高斯、B 全零。
  • 以为 lora_rank 越大越好。 旁路参数量是 r×(M+N)r 翻倍参数就翻倍。秩越高越接近全量微调,省显存的好处也就越少。
  • 把「省到万分之一」当成 LoRA 的固定属性。 那是 300000×500000 这种超大矩阵的数。真实 7B 模型挂满旁路后可训练参数约占 0.53%,因为实际矩阵小得多。要记公式 r×(M+N)/(M×N),不是记结论。
  • 只在最后一层挂旁路。 实际做法是每个 transformer 层都添加低秩旁路,配置里 lora_target: all。挂得越少越省,但效果也越弱。

⚠️ 三、量化

  • 用均匀分布的线性量化去量化权重。 神经网络权重近似正态分布,0 附近很密、远离 0 很稀。均匀切分会在几乎没有数据的两端浪费大量档位,而在最密集的 0 附近分辨率不够。NF4 按累计概率密度切 16 格,就是为了修这个。
  • 省掉 absmax 归一化。 NF4 的分位点全部落在 [-1, 1],不先归一化就无法比较。而且归一化产生的那个 absmax 就是量化常数,必须原样存下来,否则反量化时无从还原尺度。
  • 忘了量化常数本身也占空间。 理论压缩比是 4/16 = 25%,实测算出来是 28.1%,多出来的 3.1% 正是常数。块越小常数越多,这部分越显眼——这也正是双重量化存在的理由。
  • 以为双重量化是「把权重量化两次」。 第二次量化只作用在第一次产生的量化常数上,权重本身一个字节都不再动。
  • 把分页优化当成「显存可以随便超配」。 它治的是偶发峰值,不是让你长期超配。每一步都在换进换出时 PCIe 会成为瓶颈,训练慢得不像话——能跑完,但不报错。训练巨慢又查不出原因时,先看是不是一直在分页。
  • 把分页优化和 ZeRO-Offload 混为一谈。 前者是 QLoRA 侧、靠统一内存自动换出;后者是 DeepSpeed 配置项、由你主动指定哪些状态常驻 CPU。触发方式和配置位置都不同。

⚠️ 四、DeepSpeed 与并行

  • 以为朴素流水线并行天然高效。 恰恰相反:一个 GPU 在算时其他全闲着,四卡利用率最高只有四分之一。GPipe 用 chunks 把 mini-batch 切成 micro-batch,让流水线填满,四卡才真的同时工作。
  • 无脑上 ZeRO-3。 切得越多越省显存,卡之间的通信也越频繁。ZeRO-2 能装下就别上 3,否则换来的是通信瓶颈。
  • 记混三个等级切的是什么。 ZeRO-1 切优化器状态;ZeRO-2 再切梯度;ZeRO-3 再切模型参数。顺序是固定的,后一级包含前一级。
  • 在 json 里把 "auto" 改成具体数字。 train_batch_sizegradient_accumulation_stepslr 这些写 auto 是让 LLaMA-Factory 从 yaml 推导后填进来。两边都写死且不一致,会直接报冲突。要改就改 yaml。
  • ZeRO-3 下没开 stage3_gather_16bit_weights_on_model_save 参数是分片的,保存时不聚合成完整的 16 位权重,存出来的 checkpoint 没法直接用
  • 随手把 offload_optimizer.device 改成 "cpu" 能让显存降下来,但训练会明显变慢。它是最后的手段,不是默认配置。

⚠️ 五、训练配置

  • LoRA 用了全量微调的学习率(1e-5)。 旁路是刚随机初始化的新模块,用 1e-5 慢到几乎不动。LoRA / QLoRA 用 1e-4 量级。反过来,全量微调用 1e-4 会把预训练学到的东西冲垮。
  • 小数据集训很多轮。 1500 条训 10 轮,模型会把这批数据背下来,典型过拟合。数据太小时应当减少轮数 + 做数据增强,而不是反复刷同一批。
  • 只看 per_device_train_batch_size 判断批次大小。 实际总批次 = 它 × gradient_accumulation_steps × 卡数。两行要合起来读。
  • template 填错。 Qwen 系列一律 qwen。填错不报错,但对话格式全乱,训出来的东西没法用。
  • 三份 yaml 共用一个 output_dir overwrite_output_dir: true 会直接覆盖,后跑的那次把前一次的 checkpoint 整个抹掉,而且不会问你。
  • 跳过小样本试跑。 数据集名对不上、路径拼错、显存装不下这类问题,小样本几分钟就能暴露;直接开整轮训练要等几小时。而 ddp_timeout: 180000000 约等于 50 小时,它不会替你早点失败。
  • 不开 warmup_ratio 一上来就用峰值学习率,参数会剧烈震荡。预热就是防这个的,建议一直开着。
  • 忘了 overwrite_cache: true 改完数据集却用着旧的预处理缓存,训练用的还是老数据——loss 曲线看着正常,改动完全没生效。

⚠️ 六、合并权重

  • 全量微调之后还去执行合并。 不需要。全量训练直接产出完整模型,只有 LoRA / QLoRA 才只保存适配器。
  • 用量化模型做合并,或带上 quantization_bit 合并是把 A·B 加回原始 W,需要高精度的 W。在 4-bit 主干上做加法,等于把量化误差永久焊进权重。
  • QLoRA 训的模型,合并时把 finetuning_type 写成别的。 无论当初 LoRA 还是 QLoRA,合并时一律填 lora
  • adapter_name_or_path 指错。 它必须与微调时的 output_dir 对应;指到某个中间 checkpoint 还是最终目录,直接决定你合并的是哪一版权重。
  • 合并时漏了 template: qwen 合并 Qwen2 模型权重务必设为 qwen,否则导出的模型对话模板不对。

07自测题

点击题目展开答案;这 12 题答得下来,三份 yaml 就能自己改了

一、为什么要 LoRA
使用全参数微调有哪三个缺点?

① 训练成本高:需要更新千亿甚至万亿级别的参数,消耗大量计算资源,而且为了调整海量参数时不跑偏还需要准备相当规模的数据集。② 训练时间长:周期长,跟不上需要快速迭代的业务节奏。③ 灾难性遗忘:要调整和重构预训练模型的所有参数,可能破坏原有的知识表征,新任务表现好但旧任务急剧下降。

LoRA 微调为什么能避免灾难性遗忘?

因为原始矩阵 W 全程冻结,梯度根本不流到它上面,原有知识表征在物理上就不可能被破坏。学到的新东西全部存在旁路 A·B 里。用比喻说:机床本体一颗螺丝都不动,换活儿只换那套快换夹具——夹具摘掉,机床还是原来那台。

LoRA 微调为什么能减小训练参数量?

矩阵分解:不去真正训练那个和 W 一样大的 ΔW,而是训练两个小矩阵 AM×r)和 Br×N),使 A·B ≈ ΔW。只要两个结果足够近似,工程上就可行;r 远小于 M 和 N 时参数量被极大压缩。这个思路和 PCA、潜在语义分析 LSA、推荐系统的协同过滤是同一套。

M=300000、N=500000、r=16 时,参数量从多少变成多少?占比多少?

原矩阵 W 是 300000×500000 = 1500 亿A 是 300000×16 = 480 万B 是 16×500000 = 800 万,合计 1280 万,约为总参数量的 0.000085,即不到万分之一

二、初始化与秩
LoRA 的 A、B 矩阵是怎样初始化的?为什么不能全部初始化为 0?为什么不能全部做高斯初始化?

A 高斯初始化,B 初始化为全 0 矩阵。
都为 0 学不动B 的梯度正比于 AA 的梯度正比于 B,两边互相为零,梯度恒为 0,参数永远停在原地。
都高斯则一开始就把原模型搞坏了:此时 A·B ≠ 0,训练第 0 步的模型输出就已经被随机噪声扰动,不再等于原模型。
一高斯一全零同时解决两个问题B=0 保证起点严格等于原模型,A≠0 保证 B 的梯度不为零、立刻能学。另外,每个 transformer 层都要添加低秩旁路,不是只加最后一层。

lora_rank 是不是越大越好?

不是。旁路参数量是 r×(M+N)r 翻倍参数就翻倍;秩越高越接近全量微调,省显存的好处也就越少。秩的含义是「学到信息的丰富程度」,要按任务复杂度选,不是按「越大越强」选。

为什么真实 7B 模型挂满旁路后可训练参数占比是 0.53%,而不是 0.0085%?

两个数算的不是一回事。0.008533% 是单个 300000×500000 超大矩阵的压缩比;真实模型里最大的矩阵也就 3584×18944。占比公式是 r×(M+N) / (M×N)——M、N 越小,低秩分解的相对收益越小。要记公式,不要记结论。

三、量化
QLoRA 的三个重点分别是什么?

① 4 位标准浮点数量化(NF4)② 双重量化,第二次量化只作用在第一次量化产生的量化常数上,可以进一步节约显存;③ 分页优化,使用 CPU 内存代替 GPU 显存保存部分状态。

为什么权重量化不用均匀分布的线性量化,而要用 NF4?

因为对于神经网络,一般参数值都符合正态分布——0 附近的参数很多,远离 0 的参数很少,用均匀分布不太合适。NF4 把正态分布按照累计概率密度分为 16 个区域(4-bit 量化后的整数正好 16 个),越靠近 0 越密集,越远离 0 越稀疏,把分辨率放在数据真正密集的地方。

把 X=[0.32, -1.76, 0.025, -1.22] 走一遍 NF4,前两步的结果是什么?

先找 absmax(X) = 1.76,再用 X / absmax(X) 归一化成 [0.1818, -1, 0.0142, -0.6932],然后按 NF4 的 16 个分位点舍入。归一化不能省——分位点都落在 [-1, 1] 区间,不缩放就没法比。而这个 absmax 就是量化常数,必须原样存下来,否则反量化时无从还原尺度。

对称量化和非对称量化的区别?为什么权重量化偏爱对称?

对称量化量化后的分布关于零对称,只需要 Scale = |R_max|/Q_max,量化 Q = Round(R/Scale)Clip 到 -127~127(工程上舍弃 -128),反量化 R' = Q×Scale非对称量化把浮点的最小值 β 和最大值 α 映射到整数范围两端,需要额外的 zero-point 偏移量,能充分利用整个整数范围
矩阵乘法展开时,对称只有 1 项(s_x·s_w·(X_q@W_q)),非对称有 4 项。而神经网络权重本来就大致以 0 为中心对称,用非对称多背一个 zero-point、多算三项,收益却很小,所以权重量化偏爱对称。

「QLoRA 是在 4-bit 上做矩阵乘法」这句话对吗?

不对。量化改的只是存放方式,不是计算方式。QLoRA 把预训练模型量化为 4 位存放,但训练过程中使用高精度格式(如 bf16)进行反量化再参与计算——配置里那行 bf16: true 就是干这个的。存和算是两件事。

四、DeepSpeed 与配置
ZeRO-1/2/3 分别分片什么?代价是什么?

ZeRO-1优化器状态分片到每个数据并行进程;ZeRO-2 再加上梯度ZeRO-3 再加上模型参数。后一级包含前一级。代价是切得越多、卡之间通信越频繁,所以 ZeRO-2 能装下就别上 3。另有 ZeRO-Offload:Optimizer Offload 在 Stage2 基础上把梯度和优化器状态下沉到 CPU 内存或硬盘,Param Offload 在 Stage3 基础上把模型参数也下沉。

朴素流水线并行最大的缺陷是什么?GPipe 怎么修的?

缺陷是 GPU 利用率太低:一个 GPU 计算时其他层的 GPU 都闲着,模型分成四组装到四块卡上,利用率最高只有四分之一。GPipe 引入超参数 chunks把每个 mini-batch 划分成 chunks 个 micro-batch。以 chunks=4 为例:t0 只有 device0 在算第 1 个 micro-batch,t1 时 device1 接着算第 1 个、device0 同时开始算第 2 个(2 台在工作),t2 三台,t3 四台全部工作。反向传播则从 device3 依次到 device0。

为什么三份 yaml 都写 bf16: true 而不是 fp16

看精度范围:FP16 上限只有 65504,大模型训练中一旦中间值超过就溢出成 inf、loss 变 NaN——这正是 FP16 要配一整套 loss scaling 的原因。BF16 范围是 1.18e-38 ~ 3.39e38,和 FP32 几乎一样,牺牲的是尾数精度而不是动态范围,不用调 loss scaling 就能稳定训练。代价是需要 Ampere 架构以上的 GPU。(TF32 范围 1.18e-19 ~ 3.40e38,显存占用高,用于数学运算加速。)

全量、LoRA、QLoRA 三种方式的实测显存各是多少?三刀分别砍在哪?

全量 42.18GB、LoRA 20.17GB、QLoRA 10.97GB。
第一刀(全量→LoRA)砍的是梯度和优化器状态,因为可训练参数少了几个数量级;第二刀(LoRA→QLoRA)砍的是冻结的主干权重本身,靠 4-bit 存放。两刀砍的不是同一块肉——这也解释了为什么 LoRA 只降到一半左右:主干权重那一大块它动不了。
注意这三个数与训练参数配置息息相关,换 cutoff_len、换 batch 大小数字都会变,该记的是比例关系和省在哪一层。

某人用 1500 条数据集、学习率 0.01、训练 10 轮,效果不好,问题在哪?

两处都错。学习率错了两个量级0.01从头训练的标准初始学习率,拿来微调会把预训练学到的东西冲垮;微调该用 0.0001 量级(全量微调更要小到 1e-5)。轮数也过多:1500 条却训 10 轮,模型会把数据背下来,典型过拟合。数据太小时应当减少轮数 + 做数据增强

训练启动后很快停止,日志报 torch.OutOfMemoryError: CUDA out of memory,怎么办?

显存不够。按代价从小到大依次试: 调小 per_device_train_batch_size,用 gradient_accumulation_steps 把等效批次补回来; 调小 cutoff_len(要同步检查数据侧的长度过滤); 换更省的训练方式——全量 42.18GB → LoRA 20.17GB → QLoRA 10.97GB; 上 DeepSpeed,ZeRO-2 不够再上 ZeRO-3; 最后才是把 offload_optimizer.device 改成 "cpu",能跑但会明显变慢。

LoRA 训完之后要做什么?合并时有哪几个坑?

LoRA / QLoRA 只保存适配器权重,必须合并才能推理(全量训练无需此步):llamafactory-cli export qwen2-7b-merge-lora.yaml。坑有三个: 合并 Qwen2 权重务必把 template 设为 qwen 无论 LoRA 还是 QLoRA 训练,合并时 finetuning_type 均为 lora 不要使用量化模型或带 quantization_bit——合并是把 A·B 加回原始 W,需要高精度的 W,在 4-bit 主干上做加法等于把量化误差永久焊进权重。另外 adapter_name_or_path 要与微调时的 output_dir 对应。

术语表

这一页出现过的名词,一句话说清各自管什么

术语一句话解释
LoRA
Low-Rank Adaptation
冻结原权重 W,只训练低秩旁路 A·B 去逼近 ΔW 的微调方法。比喻里的「快换夹具」。
QLoRALoRA + 基座 4-bit 量化。配置里 finetuning_type 仍是 lora,靠 quantization_bit 区分。
灾难性遗忘全量微调重构所有参数后,原有知识表征被破坏,新任务好、旧任务塌。LoRA 靠冻结 W 从物理上规避。
lora_target给哪些线性层挂旁路。all 表示所有线性层,每个 transformer 层都加。
lora_rank(秩 r)旁路的维度,决定学到信息的丰富程度,同时线性决定可训练参数量(r×(M+N))。
lora_alpha缩放系数,决定旁路的重要程度,常取与 rank 相等。
lora_dropout随机丢弃旁路层权重的概率,正则化手段,防止模型过度依赖旁路。
对称量化量化后分布关于零对称,Scale = |R_max|/Q_max,不需要 zero-point。适合权重这类以 0 为中心的数据。
非对称量化把浮点区间两端映射到整数区间两端,需要 zero-point 偏移量,能充分利用整数范围,矩阵乘法展开有 4 项。
Scale / Zero-point量化的两个系数:前者是尺度,后者是「浮点 0 落在整数轴的哪个位置」。反量化时先减偏移再乘尺度。
NF4
4-bit NormalFloat
按正态分布的累计概率密度把区间切成 16 个档位的 4-bit 数据类型,靠近 0 更密。
量化常数每一块数据归一化时用的 absmax。必须原样存下,否则无法反量化;它本身也占空间。
双重量化把第一次量化产生的那批量化常数再量化一次,权重本身不动。块越小越划算。
分页优化
Paged Optimizer
靠 NVIDIA 统一内存,在显存不够时自动把状态换出到 CPU RAM,需要时取回。治的是偶发峰值
DeepSpeed微软开源的大规模训练库,基于 PyTorch,提供并行策略、ZeRO 显存分片、Offload 与混合精度。
数据并行每张卡各存一整套模型,把 batch 分给不同卡,汇总梯度后再分发回去更新。
张量并行矩阵切:每个 GPU 只算矩阵的一部分,算子需要整个矩阵时再聚合。
流水线并行网络层切:模型分段放到不同卡上。朴素做法利用率只有 1/N。
GPipe / chunks把每个 mini-batch 切成 chunks 个 micro-batch,让流水线填满,多卡同时工作。
ZeRO
Zero Redundancy Optimizer
在数据并行进程之间划分模型状态而不是复制它们。等级 1/2/3 依次切优化器状态、梯度、模型参数。
ZeRO-Offload同时利用 CPU 与 GPU 内存。Optimizer Offload 下沉梯度与优化器状态,Param Offload 下沉模型参数。
FP16 / BF16 / TF32三种精度格式。FP16 上限 65504 易溢出需 loss scaling;BF16 动态范围接近 FP32,大模型训练首选;TF32 用于数学运算加速。
梯度累积
gradient accumulation
累积多步梯度再更新一次参数,用来在小显存下模拟大批次。实际总批次 = 每卡批次 × 累积步数 × 卡数。
warmup_ratio前若干比例的 step 用于线性预热学习率,防止训练初期参数剧烈震荡。
lr_scheduler_type: cosine余弦退火:学习率沿余弦曲线从峰值平滑降到接近 0,收尾阶段自动变细致。
LLaMA-Factory封装完善的大模型训练框架,支持命令行与 Web 界面,把「改代码」变成「改一份 yaml」。
FORCE_TORCHRUN=1强制用 PyTorch 的 torchrun 启动分布式训练,用于多卡环境或需要显式控制分布式初始化时。
合并权重
export / merge
把适配器 A·B 加回主干导出成完整模型。LoRA/QLoRA 必做,全量微调不需要;合并时不得使用量化模型。