Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

量化、蒸馏与模型压缩

上一章里,我们让语言模型记住已经算过的 K、V,省下一遍遍重算的工作。但有一件事还没变:模型本身依旧那么大。

现在想把模型放到一台内存更小的电脑上。我们能不能少存一些数字?或者每个数字少占几位?再或者,训练一个更小的模型,让它接过原来的工作?

这三条路,分别通向剪枝、量化和蒸馏。它们都在讨论“怎样变小”,改变的东西却不同。本章会先看清每种做法,再用同一批手写数字把它们放在一起比较。我们也会保留失败结果:模型缩小了,认错的字却多了,这样的方案当然需要解释。

实验只用 CPU。我们会真正训练模型、打包 4 位权重、保存再加载,并逐个检查预测发生了什么变化。它是一个看得清每一步的小实验;大语言模型中的量化方法,会在最后接回这条主线。

1. 先弄清楚,究竟想省什么

一张模型文件占多少空间,模型运行时占多少内存,一次预测需要多久,是三个相关但不同的问题。

假设一个模型有十亿个权重,每个权重用 FP32 保存。只算这些权重,就需要约 40 亿字节。若每个权重改用 4 位编码,这部分的理想体积约为原来的八分之一。但模型还要保存量化步长、偏置等信息;运行时还需要激活、中间结果、缓存和计算工作区。

因此,看见“4 位模型”时,先问一句:哪些数字用了 4 位?

做法主要改变什么还要检查什么
量化数值的表示方式误差、额外参数、计算内核
剪枝保留哪些连接或单元存储格式、形状、能力变化
蒸馏重新训练一个学生模型学生结构、训练成本、任务表现

量化可以保留原来的网络形状;结构化剪枝会直接缩小某些层;蒸馏则允许重新选择学生结构。它们也可以组合,例如先训练小学生,再量化它的权重。不过,我们这次先分别比较,免得同时改了太多东西,最后说不清收益来自哪里。

第 36 章的混合精度主要围绕训练中的数值范围与计算展开。本章更关心部署时如何存储权重、怎样让压缩后的模型继续完成任务。小数位少了,不代表模型一定变快;最终还要看设备如何执行这些计算。

2. 量化:把连续数值放到有限的刻度上

先从一把刻度较粗的尺子想起

假设只能记录 0.25 的整数倍。原来是 0.62,就记成附近的 0.50;原来是 0.76,就记成 0.75。每个数都被放到最近的刻度上。

这里的刻度间距叫量化步长,常记作 ss,也常在代码里叫 scale。先用一种最简单的对称量化:

q=clip⁡(round⁡(r/s),−Q,Q)r^=sq \begin{aligned} q&=\operatorname{clip} \left(\operatorname{round}(r/s),-Q,Q\right)\\ \hat r&=sq \end{aligned}

rr 是原来的浮点数,qq 是保存的整数码,r^\hat r 是用整数码还原出的近似值。round 取最近的整数,clip 把超出范围的结果截到两端。本章使用 bb 位时,取 Q=2b−1−1Q=2^{b-1}-1:4 位使用 −7-7 到 7,8 位使用 −127-127 到 127。为了让两边对称,各留了一个未使用的编码。

取 s=0.25s=0.25、r=0.62r=0.62,就有:

q=round⁡(2.48)=2r^=0.25×2=0.50 \begin{aligned} q&=\operatorname{round}(2.48)=2\\ \hat r&=0.25\times2=0.50 \end{aligned}

如果原值没有超出量化范围,舍入误差最多约为半个步长。本例半个步长是 0.125,实际误差是 0.12。若原值达到 2.0,而最大刻度只有 7×0.25=1.757\times0.25=1.75,它就会被截到 1.75;此时误差可以超过半个步长。

图 1:蓝线是一层层台阶。台阶之间有舍入误差,两端浅色区域还有截断误差。这里的步长是为了画图指定的。

也有不以零为中心的量化方式,需要再保存一个零点 zz。一种常见写法是:

q=clip⁡(round⁡(r/s)+z,qmin⁡,qmax⁡)r^=s(q−z) \begin{aligned} q&=\operatorname{clip} \left(\operatorname{round}(r/s)+z,q_{\min},q_{\max}\right)\\ \hat r&=s(q-z) \end{aligned}

此时 zz 是实数零对应的整数码。步长控制间隔,零点控制整数刻度与实数刻度怎样对齐。后面的代码采用前一种对称方案,数学上的零点为 0。相关量化与训练机制可参看 Jacob 等人的原始论文;我们的程序只量化权重,不实现论文中的完整整数推理流程。

谁来决定步长

一整层权重共用一个步长,写起来很省事,但它们的大小未必接近。假如某组权重是 [100,−0.1,0.2][100,-0.1,0.2],为了覆盖 100,4 位对称量化需要的步长约为 100/7=14.29100/7=14.29。另外两个数都会被舍入成 0。

