PyTorch 训练框架内核深度解析

第20章 量化与混合精度训练

作者 杨艺韬 · 14,406 字 · 发布于 · 更新于

"torch.ao.quantization is deprecated and will be removed in 2.10. ... pt2e quantization has been migrated to torchao"

—— torch/ao/quantization/utils.py:815 的 DEPRECATION_WARNING

本章要点

  • 混合精度 (AMP) 让 forward / backward 大部分走 fp16 或 bf16:省一半参数显存,GEMM 走 Tensor Core 提速。autocast + GradScaler 两件套
  • autocast 是 dispatcher 里的一层 dispatch key(第 5 章 §5.5):拦截每个算子,根据 op 类别决定要不要把 fp32 输入 cast 到 fp16 / bf16
  • GradScaler 解决 fp16 梯度下溢:把 loss 乘上一个(动态调整的)倍数让 grad 不变 0,反向后再 unscale;如果 inf/nan 检测到则跳过该 step
  • bf16 不需要 GradScaler(数值范围与 fp32 同),是大模型训练首选
  • 量化(quantization)针对推理,不是训练:把 fp32 模型转 int8,参数显存降到 1/4,int8 GEMM 吞吐高于 fp16
  • 量化栈已经搬出 PyTorch 主仓:v2.11 的 torch.ao.quantization 里 eager / FX 的入口函数都挂着 @deprecated(PR #153892,v2.8 起),PT2E(prepare_pt2e / convert_pt2e)在 PR #169151 被从 pytorch/pytorch 删除、迁到 torchao

20.1 为什么要降精度

fp32 单精度浮点:4 字节/数。70B 模型 = 280GB params。fp16 半精度:2 字节/数 → 140GB,省一半。int8:1 字节/数 → 70GB,省 3/4。

但精度降低有代价:

  • fp16 数值范围约 ±65504,太小的梯度(如 1e-8)会下溢成 0
  • bf16 指数位与 fp32 一样是 8 位(范围一样大),但尾数只有 7 位(fp16 是 10 位、fp32 是 23 位),表达精度低
  • int8 只能表示 256 个值,需要"量化-反量化"过程

PyTorch 把这些权衡封装进两套独立机制:AMP(混合精度训练) 与 量化(推理)。

20.2 AMP:混合精度训练

torch.amp.autocast + torch.amp.GradScaler 是经典组合:

import torch
from torch.amp import autocast, GradScaler

scaler = GradScaler()

for batch in loader:
    optimizer.zero_grad()
    with autocast(device_type='cuda', dtype=torch.bfloat16):
        out = model(batch)             # forward 自动 cast 到 bf16
        loss = loss_fn(out, target)
    scaler.scale(loss).backward()      # bf16 不用 scale, fp16 时 scaler 放大梯度
    scaler.step(optimizer)             # 检查 inf/nan, 没问题就 step
    scaler.update()                     # 调整 scale 系数

20.2.1 autocast 是一层 dispatch key

autocast 是 dispatcher 里的一层 dispatch key(第 5 章 §5.5 的"洋葱式中间层"之一),不是 §5.7 那种 Python 端的 TorchDispatchMode。它工作机制:

  1. 进入 with autocast(device_type='cuda', dtype=torch.bfloat16):把 AutocastCUDA 从 thread-local 的 excluded 集合里移除(张量 keyset 本来就带 Autocast key,见第 5 章 §5.3.1;开关函数是 set_autocast_enabled,在 aten/src/ATen/autocast_mode.cpp:16)。AutocastCUDA 定义在 c10/core/DispatchKey.h:359(:492 那行 Autocast = AutocastCUDA 是它的别名,所以源码里 TORCH_LIBRARY_IMPL(aten, Autocast, m) 注册的就是 CUDA 那套)
  2. 每次算子调用进 dispatcher,命中 AutocastCUDA key
  3. autocast kernel 检查这个 op 的"cast 策略":
    • 应该用 bf16(GEMM 类):cast 输入到 bf16,调底层 op
    • 应该用 fp32(reduction、normalize 等,数值敏感):保留 fp32
    • 保留输入 dtype:不 cast
  4. redispatch 到下一层(autograd / backend)

每个 op 的策略以 X-macro 列表的形式写在 aten/src/ATen/autocast_mode.h(AT_FORALL_LOWER_PRECISION_FP 等,:819 起),aten/src/ATen/autocast_mode.cpp 再把同一份列表展开到 CUDA / XPU / MTIA / MAIA 各自的 dispatch key 上;CPU 与 MPS 不用这组宏,而是在同一文件里各写一份手工列表(CPU 从 :341 起,见 §20.6 末尾)。以 CUDA 为例, mm / matmul / linear / conv2d / scaled_dot_product_attention 走低精度,layer_norm / exp / log / pow / cdist / 各种 loss 强制 fp32。这套白名单是社区调出来的"哪些 op 在低精度下安全"的经验总结。

20.2.2 GradScaler:解决 fp16 下溢

fp16 数值范围 ±65504,但梯度里常有大量很小的值:fp16 最小正规数约 6.1e-5,再小只能用精度越来越差的次正规数表示,低于约 3e-8 直接变 0(本机实跑:torch.tensor(1e-7).half() 得 1.19e-7,2e-8 得 0)。直接 fp16 backward,这部分梯度要么失真要么归零 → 训练不收敛。

GradScaler 的招数:

loss * scale (default 65536) → backward → grads 自动放大 scale 倍
                                       ↓
                                      unscale grads (除以 scale)
                                       ↓
                                      检查 inf/nan
                                       ↓
                              没问题 → optimizer.step()
                              有问题 → 跳过 step, 把 scale 减半(下一步用新 scale)

scaler.update() 用一个状态机调 scale:连续 N 步无 inf 就翻倍 scale(找最大可用值),出现 inf 就减半。这种"动态 loss scaling"让 fp16 训练可用。

20.2.3 bf16 不需要 GradScaler

bf16 与 fp32 同 8 位指数 → 数值范围一样大(约 ±3.4e38)。实际训练里的梯度量级基本不会下溢,不需要 loss scaling。代价是尾数只 7 位(fp32 是 23 位),相对精度低了 2^16 倍。

反过来说,bf16 用"少几位尾数"换"不用维护 loss scale",这笔交易对训练极其划算 —— 梯度本来就是噪声估计,尾数精度损失能被优化过程吸收,而指数下溢是不可恢复的信息丢失。bf16 + fp32 master weights 是当前大模型训练的事实标准。

# bf16 标准训练 (无 GradScaler)
with autocast(device_type='cuda', dtype=torch.bfloat16):
    out = model(x)
    loss = loss_fn(out, y)
loss.backward()
optimizer.step()

代码比 fp16 简单。

20.3 量化:推理时把权重压成 int8

训练用 fp32 / bf16 已经够,推理时再量化。流程:

  1. 训练完拿到 fp32 / bf16 模型
  2. 用一批 calibration data 跑模型,记录每层的激活值范围
  3. 算出每层的量化参数(scale + zero_point):int8_val = round(fp32_val / scale) + zero_point
  4. 把 weight 与 activation 都用 int8 存储
  5. 推理时用 int8 GEMM kernel 算,输出反量化回 fp32

收益:

  • weight 显存缩小到 1/4(fp32 → int8),带宽读取量同比例下降 —— 对 memory-bound 的 decode 阶段直接就是提速
  • int8 Tensor Core 的每周期 MAC 数高于 fp16(同一代硬件上通常是两倍)

代价是精度损失。损失多大高度依赖模型、量化粒度与 calibration 质量,必须在自己的评测集上量,不能抄别人的数字。

20.4 PT2E:已经搬去 torchao 的新一代量化

先说一件对读代码的人最要紧的事:v2.11 的 pytorch/pytorch 里已经没有 PT2E 了。

torch/ao/quantization/quantize_pt2e.py 在 PR #169151(标题为 "Delete pt2e flow from pytorch/pytorch since it's migrated to torchao")里被删除,X86InductorQuantizer / XNNPACKQuantizer 等 Quantizer 一并从主仓删掉(删除前的弃用提示写明 XNNPACKQuantizer 已迁到 ExecuTorch 的 backends/xnnpack/quantizer)。

更早的 PR #153892(v2.8 起)给 torch.ao.quantization 的 eager / FX 入口函数(quantize / quantize_dynamic / prepare_fx / convert_fx 等)挂上了 @deprecated,警告文本就在 torch/ao/quantization/utils.py:815:

torch.ao.quantization is deprecated and will be removed in 2.10.
1. Eager mode quantization ... please migrate to use torchao eager mode quantize_ API instead
2. FX graph mode quantization ... please migrate to use torchao pt2e quantization API instead (prepare_pt2e, convert_pt2e)
3. pt2e quantization has been migrated to torchao
   (https://github.com/pytorch/ao/tree/main/torchao/quantization/pt2e)

(那句 "removed in 2.10" 是写警告时的计划;实际到 v2.11 这些 eager / FX 接口仍在,只是带着 deprecation 装饰器。)

所以 PyTorch 量化历史现在读起来是这样:

  • v1.x eager mode:手动 prepare → calibrate → convert,麻烦;v2.11 仍在,但已 deprecated
  • v1.x FX graph mode:自动化但用 torch.fx.symbolic_trace,对动态控制流处理差;同样 deprecated
  • PT2E (PyTorch 2 Export):基于 torch.export,是当前推荐路径 —— 但实现住在 torchao 仓库,pip install torchao 后从 torchao.quantization.pt2e 导入

下面讲的 PT2E 概念(Quantizer / annotate / observer / convert)一条没变,变的只是 import 路径与维护仓库。本章讲到 prepare_pt2e 一类符号时,指的都是 torchao 里的实现。

PT2E 流程:

flowchart LR
    Model[fp32 model]
    Model --> Export["torch.export(model)<br/>导出 ExportedProgram"]
    Export --> Prep["prepare_pt2e<br/>插入 observer 节点"]
    Prep --> Cal["跑 calibration data<br/>observer 记录激活范围"]
    Cal --> Conv["convert_pt2e<br/>observer → quantize/dequantize"]
    Conv --> Comp["torch.compile<br/>Inductor 生成 int8 kernel"]
    Comp --> Run[int8 推理]
    style Conv fill:#fef3c7,stroke:#f59e0b,stroke-width:2px

代码:

import torch
# 注意 import 路径: v2.11 起在 torchao, 不在 torch.ao
from torchao.quantization.pt2e.quantize_pt2e import prepare_pt2e, convert_pt2e

example_inputs = (torch.randn(1, 3, 224, 224),)
exported = torch.export.export(model, example_inputs)
quantizer = ...                        # 后端自己的 Quantizer, 见 §20.7
prepared = prepare_pt2e(exported.module(), quantizer)
# ... calibration loop ...
quantized = convert_pt2e(prepared)
optimized = torch.compile(quantized)   # Inductor 生成 int8 kernel

关键好处:

  • 与 torch.compile 完全集成:量化模型仍享受 Inductor 的 fusion、autotune
  • Quantizer 抽象:不同硬件后端实现自己的 Quantizer,决定哪些 op 怎么量化
  • 基于 ExportedProgram:稳定的 IR,避免 FX 的 trace 不全问题

搬到 torchao 之后这几条一条没丢,反而解耦得更干净:量化算法的迭代节奏不再受 PyTorch 主仓发布周期约束。

20.5 量化粒度与精度

量化的几个维度:

粒度 描述 精度 / 性能 trade-off
Per-tensor 整张量一个 scale 简单但精度差
Per-channel 每个 channel 一个 scale 精度好(CV 必备)
Per-token NLP 中每个 token 一个 scale 适合 LLM
Per-group 每 N 个元素一个 scale(如 128) LLM 量化新标准 (GPTQ / AWQ)

LLM 量化(如 4-bit AWQ / GPTQ)通常是 per-group + 复杂 calibration,不在 PyTorch 内置量化路径里 —— 用专门库(bitsandbytes / auto-gptq)。

20.6 autocast 的 5 类 CastPolicy

aten/src/ATen/autocast_mode.h:416 定义了 5 种 CastPolicy,每个算子归到某一类决定 autocast 怎么处理:

CastPolicy 含义 算子列表所在宏 典型算子
lower_precision_fp 把输入全部 cast 到 lower precision(dtype 由 autocast(dtype=...) 指定;不指定时 CUDA 默认 fp16、CPU 默认 bf16) AT_FORALL_LOWER_PRECISION_FP(:819) mm / addmm / bmm / matmul / einsum / conv1d~3d / linear / prelu / lstm_cell / scaled_dot_product_attention
fp32 强制全部 fp32 算 AT_FORALL_FP32(:854) exp / log / pow / rsqrt / layer_norm / native_layer_norm / group_norm / cosine_similarity / mse_loss / nll_loss / binary_cross_entropy_with_logits / cdist / logsumexp / 各 upsample_*
fp32_set_opt_dtype op 本身带 std::optional<ScalarType> 输出 dtype 参数:用户设了就不动,没设就填 fp32 AT_FORALL_FP32_SET_OPT_DTYPE(:914) softmax / log_softmax / sum / prod / cumsum / cumprod / linalg_vector_norm / linalg_matrix_norm
fp32_append_dtype 给本来无 dtype 参数的 overload 自动 append fp32、redispatch 到带 dtype 的 overload AT_FORALL_DIFFERENT_REDISPATCH_SIGNATURE norm 的几个 overload
promote 取所有输入 dtype 里最宽的 AT_FORALL_PROMOTE(:960) addcdiv / addcmul / atan2 / bilinear / cross / dot / vdot / grid_sampler / index_put / tensordot / scatter_add

上表是 CUDA(以及 XPU / MTIA / MAIA)共用的宏列表。CPU 和 MPS 各有一份手写列表,归类并不相同(aten/src/ATen/autocast_mode.cpp 中 MPS 从 :225、CPU 从 :341 起):比如 CPU 上 layer_norm / exp / softmax / sum 都不在名单里,输入什么 dtype 就按什么算;prod 走 fp32;cat / stack 走 promote;binary_cross_entropy 走 fp32 而不是被禁(本机 CPU 版 torch 在 torch.autocast("cpu") 下实跑确认)。下面几条说的都是 CUDA 这一套。

几个容易记错的点:

  • softmax 不在 fp32 类,在 fp32_set_opt_dtype。区别是实打实的:fp32 会把输入 cast 成 fp32,而 fp32_set_opt_dtype 一根输入都不 cast,只是把输出 dtype 定到 fp32(源码注释:if the output type is already set, don't touch it, otherwise, set it to at::kFloat)。sum / prod / cumsum / linalg_*norm 同理
  • layer_norm 才是真的 fp32,会 cast 输入
  • binary_cross_entropy 既不是 fp32 也不是任何一类,而是被直接禁掉:aten/src/ATen/autocast_mode.cpp:163 的 binary_cross_entropy_banned 会抛错,让你改用 binary_cross_entropy_with_logits(CUDA / XPU / MTIA / MAIA 都注册了这个禁用 kernel,CPU 没有)
  • cat 不在 CUDA 的 promote 列表里(CPU 那份里有)

为什么 exp / pow / logsumexp 强制 fp32? 指数运算极易把值推出 fp16 的 ±65504。layer_norm / cdist 这些要算平方和,同样容易炸。mm / conv 这类线性算子在低精度下数值稳定,可以放心走 bf16/fp16。

新加自定义算子要参与 amp,得自己显式声明 CastPolicy(用 torch.amp.custom_fwd(device_type=..., cast_inputs=...),见 §20.20)。

理解这套白名单,你能解释为什么 amp 训练时某个算子"看起来没省内存" —— 它被归在 fp32 类、根本没 cast 到低精度。

20.7 PT2E Quantizer 接口

PT2E 把"哪些 op 怎么量化"抽象成 Quantizer 类。每个硬件后端实现自己的:

Quantizer 后端
X86InductorQuantizer x86 CPU + Inductor
XNNPACKQuantizer 移动端 XNNPACK
厂商自家 各自实现

(这些类在 v2.11 都已随 PT2E 从主仓删除,torch/ao/quantization/quantizer/ 目录已不存在;XNNPACKQuantizer 按删除前的弃用提示迁往 ExecuTorch,其余以 torchao 为准。)

Quantizer 主要 3 个方法:

  • annotate(model):扫描 graph,在每个要量化的 op 上加 QuantizationAnnotation 标记(哪些输入要 quantize、什么 scale 范围)
  • validate(model):检查标注后的图是否被该后端支持
  • transform_for_annotation(model):annotation 之前的图变换(如把 SDPA 拆成 bmm + softmax,好让只会量化后两者的 Quantizer 也能处理;conv-bn fuse 则由 prepare_pt2e 在它之前单独做)

prepare_pt2e(exported, quantizer) 内部就是调 quantizer.annotate → 在每个标记位置插 observer 节点。convert_pt2e 把 observer 替换成 quantize / dequantize 节点对。最后 torch.compile 让 Inductor 看到 q → kernel → dq 这种模式自动 fuse 成 int8 kernel。

这套 Quantizer 抽象 + Inductor codegen 让芯片厂商接量化路径不用改 PyTorch 主仓 —— 实现自家 Quantizer 即可。PT2E 整体搬到 torchao 之后,这条扩展点也跟着搬,接入方式不变。

20.8 fake_quantize 与 QAT

torch/ao/quantization/fake_quantize.py 的 FakeQuantize 是个 nn.Module:

class FakeQuantize(FakeQuantizeBase):          # torch/ao/quantization/fake_quantize.py:128
    def forward(self, X):
        if self.observer_enabled[0] == 1:      # 先让内部 observer 更新 scale / zero_point
            self.activation_post_process(X.detach())
            _scale, _zero_point = self.calculate_qparams()
            ...
            self.scale.copy_(_scale)
            self.zero_point.copy_(_zero_point)

        if self.fake_quant_enabled[0] == 1:    # 再做 fake quantize
            if self.is_per_channel:
                X = torch.fake_quantize_per_channel_affine(
                    X, self.scale, self.zero_point, self.ch_axis,
                    self.activation_post_process.quant_min,
                    self.activation_post_process.quant_max)
            else:
                X = torch.fake_quantize_per_tensor_affine(
                    X, self.scale, self.zero_point,
                    self.activation_post_process.quant_min,
                    self.activation_post_process.quant_max)
        return X

注意两个 buffer 开关是独立的:observer_enabled 控制要不要继续更新 qparams,fake_quant_enabled 控制要不要注入量化噪声。QAT 里常见的做法就是先只开 observer 让 scale 收敛、再打开 fake quant。

类的 docstring 把语义写得很清楚:

x_out = (clamp(round(x / scale + zero_point), quant_min, quant_max) - zero_point) * scale

"先量化再反量化",模拟量化噪声但保持张量为浮点。这让训练时模型能看到量化误差、调整权重适应它。这是 QAT (Quantization-Aware Training) 的核心。

PT2E 提供 prepare_qat_pt2e(exported, quantizer) 走 QAT 路径:

  1. 在每个量化点插入 FakeQuantize 模块(比 PTQ 的 observer 多了"反量化"动作)
  2. 用户继续训练几个 epoch,模型在 fake quantize 下学习
  3. convert_pt2e(prepared) 把 FakeQuantize 替换成真量化 / 反量化对

QAT 通常比 PTQ 精度更好,代价是要额外训练。精度差距有多大取决于模型对量化的敏感程度 —— 先做 PTQ 量一遍、评测掉多少,再决定值不值得上 QAT,别一上来就假定需要。

20.9 Observer 类型:量化校准的核心算法

量化流程(eager / FX 的 prepare,以及 PT2E 的 prepare_pt2e)在每个量化点插入的"observer"是 nn.Module,forward 时记录激活值范围、校准完用记录的范围算 scale/zero_point。torch/ao/quantization/observer.py 提供 5 种主要 Observer:

Observer 行号 算法
MinMaxObserver 440 记录所有见过的 min / max,用 [min, max] 算 scale
MovingAverageMinMaxObserver 587 min/max 用 EMA 平滑,避免被 outlier batch 拖动
PerChannelMinMaxObserver 686 每个 channel 独立 min/max(per-channel 量化)
MovingAveragePerChannelMinMaxObserver 897 上面两者的组合:per-channel + EMA
HistogramObserver 987 记录值的直方图,搜索使量化误差最小的截断点

算法差异 + 适用场景:

  • MinMaxObserver(最简):scale = (max - min) / (qmax - qmin)。问题:一个 outlier 让整段 range 拉大 → 大部分值挤在窄区间 → 量化精度差
  • MovingAverageMinMax:用滑动平均吸收 outlier 影响。生产 PTQ 推荐
  • PerChannelMinMax:CV 模型必备 —— 不同 channel 的值范围差异大,per-tensor 量化精度损失严重
  • HistogramObserver:最精细 —— 用直方图(默认 bins=2048)近似分布,再搜索使量化误差的 L2 范数最小的截断点(_compute_quantization_error / _get_norm,torch/ao/quantization/observer.py:1060 起;docstring 原话是 minimization of the quantization error,不是常被误传的 KL divergence)。计算贵但 range 选得最好

实战推荐:

  • weights:PerChannelMinMaxObserver(输出 channel 维 per-channel)
  • activations:MovingAverageMinMaxObserver(per-tensor 但平滑)
  • 极致精度:HistogramObserver(要维护直方图 + 搜索最优截断点,calibration 明显更慢,换来更好的 range 选择)

PT2E 的 Quantizer.annotate 在每个量化点指定用哪种 Observer,最终 PTQ 精度很大程度由这个选择决定。这套 observer 算法源自 v1.x 量化路径;v2.11 主仓里它服务 eager / FX 两条路径,PT2E 迁到 torchao 后用哪份实现以 torchao 为准。

20.10 FP8 训练:H100 / B100 时代的新精度

NVIDIA 从 Ada(sm_89)与 Hopper(sm_90,H100)起带 FP8 Tensor Core(torch._scaled_mm 的设备检查即 compute capability ≥ 9.0 或 8.9,见 aten/src/ATen/native/cuda/ScaledBlas.cpp:475),按位宽减半的规律(§20.24),理论峰值 MAC 吞吐约为 BF16 的两倍。FP8 在深度学习里有两种常用格式:

格式 指数位 尾数位 范围 适用
E4M3 4 3 ±448 精度高、范围小 → forward / weight 适合
E5M2 5 2 ±57344 精度低、范围大 → backward / gradient 适合

PyTorch 内置了 FP8 的存储类型:torch.float8_e4m3fn / torch.float8_e5m2(还有 _e4m3fnuz / _e5m2fnuz 两个 AMD 变体,以及块级缩放时当 scale 用的 float8_e8m0fnu,见 c10/util/Float8_*.h),计算侧的入口是私有算子 torch._scaled_mm(aten/src/ATen/native/native_functions.yaml:7310,签名里必带 scale_a / scale_b)。但 FP8 训练不能直接靠 autocast 顶上 —— AT_FORALL_LOWER_PRECISION_FP 那套只做无脑 cast,而 FP8 范围太窄,每个 tensor 都得配 per-tensor scale 才能不溢出(本机实跑:torch.tensor(500.).to(torch.float8_e4m3fn) 不会饱和到 448,而是直接变 NaN)。

实战 FP8 训练要用 NVIDIA 的 TransformerEngine 库(基于 PyTorch)或 PyTorch 自家的 torchao.float8:

这两个库都不在 pytorch/pytorch 里,具体 API 以各自仓库文档为准(torchao 的 float8 模块在 pytorch/ao)。共同思路是:把 nn.Linear 换成一个自己管 scale 的 fp8 版本,其余训练循环不动。

内部机制有两大流派:

  • delayed scaling:维护每个 tensor 的 amax 历史,用上一步(或前几步)的 amax 推这一步的 scale。省掉一次全量 reduction,但换 step 时 scale 可能滞后
  • dynamic scaling:每步现算当前 tensor 的 amax。数值上更稳,代价是每个 fp8 tensor 多一次 reduction

FP8 的加速上限来自硬件:同代 Tensor Core 上 fp8 的每周期 MAC 数高于 bf16。实际能拿到多少要看模型有多 GEMM-bound、scale 管理开销占比多大 —— 本机无 GPU,无法给实测倍数。工程建议很明确:先把 BF16 训练调通、拿到 loss baseline,再迁 FP8 并逐段对齐,因为 FP8 对超参更敏感。

理解 FP8 让你看到精度降级是个"持续向下"的工程方向:fp32 → bf16 → fp8 → 未来 fp4 / int4。每一步硬件支持先到位、PyTorch 跟进、社区调出 best practice。

20.11 INT4 LLM 量化:GPTQ / AWQ / NF4 三家算法

LLM 推理量化已经从 int8 卷到 int4 —— 70B 模型 140GB(FP16)→ 35GB,单机 H100 可用。三种主流算法:

GPTQ(One-shot Post-Training):

逐层量化,每层用一批 calibration data 找出最优 4-bit 量化参数。具体:按列依次量化 weight(所有行共用同一列顺序),每量化一列就用 Hessian 信息更新尚未量化的列来补偿误差,让该层 output error 最小。算法复杂但 calibration 快(几小时)。

AWQ(Activation-aware):

观察到不同 weight 通道的"重要程度"差异大:按 activation 幅度挑出约 1% 的 salient 通道保留 fp16,量化误差就明显下降。但混合精度对硬件不友好,AWQ 实际的做法是量化前对这些通道做 per-channel 缩放(数学上等价的变换),所有权重仍统一量化到 4-bit。它不需要 GPTQ 那样的逐列误差补偿,实现更简单(arXiv:2306.00978)。

NF4 (Normal Float 4-bit):

不是均匀量化,而是按"标准正态分布的分位数"量化。深度网络权重大致服从 N(0, σ²),按分位数量化在概率密度高的区域分辨率高。bitsandbytes / QLoRA 用 NF4。

graph LR
    Fp16[Llama-70B<br/>fp16: 140GB]
    Fp16 -->|GPTQ| GP[GPTQ INT4<br/>~35GB + scales]
    Fp16 -->|AWQ| AW[AWQ INT4<br/>~35GB + scales]
    Fp16 -->|NF4 + double quant| NF[NF4<br/>~35GB, scales 再压一层]
    style GP fill:#dcfce7
    style AW fill:#dbeafe
    style NF fill:#fef3c7

(图里只标显存 —— 精度损失高度依赖模型与评测集,三家算法的排序在不同 benchmark 上并不一致,不要把任何固定百分比当结论。)

pytorch/pytorch 主仓不内置这些算法(在 auto-gptq / bitsandbytes 等独立库),torchao 则在把它们标准化收编 —— 这与 §20.4 说的 PT2E 迁移是同一个方向:量化算法归 torchao,PyTorch 主仓只留 dtype 与 dispatcher 这层地基。

实战决策:

  • 通用推理服务:AWQ 或 GPTQ INT4,先各量一版在自己的评测集上对比
  • 受限显存 + LoRA fine-tuning:NF4 + double quant(QLoRA)
  • 有精度红线的场景:先退回 INT8,再考虑要不要往 INT4 压

理解三家算法让你看到"4-bit 量化"不是黑魔法,而是工程妥协。每家通过不同的 calibration 策略 / 量化方式逼近精度极限。

20.12 KV cache 量化:推理服务的关键

在长序列、大 batch 的推理服务里,KV cache 常常是显存的大头。以 Llama-2-70B 的几何(80 层、8 个 KV head、head_dim=128)按 §20.22 的公式算,单条 4K 序列的 fp16 KV cache 约 1.25 GiB,但它随 batch 与序列长度线性增长 —— 并发几十条、上下文拉到 32K 时就是几十到上百 GB。把 KV cache 也量化能省下可观的显存。

KV cache 量化的特殊性:

  • 运行时生成:不像 weight 提前量化好,KV 是 forward 时新生成的
  • read-write 频繁:每个 token 都要写新 KV、读历史 KV
  • 精度敏感:attention 输出对 KV 误差敏感

主流方案:

FP8 KV cache(vLLM 用):

generate K, V (bf16) → quantize 到 FP8(E4M3 / E5M2,由 --kv-cache-dtype 指定;裸 "fp8" 别名走 E4M3)→ 存进 cache
读 cache → dequantize → attention 计算

量化 / 反量化本身有开销,但 KV cache 是典型的 memory-bound 结构 —— 少读一半字节通常比多做一次 cast 更划算。净收益是正是负,要在目标 batch size / 序列长度下实测。

INT8 KV cache:

相对 fp16 同样是省一半字节,但整数量化需要显式的 scale / zero_point,且 K 与 V 的分布差别大、要分别校准。

PyTorch 这层提供的是 torch.float8_e4m3fn / torch.float8_e5m2 这类 dtype,KV cache 的分配、量化时机、attention kernel 怎么读它,全在推理引擎侧实现(vLLM 的 kv_cache_dtype 就是这个开关;v0.8.5 甚至把 fp8 KV cache 按 torch.uint8 分配,编解码交给自家 kernel,见 vllm/utils.py:165-167)。长上下文部署几乎都要用 —— 否则 KV cache 会先于权重把显存吃光。

理解 KV cache 量化让你看到 LLM 推理的"显存优化"不只是 weight 量化,KV 也是一大块。两个一起量化,同样的显存才能装下更多并发、更长的上下文。

20.13 SmoothQuant:处理 outlier 的精度恢复

LLM 量化的真正痛点是 activation 含 outlier —— 少数维度的值远大于其他(差距 10-100x)。直接量化这些维度让 scale 巨大、其他正常维度精度降到几个 bit、模型崩盘。

SmoothQuant 的解:把 outlier "迁移" 到 weight 层。

graph LR
    A[activation X<br/>有 outlier] --> S[除以 scale α]
    W[weight W] --> M[乘以 scale α]
    S --> Mul[X' = X / α]
    M --> WW[W' = W * α]
    Mul --> Out[X' @ W' = X @ W<br/>等价]
    WW --> Out
    style S fill:#fef3c7
    style M fill:#fef3c7

数学上 X @ W = (X/α) @ (αW) 完全等价。但 X' 没有 outlier(被 α 平滑了)、W' 多了点动态范围。激活值更"均匀" → 量化精度变好;权重在量化前已经 calibration 过、能容忍微小动态范围扩大。

实战:先在 calibration 阶段计算每层的 α(让 activation 与 weight 量化误差平衡)、把 α 应用到 weight、再量化。整套预处理是"无损"的(α 系数可吸收进 W)。

PyTorch 主仓不提供 SmoothQuant 的校准算法(Inductor 里只有识别 torchao SmoothQuant 产物的 _register_smooth_quant_int_mm_pattern,torch/_inductor/fx_passes/quantization.py:2631),算法由 torchao 与第三方库提供。原始工作是 SmoothQuant(arXiv:2211.10438)。实战 LLM INT8 量化通常先做这层平滑再量化 —— outlier 是 LLM 激活量化最主要的精度杀手。

理解 outlier 处理让你看到量化不是"扔掉 bits 就完了",而是"扔掉 bits + 重新分配数值范围"的完整流水线。

20.14 Master Weights:训练精度的双重存储

混合精度训练的标准实践不只是"算 bf16",而是 master weights in fp32:

fp32 master weight (训练状态, optimizer 看到这个)
    ↓ cast 到 bf16
bf16 weight (forward 用的副本)
    ↓ forward + backward
bf16 grad
    ↓ accumulate 到 fp32 grad
fp32 grad (用 master weight 更新)
    ↓ optimizer.step()
fp32 master weight (新值)
    ↓ cast 到 bf16
bf16 weight (更新后的副本)

为什么需要 master weights?

  • bf16 / fp16 加法数值不稳定:weight = weight - lr * grad,lr 通常 1e-4,weight 1.0,grad 1e-3 → 更新量 1e-7。它相对 weight 太小,fp16 下 1.0 + 1e-7 仍是 1.0,更新被 round 掉
  • fp32 master 让"小步累积"可行 —— 即便单次更新 1e-7,几千步后能累积成 1e-4 的实质变化

代价:权重要存两份。70B 模型 bf16 weight 140 GB + fp32 master 280 GB = 420 GB。FSDP / ZeRO 通过 sharding master 来解决。

PyTorch 实现:

  • torch.amp.autocast:模型参数本身一直是 fp32,autocast 只在算子入口临时 cast 出低精度副本,反向回来的 .grad 也是 fp32 —— fp32 参数就是 master weight,AdamW 等 optimizer 的 state 跟着参数 dtype 走,同样是 fp32(本机 CPU 版实跑确认)
  • FSDP-2 MixedPrecisionPolicy:分别控制参数(param_dtype)、梯度 reduce(reduce_dtype)与输出(output_dtype)的 dtype;与 FSDP-1 不同,没有 buffer_dtype(第 18 章 §18.6)

理解 master weights 让你看到"AMP 训练"实际是 mixed dtype 流水线:bf16 算、fp32 累、fp32 更新。每个环节用最合适的精度。

20.15 Stochastic Rounding:低精度训练的另一招

bf16 算 fp32 加法时如果走标准 round-to-nearest,小幅更新被吃掉(如 1.0 + 1e-7 → 1.0)。Stochastic Rounding:以概率 round 到 floor 或 ceil。

原值: 1.0 + 1e-7 = 1.0000001
fp16 round-to-nearest: 1.0 (差 1e-7 被吃掉)
fp16 stochastic rounding:
  - 99.99% 概率 round 到 1.0 (floor)
  - 0.01% 概率 round 到 1.000977 (ceil, 即 1 + 2^-10, fp16 在 1.0 之后的下一个值)
期望值 ≈ 1.0000001 (与原值相等)

虽然单次仍可能被 round,但统计期望保留信息。配合大量 batch 训练,最终 weight 收敛到正确位置。

PyTorch 的 autocast 没有这个开关 —— torch/ 下搜不到任何 stochastic_rounding 参数,autocast 走的是普通的 to(dtype) cast。随机舍入要么靠硬件指令(部分 GPU 在低精度累加路径上提供),要么靠库自己在 kernel 里实现(NVIDIA TransformerEngine 一类)。

方向上确定的是:降精度越激进(FP8 / FP4),舍入方式对收敛的影响越大 —— 位宽越窄,被 round-to-nearest 吃掉的更新占比越高。

20.16 Dynamic vs Static Quantization

PyTorch 量化路径分两类:

Static Quantization (PTQ):

calibration 阶段记录 activation 范围 → 推理时用记录的 scale。但实际 activation 可能与 calibration 时不同(用户 input 分布不一定)→ 出现 outlier 时精度差。

Dynamic Quantization:

不预先记录 scale,每次 inference 时实时算 input 的 min/max。

import torch
import torch.nn as nn
from torch.ao.quantization import quantize_dynamic   # torch/ao/quantization/quantize.py:484
model_quant = quantize_dynamic(
    model,
    {nn.Linear, nn.LSTM},      # 哪些 module 要量化
    dtype=torch.qint8,
)

(这个 API 在 v2.11 仍能用,但已带 deprecation 装饰器 —— 见 §20.4;torch.quantization 是 torch.ao.quantization 的旧别名。)

每次 forward:

  1. input 来 → 计算 input 的 min/max
  2. 用这个范围算 scale
  3. quantize input
  4. int8 GEMM
  5. dequantize output

代价:每次 inference 多两个 reduction(min/max)加若干 cast op。这部分开销是固定的 per-op 成本,张量越小占比越高。

适用场景:

  • Dynamic:input 分布不稳定(如 NLP 不同长度文本、推理服务多租户)
  • Static:input 分布稳定(如 CV 固定图像 size)

LLM 推理通常用 weight static + activation dynamic 混合:weight 已知不变、activation 因输入而异。这是 vLLM / SGLang 的实际做法。

PyTorch 主仓这边是 deprecated 的 quantize_dynamic(eager)与 quantize_fx(FX graph mode);现代路径是 torchao 的 PT2E 与 quantize_ API。理解 static / dynamic 的差异让你选对量化策略 —— 这条与用哪个库无关。

20.17 Symmetric vs Asymmetric Quantization

量化的另一个维度:是否需要 zero_point。

Symmetric:q = round(x / scale),0 量化为 0。范围 [-127*scale, 127*scale]。

Asymmetric:q = round(x / scale) + zero_point,可表示任意 [min, max]。以 uint8(q ∈ [0, 255])为例,范围 [-zero_point*scale, (255-zero_point)*scale]。

graph LR
    F1[fp32 范围: -3 to +5] --> Sym[Symmetric:<br/>对称到 ±5<br/>scale=5/127<br/>浪费 -5 to -3 区间]
    F1 --> Asym["Asymmetric:<br/>zero_point 让 [-3, 5] 完全填满<br/>scale=8/255, 分辨率约高 25%"]
    style Sym fill:#fee2e2
    style Asym fill:#dcfce7

取舍:

  • Symmetric:硬件友好(int8 mul 只算乘法、不需要 zero_point 减法)。NVIDIA Tensor Core 偏好 symmetric weights
  • Asymmetric:精度高,特别是 ReLU 后的 activation(值都非负,asymmetric 范围利用率高 2x)

实战:

  • weight:symmetric per-channel(硬件加速友好 + 精度足够)
  • activation:symmetric per-tensor(硬件友好)或 asymmetric per-tensor(精度更好)

PT2E 的 Quantizer 在 annotate 时指定每处用 symmetric 还是 asymmetric。具体默认值随 Quantizer 实现而定(这些类现在都在 torchao 里,见 §20.4),读它的 annotate 就能看到。知道这个细节,debug 量化精度问题时才知道该去哪一层改。

20.18 量化精度调试流程

PTQ 完后发现精度掉 5%+,怎么定位是哪一层量化崩了?标准 flow:

flowchart TD
    Loss[精度严重下降]
    Loss --> Check1{是不是 outlier?}
    Check1 -->|histogram 看激活值分布| Y1[尝试 SmoothQuant + per-channel]
    Check1 -->|否| Check2
    Check2{是不是某层敏感?}
    Check2 -->|Per-layer A/B test:<br/>逐层关闭量化看精度恢复多少| Y2["找出'敏感层' 改 fp16 跑"]
    Check2 -->|否| Check3
    Check3{Calibration data 够吗?}
    Check3 -->|加 batch / 改用 in-domain data| Y3[重新 PTQ]
    Check3 -->|够了仍不行| Y4[切到 QAT 或更高精度 INT8]
    style Y2 fill:#dcfce7
    style Y4 fill:#fef3c7

具体动作:

  1. 看激活分布:对每层 dump activation histogram,看是否有 outlier。outlier 多 → SmoothQuant
  2. A/B test 量化层:逐层把量化关掉(这层用 fp16)测精度。能恢复多少决定这层是否"敏感"
  3. 改 calibration data:用更多 / 更接近真实 input 的 data。NLP 模型用真实推理 prompt、不要用训练数据 calibration
  4. 混合精度:敏感层(通常是 first / last layer + LN)保留 fp16,其他 INT8。这是 LLM INT8 推理的实战做法

PyTorch 自带的逐层比较工具是 Numeric Suite(torch/ao/ns/_numeric_suite.py 与 _numeric_suite_fx.py),提供 compare_weights / shadow module 等能力,把 fp32 与量化模型的对应层输出摆在一起看。注意它服务的是 eager / FX 这两条已 deprecated 的量化路径(torch/ao/ns/ 本身在 v2.11 没挂 @deprecated)。

理解这套 flow 让你在量化崩盘时不抓瞎。"精度损失多少能接受" 也是产品决策(如 search 类应用 1% drop 不可接受、聊天 bot 5% drop 可能 OK)。

20.19 group-wise quantization:4-bit 量化的精度密码

LLM 4-bit 量化(GPTQ / AWQ / NF4)几乎都是 group-wise:把 weight 切成 N 个连续元素一组,每组独立 scale。

# weight: shape [out_features, in_features]
# group-wise int4: 每 128 个 in_feature 一个 scale
group_size = 128
weight_int4 = ...   # shape [out, in], dtype=int4
scales = ...        # shape [out, in // group_size], dtype=fp16
zero_points = ...   # shape [out, in // group_size]

为什么不 per-tensor 或 per-channel?

  • per-tensor:精度差到不可用(scale 范围太广)
  • per-channel:精度好但 group_size = in_features(如 4096),仍有 outlier 分布问题
  • per-group (g128):精度明显好于 per-channel,显存只多约 3%(每 128 个 int4 权重共 64 字节,只配一个 2 字节的 fp16 scale)

scale 存储开销的具体数字:

算一笔账:g128 意味着每 128 个权重共享一个 fp16 scale(外加一个 zero_point)。只算 scale,摊到每个权重上是 2 byte / 128 ≈ 0.016 byte,相对 int4 本体的 0.5 byte 只多约 3%;zero_point 若也按 fp16 存,再翻一倍到约 6%。

配置 weight 每 param 的 scale 开销
fp16 2 byte/param 0
int4 per-channel(in_features=4096) 0.5 byte ≈ 0.0005 byte
int4 g128 0.5 byte ≈ 0.016 byte(fp16 scale)

g128 只多几个百分点的存储,却把 scale 的作用域从整行缩到 128 个元素,outlier 的影响被局限在一个 group 内而不是拖垮整行。这是 LLM 4-bit 量化的工程精髓。

torchao 提供了对应的 int4_weight_only(group_size=128) 一类 API(具体签名以 torchao 版本为准),把 nn.Linear 的 weight 换成 int4 + group scale,推理时 dequantize-on-the-fly。这类 kernel 的收益主要来自少读 3/4 的权重字节 —— decode 阶段是 memory-bound,能不能兑现取决于 dequantize 的开销压不压得住。

理解 group-wise 让你看到现代 LLM 量化的实质:用极少存储开销换回大段精度。

20.20 amp 接入自定义算子:custom_fwd / custom_bwd

写自定义 torch.autograd.Function 时如何让它 amp-friendly?用 custom_fwd / custom_bwd 装饰器:

# torch.cuda.amp.custom_fwd 已 deprecated, 用 torch.amp 版本
# 注意 device_type 是必填的 keyword-only 参数 (torch/amp/autocast_mode.py:441)
from torch.amp import custom_fwd, custom_bwd

class MyOp(torch.autograd.Function):
    @staticmethod
    @custom_fwd(device_type="cuda", cast_inputs=torch.float16)
    def forward(ctx, x, weight):
        # x, weight 已被 cast 到 fp16
        ctx.save_for_backward(x, weight)
        out = my_kernel(x, weight)
        return out

    @staticmethod
    @custom_bwd(device_type="cuda")
    def backward(ctx, grad_out):
        # backward 以 forward 当时的 autocast 状态运行: 这里 forward 设了 cast_inputs、关着 autocast 跑, backward 也关着
        x, weight = ctx.saved_tensors
        # ... compute grads ...
        return grad_x, grad_weight

docstring 把语义写得很准:cast_inputs 不为 None 时,在 autocast 区域内把传入的浮点张量 cast 到目标 dtype,然后关掉 autocast 再执行 forward;为 None(默认)时不 cast,forward 内部的算子沿用当前 autocast 状态。在 autocast 区域外调用时,custom_fwd 整个是 no-op。

custom_bwd 则让 backward 复原 forward 当时的 autocast 状态,避免反向里再来一轮 cast。反向应该用 forward 时的 dtype(saved_tensors 是什么 dtype 就用什么)。

实战:写自定义 attention / 自定义 normalization 算子时必须加这两个装饰器,否则 autocast 行为不对(要么没省内存、要么输出 dtype 错乱)。

PyTorch 标准库的算子已经在 autocast_mode.h / .cpp 的白名单里注册了 CastPolicy(§20.6),用户层 torch.autograd.Function 是另一条路,要靠装饰器主动声明。

20.21 量化的演进时间线

PyTorch 量化的关键节点:

与其列一堆记不住的版本号,不如记住能从仓库里查证的几个节点:

节点 依据
eager mode 量化(quantize / quantize_dynamic) 至今仍在 torch/ao/quantization/quantize.py,:484 是 quantize_dynamic
FX graph mode 量化(quantize_fx.prepare_fx / convert_fx) 仍在 torch/ao/quantization/quantize_fx.py
observer 体系(MinMax / MovingAverage / PerChannel / Histogram) torch/ao/quantization/observer.py,v2.11 仍在主仓
torch.ao.quantization 的 eager / FX 入口标为 deprecated PR #153892(v2.8 起),警告文本在 torch/ao/quantization/utils.py:815
PT2E 从主仓删除、迁到 torchao PR #169151

方向非常清楚:PyTorch 主仓退守到 dtype 与 dispatcher 这层地基(Float8_*、autocast 白名单、fake_quantize_* 算子),量化算法与流程整体上交 torchao。

对读者的实际影响:网上大量 PT2E 教程写的还是 from torch.ao.quantization.quantize_pt2e import ...,在 v2.11 上直接 ImportError。照着做之前先确认版本。

20.22 精度 vs 速度 vs 显存:综合 trade-off 表

精度损失与实际加速比都强依赖模型、评测集与硬件,抄不来。能抄的只有显存账,因为那纯粹是算术。以 7B 模型为例:

权重精度 每 param 字节 7B weight 显存
fp32 4 28 GB
bf16 / fp16 2 14 GB
fp8 (E4M3 / E5M2) 1 7 GB
int8 1 7 GB
int4 g128 0.5 + 约 0.016(scale) 约 3.6 GB

KV cache 同理按 2 × n_layers × n_kv_heads × head_dim × seq_len × batch × dtype_bytes 算,精度减半、显存减半。

实战决策:

  • 训练:bf16 + autocast + fp32 master weights(业界标准);有 fp8 硬件再考虑迁 fp8
  • 服务推理(精度优先):bf16 / fp16
  • 服务推理(吞吐优先):INT8 + SmoothQuant 预处理
  • 受限显存:INT4(AWQ / GPTQ)+ FP8 KV cache
  • fine-tuning 小显存:QLoRA(NF4 + double quant)

每一档都要在自己的评测集上量一遍精度再上线。这张表给的是选型顺序,不是可以直接引用的结论。

20.23 QLoRA:量化 + LoRA 的工程组合

QLoRA(arXiv:2305.14314)把三个技术组合起来,让原本需要多卡的 fine-tune 塞进单卡:

1. NF4 base model

把 base model weight 量化到 NF4(4-bit 正态分布量化)。位宽从 16 降到 4,权重字节数降到 1/4:65B 的 130GB 降到 33GB 量级。

2. LoRA adapter

冻结 base、只训练 LoRA 的 A / B 矩阵(fp16)。LoRA 参数量是 base 的 0.1-1%。

3. Double Quantization

NF4 是 group-wise 的,每个 group 一个 fp32 scale;double quantization 把这些 scale 自身再量化一遍(8-bit + 更粗的二级 group)。省下的是 scale 那一层的开销:按论文的配置(block 64、二级 block 256),scale 开销从每参数 0.5 bit 降到约 0.127 bit,省约 0.37 bit —— 不是省掉整个模型的一大块,而是把 scale 税从 4-bit 本体的约 12.5% 压到约 3%。

graph TB
    Base[65B base model<br/>fp16: 130GB] --> Quant1[NF4 量化<br/>约 33GB + scale 开销]
    Quant1 --> Quant2[Double Quantization<br/>scale 再量化一层, 省掉大部分 scale 税]
    Quant2 --> LoRA[+ LoRA adapter<br/>fp16: 几百 MB]
    LoRA --> Train[fine-tune<br/>仅训练 LoRA]
    Train --> Save[保存 LoRA<br/>只存 adapter]
    style Quant1 fill:#dcfce7
    style Quant2 fill:#fef3c7
    style LoRA fill:#dbeafe

forward 流程:

  1. NF4 weight → on-the-fly dequantize 到 fp16
  2. fp16 weight + LoRA → 算 attention / MLP
  3. backward 只算 LoRA 梯度(base 冻结)
  4. optimizer 只更新 LoRA

代价:每次 forward 都要把 NF4 权重 dequantize 回浮点再算,这部分算力是白花的。但在原本根本装不下的场景里,这笔交易没有对手。

PyTorch 实现:bitsandbytes 库(最早)+ torchao。HuggingFace peft 库把 QLoRA 封装成几行代码:

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

# 加载 base model + 直接量化
model = AutoModelForCausalLM.from_pretrained(
    "Llama-65B",
    quantization_config=BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_use_double_quant=True,
    ),
)

# 加 LoRA
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
model = get_peft_model(model, lora_config)

# 训练
trainer.train()

理解 QLoRA 让你看到现代大模型工程是多技术栈合成的产物:量化 + PEFT + ckpt 增量保存 + 推理时 dequantize。每个独立技术不重要、组合在一起改变可能性。这是 LLM 时代的工程哲学。

20.24 INT8 GEMM 的 Tensor Core 实现

硬件侧的规律很简单:Tensor Core 的 MMA 指令按数据位宽定形状,位宽减半,同一条指令能吃下的 K 维元素数翻倍,于是峰值 MAC 吞吐大致翻倍。fp16/bf16 → fp8/int8 → int4 这条链上每降一档都是这个规律。具体每代 SM 的数字请查对应架构的 whitepaper —— 各代差异很大,而且 structured sparsity 开不开还会再翻一倍,二手表格基本都是错的。

但实际收益往往远不到理论值:

  • memory bound 才是常态:LLM decode 阶段每个 token 只做一次很瘦的 GEMV,瓶颈是把权重从 HBM 搬出来,而不是 Tensor Core 算不过来。这时降精度的收益来自少搬字节,与 MMA 峰值无关
  • dequantize 开销:INT4 weight 通常要 on-the-fly dequantize 才能进 Tensor Core,这部分算力是白花的
  • kernel 成熟度:低精度 kernel 的调优程度远不如 fp16 —— 同样的理论峰值,实现未必拿得到

所以位宽减半等于速度翻倍永远只是上限。做量化决策时按上限打个对折去预期,再用 profiler(第 21 章)实测,比较不容易翻车。配合 batching 把 memory bound 推向 compute bound,才有机会逼近上限。

20.25 量化模型部署的端到端 flow

实战量化部署的 7 步流程:

flowchart TD
    Step1[1. 训练 fp32/bf16 模型]
    Step1 --> Step2[2. 选量化策略<br/>INT8 PTQ / INT4 AWQ / FP8]
    Step2 --> Step3[3. 准备 calibration data<br/>几百个 in-domain samples]
    Step3 --> Step4[4. 跑 calibration]
    Step4 --> Step5[5. 验证精度<br/>vs fp32 测 metrics]
    Step5 --> Q1{精度 OK?}
    Q1 -->|否| Step6[6a. 调 SmoothQuant /<br/>per-channel / 改算法]
    Q1 -->|是| Step7[7. 集成到推理引擎<br/>vLLM / SGLang / TensorRT]
    Step6 --> Step5
    Step7 --> Done[部署上线]
    style Step5 fill:#fef3c7
    style Q1 fill:#dbeafe

每步的工程细节:

  • calibration data:用真实 prompt(不是训练数据)。经验上几百条样本通常够用(NLP 用 prompt、CV 用图像),以验证集结果为准
  • 精度验证:定 metrics(如 perplexity / accuracy / BLEU),与 fp32 baseline 对比。关键指标 drop > 1% 立刻调
  • 集成推理引擎:先确认目标引擎在你用的那个版本上认得这份 checkpoint 的量化格式(vLLM 侧可以直接查 vllm/model_executor/layers/quantization/__init__.py 里注册的名字,v0.8.5 上是 27 个),不要按印象里的"某某引擎支持某某格式"下结论
  • A/B test:上线前先 5% 流量灰度、看用户反馈是否有质量下降。即便 perplexity 几乎一致,用户偶尔能感觉到"答案变笨"

整套流程耗时主要取决于精度调试要跑几轮。

20.26 PT2E + Inductor:量化 kernel 怎么 codegen

PT2E 把 model 转成"含 quantize/dequantize 节点的 fx graph",但真正的 INT8 kernel 是 Inductor 生成的。看具体怎么协作:

# convert_pt2e 后 model 的 fx graph (简化):
def forward(x):
    x_q = quantize(x, scale=0.1, zp=128)        # fp32 → int8
    w_q = quantize(weight, scale=0.05, zp=0)    # static, 已量化好
    out_q = qlinear(x_q, w_q, bias)              # int8 GEMM, 输出 int32 累加
    out = dequantize(out_q, scale=0.005, zp=0)  # int32 → fp32
    return out

torch.compile(quantized_model) 让 Inductor 看到 quantize → qlinear → dequantize 这种模式。Inductor 的 fusion pattern matcher(§14.7.2)认得这种 "Q-Linear-DQ" 三元组、合并成一个融合算子(主仓里的实际落点见下方代码后的说明)。下面这段 Triton 风格的伪代码只示意"GEMM 与 dequantize 在同一个 kernel 里完成"的思路,不是 Inductor 的实际产物:

# 示意伪代码 (Triton 风格, 非 Inductor 实际产物)
@triton.jit
def fused_qlinear(x_ptr, w_ptr, scale_x, scale_w, ...):
    x_int8 = tl.load(x_ptr + offs_x, mask=mask_x)   # int8 输入
    w_int8 = tl.load(w_ptr + offs_w, mask=mask_w)
    out_int32 = tl.dot(x_int8, w_int8)            # INT8 Tensor Core
    out_fp32 = out_int32 * (scale_x * scale_w)    # 直接 dequantize
    tl.store(out_ptr, out_fp32)

整个 GEMM + dequantize 一个 kernel 完成,不把中间 int32 buffer 写回内存。主仓里这组 Q-Linear-DQ pattern 在 torch/_inductor/fx_passes/quantization.py,经 torch/_inductor/fx_passes/mkldnn_fusion.py 注册,面向 x86 CPU / XPU,替换成的是 oneDNN 的 onednn.qlinear_pointwise 融合算子。这是 PT2E + Inductor 的核心价值 —— 让量化模型享受 fusion 红利。

对比 v1.x FX graph mode:每个 quantize / qlinear / dequantize 都是独立 ATen 算子、各自一个 kernel、中间结果都要过一遍内存。PT2E + Inductor 把这套合一,省掉的是 q / dq 那两趟额外的内存往返 —— 对本就 memory-bound 的量化推理,这部分开销占比不小。

理解这层让你看到 PyTorch v2.x 量化的现代化不只是"换 IR",是让量化与编译栈深度集成。这也解释了为什么 PT2E 能整体搬去 torchao 而不损失能力:它依赖的是 torch.export 的 IR 与 Inductor 的 pattern matcher,这两样都留在主仓。

20.27 AMP × FSDP:分布式训练的精度协作

第 18 章 §18.6 提过 FSDP 的 MixedPrecision 配置。本章看怎么与 autocast 协作。

两条路径:

1. autocast + FSDP(能用,但要分清两层各管什么)

fsdp_model = FSDP(model)
with autocast(device_type='cuda', dtype=torch.bfloat16):
    out = fsdp_model(x)

要点:FSDP 内部 unshard / reshard 的参数 dtype 由 FSDP 的 mixed precision 配置决定,外层 autocast 只影响 forward op 的 cast。两层都配时,要清楚最终每个算子按什么精度算。

2. FSDP MixedPrecision(推荐)

# FSDP-1
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP, MixedPrecision

mp_policy = MixedPrecision(
    param_dtype=torch.bfloat16,        # weight 用 bf16
    reduce_dtype=torch.float32,        # 梯度 reduce 用 fp32
    buffer_dtype=torch.bfloat16,       # buffer (如 BN running stats) 用 bf16
)
fsdp_model = FSDP(model, mixed_precision=mp_policy)

# FSDP-2: 类名与参数名都不同, 且没有 buffer_dtype
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy

fully_shard(model, mp_policy=MixedPrecisionPolicy(
    param_dtype=torch.bfloat16,
    reduce_dtype=torch.float32,
))

# 两者一般不再套 autocast
out = fsdp_model(x)                     # forward 自动 bf16; FSDP-2 下直接 model(x)

两代 API 的字段差异见第 18 章 §18.6。

FSDP 把 all-gather 后的参数 cast 到 param_dtype(于是 forward 整体按低精度参数计算)、grad reduce 用 reduce_dtype、buffer 用 buffer_dtype(仅 FSDP-1)。它管的是参数与通信的 dtype;autocast 管的是逐算子的计算精度(layer_norm / softmax 等按白名单留在 fp32,§20.6),两者粒度不同。

实战决策树:

  • 单卡训练:autocast 简单够用
  • DDP:autocast,配合 default reduce dtype
  • FSDP:用 FSDP 自己的 MixedPrecision / MixedPrecisionPolicy,不要再叠 autocast
  • TP / PP:用对应框架(Megatron / PiPPy)的 mp 配置

这种"分布式训练框架接管 mp 配置"是 v2.x 时代的工程标准。autocast 本身仍重要(dispatch key 的实现机制是教材),但生产代码里被框架包了一层。理解两条路径让你不会"抄两份代码导致 dtype 错乱"。

20.28 一段总结:精度的多维度

把全章话题合起来,精度策略实际是多维度配置:

graph TB
    subgraph Train[训练阶段]
        T1[Forward dtype: bf16/fp16]
        T2[Backward dtype: bf16/fp16]
        T3[Master weight: fp32]
        T4[Optimizer state: fp32 or 8-bit]
        T5[Grad reduce dtype: fp32]
        T6[Buffer dtype: 同 forward]
    end
    subgraph Infer[推理阶段]
        I1[Weight: int8 / int4]
        I2[Activation: int8 / int8 dynamic]
        I3[KV cache: fp8 / int8]
        I4[Quantization scheme: per-tensor / per-group]
        I5[Calibration: PTQ / QAT]
    end
    style T1 fill:#dcfce7
    style I1 fill:#dbeafe
    style I3 fill:#fef3c7

每个维度独立可调。单看任何一个都简单,但组合空间巨大:bf16 train + int4 AWQ infer + fp8 KV、bf16 train + int8 QAT infer、fp8 train + int8 PTQ infer……

现实生产里通用配置:

  • 训练:bf16 forward + fp32 master + fp32 reduce
  • 推理:INT4 AWQ weight + bf16 activation + FP8 KV cache(vLLM 上的常见组合之一)

但 cutting edge 在不断推进:FP8 训练(sm_89 / H100 起)、INT4 训练(实验)、FP4 等更低位宽(PyTorch 已有 torch.float4_e2m1fn_x2 dtype)。理解这些维度让你能跟踪前沿、做出有依据的工程决策。

20.29 模型显存估算:训练 vs 推理

经常被问的问题:"这台机器能 train / infer 多大模型?" 用本章的精度知识算。

训练显存(Adam optimizer + 标准 mixed precision):

Per param 显存:
- bf16 weight: 2 bytes
- bf16 gradient: 2 bytes
- fp32 master weight: 4 bytes
- fp32 Adam exp_avg: 4 bytes
- fp32 Adam exp_avg_sq: 4 bytes
合计: 16 bytes/param

70B 模型: 70e9 × 16 = 1120 GB

这是 ZeRO 论文的混合精度口径(2 + 2 + 12 bytes)。PyTorch 原生 autocast 下没有单独的 bf16 权重副本、梯度是 fp32(§20.14),按 fp32 weight 4 + fp32 grad 4 + Adam 8 算,合计同样是 16 bytes/param。

再加上 activation memory —— 这一项与参数量无关,由 batch size × seq_len × hidden × 层数决定,长序列大 batch 下能与参数显存同量级,必须按自己的配置单独算(第 18 章 §18.32)。合计远超单卡容量 → 必须 FSDP / ZeRO-3 分到多卡。

FP8 训练(sm_89 / H100 起):

FP8 只改 GEMM 的计算精度时,master weight、grad、optimizer state 这些常驻项不变,按参数算的显存账基本不动;哪怕粗估降到 ~10 bytes/param,70B 也要 700 GB,已超过 8 卡 H100(8 × 80 GB = 640 GB)的总显存,还没算 activation。

推理显存(不带 KV cache):

Per param:
- fp16: 2 bytes
- int8: 1 byte
- int4 g128: 约 0.52 byte

70B 模型推理:
- fp16: 140 GB → 双卡 H100
- int8: 70 GB → 单卡 H100
- int4 AWQ: 35 GB → 单卡 A100/L40 即可

推理 + KV cache:

Llama-2-70B(80 层, 8 个 KV head, head_dim=128), seq=4096, batch=1:
- KV cache fp16: 2 × 80 × 8 × 128 × 4096 × 2 B ≈ 1.25 GiB
- KV cache fp8: ≈ 0.63 GiB

总:
- fp16 weight + fp16 KV: 约 141 GB
- int4 + fp8 KV: 约 35 + 0.7 ≈ 36 GB

→ int4 + fp8 KV 让 70B 模型 + 4K 序列单卡 A100 (80GB) 跑得动

把这套估算公式记下,让你 5 秒回答"X 模型在 Y 配置上能不能跑、要 reshape 到什么精度"。具体公式:训练显存 ≈ 16 byte/param × 模型参数数;推理显存 ≈ (precision_byte) × 参数数 + KV cache。代入参数数、batch、seq_len 即可估算单卡 / 多卡是否够用。

20.30 量化精度问题排查 cheat sheet

实战排查 Cheat sheet:

症状 可能原因 解决
INT8 推理输出 NaN 某层 scale 算成 0 calibration data 质量差,加更多样本
INT8 推理输出全 0 所有 activation 量化到 0 scale 太大,改 per-channel 或 SmoothQuant
INT8 perplexity 飙升 50% 某层 outlier 主导 用 SmoothQuant 或保留该层 fp16
INT4 推理跑得比 INT8 慢 dequantize 开销 + small batch 加 batch 让 GEMM 主导、或换 INT8
FP8 训练 loss 发散 delayed scaling 的 amax 历史跟不上分布变化 改用 dynamic scaling(每步现算 amax),或缩短 amax 历史窗口
AMP 训练慢于 fp32 大量 cast 开销 检查模型是否大量小算子(kernel launch 主导)
scaler.get_scale() 一路减半到很小 fp16 下 grad 频繁 inf 改用 bf16(不需要 GradScaler),或先查是不是 lr / 初始化本身有问题
QAT 收敛慢 fake_quantize 引入太多噪声 先训几 epoch fp32、再开 fake_quantize
PT2E export 失败 某个 op 不支持 export v2.11 的 torch.export.export 默认已是 strict=False(torch/export/__init__.py:65);仍失败就给它写 decomposition 或注册 custom op(第 22 章)
量化后 KV cache 仍占大显存 KV 没量化 单独配置 KV cache 量化(vLLM 的 kv_cache_dtype)

这几类症状覆盖了量化调试里的绝大多数情况,遇到问题先按表对号入座能少走很多弯路。

20.31 量化的"哲学":精度是分配的

把全章看完最后留下的核心 insight:

精度不是单一标量,是个分配问题。整个网络总精度预算固定(如 INT8 总位数),怎么分配到不同位置最优?

  • GEMM-heavy 算子(linear / conv / mm):bf16/fp16 安全
  • 数值敏感算子(layer_norm / exp / logsumexp / 各类 loss):必须 fp32;softmax / sum 这类走 fp32_set_opt_dtype,输出定 fp32 而不 cast 输入
  • 大权重 layer(如 transformer 的 FFN):可激进量化(INT4 OK)
  • 小权重 + 关键 layer(first / last layer + LN):保留 fp16
  • 激活值(dynamic):per-tensor 或 per-token
  • 权重(static):per-channel 或 per-group

设计精度策略 = 把精度预算"分配到最需要的地方"。这种思想适用于任何"资源受限的优化问题":内存预算、算力预算、注意力预算……

autocast 用白名单显式分配(哪些 op 哪个精度);PT2E 用 Quantizer + annotate 灵活分配(哪些位置 INT8 哪些 fp16);FP8 训练用 per-tensor scale 动态分配。每套机制都在解决同一个 meta 问题:给定数据流,把精度预算分配到对的位置。

理解这条让你看 PyTorch 量化生态不是 "一堆零散的 API",而是"精度分配工具的工具箱"。每个工具服务一类分配场景。这是看完全章应该带走的最终认知。

20.32 H100 + Llama-70B 训练的实战精度配置

把全章理论落到一份具体配置(这是把前面各节的选择拼起来的示意,不是某个已发布模型的官方配方):

import torch
import torch.distributed as dist
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP, MixedPrecision, ShardingStrategy
import torchao.float8 as float8

# 1. 模型 + FSDP 配置
model = Llama70B(config)

mp_policy = MixedPrecision(
    param_dtype=torch.bfloat16,
    reduce_dtype=torch.float32,        # 千卡训练防 outlier 累积
    buffer_dtype=torch.bfloat16,
)

fsdp_model = FSDP(
    model,
    sharding_strategy=ShardingStrategy.FULL_SHARD,
    mixed_precision=mp_policy,
    device_id=torch.cuda.current_device(),
)

# 2. (可选) 把 nn.Linear 换成 fp8 版本
#    具体 API 以 torchao 版本为准, 这里只标出它在流程里的位置
float8.convert_to_float8_training(fsdp_model)

# 3. Optimizer 用 fp32 master + 8-bit Adam (省显存)
import bitsandbytes as bnb
optimizer = bnb.optim.AdamW8bit(
    fsdp_model.parameters(),
    lr=1e-4,
    weight_decay=0.01,
)

# 4. 训练循环(不需要 autocast,FSDP 接管)
for step, batch in enumerate(loader):
    optimizer.zero_grad()
    out = fsdp_model(batch)
    loss = compute_loss(out, target)
    loss.backward()
    optimizer.step()

这套配置的精度分配:

  • forward / backward:bf16(FSDP MixedPrecision)→ FP8(torchao.float8 转换)
  • gradient reduce:fp32(防 outlier 累积)
  • master weight:fp32(FSDP 内部维护)
  • optimizer state:8-bit Adam(bitsandbytes 量化的 Adam moments)

显存账(70B,32 卡 FULL_SHARD,逐项按位宽算):

  • fp32 master weight(sharded):280 GB / 32 ≈ 8.8 GB。bf16 那份只在 AllGather 前临时生成分片(_mp_shard,用完即释放,torch/distributed/fsdp/_flat_param.py:1534),不常驻
  • 8-bit Adam state(sharded):560 GB 按 1/4 位宽降到 140 GB,再 / 32 ≈ 4.4 GB
  • grad(sharded):reduce 完会被 cast 回参数 dtype(torch/distributed/fsdp/_runtime_utils.py:1030 的 _cast_grad_to_param_dtype),是 fp32,≈ 8.8 GB
  • activation:与 batch size / seq len / 是否开 activation checkpoint 强相关,须自己算
  • unsharded peak:还要加上单个 wrap unit 完整 AllGather 后的那一份(第 18 章 §18.32)

常驻的前三项加起来约 22 GB —— 剩下的预算留给 activation 与 unsharded peak。这就是"精度分配"落到显存账上的样子:每一项都选了它能承受的最低位宽。

20.33 vLLM 推理服务的精度配置示例

部署侧的实战配置(参数名以本专栏核对的 vLLM v0.8.5 为准,70B 模型 H100 推理):

from vllm import LLM, SamplingParams

# 配置 1: 极致吞吐 (INT4 weight + FP8 KV)
llm = LLM(
    model="path/to/llama-70b-awq",       # 已量化好的 INT4 AWQ checkpoint
    quantization="awq",
    kv_cache_dtype="fp8_e5m2",            # KV cache 用 FP8
    max_model_len=8192,
    tensor_parallel_size=1,
)

# 配置 2: 平衡精度 (INT8 + FP8 KV)
llm = LLM(
    model="path/to/llama-70b",            # 原始 fp16 checkpoint
    quantization="...",                   # 具体名称以 vLLM 版本支持的后端为准
    kv_cache_dtype="fp8_e5m2",
)

# 配置 3: 最高精度 (BF16 + BF16 KV)
llm = LLM(
    model="path/to/llama-70b",
    dtype="bfloat16",
    kv_cache_dtype="auto",                 # 与 weight 同 dtype = bf16
    tensor_parallel_size=2,                # 单卡装不下,TP=2
)

三种配置的显存账(70B,seq=8192):

配置 weight KV cache 单卡是否装得下
AWQ INT4 + FP8 KV 约 35 GB fp16 的一半 单卡有余量
INT8 + FP8 KV 约 70 GB fp16 的一半 单卡勉强
BF16(TP=2) 140 GB / 2 也按 TP 切 需要两卡

吞吐与精度都要在自己的负载上量 —— 它们同时取决于 batch 分布、序列长度分布、kernel 实现成熟度,没有可搬运的数字。能提前算准的只有显存,而显存往往就是选型的第一道硬约束。

理解这套对照让你看到本章理论与实际部署的最后一公里:精度策略最终要落在生产 metrics 上,显存先卡死可行域,吞吐与精度再在可行域里做取舍。

20.34 GradScaler 的"_growth_tracker"状态机细节

§20.2.2 提了 GradScaler 动态调 scale,具体内部状态机在 torch/amp/grad_scaler.py(v2.11 共 714 行;torch/cuda/amp/grad_scaler.py 只剩 38 行的 deprecated 转发壳):

# 简化示意: 省略 device / enabled 参数; 实际 _scale / _growth_tracker 在首次 scale() 时才惰性建成张量
class GradScaler:
    def __init__(
        self,
        init_scale=2.**16,        # 起始 scale
        growth_factor=2.0,         # 没问题时翻倍
        backoff_factor=0.5,        # 出问题时减半
        growth_interval=2000,      # 连续 2000 步无 inf 才翻倍 scale
    ):
        self._scale = init_scale
        self._growth_tracker = 0   # 连续无 inf 计数器

scaler.update() 的逻辑(示意,实际计算见代码后的说明):

def update(self, new_scale=None):
    if any_inf_per_device():           # 这步有 inf/nan
        self._scale *= self._backoff_factor    # scale 减半
        self._growth_tracker = 0                # 重置计数
    else:
        self._growth_tracker += 1
        if self._growth_tracker >= self._growth_interval:
            self._scale *= self._growth_factor  # 翻倍 scale
            self._growth_tracker = 0

实际这段判断由 torch._amp_update_scale_ 在设备上完成(CPU 实现见 aten/src/ATen/native/cpu/AmpGradScalerKernels.cpp),计数到 growth_interval 才翻倍,且翻倍后若溢出成 inf 就保持原值。

growth_interval 默认 2000(torch/amp/grad_scaler.py:116 的 docstring 与 :129 的签名默认值都能查到)。这个值的取舍是:

  • 太小:scale 反复翻倍又减半,来回震荡
  • 太大:scale 追不上梯度分布的变化,长期停在偏保守的值上
  • 2000 是社区调出的经验值,让 scale 慢慢逼近"当前最大可用 scale"再稳住

实战:训练开始后监控 scaler.get_scale()。健康的形态是从 init_scale(2**16 = 65536)出发,头几步可能因 inf 被连续减半(docstring 原话:the scale factor often causes infs/NaNs to appear in gradients for the first few iterations,torch/amp/grad_scaler.py:103),之后在某个量级附近来回微调 —— 那个量级就是当前梯度分布下"不触发 inf 的最大 scale"。如果它一路减半停不下来,说明 fp16 下数值根本稳不住,该换 bf16(或先查 lr 与初始化)。

理解这个状态机让 fp16 训练的"为什么 scale 在变"不再神秘 —— 它在自适应找最大可用 scale。

20.35 几条工程经验

1. 训练用 bf16 + autocast,不用 fp16:bf16 更稳、不需要 GradScaler 维护。Llama 类大模型几乎都 bf16

2. 但旧硬件(V100 / T4)只支持 fp16:A100 / H100 都同时支持 fp16 + bf16。生产部署确认硬件能力

3. AMP 的"哪些 op 走低精度"是社区调出的白名单:自定义算子要 torch.amp.custom_fwd(device_type=..., cast_inputs=...) 显式声明(torch.cuda.amp 那套已 deprecated)

4. GradScaler.step() 内部检查 inf/nan(没先手动调 unscale_ 时,由 step 调它,用 torch._amp_foreach_non_finite_check_and_unscale_ 一次完成 unscale 与 inf 检查):直接看 scaler.get_scale() 监控,scale 一直被减半说明 fp16 下数值不稳定,要么改 bf16 要么减小 lr

5. PT2E 是新代码首选,但要从 torchao 导入:v2.11 的 torch.ao.quantization 里 eager / FX 入口都已 deprecated,PT2E 已迁出主仓(§20.4)

6. activation_checkpoint + AMP 兼容:两者正交,可叠加

7. FSDP + AMP:用 FSDP 自己的 mp 配置(FSDP-1 的 MixedPrecision / FSDP-2 的 MixedPrecisionPolicy,第 18 章 §18.6),一般不再叠 autocast;要叠就按 §20.27 分清两层各管什么

8. 量化感知训练 (QAT):边训练边模拟量化,让模型适应量化噪声。prepare_qat_pt2e API。比直接 PTQ 精度高但训练时间多

20.36 跨专栏关联

  • 第 5 章 §5.5 redispatch:autocast 这层 dispatch key 的实现机制
  • 第 7 章 §7.5.3 saved_tensors_hooks:activation_checkpoint 与 AMP 配合的底座
  • 第 18 章 §18.6 FSDP MixedPrecision:分布式训练里 mp 的精细控制
  • 《vLLM 推理内核深度解析》专栏第 13 章 量化:vLLM 的推理量化(AWQ / GPTQ / FP8)与本章互补 —— 那边关注推理时执行,本章关注训练与模型转换

20.37 设计启示

精度策略的核心思想:

第一:不同操作有不同 dtype 适配性:GEMM 在低精度下数值稳定,softmax / norm / reduce 不行。混合精度比"全模型一种 dtype"灵活得多

第二:训练与推理的精度要求完全不同:训练需要稳定的梯度信号,推理只需准确的输出。所以训练用 bf16/fp16,推理可以激进到 int8 / int4

第三:autocast 用 dispatch key 实现:让"自动 cast"成为非侵入式特性,用户代码不用改

第四:GradScaler 的状态机:动态 loss scaling 是"自适应数值稳定"的经典工具

第五:精度是分配问题,不是单一标量:每个算子、每层、每个 tensor 都可以独立选 dtype。混合精度的本质是"在数据流的不同位置用不同精度",把固定预算分配到最需要的地方

第六:编译栈与精度栈共用基础设施:dispatch key 让 autocast 落地、export 让 PT2E 落地、Inductor 让两者的产物高效执行。这种"基础设施复用"让 PyTorch 的多个高级特性能持续向前演进而不互相阻塞

20.38 跨章呼应:精度与编译栈的协作

把第 12-15 章(编译栈)+ 本章(精度)合起来看:

  • autocast 是一层 dispatch key(§5.5、§20.2.1):编译时 Dynamo 把 autocast 上下文记成图里的 _enter_autocast / _exit_autocast,AOTAutograd 追踪时再把"哪些 op cast 到 bf16"固化成 aten 图里的 _to_copy(本机 CPU 版用 torch.autocast("cpu") 实跑确认)
  • PT2E 用 torch.export(§12.32、§20.4):量化路径与 export 路径共用同一套 IR
  • Inductor 编译量化 graph(§20.26):Q-Linear-DQ 三元组被 fusion pattern 识别为 INT8 GEMM kernel
  • FP8 训练 + torch.compile:torchao.float8 的 fp8 module 也能被 Dynamo trace、由 Inductor 生成 fp8 kernel

这种"精度策略与编译栈深度耦合"是 v2.x 的工程红利。v1.x 时代量化与 torchscript 半割裂、autocast 与 trace 难协作。v2.x 让两条线在 dispatch key + export + Inductor 三层统一 —— "我用了 amp 还能 compile 吗",绝大多数情况下答案是"能"。

下一章拆 Profiler —— 当训练慢、显存爆、卡 idle,这些症状的诊断都靠 profiler。本章说"精度策略多种",下一章给"诊断每种策略实际效果"的工具。两章配合让你在精度优化时有据可依:profile 看吞吐 / 显存 / 实际 dtype、再回到本章选合适配置、再 profile 验证。这种"测量-优化-再测量"的工程闭环是大模型时代的核心方法论。