查看全课大纲(第 40 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

推理部署与成本:量化/蒸馏概览

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

训练好的模型怎么塞进手机、又怎么在高并发服务器上省钱?本节讲两条互补路线:量化把权重和激活从 fp32 压到 int8,蒸馏让小模型模仿大模型的软输出。本节是第 9 章《工程实践与收口》的收尾。先修:前向传播与张量 dtype、交叉熵、softmax 与温度。后续:本课到此收束,可回第 1 章串一遍整条主线。

💡 核心导读

  1. 量化:通过降低模型权重和激活值的数值精度(如从 32 位浮点数到 8 位整数),大幅减少模型存储空间和加速推理计算。
  2. 蒸馏:用一个轻量级的“学生模型”去学习复杂“教师模型”的输出行为(不仅是最终预测,还包括中间层的“知识”),从而获得一个更小、更快但性能接近的模型。
  3. 核心机制:量化涉及校准(Calibration) 和量化/反量化(Quantize/Dequantize) 操作;蒸馏则依赖于一个特殊的蒸馏损失函数,它结合了教师模型的“软标签”和学生模型的预测。
  4. 形状与代价:量化不改变模型的层数或结构形状,只改变张量的数据类型(dtype);蒸馏则会改变模型结构(学生模型更小),其训练成本是“教师训练 + 蒸馏训练”,但推理成本显著降低。

🧭 前置知识

  1. 神经网络的前向传播过程与张量形状变化。
  2. 损失函数(如交叉熵损失)的基本概念。
  3. PyTorch 中张量的数据类型(dtype),如 torch.float32 和 torch.int8。
  4. softmax 函数与温度参数 T 的概念(不展开,见序列模型相关章节)。

模型部署的挑战:为什么需要“瘦身”?

训练只是第一步。部署到手机、嵌入式设备或高并发服务器时,会遇到三个硬约束:存储(参数动辄数百 MB 到数 GB)、算力与功耗(fp32 运算慢且耗电)、内存带宽(读参数本身就是瓶颈)。量化和蒸馏是两条互补的主流压缩路线。

量化:降低数值精度,直接压缩

量化的核心思想非常直观:用更少的比特数来表示模型的权重和计算过程中的激活值。最常见的操作是将 float32 (32位浮点数) 转换为 int8 (8位整数)。理论上,这能带来 4 倍的存储节省和显著的整数计算加速。

怎么做:均匀仿射量化

最常用的量化方法是均匀仿射量化:把浮点区间 [min, max] 线性映射到整数区间 [q_min, q_max]。有符号 int8 用 [-128, 127],无符号 uint8 用 [0, 255]。

定义两个关键参数:

  • 缩放因子(scale):s = (max - min) / (2^b - 1)
  • 零点(zero point):z = round(-min / s) (用于非对称量化,使 0 值能被精确表示)

量化公式:q = round(r / s) + z 反量化公式:r' = s * (q - z)

其中 r 是原始浮点值,q 是量化后的整数值,r' 是反量化后恢复的浮点值(存在精度损失)。

在 PyTorch 里这一步已经封装好。下面手动算一遍看原理——这里用的是对称量化(零点取 0),比上面的一般形式少了零点项,实现最简。

import torch
torch.manual_seed(0)

# 1. 模拟一个训练好的小型全连接层权重 (FP32)
weight_fp32 = torch.randn(3, 4) * 0.1 + 0.5  # 形状 (3,4),值在0.5附近
print("原始 FP32 权重 (部分):")
for i in range(min(3, weight_fp32.shape[0])):
    print(f"  Row {i}: {[round(v.item(), 4) for v in weight_fp32[i][:3]]} ...")

# 2. 执行动态量化 (权重转换为 int8)
# 注意:这是极其简化的演示。实际部署用 `torch.ao.quantization.quantize_dynamic` 等 API(2.x 起 `torch.quantization` 已迁到 `torch.ao`)。
# 这里手动计算是为了展示原理。
def naive_quantize(tensor, quant_min=-128, quant_max=127):
    """一个简单的均匀对称量化(假设零点为0)"""
    r_max = tensor.abs().max().item()
    s = r_max / quant_max  # 缩放因子
    q = torch.clamp(torch.round(tensor / s), quant_min, quant_max).to(torch.int8)
    return q, s

weight_int8, scale = naive_quantize(weight_fp32)
print(f"\n量化后 INT8 权重 (部分) 和缩放因子:")
print(f"  scale = {scale:.6f}")
print(f"  weight_int8 形状: {tuple(weight_int8.shape)}")
print(f"  值样例: {weight_int8[0, :3].tolist()} ...")

# 3. 反量化回 FP32 以观察误差
weight_dequant_fp32 = weight_int8.float() * scale
print(f"\n反量化后的 FP32 权重 (部分):")
for i in range(min(3, weight_dequant_fp32.shape[0])):
    print(f"  Row {i}: {[round(v.item(), 4) for v in weight_dequant_fp32[i][:3]]} ...")

# 4. 计算量化误差(MSE)
mse = torch.mean((weight_fp32 - weight_dequant_fp32) ** 2)
print(f"\n量化/反量化导致的均方误差 (MSE): {mse.item():.8f}")
原始 FP32 权重 (部分):
  Row 0: [0.6541, 0.4707, 0.2821] ...
  Row 1: [0.3915, 0.3601, 0.5403] ...
  Row 2: [0.4281, 0.4597, 0.4403] ...

量化后 INT8 权重 (部分) 和缩放因子:
  scale = 0.005150
  weight_int8 形状: (3, 4)
  值样例: [127, 91, 55] ...

反量化后的 FP32 权重 (部分):
  Row 0: [0.6541, 0.4687, 0.2833] ...
  Row 1: [0.3914, 0.3605, 0.5408] ...
  Row 2: [0.4275, 0.4584, 0.4378] ...

量化/反量化导致的均方误差 (MSE): 0.00000181

可以看到,量化后权重用 int8 存储,反量化后数值与原始值非常接近,误差很小。在实际推理中,矩阵乘法等操作可以直接在 int8 域进行,最后再将结果反量化到 fp32,这比全程 fp32 计算快得多。

量化模式:训练后量化与量化感知训练

  • 训练后动态/静态量化(Post-Training Quantization, PTQ):模型训练完成后,根据一批校准数据统计出权重和激活的 min/max 来确定缩放因子。动态量化每批数据计算激活的缩放因子;静态量化则固定激活的缩放因子。这是最常用的部署方式,成本低。
  • 量化感知训练(Quantization-Aware Training, QAT):在训练的前向传播中插入“伪量化”节点,模拟量化带来的精度损失,让模型在训练期间就适应这种误差,从而获得更高的量化后精度。代价是需要重新训练或微调。

边界与坑:

  • 精度损失:量化是有损压缩,可能带来模型精度下降,尤其是对量化敏感的层(如第一个和最后一个全连接层)。
  • 部署支持:需要硬件(如 CPU 的 INT8 指令集)和推理框架(如 ONNX Runtime, TensorRT)的支持才能获得真正的加速。
  • 校准数据:静态量化需要具有代表性的校准数据,否则缩放因子不准确会导致严重性能下降。

知识蒸馏:让“小模型”学习“大模型”

知识蒸馏(Knowledge Distillation,Hinton et al., 2015,arXiv:1503.02531)的思路不同于量化。它不直接压缩原模型,而是训练一个新的、更小、更简单的学生模型(Student),去模仿一个更大、更复杂的教师模型(Teacher)的行为。教师模型通常是精度很高的预训练模型。

核心机制:软标签与蒸馏损失

教师模型的“知识”体现在其输出上。传统训练使用“硬标签”(one-hot 向量,如 [0, 0, 1, 0]),而教师模型能提供更丰富的“软标签”(softmax 输出,如 [0.05, 0.15, 0.7, 0.1])。软标签包含了类别间的相似性关系(例如,猫和狗可能比猫和汽车更相似)。

为了得到更“软”、更平滑的概率分布,在计算 softmax 时引入一个温度参数 T: softmax(z_i, T) = exp(z_i / T) / ∑_j exp(z_j / T) 当 T=1 时是标准 softmax;T>1 时概率分布更平滑,暗含的类别间关系信息更丰富。

蒸馏损失函数通常由两部分组成:

  1. 蒸馏损失(Distillation Loss):让学生模型的软预测(高温 T)去匹配教师模型的软预测(高温 T),常用 KL 散度衡量。
  2. 学生损失(Student Loss):让学生模型的硬预测(T=1)去匹配真实硬标签,用标准的交叉熵损失。

总损失是两者的加权和:Loss = α * L_distill + (1-α) * L_student。

下面我们用一个极简的例子演示蒸馏的思想。

import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)