大数保住了,小数却挤在同一格里。

一种改进是让不同的输出通道各自选择步长。对一个权重矩阵 WW,本章按行计算:

si=max⁡j∣Wij∣Q s_i=\frac{\max_j|W_{ij}|}{Q}

ii 选输出通道,jj 沿该通道的输入权重移动。如果整行都是零,代码直接令 si=1s_i=1、整数码全为零,避免除以零。这里采用 PyTorch 线性层的存储约定:权重形状是“输出数 × 输入数”,与第 1 章为了展示 XWXW 而使用的排法不同。

按通道设置步长,可以避免某一行的大数拖累所有行;但同一行里如果仍有特别大的数,问题还在。进一步按小组设置步长,能把适应范围缩小,却需要保存更多步长。压缩从这里开始就有了取舍:刻度越贴合局部数据,额外信息往往越多。

四位码,真的只存四位了吗

torch.int8 的每个元素占一个字节。如果把 −7-7 到 7 存进这样的张量,虽然只用了少数几个数值,空间仍是每项 8 位。

本例会再做一次打包。先把整数码加 7,变成 0 到 14;然后把两个码分别放进同一个字节的低四位和高四位。这个“加 7”只是文件编码约定,还原时会减回去,不是又换了一套量化步长。

例如,两个量化码 [−7,0][-7,0] 先变成 [0,7][0,7],合成的字节值为:

0+16×7=112 0+16\times7=112

读取时拆开高低四位,再减去 7,就能取回原来的两个整数码。如果元素总数是奇数,最后半个字节补齐,并另外保留原来的元素数。

这一步让模型状态里的权重确实缩小了。可是执行预测时,本章的 PackedLinear 仍会解包、还原成 FP32,再调用浮点线性运算。因此,这个程序演示了紧凑存储与数值误差,没有演示整数内核加速。还原出的浮点权重也会占临时内存,不能把状态体积当成运行峰值。

3. 先训练再压缩,还是边训练边适应

训练后量化:先试一试原模型能承受多少误差

训练后量化(PTQ)从已经训练好的模型出发,选择量化参数,再转换权重。本章的 PTQ8 和 PTQ4 都直接读取同一个教师模型的权重范围,不再更新它。

由于本例只处理权重,步长可以从权重本身算出来。若还要量化激活,激活的范围会随输入变化,就常需要一批有代表性的校准输入。校准数据应该来自允许用于开发的数据,不能拿最后的测试答案反复挑方案。支持动态量化的实现,也可能在运行时根据当前输入选择部分参数。

训练后量化的吸引力是省下重新训练的成本。问题也直观:模型原来并没有被要求适应这些新误差。

量化感知训练:让模型提前见到台阶

量化感知训练(QAT)把量化误差放进训练过程。本例保留可更新的 FP32 主权重,但每次前向计算都先做“舍入再还原”,让网络看到近似值。

真正的舍入是阶梯函数。大部分位置上,小小地改变输入,输出不会动;在台阶边缘,它又突然跳一下。直接拿这种函数的导数训练,并不好用。

代码采用一种刻意的近似,叫直通估计(STE)。记当前权重为 ww,量化还原结果为 wqw_q,构造:

w~=w+stopgrad⁡(wq−w) \tilde w=w+\operatorname{stopgrad}(w_q-w)

stopgrad 表示括号里的表达式参与前向数值计算,但反向时不沿它传梯度。因此,前向结果是 wqw_q;反向对主权重的局部导数被设成 1。PyTorch 代码里的 detach() 完成了这里的停止梯度操作。

这不是舍入函数的真实导数,而是为了训练选用的梯度近似。本例每行的步长也不参与求导;其他 QAT 实现可以采用不同的范围估计和梯度处理,不能只凭“都叫 QAT”就认为算法相同。

训练结束后,还要把浮点主权重转换为真正的紧凑编码。只保存带有“假量化”计算的训练模型,不会自动获得 4 位存储。本例显式调用转换函数,再检查保存与加载后的预测是否一致。

4. 剪枝:删掉一些数,还是删掉一些计算

把权重置零,文件未必变小

先尝试最直接的办法:把绝对值较小的权重设成零。单个连接可以独立删除,这通常叫非结构化剪枝。

一个含 1000 个 FP32 数的密集数组,原来占 4000 字节;把其中 900 个数改成零,仍然是 4000 字节。只有换成能省略零的存储格式,才可能省空间,而索引等信息也要计入。运行得更快则还依赖相应的稀疏计算实现。

本章故意保留密集数组,看看这件事有多明显:我们删除绝对值最小的 90% 权重,偏置不变,也不继续微调。最终有 4262 个权重被置零,但状态字节数一点没少。

真的把一层变窄

另一条路是删除完整的隐藏单元、通道或其他结构,这叫结构化剪枝。

