mini-infer系统实战-19-量化推理:把 Linear 改成 INT8 之后,问题才刚开始

这一篇不是“我把 nn.Linear 换成 INT8,所以推理变快了”的成功故事。相反,它记录的是一个更接近真实 AI Infra 工作的过程:先做出一版看起来正确的 W8A8,再用正式 benchmark 证明它在真实 decode workload 上其实失败了,然后把问题修到“显存下降、正确性过线、性能根因说清楚”。

背景:为什么在 Phase 16 才做量化

到了 Phase 15,mini-infer 已经把推理系统主线上几类更结构性的东西都补齐了:

  • Paged KV Cache
  • Continuous Batching
  • True PagedAttention
  • Chunked Prefill / Prefix Caching
  • Speculative Decoding
  • CUDA Graph
  • Tensor Parallelism
  • MLA
  • PD 解耦

这时候再回头看单卡 decode 主路径,剩下一个很现实的问题:权重显存和权重带宽仍然很贵。如果项目要继续向生产推理系统靠拢,量化是绕不过去的。

但量化这件事最好不要做得太早。

如果在 Phase 13-15 之前就引入量化,你很难说清楚一次性能变化到底来自:

  • TP / MLA / PD 这种结构变化
  • 还是量化误差
  • 还是量化 kernel 路径本身

所以我把量化放到了 Phase 16。它的目标不是“顺手把 INT8 做一下”,而是把下面三件事闭环:

  1. 权重显存是否真的下降
  2. 长生成正确性是否真的还能接受
  3. 如果 decode 没提速,瓶颈到底在哪里

这个顺序很重要。对推理 infra 来说,先把证据链跑通,再谈优化,比一开始就追漂亮吞吐数字更接近真实工作。

问题定义:Phase 16 到底要解决什么

Phase 16 最初的计划很简单:

  • 模型:Qwen2.5-1.5B-Instruct
  • 路径:W8A8
  • 对照:同一套 mini-infer runtime 的 fp16 基线
  • 验收:
    • 权重显存下降 >= 30%
    • 固定 12 条 prompt、max_new_tokens=50 下 greedy token match >= 70%
    • benchmark 同时输出 prefill / decode / e2e
    • 如果 decode 没提速,必须解释 _int_mm 和 fallback 到底发生了什么

注意这里的对照不是 HuggingFace baseline。

这不是因为不重要,而是因为本阶段的问题不是“mini-infer 和 HF 谁快”,而是:

在同一个 runtime 中,量化到底带来了什么变化?

如果这件事都没说清楚,直接拿 HF 做对比会把问题搅浑。

第一版方案:看起来像标准 W8A8

第一版实现的核心思路是:

  • 权重量化:按输出通道做 int8_per_channel
  • 激活量化:按输入行做 per_row
  • 大 M CUDA 路径:走 torch._int_mm
  • 小 M / CPU 路径:走 fallback
  • 量化范围:优先量化 MLP 主线,保留 embed_tokenslm_head、attention 的 q/k/v/o_proj 为 fp16

量化核心层 QuantLinear 的 contract 是写死在代码里的:

1
2
3
4
5
6
7
8
9
10
_contract = {
"weight_storage": "int8_per_channel",
"int_mm_activation_granularity": "per_row",
"fallback_activation_granularity": "fp32",
"int_mm_min_rows": 17,
"fallback_compute": "float_activation_x_dequant_weight",
"skip_suffixes": ("lm_head", "embed_tokens", "q_proj", "k_proj", "v_proj", "o_proj"),
"min_param_size": 4096,
"in_feat_align": 8,
}

接入点也很克制,只放在 ModelRunner 初始化里:

1
2
3
if config.quant_mode == "w8a8":
from .quantization import quantize_model
quantize_model(self.model)

也就是说,默认 fp16 路径完全不动;只有显式打开 quant_mode="w8a8" 才替换 nn.Linear

从实现角度看,这套方案没有什么特别花哨的地方。真正麻烦的不是“怎么写量化层”,而是:

这套量化在真实 engine workload 上,到底是不是你以为的那个路径?

第一个坑:短生成看起来没问题,正式 benchmark 直接失败

早期小样本测试一度给了我一个错觉:这版 W8A8 似乎已经差不多了。

但等我把 benchmark 口径收紧到正式标准之后,问题马上暴露出来了。
修复前的正式 benchmark 来自 2026-03-23,workload 是:

  • 固定 12 条 prompt
  • greedy
  • max_new_tokens=50
  • batch_size in {2, 4, 8}
  • warmup=2
  • bench=3

