🦙 第3章 微调 Llama 2 大模型
📄 一句话概括

加载这一行代码里藏着四件事:用什么精度、放哪张卡、要不要量化、量化到几位。 四个选择里,只有精度和量化会改结果,另外两个只改快慢。 这一节把 4 bit 的误差真的量一遍:权重压掉四分之三,余弦相似度还剩多少,输出偏多少。

📝 本章和前面几章的关系

上一节备了数据,这一节备模型。 24 课 计算机是怎样跑起来的 讲过浮点数的位怎么排, 这一节讲的是这几位怎么影响一个 67 亿参数的模型——同一个数,少存几位,结果就差多少。

一、那一行代码到底做了什么

from_pretrained 做的事是:读配置、比对清单、缺的下载、校验哈希、映射文件、 按 device_map 把层分到不同的卡上、最后转成运行精度。 device_map='auto' 值得单独说。它按层切,能放显存的放显存,放不下的留内存,用到再搬。 所以有些配置「能跑但很慢」——它在不停地搬。 torch_dtype 决定运行精度。 不写这一项,很多仓库默认按 fp32 加载,显存直接翻倍,然后你以为是模型太大。

还有一个容易被忽略的:分词器要跟权重配套加载。 用错分词器不会报错,只会把话说得颠三倒四——因为词表对不上,一个词被切成两半。

二、fp32、fp16、bf16:三个精度的取舍

fp32 是 1 位符号、8 位指数、23 位尾数。范围大、精度高,体积也大,一个参数四个字节。 fp16 是 1 位符号、5 位指数、10 位尾数。尾数比 fp32 少一半,够用; 但指数只有 5 位,能表示的最大值约 65504,训练时梯度一小就下溢成零。 bf16 是 1 位符号、8 位指数、7 位尾数。指数跟 fp32 一样宽,所以不会溢出; 尾数只有 7 位,精度比 fp16 还低。

结论不是「哪个更好」,是「哪个更适合」:推理用 fp16 就够; 训练优先 bf16,因为它不怕溢出,精度那点损失靠学习率能补回来。

三、量化加载:省的是显存,不是算力

最常见的做法是对称量化:找出整个矩阵的最大绝对值,把它切成若干等距格子,每个权重就近落到格子上, 只存格子的编号。8 bit 是 256 格,4 bit 是 16 格。 格子的步长由一个缩放系数决定,这个系数要额外存一个浮点数。 分 16 组量化就是存 16 个系数——误差小了,代价可以忽略。 误差有多大,演示里量得很清楚:8 bit 的余弦相似度 0.99995,几乎无损;4 bit 是 0.98257,还能用; 2 bit 掉到 0.38853,方向都丢了。

这里有个容易误解的点:4 bit 加载省的是显存,不是算力。 算的时候权重还是要展开成 fp16,矩阵乘还是那些乘加。真省算力得把算子也写成低位。

四、加载时的几类报错,各指向什么

尺寸不匹配,多半是配置文件里的维度跟权重对不上——下错了版本,或者混了不同档的权重。 缺 key,指向分片没下全。这时候先看清单里列了几个文件,再数本地有几个。 显存不足的报错最没用,因为真正的原因可能是精度没设、可能是并发太多、 也可能是 KV 缓存按默认最大长度预分配了。

还有一类是版本:transformers 太老认不出新的量化配置,会安静地忽略掉,于是你以为在跑 4 bit, 其实在跑 fp16。加载完看一眼显存占用,比看日志可靠。

五、动手 Demo:加载时把权重从 fp16 压到 int4,误差有多大

造一个 512×512 的权重矩阵,标准差 0.02,跟真实权重量级一致。 按四种精度各压一遍,量误差标准差、绝对误差占比、余弦相似度。 再量一下「误差出在哪些数上」和「分组量化能救回多少」。 真机上这些都在 CUDA 里,这里用 numpy 把同一套算法走一遍。