我们的分类器先把 64 个像素变成 64 个隐藏值,再输出 10 个数字类别的分数。若只保留 16 个隐藏单元,就可以把第一层的相应行、第二层的相应列一起删除。新模型的矩阵形状变小,普通密集存储也能直接受益。

哪些单元留下?本例只用训练输入,计算每个隐藏单元的平均激活,再乘上它通向输出层的权重绝对值之和,用这个简单分数留下前 16 个。它只是一个教学启发式,不保证能找到最佳子网络。

我们也不做剪枝后的恢复训练,所以这次对照会相当粗暴。后面若看到准确率大跌,结论应该是“这套直接删法没有保住能力”,而不能推成“剪枝没有用”。Deep Compression 研究的是包含剪枝、量化、重训练与编码的完整方案,本章没有复现它的全部流程。

5. 蒸馏:让小模型学习老师怎样判断

假如不直接拆掉大模型,而是从头训练一个小模型呢?

标签会告诉学生“这张图是 7”。教师还能提供十个类别的概率,让学生看到它在几个候选之间怎样分配信心。这些概率叫软目标。如果只取教师的最高分标签,剩下候选之间的关系就丢掉了。

我们已经在后训练章节见过用教师答案构造训练数据。本章采用分类任务中常见的概率蒸馏,使用 Hinton、Vinyals 和 Dean 的蒸馏思路:教师保持固定,学生同时参考真实标签和教师的输出分布。

温度让次要候选更容易看见

设类别 cc 的原始分数是 zcz_c,温度为 T>0T>0,经过 softmax:

pT(c)=exp⁡(zc/T)∑jexp⁡(zj/T) p_T(c)= \frac{\exp(z_c/T)} {\sum_j\exp(z_j/T)}

T=1T=1 是通常的概率输出。提高温度后,分布会变平;原来很小的候选概率更容易显现出来。

图 2:这是三个候选的假设分数,不是数字分类实验的实测输出。提高温度保留候选排序,但改变它们的概率差距。

本章用下面的损失训练学生:

L=(1−α)Lhard+αT2LsoftLhard=−log⁡ps(y∣x)Lsoft=∑cpt,T(c∣x)log⁡pt,T(c∣x)ps,T(c∣x) \begin{aligned} L&=(1-\alpha)L_{\mathrm{hard}} +\alpha T^2L_{\mathrm{soft}}\\ L_{\mathrm{hard}}&=-\log p_s(y\mid x)\\ L_{\mathrm{soft}}&= \sum_c p_{t,T}(c\mid x) \log\frac{p_{t,T}(c\mid x)}{p_{s,T}(c\mid x)} \end{aligned}

xx 是输入,yy 是真实类别;下标 tt 表示教师,ss 表示学生。硬标签项使用温度 1,软目标项让教师和学生都使用温度 TT,方向是 DKL(pt∥ps)D_{\mathrm{KL}}(p_t\|p_s)。α\alpha 控制两项的相对权重;T2T^2 是常用的梯度尺度补偿,避免温度变化时直接把软目标的作用削得过小。

这里取 T=2T=2、α=0.7\alpha=0.7。程序先对每张图求出损失,再对一个批次取平均。F.kl_div 的输入约定容易写反:第一个参数是学生的对数概率,第二个才是教师概率;batchmean 对类别求和、对样本平均。可对照 PyTorch 的 KLDivLoss 说明。

教师的输出并不是真理。它也会把某个 5 看成 9,因此我们保留真实标签项,并用真实标签评估学生。蒸馏也不会凭空省下训练成本:先训练教师、计算教师输出,都要付出工作量。省下来的主要是学生部署时的规模。

6. 同一批数字,八种模型

先把对照条件摆在桌面上

沿用第三篇手写数字实践的数据及固定划分:1077 张训练图、360 张验证图、360 张回归测试图,每张都是 8×88\times8 灰度图。像素统一除以 16,转换为 FP32。数据来源与许可沿用原实践中的 UCI 手写数字说明。

这次重新训练一个 64 → 64 → 10 的全连接教师,隐藏层使用 ReLU。它不是第三篇的卷积模型,也没有沿用那个模型的权重。学生是 64 → 16 → 10。

教师和两种学生都训练 80 轮,用验证交叉熵选择保存的轮次。普通学生只学真实标签,蒸馏学生同时学标签和教师;两者从同一份初始权重出发,使用相同的批次顺序、优化器配置与轮数上限。教师输出只在训练输入上计算并缓存。

QAT 则从训练好的教师出发,额外训练 20 轮,学习率从 0.003 降到 0.0003。因此,QAT 与 PTQ 的训练成本不同。两个剪枝方案都直接从教师转换,没有恢复训练。