结果如下:

Batch fp16 decode TPS W8A8 decode TPS Token match Seq exact Decode fallback rows
2 278.3 149.6 26.3% 8.3% 100.0%
4 559.2 271.8 33.2% 0.0% 100.0%
8 819.2 407.6 43.3% 8.3% 100.0%

这组结果有两个关键结论:

1. 正确性不过线

Phase 16 的硬 gate 是 token match >= 70%
结果只有 26.3% ~ 43.3%,这不是“有点误差”,而是长生成已经崩掉了

2. decode 也没有提速

更糟的是,decode throughput 只有 fp16 的 0.49x ~ 0.54x
而且三档 batch 的 decode 全都是:

1
fallback_rows_ratio = 100.0%

也就是说,正式 workload 根本没有命中 _int_mm 快路径。

这时候如果还把阶段写成“W8A8 已完成”,就是在自欺欺人。

第二个坑:你以为自己在测 W8A8,其实测到的是 fallback

这件事是 Phase 16 最重要的教训。

在纸面上,我们当然可以把它叫做 W8A8
但从真实 engine workload 看,decode 活跃 batch 的 M 一直很小,全部落在 M <= 16 的区间里,而 _int_mm 的阈值是 M >= 17

所以正式 decode 实际走的是 fallback,不是 _int_mm

为了把这件事说清楚,我专门加了 linear sweep:

M(rows) fp16 latency W8A8 latency W8A8 quant path
1 0.0316 ms 0.1831 ms fallback 100%
2 0.0100 ms 0.1922 ms fallback 100%
4 0.0105 ms 0.2030 ms fallback 100%
8 0.0104 ms 0.2040 ms fallback 100%
16 0.0116 ms 0.2074 ms fallback 100%
32 0.0154 ms 0.0682 ms _int_mm 100%
64 0.0217 ms 0.0708 ms _int_mm 100%

这张表直接把问题钉死了:

  • decode workload 对应的是 M <= 16
  • M <= 16 全部 fallback
  • 当前 fallback 比 fp16 慢得多

所以“为什么量化没有提速”不是一个抽象问题,而是一个非常具体的问题:

因为你真正跑的不是 _int_mm 路径,而是小 M fallback 路径。

这也是为什么 benchmark 里后来必须加一条 Quant compute note

1
2
3
4
5
6
weight_storage=int8_per_channel;
int_mm_activation=per_row (M>=17);
fallback_activation=fp32;
fallback_compute=float_activation_x_dequant_weight;
fallback dequantizes weight per forward;
current decode result is mixed fallback compute, not pure A8 matmul

如果没有这条 note,很容易把“int8 权重存储 + mixed fallback”的结果误写成“pure W8A8 compute”,这在 infra 工作里是不能接受的。

修复策略:先保住正确性,再诚实面对性能

既然正式 decode 全在 fallback,那就不要再假装 fallback 只是一个无关紧要的边缘路径。

修复前的 fallback 仍然太激进,本质上还是沿着“量化激活 × 量化权重”的思路在做近似,这在长生成里会不断累积误差。

所以我做了一个很保守但很实用的修改:

1
2
3
4
5
6
7
if not self._should_use_int_mm(x.device.type, M):
out = x_fp @ self._dequantize_weight().float()
else:
x_int8, scale_a = self._quantize_activation_per_row(x_fp)
out = torch._int_mm(x_int8.contiguous(), self.weight_int8)
dequant_scale = scale_a.float() * self.scale_w.float()
out = out.float() * dequant_scale.float()

这个改动的含义是:

  • 大 M CUDA 路径:仍然保留真正的 W8A8 _int_mm
  • 小 M / CPU 路径:不再量化激活,直接用 float activation x dequantized weight
  • 权重存储:仍然是 int8,所以显存收益不变

换句话说,当前版本的真实 contract 是:

int8 权重存储 + 大 M W8A8 + 小 M 高保真 mixed fallback

这当然不是“最激进”的量化方案,但它更符合这个阶段的真实目标:

  • 先把显存收益保住
  • 先把长生成正确性修回来
  • 再把 benchmark 口径讲清楚

这比继续硬追一个在当前 workload 根本用不上的 _int_mm 性能数字,要靠谱得多。

正式结果:硬 gate 过了,但它不是性能版 W8A8

修复后的正式 benchmark 在 2026-03-24 重跑,结果如下:

Batch fp16 weight MB W8A8 weight MB fp16 decode TPS W8A8 decode TPS fp16 e2e TPS W8A8 e2e TPS Token match Seq exact Decode fallback rows
2 3392.4 2292.0 279.7 98.6 273.0 96.5 71.8% 50.0% 100.0%
4 3392.4 2292.0 559.7 189.7 523.5 179.0 71.8% 50.0% 100.0%
8 3392.4 2292.0 803.2 284.2 723.9 258.5 71.8% 50.0% 100.0%

如果只看 Phase 16 的硬验收,这一版已经够了:

  • 权重显存下降 32.4%通过
  • token match 71.8%通过
  • prefill / decode / e2e + _int_mm / fallback 统计:通过
  • decode 未提速的根因说明:通过

但如果从工程诚实度看,还必须补上另一句话:

当前实现不是性能版 W8A8,而是“正确性优先”的 mixed fallback 版。

因为它的 decode 吞吐只有 fp16 的:

  • 0.352x(bs=2)
  • 0.339x(bs=4)
  • 0.354x(bs=8)

也就是说,这一版的价值不在于“INT8 更快”,而在于:

  1. 显存收益是真实的
  2. 长生成正确性已经过线
  3. 性能为什么不行也已经解释清楚

这三个点合在一起,才让这个阶段真正成立。

为什么这一轮更像真实的 AI Infra 工作

如果把 Phase 16 写成“实现 W8A8,显存下降 32.4%,完”,这篇文章会很像模板化技术博客,但它会错过真正重要的部分。

对推理 infra 来说,更有价值的往往是下面这些判断:

1. 短生成正确,不代表正式 workload 正确

max_new_tokens=8 看起来没问题,不代表 max_new_tokens=50 还没问题。
量化误差在 decode 里是逐步累积的,不做长生成 gate,很容易得到假的乐观结论。

2. “用了 INT8”不等于“真正走了 INT8 快路径”

只看实现代码,不看运行时统计,你会以为自己在测 _int_mm
加上 quant stats 和 linear sweep 后才发现,正式 decode 其实全程都在 fallback。

3. benchmark 不是跑个 tok/s 就完了

如果没有:

  • 固定 12 条 prompt
  • token match / seq exact
  • prefill / decode / e2e 分段
  • _int_mm / fallback 统计
  • Quant compute note

你很难说服别人这套量化到底发生了什么。

4. 允许“收缩目标”,但不能模糊口径

Phase 16 最终从理想中的“W8A8 主线提速”收缩成了“显存 + 正确性 + benchmark 解释闭环”。
这不是失败,而是更真实的阶段收口方式。

真正不行的是另一种做法:

  • 明明 100% fallback
  • 明明没有性能收益
  • 还把它写成“INT8 推理加速完成”

那才是对项目和读者都不负责。

这一阶段还没有解决什么

Phase 16 过了硬 gate,不等于量化这条线已经结束。

当前仍然有几件事没解决:

  1. 没有量化性能收益
    当前 decode 全在小 M fallback,吞吐反而更差。

  2. sequence exact 只有 50.0%
    虽然这不属于本阶段硬 gate,但它说明当前 mixed fallback 还不是最终生产级方案。

  3. TTFTpeak memory 还是 N/A
    当前 benchmark 脚本没有这两个指标的正式口径。

  4. 未覆盖更多组合路径
    quant + CUDA Graphquant + prefix cachequant + PD 还没有系统 benchmark。

  5. 未复核 0.5B / 7B
    当前正式数据只覆盖 Qwen2.5-1.5B-Instruct

所以如果下一步还要继续追量化性能,路线应该是:

  • FP8
  • Triton INT8 GEMM
  • fused dequant
  • 更适合小 M decode 的 kernel 路径

而不是继续把 mixed fallback 当成“性能量化”的最终答案。

总结

Phase 16 最终交付的,不是一个漂亮的“INT8 提速故事”,而是一版更可信的量化闭环:

  • 权重显存下降 32.4%
  • 长生成 token match 稳定到 71.8%
  • decode 没提速的根因被明确定位为 100% fallback
  • benchmark 口径从“可能误导自己”变成了“足够解释系统行为”

如果让我用一句话概括这一阶段,我会这么说:

量化推理最难的部分,不是把 Linear 改成 INT8,而是当结果不符合预期时,你能不能用真实数据说清楚:到底是数值错了,还是 kernel 没命中,还是 benchmark 口径本身有问题。

这也是我觉得 Phase 16 真正有价值的地方。


系列导航