W = np.random.randn(D, D) * 0.02        # 一层权重

def quant(W, bits):
    qmax = float(2 ** (bits - 1) - 1)   # 8 bit 是 127,4 bit 是 7
    s = np.abs(W).max() / qmax          # 全矩阵共用一个标尺
    return np.clip(np.round(W / s), -qmax - 1, qmax) * s

cos = (W * Wq).sum() / (np.linalg.norm(W) * np.linalg.norm(Wq))

# 分组量化:每组用自己的最大绝对值当标尺
for g in (1, 4, 16):
    step = D // g
    for i in range(g):
        blk = W[i * step:(i + 1) * step]
        Wq[i * step:(i + 1) * step] = quant(blk, 4)

实跑输出:

--------------------------------------------------------------------
加载时把权重从 fp16 压到 int4,误差有多大
--------------------------------------------------------------------


  ① 造一层权重
  --------------------------------------------------------------------
  512 × 512 的矩阵,标准差 0.02,跟真实权重一个量级。
  它占 1.0 MiB(fp32)。真模型一层是 4096 × 4096,占 64 MiB。
  ! 权重通常是很小的数:初始化时按 1/根号 d 缩放,训练完也在这个量级。
  「小」这一点对量化有利——小的动态范围更容易压。

  ② 四种精度各压一遍
  --------------------------------------------------------------------
| 精度 | 误差标准差 | 绝对误差占比 | 余弦相似度 | 一层占多少 |
|---|---:|---:|---:|---:|
| 8 bit | 0.000 | 1.13% | 0.99995 | 0.2 MiB |
| 4 bit | 0.004 | 20.56% | 0.98257 | 0.1 MiB |
| 3 bit | 0.009 | 47.96% | 0.91471 | 0.1 MiB |
| 2 bit | 0.019 | 98.13% | 0.38853 | 0.1 MiB |

  8 bit 的余弦相似度 0.99995,几乎无损;4 bit 到 0.98257,还能用;
  2 bit 掉到 0.38853,已经不能看了。
  ! 余弦相似度高,说明「方向」还在。
  这解释了为什么 4 bit 能跑——模型要的是向量的方向,不是它的精确长度。

  ③ 误差出在哪几个数上
  --------------------------------------------------------------------
| 位置 | 原值 | 量化后 | 误差 |
|---|---:|---:|---:|
| (256, 225) | +0.0066 | +0.0131 | 0.0066 |
| (120, 165) | -0.0197 | -0.0131 | 0.0066 |
| (345, 195) | +0.0066 | +0.0131 | 0.0066 |
| (29, 284) | -0.0066 | -0.0000 | 0.0066 |
| (348, 296) | -0.0066 | -0.0131 | 0.0066 |

  误差最大的几个,全是绝对值偏大的那些。
  ! 因为对称量化用「全矩阵最大绝对值」当标尺,格子是等距的。
  小数被粗糙的格子压扁,大数还好——这叫「小值欠采样」。
  后来那些 4 bit 方法(NF4、GPTQ)主要就在修这个问题。

  ④ 逐层量化 vs 分组量化
  --------------------------------------------------------------------
| 怎么分组 | 平均均方误差 |
|---|---:|
| 分 1 组 | 0.000014 |
| 分 4 组 | 0.000013 |
| 分 16 组 | 0.000012 |

  分组越细,误差越小——因为每组用自己的最大绝对值当标尺,避免了「一个大数拖累全组」。
  ! 代价是每组要额外存一个缩放系数。分 16 组就是多存 16 个浮点数,可以忽略;
  但分到每个元素一组,就等于没压。工程上是个折中。

  ⑤ 真机上怎么加载
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | from transformers import BitsAndBytesConfig
  | 
  | bnb = BitsAndBytesConfig(
  |     load_in_4bit=True,
  |     bnb_4bit_quant_type='nf4',
  |     bnb_4bit_compute_dtype=torch.float16,
  | )
  | model = AutoModelForCausalLM.from_pretrained(ckpt, quantization_config=bnb)
  这段在真机上把权重压到 4 bit 加载,本沙箱没有 bitsandbytes,不执行。

  注意 bnb_4bit_compute_dtype 这一项:存的时候 4 bit,算的时候转回 fp16。
  ! 所以 4 bit 加载省的是显存,不是算力——算的时候还是要展开。
  真正省算力的是把算子也写成 4 bit,那是另一回事。