记录名称实际做法
teacher64 个隐藏单元,FP32 教师
ptq8教师权重按输出通道量化为 8 位
ptq4同样的教师,量化并打包为 4 位
qat4额外进行 QAT,再打包为 4 位
prune9090% 权重置零,保留密集数组
units16直接删除隐藏单元,只留下 16 个
hard1616 个隐藏单元,只用真实标签训练
distill16同样的小模型,加入教师软目标

在看回归测试结果之前,先约定选择规则:状态张量不超过 6000 字节,验证准确率比教师下降不超过 1 个百分点。在满足条件的压缩模型中,选择状态最小的;体积相同时,选择验证交叉熵较低的。若没有模型满足条件,就保留教师。

注意“下降 1 个百分点”是准确率从例如 97% 变为 96%,不是计算相对下降 1%。这个门槛只是本次教学实验的要求,不是压缩任务通用的合格线。

在自己的电脑上运行

在仓库根目录安装已有的固定依赖,并运行:

python3 -m pip install -r requirements.txt
python3 src/examples/model_compression.py \
  --out runs/compression.json \
  --save-dir runs/compression-models

也可以下载主脚本、数据划分脚本与依赖清单,把两个 Python 文件放在同一目录后运行。数据随 scikit-learn 提供;本例不用 GPU,也不用下载大模型。

本书记录使用 Python 3.12.12、PyTorch 2.14.1、CPU、单个计算线程。模型初始化与批次顺序的随机种子都保存在 CONFIG 中。更换软件版本或计算设备后,浮点运算和最佳轮次可能变化;应保留新的完整记录,不能把书中的小数当作所有机器都必须输出的常数。

展开完整实验代码
"""第 38 章:真实训练、权重 INT8/INT4、QAT、剪枝与蒸馏;CPU 小实验。"""

import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path
import platform
from tempfile import TemporaryDirectory
import torch
from torch import nn
from torch.nn import functional as F
from digits_project import make_data

CONFIG = dict(teacher_width=64, student_width=16, epochs=80, qat_epochs=20,
              batch_size=64, lr=.003, qat_lr=.0003, weight_decay=.0001,
              temperature=2., alpha=.7, teacher_seed=2038, student_seed=2039,
              order_seed=2040, max_accuracy_drop=.01, max_state_bytes=6000)


def quantize_rows(weight, bits):
    """对 Linear 的每个输出通道求一个 scale;使用对称范围,保留一个未用码。"""
    if bits not in (4, 8) or weight.ndim != 2 or not torch.isfinite(weight).all():
        raise ValueError("需要有限二维权重,位数为 4 或 8")
    limit = 2**(bits-1)-1
    maximum = weight.detach().abs().amax(dim=1, keepdim=True)
    scale = torch.where(maximum > 0, maximum/limit, torch.ones_like(maximum))
    codes = torch.round(weight.detach()/scale).clamp(-limit, limit).to(torch.int8)
    return codes, scale


def fake_weight(weight, bits):
    codes, scale = quantize_rows(weight, bits)
    rounded = codes.to(weight.dtype)*scale
    # 前向使用舍入后的值;反向对浮点主权重采用恒等直通估计,不对 scale 求导。
    return weight+(rounded-weight).detach()


def pack_int4(codes):
    if codes.dtype != torch.int8 or torch.any(codes < -7) or torch.any(codes > 7):
        raise ValueError("本例的对称 4 位码范围为 -7 到 7")
    unsigned = (codes.flatten().to(torch.int16)+7).to(torch.uint8)
    if unsigned.numel() % 2:
        unsigned = torch.cat([unsigned, torch.zeros(1, dtype=torch.uint8)])
    return unsigned[::2] | (unsigned[1::2] << 4)


def unpack_int4(packed, count):
    if packed.dtype != torch.uint8 or count < 1 or packed.numel() != (count+1)//2:
        raise ValueError("压缩字节数与原元素数不匹配")
    unsigned = torch.stack([packed & 15, packed >> 4], dim=1).flatten()[:count]
    if torch.any(unsigned > 14):
        raise ValueError("遇到本格式未使用的码 15")
    return (unsigned.to(torch.int16)-7).to(torch.int8)


class PackedLinear(nn.Module):
    def __init__(self, layer, bits):
        super().__init__()
        codes, scale = quantize_rows(layer.weight, bits)
        self.bits = bits
        self.rows, self.columns = layer.weight.shape
        self.register_buffer("codes", pack_int4(codes) if bits == 4 else codes)
        self.register_buffer("scale", scale.clone())
        self.register_buffer("bias", layer.bias.detach().clone())

    def weight(self):
        codes = (unpack_int4(self.codes, self.rows*self.columns).reshape(self.rows, self.columns)
                 if self.bits == 4 else self.codes)
        return codes.float()*self.scale

    def forward(self, x):
        # 真正紧凑保存,但每次解包回 FP32 计算;这里没有高效整数矩阵内核。
        return F.linear(x, self.weight(), self.bias)