# 1. 定义极简的教师模型和学生模型
class TinyModel(nn.Module):
    def __init__(self, input_dim=4, hidden_dim=8, output_dim=3):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, output_dim)
    def forward(self, x):
        x = torch.relu(self.fc1(x))
        return self.fc2(x)  # 返回 logits

# 假设教师模型更大(隐藏层16维),且已经训练好。
teacher = TinyModel(input_dim=4, hidden_dim=16, output_dim=3)
# 学生模型更小(隐藏层4维),待训练。
student = TinyModel(input_dim=4, hidden_dim=4, output_dim=3)

# 2. 生成模拟数据
batch_size = 4
x = torch.randn(batch_size, 4)
# 真实硬标签
hard_targets = torch.tensor([2, 0, 1, 2])

# 3. 前向传播:获取教师和学生的 logits
with torch.no_grad():  # 教师不更新参数
    teacher_logits = teacher(x)
student_logits = student(x)

# 4. 设置蒸馏参数
temperature = 3.0
alpha = 0.7  # 蒸馏损失的权重

# 5. 计算蒸馏损失 (KL散度,等价于高温下的交叉熵)
def distillation_loss(student_logits, teacher_logits, T):
    # 计算高温 softmax
    p_teacher = F.softmax(teacher_logits / T, dim=-1)
    p_student = F.log_softmax(student_logits / T, dim=-1)
    # KL(P_teacher || P_student) * T^2,缩放回原尺度
    loss_kl = F.kl_div(p_student, p_teacher, reduction='batchmean') * (T * T)
    return loss_kl