先看①:权重是很小的数,标准差 0.02。这一点对量化有利——小的动态范围更容易压,格子够密。 如果权重里有几个特别大的离群值,全矩阵共用一个标尺就会被它们拖坏。 ②是主表。8 bit 余弦 0.99995,基本无损;4 bit 0.98257,还能用;2 bit 0.38853,方向都丢了。 这就是为什么工程上停在 4 bit,再往下省一半体积,换来的是「不能看」。 注意「绝对误差占比」和「余弦相似度」是两个口径:前者看每个数差多少,后者看向量的方向还在不在。 模型要的是方向,所以后者更能说明「还能不能用」。 ③列了误差最大的几个位置。 它们的共同点是绝对值偏大——这不奇怪,对称量化用全矩阵最大值当标尺,格子是等距的, 小数被粗格子压扁,大数反而还好。 这个现象叫小值欠采样。 后来的 4 bit 方法主要就在修它:NF4 用分位数切格子,让格子在小值区密、大值区疏; GPTQ 按每一列对输出的影响决定舍入方向。 ④是分组量化的效果:分 16 组比分成 1 组的均方误差小。 代价是每组存一个缩放系数——分组越细越省误差、越费索引,工程上是折中。 ⑤那段真机代码本沙箱跑不了。 要看的是最后一行那句 bnb_4bit_compute_dtype:存的时候 4 bit,算的时候转 fp16。 所以省的是显存,不是算力。

30 秒实验

  1. 把②里的精度从 8、4、3、2 改成 6 和 5,看余弦相似度落在哪。 想一想:为什么工程上不流行 5 bit、6 bit?
  2. 把①里的标准差从 0.02 改成 0.2,再压一遍 4 bit。误差是变大还是变小? 这能解释「为什么归一化对量化友好」。
  3. 在权重里手工塞一个 100 倍大的离群值,再压 4 bit。 看余弦相似度掉多少——这就是离群值为什么是量化的头号敌人。

常见误区

  1. 以为 4 bit 加载会更快。它省显存,不省算力。权重展开成 fp16 再算,乘加次数一次没少; 某些情况下反而更慢,因为多了展开这一步。
  2. 以为 bf16 是「精度更高的 fp16」。恰恰相反,bf16 尾数只有 7 位,比 fp16 的 10 位少。 它胜在范围跟 fp32 一样宽,训练时不溢出。
  3. 加载完不看显存占用。 transformers 版本太老会安静地忽略量化配置,你以为在跑 4 bit,其实整份权重都是 fp16。 看一眼显存,比读十行日志快。
自测三问(点开对答案) 1. 余弦相似度 0.98 说明什么,为什么它比「误差占比」更能说明能不能用? —— 说明向量的方向基本没变。模型算的是加权求和,结果主要由方向决定,长度影响小。 误差占比只告诉你每个数差多少,不告诉你这个差会不会改变输出。 2. 为什么对称量化对「小值」特别不友好? —— 因为它用全矩阵的最大绝对值当标尺,格子等距。 小值附近的格子太粗,大部分小数被压到同一个格子上,细节全丢; 大值附近的格子相对细,反而保住了。 3. `bnb_4bit_compute_dtype` 设成 fp16 是在做什么? —— 设的是计算时的类型。权重存成 4 bit 省显存,前向传播时按块展开成 fp16 再算。 所以省的是存储,不是运算量。

权重装进来了,下一节开始改它——先算清全参微调和 LoRA 各要多少显存。

相关笔记