class Classifier(nn.Module):
    def __init__(self, width, fake_bits=None):
        super().__init__()
        self.width, self.fake_bits = width, fake_bits
        self.first, self.last = nn.Linear(64, width), nn.Linear(width, 10)

    def forward(self, x):
        x = x.flatten(1)
        for index, layer in enumerate([self.first, self.last]):
            x = (F.linear(x, fake_weight(layer.weight, self.fake_bits), layer.bias)
                 if self.fake_bits is not None else layer(x))
            if index == 0:
                x = x.relu()
        return x


def pack_model(model, bits):
    result = deepcopy(model)
    result.fake_bits = None
    result.first, result.last = PackedLinear(model.first, bits), PackedLinear(model.last, bits)
    return result.eval()


def state_bytes(model):
    return sum(t.numel()*t.element_size() for t in model.state_dict().values())


def save_model(model, path):
    if model.fake_bits is not None:
        raise ValueError("请先把 QAT 模型转换为 PackedLinear 再导出")
    bits = model.first.bits if isinstance(model.first, PackedLinear) else None
    torch.save(dict(format=1, width=model.width, bits=bits,
                    state=model.state_dict()), path)


def load_model(path):
    data = torch.load(path, map_location="cpu", weights_only=True)
    if data["format"] != 1 or data["width"] not in (16, 64) or data["bits"] not in (None, 4, 8):
        raise ValueError("不支持的模型格式")
    model = Classifier(data["width"])
    if data["bits"] is not None:
        model = pack_model(model, data["bits"])
    model.load_state_dict(data["state"], strict=True)
    return model.eval()


def distillation_loss(student, teacher, labels, temperature=2., alpha=.7):
    hard = F.cross_entropy(student, labels)
    soft = F.kl_div(F.log_softmax(student/temperature, dim=1),
                   F.softmax(teacher.detach()/temperature, dim=1),
                   reduction="batchmean")
    return (1-alpha)*hard+alpha*temperature**2*soft


@torch.inference_mode()
def evaluate(model, dataset):
    model.eval()
    x, y = dataset
    logits = model(x)
    prediction = logits.argmax(1)
    return dict(loss=F.cross_entropy(logits, y).item(),
                accuracy=(prediction == y).float().mean().item(),
                predictions=prediction.tolist())


def fit(model, train, validation, teacher=None, epochs=None, lr=None):
    epochs = CONFIG["epochs"] if epochs is None else epochs
    lr = CONFIG["lr"] if lr is None else lr
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=CONFIG["weight_decay"])
    generator = torch.Generator().manual_seed(CONFIG["order_seed"])
    x, y = train
    teacher_logits = None
    if teacher is not None:
        teacher.eval()
        with torch.no_grad():
            teacher_logits = teacher(x).detach()
    best_state, best_loss, best_epoch = deepcopy(model.state_dict()), evaluate(model, validation)["loss"], 0
    history = []
    for epoch in range(1, epochs+1):
        model.train()
        for ids in torch.randperm(len(x), generator=generator).split(CONFIG["batch_size"]):
            optimizer.zero_grad(set_to_none=True)
            logits = model(x[ids])
            loss = (F.cross_entropy(logits, y[ids]) if teacher is None else
                    distillation_loss(logits, teacher_logits[ids], y[ids],
                                      CONFIG["temperature"], CONFIG["alpha"]))
            if not torch.isfinite(loss):
                raise RuntimeError("训练损失非有限")
            loss.backward()
            optimizer.step()
        score = evaluate(model, validation)
        history.append(dict(epoch=epoch, loss=score["loss"], accuracy=score["accuracy"]))
        if score["loss"] < best_loss:
            best_state, best_loss, best_epoch = deepcopy(model.state_dict()), score["loss"], epoch
    model.load_state_dict(best_state)
    return model.eval(), best_epoch, history


def prune_weights(model, fraction=.9):
    result = deepcopy(model)
    weights = [result.first.weight, result.last.weight]
    flat = torch.cat([w.detach().flatten() for w in weights])
    mask = torch.ones_like(flat)
    mask[flat.abs().argsort()[:int(fraction*len(flat))]] = 0
    start = 0
    with torch.no_grad():
        for weight in weights:
            count = weight.numel()
            weight.mul_(mask[start:start+count].reshape(weight.shape))
            start += count
    return result


def prune_units(model, train_x, keep=16):
    # 只用训练输入估计隐藏单元贡献,选择后整行/整列删除,不做额外微调。
    with torch.inference_mode():
        activation = model.first(train_x.flatten(1)).relu().mean(0)
        importance = activation*model.last.weight.abs().sum(0)
        chosen = importance.topk(keep).indices.sort().values
    result = Classifier(keep)
    with torch.no_grad():
        result.first.weight.copy_(model.first.weight[chosen])
        result.first.bias.copy_(model.first.bias[chosen])
        result.last.weight.copy_(model.last.weight[:, chosen])
        result.last.bias.copy_(model.last.bias)
    return result.eval()


