目标读者画像 :已懂 PTQ 基本概念 + QAT 基本原理,了解 Sigmoid 查表实现,了解 ONNX 图优化 / 端侧部署,深度学习基础原理掌握 60-70%。
学习策略 :前期用 YOLO 类视觉模型实操,后期过渡到大模型(LLM)。由简到难,层层递进 。
⏱ 预计时间:1-2 周 | 🎯 难度:⭐
#
知识点
深度要求
1
IEEE 754 浮点表示 :FP32 / FP16 / BF16 的位布局(sign/exponent/mantissa)
能手动画出 32-bit 布局图
2
量化公式推导 :q = round((r - Z) / S) 和 r = S * (q + Z),S 和 Z 怎么算
能从 FP32 的 min/max 一步步推出 INT8 的 S/Z
3
对称 vs 非对称量化 :各自的适用范围、优缺点
理解为什么 Activation 常用非对称,Weight 常用对称
4
量化粒度 :per-tensor / per-channel / per-group / group-size
知道每种粒度的 scale 存储开销怎么算
5
校准算法(Calibration) :MinMax、MSE、KL Divergence、Percentile
能手写 MinMax calibrator
6
舍入策略 :round-to-nearest、stochastic rounding
理解 stochastic rounding 为什么在低比特(≤4-bit)中重要
7
INT8 硬件加速原理 :VNNI / DP4A 指令,Tensor Cores INT8 计算流水线
知道一个 INT8 Conv 在硬件上怎么算的
8
Clip 与 Clamp :量化范围裁剪的含义和数学表达
理解 clamp(round(v/S), Q_min, Q_max)
# ========== 动手:从零实现一个 MinMax INT8 量化器 ==========
import torch
import numpy as np
class MinMaxQuantizer :
"""手写对称 + 非对称 per-tensor INT8 量化器"""
def __init__ (self , symmetric = True , per_channel = False ):
self .symmetric = symmetric
self .per_channel = per_channel
self .scale = None
self .zero_point = None
def calibrate (self , x : torch .Tensor ):
if self .per_channel :
# per-channel: 沿 channel 维度计算 min/max
dims = tuple (i for i in range (x .dim ()) if i != 0 ) # channel=0
x_min = x .amin (dim = dims )
x_max = x .amax (dim = dims )
else :
x_min , x_max = x .min (), x .max ()
if self .symmetric :
# 对称量化: Z = 0, S = max(|min|, |max|) / 127
abs_max = torch .maximum (x_min .abs (), x_max .abs ())
self .scale = abs_max / 127.0
self .zero_point = torch .zeros_like (self .scale )
else :
# 非对称量化: S = (max - min) / 255, Z = -round(min / S)
self .scale = (x_max - x_min ) / 255.0
self .zero_point = torch .round (- x_min / self .scale ).clamp (0 , 255 )
def quantize (self , x : torch .Tensor ) -> torch .Tensor :
q = torch .round (x / self .scale + self .zero_point )
q = torch .clamp (q , - 128 , 127 ) if self .symmetric else torch .clamp (q , 0 , 255 )
return q
def dequantize (self , q : torch .Tensor ) -> torch .Tensor :
return self .scale * (q - self .zero_point )
# ========== 验证:量化误差分析 ==========
x = torch .randn (3 , 64 , 64 ) * 2.0 + 0.5 # 模拟激活值
q = MinMaxQuantizer (symmetric = False )
q .calibrate (x )
x_q = q .dequantize (q .quantize (x ))
mse = ((x - x_q ) ** 2 ).mean ()
snr = 10 * torch .log10 (x .var () / mse )
print (f"Quantization MSE: { mse :.6f} , SNR: { snr :.2f} dB" )
阶段 1:PyTorch 原生 QAT — 三种模式从入门到精通
⏱ 预计时间:2-3 周 | 🎯 难度:⭐⭐
PyTorch 的 QAT API 经历了三代演进,每一代都要吃透 ,因为不同框架(AIMET、PPQ、NNCF)的底层都借鉴了这些设计模式。
#
知识点
深度要求
1
FakeQuantize 原理 :Observer + FakeQuant 的内部状态机
能讲清楚 observer_enabled / fake_quant_enabled 两个 flag 在三个阶段的切换
2
QConfig / QConfigMapping :如何为不同 op 指定不同的量化策略
知道为什么 Conv 用 per-channel,Linear 用 per-tensor
3
Fuse Modules :Conv+BN+ReLU 融合的数学原理
能手算 Conv+BN 融合后的新 weight 和 bias
4
Observer 类型 :MinMaxObserver / MovingAverageMinMaxObserver / HistogramObserver / PerChannelMinMaxObserver
知道每种适用的场景
5
FX Graph Mode 的图改写机制 :prepare_qat_fx 在图上做了什么
能用 print_readable() 打印量化前后的图结构
6
PT2E (PyTorch 2 Export) 模式 :capture_pre_autograd_graph → prepare_qat_pt2e → convert_pt2e
理解 PT2E 和 FX 的根本区别(export-based vs trace-based)
7
QAT 训练技巧 :BN 冻结、Observer 停止、学习率调度
知道什么时候 disable observer
8
Backend 适配 :fbgemm vs qnnpack vs x86 vs ARM
知道每种 backend 对 op 的支持情况
路线 1.2a:Eager Mode QAT(理解历史)
import torch
from torch .ao .quantization import (
default_qconfig , prepare_qat , convert ,
prepare , fuse_modules
)
model = torchvision .models .resnet18 (weights = "IMAGENET1K_V1" ).train ()
# Step 1: 融合 Conv+BN+ReLU
model = fuse_modules (model , [["conv1" , "bn1" , "relu" ]])
# Step 2: 设置 qconfig
model .qconfig = default_qconfig # fbgemm: act=histogram_obs, wt=per_channel_minmax
# Step 3: QAT 准备
model_prepared = prepare_qat (model )
# Step 4: QAT 训练循环
for epoch in range (5 ):
model_prepared .train ()
for data , target in train_loader :
# forward pass 自动包含 FakeQuant
output = model_prepared (data )
loss = criterion (output , target )
loss .backward ()
optimizer .step ()
# 验证阶段关闭 observer
model_prepared .apply (torch .ao .quantization .disable_observer )
model_prepared .eval ()
# ... 验证 ...
# Step 5: 转换为真 INT8 模型
model_prepared .eval ()
model_int8 = convert (model_prepared )
路线 1.2b:FX Graph Mode QAT(主力)
import torch
from torch .ao .quantization import get_default_qat_qconfig_mapping
from torch .ao .quantization .quantize_fx import prepare_qat_fx , convert_fx
model = torchvision .models .mobilenet_v3_small (weights = "DEFAULT" ).train ()
example_inputs = (torch .randn (1 , 3 , 224 , 224 ),)
# QConfigMapping — 精细控制每一层
qconfig_mapping = get_default_qat_qconfig_mapping ("qnnpack" )
# 手动定制:第一层和最后一层保持 FP32(常见技巧)
qconfig_mapping .set_global (torch .ao .quantization .get_default_qat_qconfig ("qnnpack" ))
# 准备 QAT
model_prepared = prepare_qat_fx (model , qconfig_mapping , example_inputs )
# 打印图结构
model_prepared .graph .print_tabular ()
# QAT 训练(同 eager mode)
# ...
# 转换为 INT8
model_prepared .eval ()
model_int8 = convert_fx (model_prepared )
# 保存 + 导出 ONNX
torch .onnx .export (model_int8 , example_inputs , "model_int8.onnx" ,
opset_version = 17 )
路线 1.2c:PT2E (PyTorch 2 Export) Mode QAT(未来方向)
import torch
from torch .export import export_for_training # PyTorch 2.5+
from torch .ao .quantization .quantize_pt2e import prepare_qat_pt2e , convert_pt2e
from torch .ao .quantization .quantizer .xnnpack_quantizer import (
XNNPACKQuantizer ,
get_symmetric_quantization_config ,
)
model = torchvision .models .resnet18 (weights = "IMAGENET1K_V1" ).train ()
example_inputs = (torch .randn (2 , 3 , 224 , 224 ),)
# Step 1: Capture graph
# PyTorch < 2.5: from torch._export import capture_pre_autograd_graph
exported_model = capture_pre_autograd_graph (model , example_inputs )
# Step 2: 配置后端 Quantizer
quantizer = XNNPACKQuantizer ()
quantizer .set_global (get_symmetric_quantization_config (is_qat = True ))
# Step 3: Prepare QAT
prepared_model = prepare_qat_pt2e (exported_model , quantizer )
# Step 4: 训练(同标准训练循环)
# 建议在几个 epoch 后 disable observer
for epoch in range (num_epochs ):
train_one_epoch (prepared_model , ...)
if epoch >= 3 :
prepared_model .apply (torch .ao .quantization .disable_observer )
# Step 5: Convert
quantized_model = convert_pt2e (prepared_model )
torch .ao .quantization .move_exported_model_to_eval (quantized_model )
# 技巧 1:BN 冻结 — QAT 时 BatchNorm 统计量应该冻结
def freeze_bn (model ):
for m in model .modules ():
if isinstance (m , torch .nn .BatchNorm2d ):
m .eval ()
m .weight .requires_grad = False
m .bias .requires_grad = False
# 技巧 2:Observer 停止 — 前几个 epoch 收集统计数据,之后冻结
def disable_observers (model ):
model .apply (torch .ao .quantization .disable_observer )
# 技巧 3:QAT 学习率调度 — 通常从 FP32 模型 final LR 开始
optimizer = torch .optim .SGD (model .parameters (), lr = 1e-4 ) # 低学习率
scheduler = torch .optim .lr_scheduler .StepLR (optimizer , step_size = 5 , gamma = 0.1 )
# 完整 QAT 调度示例
for epoch in range (10 ):
if epoch == 0 :
model .apply (torch .ao .quantization .enable_observer ) # epoch 0: 开启 observer
if epoch >= 3 :
model .apply (torch .ao .quantization .disable_observer ) # epoch 3+: 停止更新 scale
train_one_epoch (...)
scheduler .step ()
阶段 1.5:QAT 训练深度剖析 — 从"能跑"到"理解为什么"
⏱ 预计时间:2-3 周 | 🎯 难度:⭐⭐⭐
Stage 1 教你"怎么调 QAT API"。这个 Stage 补上中间缺失的一环:QAT 训练本身到底发生了什么。
从手写 FakeQuantize 状态机开始,到观察不同比特下的"崩溃点",到理解 LSQ 为什么是必然的下一步。
这一章是连接"API 使用者"和"算法设计者"的桥梁。
Week 1: 手写 QAT — 理解每一个细节
├── 子路径 1: 从零构造 FakeQuantize(不用 torch.ao 的任何东西)
│ ├── 手写 MinMaxObserver: register_buffer(min_val, max_val)
│ ├── 手写 FakeQuantize: forward = round(x/s) * s(STE 自动处理!)
│ ├── 手写 observer_enabled / fake_quant_enabled 两个开关
│ └── 吃饭: 理解为什么 PyTorch 用两个 flag 而不是一个 mode 枚举
│
├── 子路径 2: 手动管理三阶段状态切换
│ ├── Phase 1 (Calibration): observer=T, fq=F → 收集 min/max
│ ├── Phase 2 (QAT Training): observer=F, fq=T → scale 冻结, 训练 weight
│ ├── Phase 3 (Evaluation): observer=F, fq=T, BN.eval() → 测试
│ └── 动手: 在一个 2 层 MLP (MNIST) 上手写完整的 QAT 训练循环
│
├── 子路径 3: 对比"手写 QAT"和"PyTorch prepare_qat_fx"的输出
│ ├── 用两种方式对同一个 ResNet18 做 QAT
│ ├── 对比每层的 scale/Z、weight 更新量、最终精度
│ └── 吃饭: PyTorch 的 MovingAverageObserver 比手写 MinMax 好在哪
│
└── 子路径 4: BN 冻结的深层原理
├── 实验 A: 正常 QAT(BN 冻结) — 精度正常
├── 实验 B: BN 不冻结(继续更新 running stats)— 精度崩塌
├── 分析: 看 BN running_mean 的漂移曲线
└── 吃饭: FakeQuant 改变了激活分布 → BN stats 被"污染"
Week 2: Observer 消融 + 比特宽度系统性实验
├── 子路径 5: Observer 类型大对决
│ ├── MinMaxObserver: 最朴素, 对 outlier 极度敏感
│ ├── MovingAverageObserver: EMA 平滑, 更稳定
│ ├── HistogramObserver: KL/MSE 搜索最优截断
│ ├── PerChannelObserver: 每通道独立 scale
│ ├── 实验: 同一模型 × 同一比特 × 不同 Observer → 精度差异表
│ └── 吃饭: 什么时候用哪个 Observer?(不是背,是理解)
│
├── 子路径 6: 比特宽度系统性实验 —— 找到"崩溃点"
│ ├── FP32 baseline → 8-bit → 6-bit → 4-bit → 3-bit → 2-bit
│ ├── 记录每个比特的 test accuracy + 收敛 epoch 数
│ ├── 观察"崩溃点": 在哪个比特数下 QAT 开始失效?
│ └── 吃饭: 为什么 8-bit 几乎无损, 4-bit 开始降, 2-bit 直接崩?
│
├── 子路径 7: QAT 损失曲面可视化
│ ├── 沿着 FP32 模型的梯度方向行走,看 loss 如何变化
│ ├── 对比 FP32 训练和 QAT 训练的 loss landscape
│ └── 吃饭: FakeQuantize 在 loss 曲面上加了"台阶"(离散化)
│
└── 子路径 8: Observer 时序调度实验
├── 实验 A: observer 关闭太早(epoch 1)→ scale 不准
├── 实验 B: observer 关闭太晚(epoch 8)→ 浪费训练时间
├── 实验 C: 最佳时机(epoch 2-3)→ 精度最高
└── 吃饭: 2-3 epoch 不是魔法数字——它是"统计稳定"和"训练效率"的平衡
Week 3: 失败案例 + 从"崩溃"到 LSQ 的桥梁
├── 子路径 9: QAT 失败案例分析
│ ├── 案例 1: "QAT 精度比 PTQ 还差" → Observer 用了太短的 epoch
│ ├── 案例 2: "loss 剧烈震荡" → BN 未冻结
│ ├── 案例 3: "训练中 loss 突然上升" → LR 过大
│ ├── 案例 4: "某些层输出全变成同一个值" → reduce_range 设错了
│ └── 每个案例: 从"症状"→"根因"→"修复"的完整推理链
│
├── 子路径 10: 固定 scale QAT 的能力边界
│ ├── 在 2-bit 下深入分析: 为什么固定 scale QAT 会崩
│ ├── 量化误差溯源: 多少来自 round error, 多少来自 clip error
│ ├── 关键发现: 低比特下 clip error 主导 → scale 的精确性至关重要
│ └── 吃饭: "如果 scale 能跟着 weight 一起学, 问题不就解决了?"
│ ↑ 这就是 LSQ 的动机。学了 Stage 1.5 后, Stage 2 不再"硬着陆"
│
└── 子路径 11: 综合实验
├── 在 CIFAR-10 上, 用 ResNet-20, 做完整的 8/6/4/3/2-bit 对比
├── 画"比特数 vs 精度"曲线 — 找固定 scale QAT 的拐点
└── 输出一份实验报告: 哪个比特数下哪个 Observer 最好
### 1.5.2 关键动手实验
| 实验 | 时间 | 产出 |
|------|:--:|------|
| 手写 FakeQuantize + QAT 训练循环 (MNIST) | 1h | 2 层 MLP, 8-bit QAT, >97% |
| Observer 消融 (4 种 Observer × 4 比特) | 1.5h | 精度对比表, 每个 Observer 的适用场景 |
| 比特宽度系统性实验 (8→6→4→3→2) | 1h | 找到"崩溃点", 画比特-vs-精度曲线 |
| BN 冻结 vs 不冻结对比 | 0.5h | running_mean 漂移曲线, 理解冻结必要性 |
| QAT 失败案例复现 + 修复 | 1h | 4 个案例的症状→根因→修复推理链 |
### 1.5.3 检验标准
- [ ] 能手写 FakeQuantize(observer + fake_quant + 两个开关),不用 torch.ao
- [ ] 能手写完整 QAT 训练循环(三阶段状态切换)
- [ ] 能画出 8/6/4/3/2-bit QAT 的精度曲线,指出"崩溃点"
- [ ] 能解释 4 种 Observer 的区别和使用场景
- [ ] 能诊断 4 种 QAT 失败案例(BN 未冻结/LR 过大/Observer 时间不对/reduce_range)
- [ ] 能解释为什么固定 scale QAT 在低比特下失效 → 引出 LSQ 的动机
---
## 阶段 2:QAT 核心算法 — LSQ 与可微量化参数
> ⏱ 预计时间:2-3 周 | 🎯 难度:⭐⭐⭐
这是 QAT 理论的**核心中的核心**。LSQ 是 QAT 领域的里程碑,几乎所有后续 QAT 方法(包括 AIMET 的 QAT)都基于 LSQ 的思想。
### 2.1 你需要吃透什么
| # | 知识点 | 深度要求 |
|---|--------|----------|
| 1 | **STE (Straight-Through Estimator)**:为什么需要 STE?STE 的数学问题 | 能手写 STE 的前向和反向代码 |
| 2 | **LSQ 的核心公式**:`v̂ = round(clip(v/s, -Q_N, Q_P)) * s`,s 是可学习参数 | 能从公式推导出 s 的梯度表达式 |
| 3 | **LSQ 梯度缩放 (Gradient Scaling)**:`g = 1/sqrt(N_W * Q_P)` | 理解为什么需要对 step size 梯度缩放 |
| 4 | **LSQ+**:将 zero_point 也变为可学习参数,推广到非对称量化 | 理解 zero_point 的梯度怎么推 |
| 5 | **PACT (Parameterized Clipping Activation)**:学习 clip 上界 | 对比 PACT 和 LSQ 的异同 |
| 6 | **DoReFa-Net**:低比特权重量化 + 激活量化 | 理解不同比特宽的量化函数定义 |
| 7 | **QAT vs PTQ 的理论差异**:为什么 QAT 在 ≤4-bit 时远优于 PTQ | 理解量化噪声在训练过程中的"补偿"机制 |
### 2.2 怎么做
#### 2.2a:从零实现 LSQ(核心代码)
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
class LSQQuantizerFunction(torch.autograd.Function):
"""
LSQ 的底层 autograd 实现:
- 前向:对输入做 FakeQuant
- 反向:对 scale 和输入分别计算梯度(STE)
"""
@staticmethod
def forward(ctx, x, scale, zero_point, n_bits, symmetric):
"""
x: float tensor
scale: 可学习参数 (per-tensor 或 per-channel)
n_bits: 量化比特数
"""
if symmetric:
qmin = -2 ** (n_bits - 1)
qmax = 2 ** (n_bits - 1) - 1
else:
qmin = 0
qmax = 2 ** n_bits - 1
# 量化 + 反量化 (FakeQuant)
x_scaled = x / scale
x_rounded = torch.round(x_scaled)
x_clamped = torch.clamp(x_rounded, qmin, qmax)
x_quant = x_clamped * scale
# 保存用于反向传播
ctx.save_for_backward(x, scale, x_scaled, x_clamped)
ctx.qmin, ctx.qmax = qmin, qmax
ctx.n_bits = n_bits
return x_quant
@staticmethod
def backward(ctx, grad_output):
"""
反向传播:
- 对 x: 直通估计器 (STE) — 直接回传梯度
- 对 scale: 按照 LSQ 论文的梯度公式
"""
x, scale, x_scaled, x_clamped = ctx.saved_tensors
qmin, qmax = ctx.qmin, ctx.qmax
n_bits = ctx.n_bits
# === 对输入 x 的梯度: STE ===
# 在 (qmin, qmax) 范围内的位置直接回传梯度
grad_x = grad_output.clone()
# 超出量化范围的位置梯度置零(hard clip 不可导)
grad_x = torch.where(
(x_scaled >= qmin) & (x_scaled <= qmax),
grad_x,
torch.zeros_like(grad_x)
)
# === 对 scale 的梯度: LSQ 论文核心 ===
# ∂v̂/∂s ≈ -v/s + round(v/s) when qmin < v/s < qmax
# qmin when v/s <= qmin
# qmax when v/s >= qmax
grad_scale_inner = -x_scaled + x_clamped # 论文公式 (6)
grad_scale_inner = torch.where(
(x_scaled >= qmin) & (x_scaled <= qmax),
grad_scale_inner,
torch.where(x_scaled < qmin,
torch.full_like(grad_scale_inner, float(qmin)),
torch.full_like(grad_scale_inner, float(qmax)))
)
grad_scale = (grad_output * grad_scale_inner).sum()
# === LSQ Gradient Scaling ===
# g = 1 / sqrt(N_W * Q_P) — 论文公式 (13)
g = 1.0 / (x.numel() * qmax) ** 0.5
grad_scale = grad_scale * g
return grad_x, grad_scale, None, None, None
class LSQQuantizer(nn.Module):
"""可插入任意网络的 LSQ 量化模块"""
def __init__(self, n_bits=4, symmetric=True, init_scale=1.0):
super().__init__()
self.n_bits = n_bits
self.symmetric = symmetric
# scale 是可学习参数
self.scale = nn.Parameter(torch.tensor(init_scale))
def forward(self, x):
fq = LSQQuantizerFunction.apply
return fq(x, self.scale, None, self.n_bits, self.symmetric)
# ========== 使用示例:将 LSQ 插入 ResNet ==========
class Conv2dLSQ(nn.Module):
"""带 LSQ 量化的 Conv2d 层"""
def __init__(self, in_c, out_c, k, stride=1, padding=0, n_bits_w=4, n_bits_a=4):
super().__init__()
self.conv = nn.Conv2d(in_c, out_c, k, stride, padding)
self.weight_quant = LSQQuantizer(n_bits_w, symmetric=True)
self.act_quant = LSQQuantizer(n_bits_a, symmetric=False)
def forward(self, x):
w_q = self.weight_quant(self.conv.weight) # 量化权重
x_q = self.act_quant(x) # 量化激活
return F.conv2d(x_q, w_q, stride=self.conv.stride,
padding=self.conv.padding)
import torchvision
from torchvision import transforms , datasets
# 用自实现的 LSQ 做 ResNet18 ImageNet 4-bit QAT
model = torchvision .models .resnet18 (weights = "IMAGENET1K_V1" )
# 将 Conv2d 替换为 Conv2dLSQ
# ... (替换代码)
# QAT 训练 90 epochs, lr=1e-3, cosine schedule
# 目标: 4-bit 精度不低于 FP32 的 1%
前向: v̂ = round(clamp(v/s, -Q_N, Q_P)) * s
令 v_n = v/s, v̄ = clamp(v_n, -Q_N, Q_P), v̂ = round(v̄) * s
反向: ∂v̂/∂s 需要通过 STE 近似
当 -Q_N < v_n < Q_P 时:
∂v̂/∂s = ∂(round(v/s) * s)/∂s
≈ ∂((v/s) * s)/∂s # STE: round(x) ≈ x 在反向
= ∂v/∂s = 0 # 不对!v 不是 s 的函数
❌ 以上推导错误!
✅ 正确推导:
在 STE 框架下,round(z) 的反向导数是 1。
所以 v̂ = round(v/s) * s,对 s 求导:
∂v̂/∂s = round(v/s) + s * ∂round(v/s)/∂s
= round(v/s) + s * (∂round(v/s)/∂(v/s)) * ∂(v/s)/∂s
= round(v/s) + s * 1 * (-v/s²) # STE: round'(z) ≈ 1
= round(v/s) - v/s
∴ ∂v̂/∂s = -v/s + round(v/s) ← 这就是 LSQ 的公式 (6)!
阶段 3:PTQ 进阶算法 — AdaRound / FlexRound / GPTQ 的数学内核
⏱ 预计时间:3-4 周 | 🎯 难度:⭐⭐⭐⭐
这一段是打通 QAT 和 PTQ 任督二脉的关键 。虽然你目标是 QAT,但现代 QAT(如 AIMET)大量借鉴了 PTQ 的优化技术(Cross-Layer Equalization、AdaRound),而且 GPTQ 的自适应舍入和 LSQ 的 learnable rounding 在数学上是相通的。
#
知识点
深度要求
1
AdaRound 的理论框架 :Taylor 展开 → 二次无约束二值优化 (QUBO)
能手推 Taylor 展开到 QUBO 的完整过程
2
连续松弛 (Soft Relaxation) :离散 rounding 选择 → 连续 sigmoid 近似
理解公式 s = σ((h(W) - 0.5) / τ) 中 τ 的作用
3
FlexRound 的 Element-wise Division :与 AdaRound element-wise addition 的区别
能画出两种舍入方案的对比图
4
OBQ → GPTQ 的演进 :per-row quantization → Hessian-based error compensation
理解为什么要用 Hessian inverse
5
Block-wise Reconstruction :逐层重建 vs 逐块重建
理解 `min
6
Cross-Layer Equalization (CLE) :利用 ReLU 的 scale 不变性进行层间均衡
理解为什么 CLE 对 MobileNet 效果显著
7
Bias Correction :量化后 bias 的补偿
手算 E[y_fp] - E[y_q] 并修正 bias
import torch
import torch .nn as nn
class AdaRound (nn .Module ):
"""
AdaRound: 通过连续松弛学习每个权重的舍入方向(向上 or 向下)
核心思想:
- 普通量化: floor(x/s) 或 round(x/s)
- AdaRound: floor(x/s) + h, 其中 h ∈ {0, 1} 是可学习的
- 优化 h 使得每层的输出重建误差最小
"""
def __init__ (self , weight_shape , alpha = 0.5 , beta = 2.0 ):
super ().__init__ ()
# h 是连续变量,通过 sigmoid 映射到 [0, 1]
self .h = nn .Parameter (torch .full (weight_shape , alpha ))
def forward (self , w_scaled ):
"""
w_scaled: w / s — 已经除以 scale 的权重
返回: 软量化后的权重(连续版本,用于训练)
"""
# 软舍入: floor(z) + σ((h - 0.5) / τ)
w_floor = torch .floor (w_scaled )
# Sigmoid 映射将 h 变换到 [0, 1],控制舍入方向
w_soft = w_floor + torch .sigmoid (self .h ) # 可微
return w_soft
def get_hard_rounding (self , w_scaled ):
"""训练结束后,硬的 0/1 决定"""
return torch .floor (w_scaled ) + (self .h >= 0 ).float ()
def adaround_layer (weight , scale , input_data , n_iter = 10000 , lr = 1e-3 ):
"""
对一层做 AdaRound 优化
Args:
weight: float weight tensor
scale: 量化 scale
input_data: 校准数据通过该层后的输入 X
"""
# 量化网格
w_scaled = weight / scale
# 初始化 AdaRound 模块
adaround = AdaRound (w_scaled .shape )
# 优化 h,最小化输出重建误差
opt = torch .optim .Adam ([adaround .h ], lr = lr )
for i in range (n_iter ):
opt .zero_grad ()
# 软量化
w_soft_q = adaround (w_scaled ) * scale
# 输出重建误差
output_fp = input_data @ weight .T # 全精度输出
output_q = input_data @ w_soft_q .T # 量化输出
loss = ((output_fp - output_q ) ** 2 ).mean ()
loss .backward ()
opt .step ()
# 逐渐降低 sigmoid 温度,使逼近硬舍入
if i % 1000 == 0 :
print (f"Iter { i } , Loss: { loss .item ():.6f} " )
return adaround .get_hard_rounding (w_scaled ) * scale
3.2b:理解 Cross-Layer Equalization (CLE)
def cross_layer_equalization (conv1_weight , conv2_weight ):
"""
利用 ReLU 的 scale 不变性: s * ReLU(x / s) = ReLU(x)
对 MobileNet 的 depthwise + pointwise conv pair:
- conv1: depthwise, per-channel 权重
- conv2: pointwise (1x1), per-channel 权重
"""
# Step 1: 计算均衡因子 S_i
with torch .no_grad ():
# S_i = sqrt(max(|W1[i,:,:,:]|) / max(|W2[:,i,:,:]|))
range1 = conv1_weight .abs ().amax (dim = (1 ,2 ,3 )) # per-output-channel
range2 = conv2_weight .abs ().amax (dim = (0 ,2 ,3 )) # per-input-channel
S = torch .sqrt (range1 * range2 ) / range1
# Step 2: 均衡(不改变网络输出!)
# conv1: W1'[i,:,:,:] = W1[i,:,:,:] / S[i]
conv1_weight = conv1_weight / S [:, None , None , None ]
# conv2: W2'[:,i,:,:] = W2[:,i,:,:] * S[i]
conv2_weight = conv2_weight * S [None , :, None , None ]
# Step 3 (可选): 吸收到下一层 BN
# BN: y = γ * (x - μ) / σ + β
# 将 S 吸收到 BN 的 γ 中(如果 conv 后接 BN)
return conv1_weight , conv2_weight
⏱ 预计时间:2-3 周 | 🎯 难度:⭐⭐⭐
现在你有理论了,开始做项目。YOLO 是理想的第一个量化目标:检测头的量化难点 + NMS 的量化适配 。
#
知识点
深度要求
1
检测网络的量化敏感点 :检测头(regression)比分类头对量化更敏感
知道为什么 bbox 回归需要更高精度
2
SiLU/Swish 激活函数的量化 :不像 ReLU 那样容易量化
知道 SiLU x/sigmoid 的量化近似方案
3
NMS 的量化适配 :NMS 是 FP 操作,量化模型需要保持 FP32 bbox decode
理解 decode + NMS 的精度保持策略
4
Conv+BN+SiLU 融合 :Fuse 后 SiLU 如何处理
知道融合顺序和量化节点插入位置
5
mAP 评估 vs Top-1 Accuracy :检测任务的评估指标
知道为什么 mAP drop 0.5 是"可接受"的
6
TensorRT INT8 推理 :从 QAT 模型导出到 TensorRT 引擎
理解 calibration cache vs QAT 的关系
项目 A:YOLOv5/v8/v11 QAT(推荐从 YOLOv8 开始)
# ========== YOLOv8 QAT 完整流程 ==========
from ultralytics import YOLO
import torch
from torch .ao .quantization import get_default_qat_qconfig_mapping
from torch .ao .quantization .quantize_fx import prepare_qat_fx , convert_fx
# Step 1: 加载 YOLO 模型
model = YOLO ("yolov8n.pt" ).model .model # 提取内部 nn.Module
model .train ()
# Step 2: 融合 Conv+BN+SiLU
# 注意 YOLOv8 用 SiLU 不是 ReLU,融合策略不同
# (需要手动写 fuse pattern)
# Step 3: QAT 准备
qconfig_mapping = get_default_qat_qconfig_mapping ("qnnpack" )
model_prepared = prepare_qat_fx (model , qconfig_mapping , example_inputs )
# Step 4: QAT 训练(关键!)
# 检测损失 = 分类损失 + bbox 回归损失 + DFL 损失
for epoch in range (20 ):
model_prepared .train ()
for batch in coco_loader :
images , targets = batch
loss , loss_items = model_prepared (images ) # YOLO 内置损失
loss .backward ()
optimizer .step ()
# 每 5 个 epoch 评估 mAP
if epoch % 5 == 0 :
mAP = evaluate_coco (model_prepared , coco_val )
print (f"Epoch { epoch } , mAP@0.5: { mAP :.4f} " )
# Step 5: 转换为 INT8
model_int8 = convert_fx (model_prepared .eval ())
# Step 6: 导出 ONNX → TensorRT
torch .onnx .export (model_int8 , example_inputs , "yolov8n_int8.onnx" ,
opset_version = 17 ,
input_names = ["images" ],
output_names = ["output0" ])
项目 B:对比实验 — PTQ vs QAT on YOLO
# ========== 同一个 YOLO 模型,对比 PTQ 和 QAT ==========
# 方案 1: PTQ (calibration only, no training)
model_ptq = ptq_quantize (yolo_model , calib_loader ) # mAP: 下降 ~15-25%
print (f"PTQ mAP: { ptq_mAP } " )
# 方案 2: QAT (5 epochs)
model_qat_5ep = qat_quantize (yolo_model , train_loader , epochs = 5 ) # mAP: 下降 ~3-5%
print (f"QAT (5ep) mAP: { qat_mAP } " )
# 方案 3: QAT (20 epochs) + LSQ
model_qat_lsq = lsq_quantize (yolo_model , train_loader , epochs = 20 ) # mAP: 下降 <2%
print (f"QAT+LSQ mAP: { qat_mAP } " )
# ========== 关键洞察 ==========
# 1. PTQ 在 ≤8-bit 的检测任务上通常表现很差
# 2. QAT 可以大幅恢复精度
# 3. LSQ 进一步缩小与 FP32 的差距
实验
方法
比特宽
mAP@0.5
mAP@0.5:0.95
模型大小
推理速度 (ms)
基线
FP32
32
37.3
26.1
12.1 MB
8.5
实验1
PTQ MinMax
8
30.2
19.8
3.4 MB
3.2
实验2
PTQ MSE
8
31.5
20.1
3.4 MB
3.2
实验3
QAT (5 ep)
8
35.1
24.3
3.4 MB
3.2
实验4
QAT+LSQ (20 ep)
8
36.2
25.4
3.4 MB
3.2
实验5
QAT+LSQ
4
33.8
23.1
2.1 MB
2.1
阶段 5:工业级框架深度使用 — PPQ / AIMET
⏱ 预计时间:3-4 周 | 🎯 难度:⭐⭐⭐⭐
学完理论和 YOLO 实战后,开始用工业级框架。PPQ(商汤)和 AIMET(高通)是两个代表性框架,各有侧重。
#
知识点
深度要求
1
PPQ 的量化 IR 设计 :QuantableOperation / QuantableVariable 的抽象
理解 PPQ 如何用 IR 统一描述不同后端的量化
2
PPQ 的 Pass 系统 :27 个独立量化优化 Pass
至少吃透 5 个关键 Pass:LayerEqualization / BiasCorrection / LSQ / ChannelSplit / Fusion
3
PPQ 的多后端导出 :ONNX QDQ / TensorRT / OpenVINO / NCNN / SNPE
至少跑通 ONNX QDQ 和 TensorRT 两条路径
4
AIMET QuantSim :模拟量化的核心机制
理解 AIMET 怎么在 PyTorch 层间插入量化/反量化节点
5
AIMET QAT 模式 :带 Range Learning vs 不带 Range Learning
理解 scale/offset 作为可学习参数的具体实现
6
AIMET CLE / Bias Correction / AdaRound 在 AIMET 中的使用
走通 PTQ → QAT 的完整工作流
7
AIMET → QAIRT 部署链路 :模型 → QuantSim → ONNX + encodings → DLC
至少理解整个链路的数据格式转换
# ========== PPQ 完整量化流程 ==========
from ppq import QuantizationSettingFactory , QuantSetting , TargetPlatform
from ppq .api import quantize_torch_model , export_ppq_graph
from ppq .executor import TorchExecutor
import torchvision
# Step 1: 加载模型
model = torchvision .models .mobilenet_v2 (weights = "DEFAULT" )
dummy_input = torch .randn (1 , 3 , 224 , 224 )
# Step 2: 配置量化设置
q_setting = QuantizationSettingFactory .default_setting ()
# 启用 LSQ(PPQ 内置)
q_setting .lsq_optimization = True
# 启用层均衡
q_setting .equalization = True
# 启用 bias 校正
q_setting .bias_correction = True
# Step 3: 量化(PPQ 自动做 PTQ)
quantized = quantize_torch_model (
model = model ,
calib_dataloader = calib_loader ,
calib_steps = 32 ,
input_shape = (1 , 3 , 224 , 224 ),
setting = q_setting ,
platform = TargetPlatform .TRT_INT8 , # 目标后端
)
# Step 4: 导出为 ONNX (带 QDQ 节点)
export_ppq_graph (
graph = quantized ,
platform = TargetPlatform .ONNX ,
graph_save_to = "mobilenet_v2_int8.onnx" ,
config_save_to = "quant_config.json" ,
)
# ========== PPQ QAT(在 PTQ 基础上微调) ==========
from ppq .api import enable_qat
# 在 PPQ 的量化图上做 QAT
qat_graph = enable_qat (quantized , dataloader = train_loader )
# 训练...
# 导出...
5.2b:AIMET 实战(高通 QAT 完整流程)
# ========== AIMET QAT 完整流程(ResNet18 + ImageNet) ==========
import torch
from torchvision import models
from aimet_torch .quantsim import QuantizationSimModel
from aimet_torch .cross_layer_equalization import equalize_model
from aimet_torch .bias_correction import bias_correction
# Step 1: 加载预训练模型
model = models .resnet18 (weights = "IMAGENET1K_V1" ).eval ()
dummy_input = torch .randn (1 , 3 , 224 , 224 )
# Step 2: PTQ 初始化 — 应用 CLE + Bias Correction (可选)
# equalize_model() 需要在 BN folded 后调用
# bias_correction() 进一步减小量化误差
# Step 3: 创建 QuantSim — AIMET 的核心量化模拟器
sim = QuantizationSimModel (
model = model ,
quant_scheme = "tf_enhanced" , # 量化方案: tf / tf_enhanced / range_learning
dummy_input = dummy_input ,
rounding_mode = "nearest" ,
default_output_bw = 8 , # 激活 8-bit
default_param_bw = 8 , # 权重 8-bit
# config_file 可以传入自定义的每层配置(JSON)
)
# Step 4: 校准 — 计算激活的 scale/offset
def calibration_forward_pass (model , batch ):
model (batch .cuda ())
sim .compute_encodings (
forward_pass_callback = calibration_forward_pass ,
forward_pass_callback_args = 5 , # 使用 5 个 batch 校准
)
# Step 5: QAT 微调
# sim.model 是插入了 FakeQuant 的模型
sim .model .train ()
optimizer = torch .optim .SGD (sim .model .parameters (), lr = 1e-5 , momentum = 0.9 )
for epoch in range (15 ):
for data , target in train_loader :
output = sim .model (data .cuda ())
loss = criterion (output , target )
loss .backward ()
optimizer .step ()
optimizer .zero_grad ()
# Step 6: 导出
# 导出 ONNX + encodings 文件 → QAIRT 转 DLC
sim .export (
path = "./export/" ,
filename_prefix = "resnet18_qat" ,
dummy_input = dummy_input .cuda (),
)
# Step 7 (可选): 用 QAIRT 转 DLC 部署到高通设备
# QAIRT Converter: ONNX + .encodings → DLC
5.2c:AIMET Range Learning QAT
# ========== AIMET Range Learning QAT ==========
# 与普通 QAT 的区别:scale 和 offset 也是可学习参数(类似 LSQ)
sim_range = QuantizationSimModel (
model = model ,
quant_scheme = "range_learning" , # ← 关键:使用 range_learning scheme
dummy_input = dummy_input ,
default_output_bw = 8 ,
default_param_bw = 8 ,
)
sim_range .compute_encodings (calibration_forward_pass , 5 )
# Range Learning 训练时:
# - 权重的 scale 随训练更新
# - 激活的 scale/offset 也通过梯度更新
# - 类似 LSQ 的行为
对比维度
PPQ
AIMET
厂商
商汤科技 (OpenPPL)
高通 (Qualcomm)
设计哲学
量化编译器的思路(IR → Pass → Backend)
量化工具箱的思路(Technique → Sim → Export)
后端覆盖
ONNX, TensorRT, NCNN, OpenVINO, SNPE, DSP
高通专精(SNPE/QAIRT → DLC → 骁龙芯片)
核心技术
图调度器 (conservative/aggressive) + 27 Pass
CLE + AdaRound + QuantSim + Range Learning QAT
学习价值
学习量化编译器的设计模式
学习工业级 QAT pipeline
劣势
QAT 部分相对较新(v0.6.6 才加入)
对非高通硬件无直接后端
社区活跃度
近 2 年无新提交
持续更新,有 Slack 社区
阶段 6:大模型 PTQ — GPTQ / AWQ / SmoothQuant / SpinQuant
⏱ 预计时间:3-4 周 | 🎯 难度:⭐⭐⭐⭐
这是 2023-2025 年最重要的进展。在进入 LLM QAT 之前,必须先彻底理解 LLM 的 PTQ ,因为 LLM QAT 几乎都是建立在 PTQ 的基础上的。
#
知识点
深度要求
1
LLM 量化与 CNN 量化的根本区别 :激活值异常值 (outlier) 问题
能画出 Transformer 激活值的 heavy-tailed 分布图
2
GPTQ 算法全流程 :OBQ → Hessian inverse → Cholesky → Lazy Batch Update → Group Size
能手推 GPTQ 的 per-row quantization 误差补偿公式
3
SmoothQuant 的核心思想 :将量化难度从激活迁移到权重
理解 X * diag(s)^-1 * diag(s) * W = (X/s) * (W*s)
4
AWQ 的激活感知 :保护 ~1% 的显著权重(salient weights)
理解 channel-wise scaling factor 怎么找到
5
SpinQuant 的旋转变换 :用可学习的旋转矩阵消除 outlier
理解 A * R * R^T * W^T = (A*R) * (W*R)^T
6
QuaRot 的 Hadamard 旋转 :随机旋转 vs 可学习旋转
对比 QuaRot 和 SpinQuant
7
W4A16 vs W4A4 vs W4A4KV4 的 trade-off
知道每种方案的计算瓶颈在哪
6.2a:使用 AutoGPTQ 量化 LLaMA
# ========== GPTQ 量化实战 ==========
from transformers import AutoModelForCausalLM , AutoTokenizer , GPTQConfig
from datasets import load_dataset
model_id = "meta-llama/Llama-2-7b-hf"
tokenizer = AutoTokenizer .from_pretrained (model_id )
# 配置 4-bit GPTQ
gptq_config = GPTQConfig (
bits = 4 , # 4-bit 量化
group_size = 128 , # 每 128 列共享 scale
dataset = "c4" , # 校准数据集
desc_act = False , # 是否按激活值降序排列(影响精度/速度 trade-off)
damp_percent = 0.01 , # Hessian 阻尼系数
)
# 一键量化
model = AutoModelForCausalLM .from_pretrained (
model_id ,
quantization_config = gptq_config ,
device_map = "auto" ,
torch_dtype = torch .float16 ,
)
# 验证
perplexity = evaluate_ppl (model , tokenizer , "wikitext2" )
print (f"4-bit GPTQ Perplexity: { perplexity } " )
6.2b:从零实现 GPTQ 核心逻辑(理解底层)
# ========== GPTQ 核心算法的简化实现 ==========
import torch
def gptq_quantize_layer (W , X , bits = 4 , group_size = 128 , damp_percent = 0.01 ):
"""
GPTQ 单层量化(简化版,去掉了 lazy batch update for clarity)
Args:
W: shape [out_features, in_features] — 全精度权重
X: shape [N, in_features] — 校准数据的输入特征
bits: 量化比特数
group_size: 分组大小
"""
dev = W .device
out_features , in_features = W .shape
W = W .clone ()
# Step 1: 计算 Hessian H = X^T * X (in_features × in_features)
H = (X .T @ X ) / X .shape [0 ]
H = H + damp_percent * torch .diag (H ).mean () * torch .eye (in_features , device = dev )
# Step 2: Cholesky 分解 (用于高效求逆)
# H^-1 = (L * L^T)^-1
L = torch .linalg .cholesky (H )
# Step 3: 逐列量化(GPTQ 按固定顺序)
Q = torch .zeros_like (W ) # 量化后的权重
Scales = []
Zeros = []
for col in range (in_features ):
w_col = W [:, col ] # out_features 维
# 3a: 计算该列的量化参数
col_group = col // group_size
w_min , w_max = w_col .min (), w_col .max ()
scale = (w_max - w_min ) / (2 ** bits - 1 )
zero = torch .round (- w_min / scale )
Scales .append (scale ); Zeros .append (zero )
# 3b: 量化
w_col_q = (torch .round (w_col / scale + zero ).clamp (0 , 2 ** bits - 1 ) - zero ) * scale
Q [:, col ] = w_col_q
# 3c: 计算量化误差
error = w_col - w_col_q # [out_features]
# 3d: 用 Hessian 逆更新剩余列(OBQ 的核心更新公式)
# 获取 H^-1 的第 col 个对角元素和第 col 列
H_inv_col = torch .cholesky_solve (
torch .eye (in_features , device = dev )[:, col :col + 1 ], L
).squeeze () # [in_features]
# 更新公式: W[:, col+1:] -= error / H_inv[col] * H_inv[col+1:]
if col < in_features - 1 :
correction = (error .unsqueeze (1 ) / H_inv [col ]) * H_inv [col + 1 :].unsqueeze (0 )
W [:, col + 1 :] -= correction
return Q , Scales , Zeros
# ========== SmoothQuant: 迁移量化难度 ==========
import torch
def smoothquant_scale (X_calib , W , alpha = 0.5 ):
"""
计算 SmoothQuant 的 per-channel scaling factor
核心公式: s_j = max(|X|_j)^alpha / max(|W|_j)^(1-alpha)
X: [seq_len, in_features]
W: [out_features, in_features]
"""
# 激活的 per-channel 最大值
act_max = X_calib .abs ().max (dim = 0 ).values # [in_features]
# 权重的 per-channel 最大值
wt_max = W .abs ().max (dim = 0 ).values # [in_features]
# 计算平滑因子
s = (act_max ** alpha ) / (wt_max ** (1 - alpha ) + 1e-8 )
# 应用到激活和权重
X_smoothed = X_calib / s # 激活除 s(变小)
W_smoothed = W * s .unsqueeze (0 ) # 权重乘 s(变大)
# 验证: 网络输出不变
# X_smoothed @ W_smoothed.T = (X / s) @ (W * s).T ≈ X @ W.T
return X_smoothed , W_smoothed , s
# ========== SpinQuant 核心: 旋转矩阵消除 outlier ==========
import torch
def spinquant_hadamard_transform (x ):
"""
对输入应用 Hadamard 旋转(QuaRot 的思路)
将 outlier 的能量"平摊"到所有维度
"""
# x: [batch, seq_len, hidden_dim]
# Hadamard 矩阵 H: H * H^T = n*I
# 变换: x' = x * H / sqrt(n)
import math
n = x .shape [- 1 ]
# 递归生成 Hadamard 矩阵
def hadamard (n ):
if n == 1 :
return torch .tensor ([[1.0 ]])
H_n1 = hadamard (n // 2 )
top = torch .cat ([H_n1 , H_n1 ], dim = 1 )
bottom = torch .cat ([H_n1 , - H_n1 ], dim = 1 )
return torch .cat ([top , bottom ], dim = 0 )
H = hadamard (n ).to (x .device )
return x @ H / math .sqrt (n )
def spinquant_learn_rotation (X_calib , W , n_rotations = 4 , lr = 0.01 , steps = 100 ):
"""
可学习的旋转矩阵(SpinQuant 核心)
使用 Cayley 变换参数化正交矩阵
"""
d = X_calib .shape [- 1 ]
# 用 Cayley 参数化保证正交性: R = (I - A) * (I + A)^-1
# 其中 A 是反对称矩阵: A = (M - M^T) / 2
M = torch .randn (d , d , requires_grad = True ) * 0.01
def cayley (M ):
A = (M - M .T ) / 2
I = torch .eye (d , device = M .device )
return torch .linalg .solve ((I + A ).T , (I - A ).T ).T # R = (I-A)(I+A)^-1
opt = torch .optim .Adam ([M ], lr = lr )
for step in range (steps ):
opt .zero_grad ()
R = cayley (M )
# 量化旋转变换后的权重
W_rot = W @ R
W_q = fake_quantize (W_rot , bits = 4 ) # 模拟 4-bit 量化
W_deq = W_q @ R .T # 反变换
# 最小化量化误差
loss = ((W - W_deq ) ** 2 ).mean ()
loss .backward ()
opt .step ()
return cayley (M ).detach ()
阶段 7:大模型 QAT — LLM-QAT / QLoRA / EfficientQAT / BitDistiller
⏱ 预计时间:3-4 周 | 🎯 难度:⭐⭐⭐⭐⭐
这一阶段是整个学习路径的"珠峰"——将 QAT 的思想应用到 7B~70B 的 LLM 上。
核心矛盾 :你不可能对 70B 模型做全量 QAT(显存和时间都不允许),所以必须借助 PEFT / KD / 两阶段训练来间接实现。
Week 1: QLoRA — 4-bit 量化 + LoRA 微调
├── 子路径 1.1: NF4 数据格式
│ ├── 知识点: NF4 是非均匀量化——量化等级在概率密度高的区域更密
│ ├── 动手: 手写 NF4 量化表(16 个值的精确位置)
│ ├── 对比: NF4 vs INT4 vs FP4 在正态分布下的量化 SNR
│ └── 吃透: 为什么 NF4 对 LLM 权重(近似正态分布)最优
│
├── 子路径 1.2: Double Quantization(双重量化)
│ ├── 知识点: 对 scale 再做一次量化——省 0.4 bit/param
│ ├── 动手: 计算 7B 模型 W4A16 的显存占用,含/不含 double quant
│ └── 吃透: 为什么双层量化几乎无损(scale 的分布极窄)
│
├── 子路径 1.3: QLoRA 训练循环
│ ├── 知识点: base model 4-bit 冻结 + LoRA adapter BP16 训练
│ ├── 动手: 用 QLoRA 微调 LLaMA-7B 到 Alpaca 数据集
│ ├── 坑: LoRA target_modules 的选择影响收敛速度
│ └── 坑: 4-bit 模型不能直接 .to(device),必须用 device_map="auto"
│
└── 子路径 1.4: bitsandbytes 源码深潜
├── 知识点: bitsandbytes 如何实现 CUDA 端 4-bit 矩阵乘法
├── 知识点: NF4 dequant → FP16 → matmul 的数据流
└── 吃透: 为什么 QLoRA 的 forward 比 FP16 慢(dequant overhead)
Week 2: LLM-QAT — 数据蒸馏 + KD 驱动 QAT
├── 子路径 2.1: 为什么需要数据蒸馏
│ ├── 知识点: QAT 需要大量数据,但 LLM 训练数据不可获取
│ ├── 知识点: 用 teacher LLM 生成 pseudo-data + 保留 soft label
│ └── 动手: 用 LLaMA-7B 生成 10K 条 token 序列作为 QAT 数据
│
├── 子路径 2.2: KD + QAT 联合训练
│ ├── 知识点: L_total = α × L_LM + β × L_KD
│ ├── 知识点: 温度 T 的作用——T 大则 soft label 更平滑(提供更多信息)
│ ├── 动手: 实现 KD loss + CE loss 的联合训练循环
│ └── 对比: KD QAT vs plain QAT vs FP16 baseline 的 PPL
│
└── 子路径 2.3: LLM-QAT 的局限性
├── 知识点: 生成数据的分布偏差(teacher 的偏好被传递)
└── 知识点: 数据蒸馏适用于"知识密集型"任务,不适用于"推理型"
Week 3: EfficientQAT — 两阶段训练 + BitDistiller
├── 子路径 3.1: EfficientQAT 两阶段
│ ├── 知识点: Phase 1 (W-only QAT) 先让权重量化适应
│ ├── 知识点: Phase 2 (W+A QAT) 再加入激活量化——分阶段降低难度
│ ├── 动手: 对比单阶段和两阶段 QAT 在 7B 上的收敛曲线
│ └── 吃透: 为什么分阶段比直接 W+A QAT 收敛更稳定
│
├── 子路径 3.2: BitDistiller — 蒸馏增强量化
│ ├── 知识点: 用更大的 teacher 模型蒸馏到更小的量化 student
│ ├── 知识点: KD 补偿了量化引入的 capacity loss
│ └── 动手: 7B QAT + 13B teacher KD → 对比纯 QAT
│
└── 子路径 3.3: W4A4 的极限挑战
├── 知识点: W4A4 比 W4A16 多一个数量级的难度
├── 知识点: 需要 SmoothQuant/SpinQuant 预处理消除 Outlier
└── 吃透: W4A4 瓶颈在激活量化,不是权重量化
Week 4: 综合实战 + 对比
├── 用 QLoRA 微调 LLaMA-3-8B + 评测 MMLU
├── 对比 QLoRA / LLM-QAT / EfficientQAT 三者在 W4A16 下的 PPL + MMLU
└── 画"方法 vs 精度 vs 训练成本"的三维对比图
资源
重点
📄 QLoRA (NeurIPS 2023)
NF4 量化表推导 + Double Quant 理论
📄 LLM-QAT (ICLR 2024)
Data-free KD pipeline
📄 EfficientQAT (2024)
两阶段训练的理论依据
📄 BitDistiller (2024)
KD + Quantization 联合优化的 loss 设计
🛠 bitsandbytes
读 bitsandbytes/functional.py 的 NF4 dequant 实现
🛠 PEFT
读 peft/tuners/lora.py 理解 LoRA 在量化模型上的 forward
阶段 8:端侧部署全链路 — 从量化模型到芯片推理
⏱ 预计时间:3-4 周 | 🎯 难度:⭐⭐⭐⭐
量化做完了,怎么部署?这一阶段打通 PyTorch → ONNX → 推理引擎 → 芯片 的全链路。
核心主题 :一张计算图上哪些张量该量化、QDQ 节点如何插入和优化、各后端如何消费量化图。
这一章不是你之前学到的东西的"附录"——它回答了一个 Stage 0~7 没有系统回答的问题:
"在一张完整的计算图上,权重、激活值、Bias、残差分支、Concat 的输入——到底哪些需要 QDQ 节点?哪些不需要?"
Week 1: 计算图量化全景——哪些张量该量化,哪些不该
├── 子路径 1.1: 图中四类数据流
│ ├── 知识点: 权重(静态,推理时不变)→ 离线量化,一次完成
│ ├── 知识点: 激活值(动态,每张图不同)→ 在线量化,每层输入输出
│ ├── 知识点: Bias(量级小,精度敏感)→ 通常保持 FP32
│ ├── 知识点: 残差分支/Concat 输入 → 多源输入的 scale 需要对齐
│ └── 动手: 在 ResNet50 的 ONNX 图上手动标注哪些位置需要 QDQ
│
├── 子路径 1.2: QDQ 插入的"黄金法则"
│ ├── 知识点: 每个 weight tensor 前必须有 DQ(反量化 weight → FP)
│ ├── 知识点: 每个 INT8 op 的输出后必须有 Q(量化输出 → INT8)
│ ├── 知识点: 不支持 INT8 的 op(如 Sigmoid、Reshape)前后插 DQ→Q
│ ├── 知识点: 相邻 Q→DQ 对可以消除(scale 相同时互相抵消)
│ └── 动手: 在 PPQ/PyTorch 中打印 QDQ 插入前后的图结构
│
├── 子路径 1.3: 多输入 op 的 scale 对齐问题
│ ├── 知识点: Add/Concat 的两个输入必须量化到相同的 scale
│ ├── 知识点: 否则需要插入 Rescale 节点(额外开销)
│ ├── 知识点: 残差连接是 Add 量化的最大挑战
│ └── 动手: MobileNetV2 的残差块——分析 shortcut 和主分支 scale 对齐
│
└── 子路径 1.4: 第一层和最后一层的特殊处理
├── 知识点: 第一层 Conv 处理原始像素/embedding → 量化噪声最敏感
├── 知识点: 最后一层输出 softmax logits → 精度直接影响预测
├── 知识点: 常见策略——首尾层保持 FP32,中间层 INT8
└── 动手: 对比"全量化"vs"首尾 FP32"在 MobileNet 上的精度差异
Week 2: ONNX QDQ —— 量化图的通用语言
├── 子路径 2.1: QDQ 节点语义
│ ├── 知识点: QuantizeLinear(FP32→INT8) / DequantizeLinear(INT8→FP32)
│ ├── 知识点: QDQ 的 scale/zero_point 存在 node attributes 中
│ ├── 知识点: opset 13 引入 QDQ, opset 17 稳定
│ └── 动手: 手写一个带 QDQ 节点的 ONNX graph(用 onnx.helper)
│
├── 子路径 2.2: 从 PyTorch QAT 模型导出 QDQ
│ ├── 知识点: torch.onnx.export(model_int8, ...) → 自动生成 QDQ
│ ├── 知识点: convert_fx 后的模型已经包含 QDQ 信息
│ ├── 坑: QAT 模型必须先 convert_fx 再 export,不能直接从 prepared 模型导出
│ └── 动手: 导出 ResNet18 QAT → ONNX QDQ → Netron 可视化
│
├── 子路径 2.3: ONNX 图优化 Pass
│ ├── 知识点: DQ+Conv+Q 融合 → INT8 Conv(省两次数据转换)
│ ├── 知识点: 冗余 QDQ 消除 → 相邻 Q→DQ(同 scale)→ Identity
│ ├── 知识点: DQ 上浮 → 将 DQ 推到图的最前端(减少中间量化)
│ ├── 动手: 写 onnx-simplifier pass 融合 DQ+Conv+Q
│ └── 动手: 对比优化前后的 QDQ 节点数量和推理延迟
│
└── 子路径 2.4: ONNX Runtime INT8 推理
├── 知识点: ORT 的 INT8 EP(Execution Provider)如何消费 QDQ
├── 动手: ORT INT8 vs FP32 延迟对比
└── 坑: ORT INT8 要求 per-channel 量化的 weight 满足特定形状对齐
Week 3: 后端部署 —— TensorRT / OpenVINO / QAIRT 三条路
├── 子路径 3.1: TensorRT INT8
│ ├── 知识点: QAT 模型→ONNX QDQ→TRT INT8 engine(直接使用 QDQ scale)
│ ├── 知识点: PTQ 模型→ONNX FP32→TRT calibrator(TRT 自行校准)
│ ├── 知识点: INT8 Tensor Core vs FP16 Tensor Core 的吞吐差异
│ ├── 动手: 构建 YOLOv8 QAT INT8 engine → 测 batch 1/4/8 延迟
│ └── 坑: 某些层 TRT 可能 fallback 到 FP16(看 builder log)
│
├── 子路径 3.2: OpenVINO INT8 (Intel CPU/GPU)
│ ├── 知识点: OpenVINO 的 POT (Post-training Optimization Tool)
│ ├── 知识点: AccuracyAwareQuantization——自动搜索最优混合精度
│ └── 动手: 用 NNCF 对 MobileNet 做 QAT → OpenVINO IR → CPU 推理
│
├── 子路径 3.3: 高通 QAIRT → DLC (骁龙芯片)
│ ├── 知识点: AIMET QuantSim → Export ONNX + .encodings
│ ├── 知识点: QAIRT Converter → DLC(Deep Learning Container)
│ ├── 知识点: 骁龙 NPU 的量化约束(per-tensor 优先、不支持某些 op)
│ └── 动手: ResNet18 QAT → AIMET Export → QAIRT → DLC → 骁龙模拟器推理
│
└── 子路径 3.4: llama.cpp GGUF (LLM CPU 推理)
├── 知识点: GGUF 格式:Q4_K_M / Q5_K_M / Q8_0 等量化类型
├── 知识点: K-quant 的含义——关键层(attention)用更高比特
├── 知识点: imatrix 校准——用校准数据找到各层的最佳量化参数
└── 动手: LLaMA-7B → GGUF Q4_K_M → CPU 推理,测 tok/s
Week 4: 端到端实战 + 性能 Tuning
├── 子路径 4.1: YOLOv8 全链路
│ ├── PyTorch QAT → ONNX QDQ → TRT INT8
│ ├── 画 PyTorch→ONNX→Engine 的完整数据流图
│ ├── FP16 vs INT8 延迟 + mAP 对比
│ └── 分析:哪些层的 QDQ 被 TRT 融合了,哪些没被融合
│
├── 子路径 4.2: 量化精度 Debug 工具链
│ ├── PyTorch Numeric Suite: compare_weights / compare_outputs
│ ├── ONNX Runtime Profiling: 逐层延迟 + 输出统计
│ ├── TRT Inspector: 检查 engine 中各层的精度和格式
│ └── 动手: 用 Numeric Suite 对比 QAT 前后的每层输出分布
│
└── 子路径 4.3: 混合精度部署策略
├── 知识点: 哪些层需要退回到 FP16(精度敏感层)
├── 动手: 用逐层误差分析输出前 5 敏感层 → 设为 FP16 → 重测精度
└── 总结: "全 INT8"vs"混合精度"的精度/速度 Pareto frontier
阶段 9:前沿拓展 — FP8 / KV Cache 量化 / BitNet / MoE 量化
⏱ 预计时间:持续关注 | 🎯 难度:⭐⭐⭐⭐⭐
你已经在主路上走得很深了。这一阶段不是"学完",而是"持续追踪"——知道每个前沿方向的核心问题、代表性工作和与你的知识体系的关系。建议每月读 1-2 篇前沿论文保持手感。
9.1 七个方向的"一句话 + 论文 + 你的切入点"
方向
一句话
关键论文
和已学知识的连接点
FP8 量化
E4M3/E5M2 格式,H100 硬件支持
FP8 Formats (2022)
Stage 0 浮点位布局 → FP8 的 1+4+3 位
KV Cache 量化
128K context 的 KV Cache 可能比模型本身还大
KVQuant (2024)
Stage 6 SmoothQuant → 类似思想用于 KV
BitNet b1.58
权重三值化(-1,0,+1),用加法替代乘法
BitNet b1.58 (2024)
Stage 2 LSQ → 极致版量化
MoE 量化
Expert 路由不均衡 → 热门 expert 对量化更敏感
MC-MoE (2024)
Stage 6 Outlier → MoE 路由也是 heavy-tailed
Diffusion 量化
100 步迭代去噪 → 单步误差累积放大
Q-Diffusion (ICCV 2023)
Stage 3 AdaRound → 可适配多步特性
MX 格式
OCP 标准块浮点——一组值共享 exponent
MX Spec v1.0 (2023)
Stage 0 per-group → MX 是硬件标准化版
FP4 训练
Blackwell 原生 FP4 Tensor Core
Blackwell (2024)
Stage 2 LSQ → FP4 训练需更强梯度保护
每月 1-2 篇 → 读后写 200 字总结 → 标注"和 Stage X 的连接"
每季度深入一个方向,不要同时追 7 个
#
方向
核心问题
你应该知道什么
1
FP8 量化
E4M3 / E5M2 格式,比 INT8 更好的动态范围
FP8 的位布局,与 INT8 的 trade-off
2
KV Cache 量化
长上下文推理的最大瓶颈
KV Cache 为什么对长序列特别重要
3
BitNet b1.58
三值化(-1, 0, +1),没有浮点乘法
1.58-bit 的含义
4
MoE 量化
Expert 路由不平衡导致部分 expert 被过度量化
MoE 架构的独特量化挑战
5
Diffusion Model 量化
迭代去噪过程中的时序误差累积
与 LLM 量化的根本不同
6
Microscaling (MX)
OCP 标准的块浮点格式
MXFP4 / MXFP6 的格式定义
7
FP4 训练 (NVIDIA Blackwell)
原生 FP4 Tensor Core
NVFP4 格式和应用场景
书名
适合阶段
备注
《Efficient Deep Learning》 (2023, Menghani & Singh)
全阶段
量化、剪枝、蒸馏、NAS 全覆盖
《Deep Learning for Coders with Fastai and PyTorch》
阶段 0-2
PyTorch 实战基础
《TinyML》 (Warden & Situnayake)
阶段 8
端侧推理入门
课程
链接
相关度
MIT 6.S191: TinyML & Efficient Deep Learning
YouTube
⭐⭐⭐⭐⭐
MIT 6.5940: EfficientML
课程网站
⭐⭐⭐⭐⭐
Fast.ai Practical Deep Learning
fast.ai
⭐⭐⭐
阶段 0 (1-2周) 阶段 1 (2-3周) 阶段 1.5 (2-3周) 阶段 2 (2-3周)
┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────────┐
│量化数学基础│ ──→ │PyTorch QAT│ ──→ │QAT 训练深剖 │ ──→ │LSQ 核心算法 │
│+ 硬件原理 │ │三种模式 │ │手写→消融→崩溃 │ │+ 从零实现 │
└─────────┘ └──────────┘ └──────────────┘ └─────────────┘
│
┌───────────────────────────┘
↓
阶段 3 (3-4周) 阶段 4 (2-3周) 阶段 5 (3-4周) 阶段 6 (3-4周)
┌───────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐
│AdaRound/GPTQ │ │YOLO QAT │ │PPQ + AIMET │ │LLM PTQ 全家桶│
│数学内核 │ │端到端实战 │ │工业级框架 │ │GPTQ/Smooth/ │
└───────────────┘ └──────────┘ └────────────┘ │AWQ/SpinQuant │
│ │ │ └──────────────┘
└──────────────────┴───────────────────┘ │
│ │
↓ ↓
阶段 7 (3-4周) 阶段 8 (3-4周) 阶段 9 (追踪)
┌──────────────┐ ┌────────────┐ ┌──────────┐
│LLM QAT │ ──→ │端侧部署 │ │前沿拓展 │
│QLoRA/Efficient│ │ONNX→TRT→DLC│ │FP8/KV/...│
└──────────────┘ └────────────┘ └──────────┘
📅 预计总时间 :4-6 个月(按每周投入 15-20 小时计算)
🎯 学习后能达到的水平 :
能独立完成从模型训练到端侧部署的全链路量化优化
能读懂最新量化论文并复现核心算法
能参与或主导团队内的量化方案选型与落地
祝学习顺利!🚀