loss_distill = distillation_loss(student_logits, teacher_logits, temperature)

# 6. 计算学生损失 (标准交叉熵)
loss_student = F.cross_entropy(student_logits, hard_targets)

# 7. 总损失
total_loss = alpha * loss_distill + (1 - alpha) * loss_student

print("模拟蒸馏损失计算:")
print(f"  教师 logits 形状: {tuple(teacher_logits.shape)}")
print(f"  学生 logits 形状: {tuple(student_logits.shape)}")
print(f"  蒸馏损失 (T={temperature}): {loss_distill.item():.4f}")
print(f"  学生交叉熵损失: {loss_student.item():.4f}")
print(f"  加权总损失 (α={alpha}): {total_loss.item():.4f}")

# 注意:这只是一个损失计算示例,并非完整的训练循环。
模拟蒸馏损失计算:
  教师 logits 形状: (4, 3)
  学生 logits 形状: (4, 3)
  蒸馏损失 (T=3.0): 0.0338
  学生交叉熵损失: 1.0938
  加权总损失 (α=0.7): 0.3518

在这个例子中,学生模型的训练目标不仅是拟合真实标签(loss_student),还要模仿教师模型更“聪明”的概率输出(loss_distill)。通过这种方式,学生模型有望达到比直接用硬标签训练更好的性能。

蒸馏的变体与代价

  • 特征蒸馏:不仅模仿最终输出,还让学生模型的中间层特征图去匹配教师模型的中间层特征图(可能需要适配层来对齐维度)。
  • 自蒸馏:教师模型和学生模型是同一个模型,或者来自同一模型的不同阶段。
  • 代价:蒸馏需要额外的训练过程(蒸馏训练),这需要计算资源和时间。但其产出——学生模型——在推理时速度更快、内存占用更小。

边界与坑:

  • 教师模型质量:如果教师模型本身很差,蒸馏出的学生模型也不会好。
  • 容量差距:如果学生模型过于简单,无法捕捉教师模型的知识,则蒸馏无效。
  • 超参数敏感:温度 T 和权重 α 需要调优。

📝 动手练一练

问题:在量化演示代码中,我们使用了对称量化(零点 z=0)。请修改 naive_quantize 函数,实现一个简单的非对称量化,使得浮点范围 [min, max] 能更精确地映射到 uint8 范围 [0, 255]。计算并比较非对称量化与对称量化在相同权重上的 MSE 误差。

👉 点击查看参考答案

思路:非对称量化的关键是让浮点 0 也能被整数精确表示。步骤:① scale = (r_max - r_min) / 255;② zero_point = q_min - round(r_min / scale),把 r_min 映射到整数 0;③ 量化 q = clamp(round(r / scale) + zero_point);④ 反量化 r' = scale × (q - zero_point)。注意零点不要提前钳到 [0, 255]——它本身就是个偏移量,钳了就错位。

import torch
torch.manual_seed(0)

def asymmetric_quantize(tensor, quant_min=0, quant_max=255):
    """非对称量化:q = round(r / scale) + zero_point"""
    r_min, r_max = tensor.min().item(), tensor.max().item()
    scale = (r_max - r_min) / (quant_max - quant_min)
    zero_point = quant_min - round(r_min / scale)
    q = torch.clamp(torch.round(tensor / scale) + zero_point, quant_min, quant_max).to(torch.uint8)
    return q, scale, zero_point