def run():
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    raw, ids = make_data()
    datasets = [(x.float()/16, y) for x, y in raw]
    train, validation, test = datasets
    torch.manual_seed(CONFIG["teacher_seed"])
    teacher, teacher_epoch, teacher_history = fit(Classifier(64), train, validation)
    torch.manual_seed(CONFIG["student_seed"])
    initial = Classifier(16)
    hard, hard_epoch, hard_history = fit(deepcopy(initial), train, validation)
    student, student_epoch, student_history = fit(deepcopy(initial), train, validation, teacher)
    qat = deepcopy(teacher)
    qat.fake_bits = 4
    qat, qat_epoch, qat_history = fit(qat, train, validation, epochs=CONFIG["qat_epochs"],
                                     lr=CONFIG["qat_lr"])
    models = dict(teacher=teacher, ptq8=pack_model(teacher, 8), ptq4=pack_model(teacher, 4),
                  qat4=pack_model(qat, 4), prune90=prune_weights(teacher),
                  units16=prune_units(teacher, train[0]), hard16=hard, distill16=student)
    validation_scores = {name: evaluate(model, validation) for name, model in models.items()}
    eligible = [name for name, model in models.items()
                if name != "teacher" and state_bytes(model) <= CONFIG["max_state_bytes"]
                and validation_scores[name]["accuracy"] >=
                validation_scores["teacher"]["accuracy"]-CONFIG["max_accuracy_drop"]]
    selected = min(eligible, key=lambda name: (state_bytes(models[name]),
                                              validation_scores[name]["loss"])) if eligible else "teacher"
    # 到这里所有方案和选择均已确定;随后用已在旧章公开的测试划分做能力回归。
    reference = torch.tensor(evaluate(teacher, test)["predictions"])
    target = test[1]
    rows = []
    with TemporaryDirectory() as directory:
        for name, model in models.items():
            path = Path(directory)/"model.pt"
            save_model(model, path)
            restored = load_model(path)
            with torch.inference_mode():
                torch.testing.assert_close(model(test[0]), restored(test[0]), atol=0, rtol=0)
            score = evaluate(restored, test)
            prediction = torch.tensor(score["predictions"])
            regressed = (reference == target) & (prediction != target)
            fixed = (reference != target) & (prediction == target)
            weights = [model.first.weight(), model.last.weight()] if isinstance(model.first, PackedLinear) else [
                model.first.weight, model.last.weight]
            rows.append(dict(name=name, state_bytes=state_bytes(model),
                             weight_values=sum(w.numel() for w in weights),
                             nonzero_weights=sum(int((w != 0).sum()) for w in weights),
                             validation=validation_scores[name], test=score,
                             regressions=ids[2][regressed.numpy()].tolist(),
                             fixes=ids[2][fixed.numpy()].tolist(),
                             class_correct=[int(((prediction == target) & (target == digit)).sum())
                                            for digit in range(10)]))
    digest = hashlib.sha256()
    for x, y in datasets:
        digest.update(x.numpy().tobytes())
        digest.update(y.numpy().tobytes())
    report = dict(complete=True, config=CONFIG, selected=selected, data_sha256=digest.hexdigest(),
                  environment=dict(python=platform.python_version(), torch=torch.__version__,
                                   device="cpu", dtype="float32", threads=1),
                  split_ids=[part.tolist() for part in ids], test_targets=target.tolist(),
                  class_counts=[int((target == digit).sum()) for digit in range(10)],
                  epochs=dict(teacher=teacher_epoch, hard16=hard_epoch,
                              distill16=student_epoch, qat4=qat_epoch),
                  history=dict(teacher=teacher_history, hard16=hard_history,
                               distill16=student_history, qat4=qat_history), rows=rows)
    return report, models


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path)
    parser.add_argument("--save-dir", type=Path)
    args = parser.parse_args()
    report, models = run()
    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
    if args.save_dir:
        args.save_dir.mkdir(parents=True, exist_ok=True)
        for name, model in models.items():
            save_model(model, args.save_dir/f"{name}.pt")
    print("沿用数字划分:训练/验证/回归测试 = 1077 / 360 / 360")
    print("按验证交叉熵选轮次:", report["epochs"])
    for row in report["rows"]:
        print(f"{row['name']}:状态={row['state_bytes']} 字节;"
              f"验证准确率={row['validation']['accuracy']:.3f};"
              f"测试准确率={row['test']['accuracy']:.3f};"
              f"退步/修正={len(row['regressions'])}/{len(row['fixes'])}")
    print("按预设验证条件选择:", report["selected"])
    print("全部模型保存/加载后的分数一致;INT4 每字节实际存两个码。")
    print("量化层解包回 FP32 运算,未测整数内核加速;状态字节不等于峰值内存。")


if __name__ == "__main__":
    main()

