mini-infer系统实战-19-量化推理:把 Linear 改成 INT8 之后,问题才刚开始
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 做一下”,而是把下面三件事闭环:
- 权重显存是否真的下降
- 长生成正确性是否真的还能接受
- 如果 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_tokens、lm_head、attention 的q/k/v/o_proj为 fp16
量化核心层 QuantLinear 的 contract 是写死在代码里的:
1 | _contract = { |
接入点也很克制,只放在 ModelRunner 初始化里:
1 | if config.quant_mode == "w8a8": |
也就是说,默认 fp16 路径完全不动;只有显式打开 quant_mode="w8a8" 才替换 nn.Linear。
从实现角度看,这套方案没有什么特别花哨的地方。真正麻烦的不是“怎么写量化层”,而是:
这套量化在真实 engine workload 上,到底是不是你以为的那个路径?
第一个坑:短生成看起来没问题,正式 benchmark 直接失败
早期小样本测试一度给了我一个错觉:这版 W8A8 似乎已经差不多了。
但等我把 benchmark 口径收紧到正式标准之后,问题马上暴露出来了。
修复前的正式 benchmark 来自 2026-03-23,workload 是:
- 固定 12 条 prompt
- greedy
max_new_tokens=50batch_size in {2, 4, 8}warmup=2bench=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路径,而是小Mfallback 路径。
这也是为什么 benchmark 里后来必须加一条 Quant compute note:
1 | weight_storage=int8_per_channel; |
如果没有这条 note,很容易把“int8 权重存储 + mixed fallback”的结果误写成“pure W8A8 compute”,这在 infra 工作里是不能接受的。
修复策略:先保住正确性,再诚实面对性能
既然正式 decode 全在 fallback,那就不要再假装 fallback 只是一个无关紧要的边缘路径。
修复前的 fallback 仍然太激进,本质上还是沿着“量化激活 × 量化权重”的思路在做近似,这在长生成里会不断累积误差。
所以我做了一个很保守但很实用的修改:
1 | if not self._should_use_int_mm(x.device.type, M): |
这个改动的含义是:
- 大 M CUDA 路径:仍然保留真正的 W8A8
_int_mm - 小 M / CPU 路径:不再量化激活,直接用
float activation x dequantized weight - 权重存储:仍然是 int8,所以显存收益不变
换句话说,当前版本的真实 contract 是:
int8 权重存储 + 大
MW8A8 + 小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 更快”,而在于:
- 显存收益是真实的
- 长生成正确性已经过线
- 性能为什么不行也已经解释清楚
这三个点合在一起,才让这个阶段真正成立。
为什么这一轮更像真实的 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,不等于量化这条线已经结束。
当前仍然有几件事没解决:
-
没有量化性能收益
当前 decode 全在小Mfallback,吞吐反而更差。 -
sequence exact只有 50.0%
虽然这不属于本阶段硬 gate,但它说明当前 mixed fallback 还不是最终生产级方案。 -
TTFT和peak memory还是N/A
当前 benchmark 脚本没有这两个指标的正式口径。 -
未覆盖更多组合路径
quant + CUDA Graph、quant + prefix cache、quant + PD还没有系统 benchmark。 -
未复核 0.5B / 7B
当前正式数据只覆盖Qwen2.5-1.5B-Instruct。
所以如果下一步还要继续追量化性能,路线应该是:
- FP8
- Triton INT8 GEMM
- fused dequant
- 更适合小
Mdecode 的 kernel 路径
而不是继续把 mixed fallback 当成“性能量化”的最终答案。
总结
Phase 16 最终交付的,不是一个漂亮的“INT8 提速故事”,而是一版更可信的量化闭环:
- 权重显存下降
32.4% - 长生成 token match 稳定到
71.8% - decode 没提速的根因被明确定位为
100% fallback - benchmark 口径从“可能误导自己”变成了“足够解释系统行为”
如果让我用一句话概括这一阶段,我会这么说:
量化推理最难的部分,不是把
Linear改成 INT8,而是当结果不符合预期时,你能不能用真实数据说清楚:到底是数值错了,还是 kernel 没命中,还是 benchmark 口径本身有问题。
这也是我觉得 Phase 16 真正有价值的地方。