def asymmetric_dequantize(q, scale, zero_point):
    """反量化:r' = scale * (q - zero_point)"""
    return (q.float() - zero_point) * scale

def symmetric_quantize(tensor, quant_min=-128, quant_max=127):
    r_max = tensor.abs().max().item()
    s = r_max / quant_max
    q = torch.clamp(torch.round(tensor / s), quant_min, quant_max).to(torch.int8)
    return q, s

weight_fp32 = torch.randn(3, 4) * 0.1 + 0.5

q_sym, s_sym = symmetric_quantize(weight_fp32)
q_asym, s_asym, zp_asym = asymmetric_quantize(weight_fp32)

weight_dequant_sym = q_sym.float() * s_sym
weight_dequant_asym = asymmetric_dequantize(q_asym, s_asym, zp_asym)

mse_sym = torch.mean((weight_fp32 - weight_dequant_sym) ** 2)
mse_asym = torch.mean((weight_fp32 - weight_dequant_asym) ** 2)

print("量化误差比较:")
print(f"  对称量化 MSE: {mse_sym.item():.8f}")
print(f"  非对称量化 MSE: {mse_asym.item():.8f}")
print(f"  非对称量化参数: scale={s_asym:.6f}, zero_point={zp_asym}")
量化误差比较:
  对称量化 MSE: 0.00000181
  非对称量化 MSE: 0.00000022
  非对称量化参数: scale=0.001459, zero_point=-193

本例权重全为正(约 0.28–0.65),zero_point 算出来是负的偏移量;非对称步长约为对称的 0.28 倍(0.001459 vs 0.005150),步长更小使误差显著降低,本例 MSE 约为对称的 1/8。

常见错误

错误理解或写法现象或报错原因正确做法
认为量化后的 int8 模型可以直接用于训练。训练时梯度为 None 或报错。int8 类型不支持自动求导(requires_grad)。量化用于推理部署。训练或微调应使用 fp32 或 bf16 模型,或使用量化感知训练(QAT)模式。
静态量化时使用不具有代表性的校准数据。量化模型在真实数据上精度暴跌。缩放因子 scale 和零点 zero_point 是基于校准数据统计的,若数据分布偏差大,量化误差会剧增。使用来自训练集或真实场景的无标签数据作为校准集,确保其统计特性与推理数据一致。
蒸馏时温度 T 设置过小(如 T=1)。学生模型性能提升有限,几乎等同于用硬标签训练。温度低时,教师软标签接近硬标签,丢失了类别间关系的“暗知识”。尝试较高的温度(如 T=3~10),使概率分布更平滑,再让学生模型学习。
忘记在蒸馏损失中乘以 T^2 进行缩放。蒸馏损失项的值非常小,在总损失中几乎不起作用。高温 T 下的 softmax 输出值被“摊平”,计算出的 KL 散度值本身很小。按照原始论文,计算 `KL(teacher_soft

本章小结

  1. 量化通过降低权重和激活的数值精度(如 fp32 -> int8)来压缩模型、加速推理,核心是校准和量化/反量化操作,主要分为训练后量化(PTQ)和量化感知训练(QAT)。
  2. 知识蒸馏通过训练一个轻量级学生模型模仿复杂教师模型的输出(特别是高温下的“软标签”),来获得一个更小、更快但性能保留较好的模型,其损失函数是蒸馏损失和学生损失的加权和。
  3. 量化不改变模型结构,只改变数据类型;蒸馏会改变模型结构(学生模型更小)。
  4. 两者可结合使用:先蒸馏得到一个性能优良的小模型,再对该小模型进行量化,实现极致的压缩与加速。
  5. 模型压缩技术的选择需在精度、速度、存储、开发成本之间进行权衡。

📋 行动清单

  • 在 PyTorch 官方文档中查找 torch.ao.quantization.quantize_dynamic 的示例,对一个简单的 nn.Linear 做动态量化,并比较量化前后的输出差异。
  • 修改蒸馏示例代码中的温度参数 T(例如设为 1.0 和 10.0),观察教师模型软标签分布的变化(打印 softmax(teacher_logits / T, dim=-1))。
  • (选做)思考:对于 Transformer 模型,除了对全连接层和注意力层的权重进行量化,对嵌入层(Embedding)进行量化可能会遇到什么特殊问题?

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

  1. Distilling the Knowledge in a Neural Network (Hinton et al., 2015, arXiv:1503.02531) – 知识蒸馏的开创性论文。
  2. PyTorch 官方文档 Quantization:https://pytorch.org/docs/stable/quantization.html —— PTQ 与 QAT 的完整指南。

—— 小象教研组