代码中,quantize_rows 决定刻度,pack_int4 负责真实打包,PackedLinear 负责恢复权重参与计算。fit 只使用训练数据更新参数、验证数据挑轮次;run 先决定方案,再逐个模型保存、加载和进行回归测试。

本书环境中的输出如下:

沿用数字划分:训练/验证/回归测试 = 1077 / 360 / 360
按验证交叉熵选轮次: {'teacher': 58, 'hard16': 76, 'distill16': 77, 'qat4': 8}
teacher:状态=19240 字节;验证准确率=0.969;测试准确率=0.975;退步/修正=0/0
ptq8:状态=5328 字节;验证准确率=0.969;测试准确率=0.975;退步/修正=0/0
ptq4:状态=2960 字节;验证准确率=0.961;测试准确率=0.972;退步/修正=2/1
qat4:状态=2960 字节;验证准确率=0.967;测试准确率=0.975;退步/修正=1/1
prune90:状态=19240 字节;验证准确率=0.597;测试准确率=0.597;退步/修正=138/2
units16:状态=4840 字节;验证准确率=0.592;测试准确率=0.606;退步/修正=135/2
hard16:状态=4840 字节;验证准确率=0.964;测试准确率=0.967;退步/修正=3/0
distill16:状态=4840 字节;验证准确率=0.967;测试准确率=0.969;退步/修正=2/0
按预设验证条件选择: qat4
全部模型保存/加载后的分数一致;INT4 每字节实际存两个码。
量化层解包回 FP32 运算,未测整数内核加速;状态字节不等于峰值内存。

QAT 的 8 表示额外训练中的第 8 轮。选择的是该轮保存的主权重,再转换成打包后的模型。固定的完整结果记录还包含每轮验证结果、划分 ID、数据指纹、每个预测和每类正确数,便于继续核对。

为什么不是整整缩小八倍

教师有 4736 个权重和 74 个偏置,全部以 FP32 保存:

(4736+74)×4=19240 字节 (4736+74)\times4=19240\ \text{字节}

4 位模型除了打包权重,还有 74 个 FP32 步长,偏置也保留 FP32:

M=4736/2+74×4+74×4=2960 字节 \begin{aligned} M&=4736/2+74\times4+74\times4\\ &=2960\ \text{字节} \end{aligned}

所以,这里的状态压缩比是 19240/2960=6.519240/2960=6.5 倍。这里数的是 state_dict 中张量的实际字节;.pt 文件还包含结构与容器信息,文件大小会更大。我们没有测本章模型的峰值内存或预测耗时。

图 3:绿色是预设规则选出的 QAT 方案,红色是两种没有恢复训练的直接剪枝。准确率坐标从零开始;两个小模型只相差一个测试样本的正确与否。

prune90 的结果直接回答了前面的疑问:权重有大量零,密集存储却没有缩小。units16 的存储缩小了,但直接删除单元使准确率降到约 60.6%;重新训练的同尺寸小模型则超过 96%。小结构能学会这个任务,不意味着可以从教师里随手删出一个同样好的小模型。

蒸馏学生比普通学生多答对了一张图。本次只有一组初始化与数据划分,不能据此宣布蒸馏总是更好;换种子、换温度、换教师,都需要新的对照。我们也没有把教师训练和生成软目标的成本算成零。

7. 准确率一样,为什么还要做回归检查

teacher 和 qat4 的回归测试准确率都是 97.5%。这是否意味着它们答对的是完全相同的一批图?

并不是。QAT 模型把教师原来答对的一张图答错了,同时又修正了教师的另一处错误。两个变化在总数里抵消了。

图 4:两张图都来自固定测试划分。这里根据预测变化展示案例,没有据此继续调整模型或选择规则。

从类别看,数字 1 的正确数从 36/37 降为 35/37,数字 5 从 34/36 升为 35/36。整体分数没变,变化却落在了不同能力上。每类样本很少,这些计数能定位现象,还不足以精确估计未来的类别表现。

压缩后的回归检查,就是重新运行已有任务,确认原来的能力有没有被破坏。至少应检查总指标、重要类别或任务分组、从正确变成错误的具体案例,以及导出再加载后的行为。对语言模型,还需要使用实际生成的回答检查格式、引用、长输入和关键任务;只看平均困惑度,回答层面的变化可能被漏掉。

“回归检查”也不保证每个分数都只能上升。应先约定哪些能力可以接受多大变化,再看收益是否值得。本例的规则根据验证集选择 qat4;测试时发现的那一处退步仍然保留在报告里。

还有一个边界必须记住:这批测试图早已在第三篇公开使用,所以本章把它当作固定回归集,不把它当成一份从未接触过的新考卷。若要声称模型在新数据上也同样可靠,需要另外设计独立评估。

8. 把这些认识带回大语言模型

大模型的线性层同样由权重矩阵组成,所以“低位编码、步长、误差和计算支持”这几件事仍然成立。但直接对每行取最大值并舍入,未必足够好:同样大小的权重误差,作用在不同输入上,可能造成不同的输出误差。

例如,GPTQ 利用校准输入构造的信息,在逐层量化时处理量化误差;AWQ 则利用激活信息识别对输出更重要的权重通道并进行缩放。它们的目标都比“只让每个权重离原值最近”更贴近模型计算。本章的简单舍入与这些算法不同,没有复现它们,也不能用本章数字预测它们在语言模型上的收益。

看到 W4A16 这样的写法,可以先理解成权重采用 4 位表示、激活采用 16 位表示;具体使用哪种数据格式、按多大的组保存步长、哪些层保留更高精度,要查实际配置。KV Cache 是另一组随请求增长的状态,权重量化并不会自动把它一起压缩。

真正部署时,要把模型格式和执行环境一起确定。如果运行库不支持目标格式,可能无法加载,或在运行中转换回较大的表示。如果输入很长,KV 缓存占用也可能成为新的主要开销。模型文件变小是一个有用的起点,下一章还会测量接口中的等待、生成和进程内存。

9. 动手检查自己的理解

  1. 用图 1 的 s=0.25s=0.25 和整数范围 [−7,7][-7,7],分别量化 0.88 和 2.0。还原值与误差各是多少?
  2. 如果把 4 位整数码直接存在 torch.int8 张量中,权重的实际存储位数是多少?为什么还需要 pack_int4?
  3. 按本章规则计算 8 位教师模型的状态字节数,说明步长和偏置分别占多少空间。
  4. 把 90% 权重改成零,为什么本例的状态仍然是 19240 字节?什么变化才可能让存储缩小?
  5. QAT 中的主权重还是 FP32,前向却使用量化近似值。训练结束后,为什么不能省掉转换步骤?
  6. 蒸馏损失的 KL 方向是什么?如果只保存教师的最高分标签,还剩下哪些信息、丢掉哪些信息?
  7. 教师和 QAT 的总准确率相同,为什么数字 1 的表现还能下降?请用图 4 的两个案例解释。
  8. 把普通学生和蒸馏学生的训练种子成对换成另外三个值,其他条件保持不变。比较每次的验证指标和训练成本;如果要继续据此改配置,应使用哪部分数据?
  9. 进阶:给结构化剪枝模型增加恢复训练,与从头训练的同宽度学生比较。先写清总更新次数、教师成本和选择规则,再运行。不要把增加的训练预算藏在“剪枝”两个字里。
展开答案与实验提示
  1. 0.88 除以 0.25 得 3.52,整数码为 4,还原成 1.0,绝对误差为 0.12。2.0 的整数码原本应为 8,截为 7 后还原成 1.75,误差为 0.25。
  2. 每项仍占 8 位。打包后,一个字节保存两个 4 位码;元素总数为奇数时,最后一个字节只有一半装着有效数据。
  3. 权重占 4736 字节,74 个 FP32 步长占 296 字节,74 个 FP32 偏置再占 296 字节,合计 5328 字节。
  4. 张量的形状和数据类型没变,每个零仍占 4 字节。换用适合的稀疏格式并计入索引,或删除整行整列使形状变小,才可能减少存储。是否加速还要单独测。
  5. 假量化改变的是前向使用的数值;主权重仍按 FP32 存储。本章必须转换为 PackedLinear 并打包,才得到紧凑状态。保存后还要加载验证。
  6. 方向是 DKL(pt∥ps)D_{\mathrm{KL}}(p_t\|p_s)。最高分类别保留下来,其余候选的相对概率及教师有多犹豫都丢失了。教师概率也可能有错,所以本例还使用真实标签。
  7. 一张标签为 1 的图从正确变成错误,另一张标签为 5 的图从错误变成正确,正确总数相抵。按类别或样本检查,才能看到这个变化。
  8. 每个种子下,两个学生仍应复制同一个初始模型、使用同样的批次顺序。用训练集更新、验证集比较;不要根据回归测试分数挑一个最好种子。若继续改方案,最终泛化结论需要新的独立评估。
  9. 可先使用与 QAT 相同的额外轮数作为一组对照,但还要记录训练规模、每轮工作量及教师预训练成本。报告恢复训练前后的结果,也保留没有恢复到预期能力的情况。

10. 模型变小以后,还要走过接口这一关

现在我们已经能区分三件事:用更少的位保存数字,删除部分网络结构,以及重新训练一个小学生。也看到了一个更容易被忽略的问题:压缩后的模型需要重新验收,文件变小并不能替它回答“还能不能用”。

接下来把模型放进一个真正的本地服务。请求什么时候进入,什么时候开始计算,首个词元什么时候到达,两个用户一起请求又会怎样?模型服务与运行维护会把这些时间拆开,并完成第八篇的部署与性能对照实践。