跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 27 章编译期、后端与芯片33 分钟14 习题8 自测题

低精度、量化与数值正确性

低精度的数值基础与工程约束:fp16/bf16/fp8/MXFP4/int4 的表示与误差、block scale 的打包与存储布局,以及如何选参考实现对拍、如何区分数值误差与逻辑错误。

本章进度状态 未读完习题 0 / 14自测 未作答

学习目标

读完本章,你应当能够:

  1. 说出 fp16 / bf16 / fp8(e4m3、e5m2)/ fp4 e2m1 的有效位、u = 2^-p、 最大有限值、最小规格化值,并解释「为什么 bf16 的精度约等于 int8」 「为什么 fp8 必须配 scale 才能用」。
  2. 讲清 MX / block-scaled 的三要素:块粒度、E8M0 scale 的语义与值域、 scale 的打包与索引方式;并说明 NVIDIA 与昇腾在这一步上的差异。
  3. 写出 int8 / int4 对称量化与反量化的公式,判断给定数据上误差的上界与量级, 并说明「粒度决定误差、位宽只是其中一个因素」。
  4. 在 TileLang 里找到低精度的表达方式:dtype 系统、T.int4 / fp8 的使用、 T.gemm_blockscaled 的参数语义,以及五个官方示例各自的结构。
  5. 为一次低精度对拍先定义容差再写代码:选对参照、选对指标, 并区分数值误差、逻辑错误、参照错误、累加器不足这四类「对不上」。
  6. 在给定形状与层的场景里判断该不该量化、量化到什么程度,并说清 量化对 attention 与 MoE 的影响来自哪里。

前置知识

  • 第 9 章:T.copy / T.gemm / T.clear 的语义,以及 T.alloc_shared / T.alloc_fragment 的区别。
  • 第 12 章第 3.7 节:容差模型——u = 2^-p、长度 K 的归约误差 u/sqrt(K)、 atol / rtol / max_mismatched_ratio 的用法。本章所有的容差推导都建在它上面。
  • 第 14 章第 5 节:fp8 / int4 / dequantize / block-scaled 四类 GEMM 的结构与 调用方式;第 14 章第 7 节的对齐方法论。本章补上它们的数值部分。
  • 第 10 章:shared memory 与 fragment 的区别;本章第 4 节会用到。
  • 第 6 章(可选):NV 与昇腾的存储层级对照;本章 4.2 节涉及 L1 / L0 / TMEM。

本章的验证边界(务必先读):写作环境是 macOS (Apple Silicon), 没有 NVIDIA GPU,也没有昇腾 NPU。因此:

  • 本章 code/01-number-formats/–code/06-tilelang-audit/ 六个示例全部在 CPU 上实际运行过(Python 3.11.7 + numpy 1.24.1),输出的每一个数字都可复算,验证级别是「CPU 逻辑验证」;
  • 凡涉及硬件行为的结论(scale 放在 TMEM、粒度是 32 还是 128、fp8 的 K 对齐), 来源都是锁定版本的源码或 corpus 里的厂商文档,本章引用 逐条给出 路径与核验方式;
  • 本章没有任何一条性能数字,也没有任何一条「在加速器上运行过」的声明。 凡是有硬件才能确认的事,本章只写「怎么验证」,不写结论。

1. 问题的由来

1.1 低精度不是「省一点」,而是三笔账同时改写

fp16 换成 fp8,表面上只改了一个 dtype,实际上三笔账同时变了:

账 fp16 → fp8 的变化 受益的算子类型
访存 权重/激活的字节数减半 memory-bound(decode 阶段的 GEMV、norm、elementwise)
算力 张量核的 fp8 峰值通常是 fp16 的 2 倍 compute-bound(prefill 的 GEMM、attention 的 QK/PV)
容量 同样显存能放下的权重翻倍 长上下文推理、大专家数的 MoE

所以「该不该量化」从来不是一个精度问题,而是一个先看账、再看精度的问题 (第 14 章第 1 节的性能账本在这里同样适用)。真正需要认真对待的是第三笔账的对价: 低精度会改变数值结果,而改变多少必须能提前算出来、事后能验证。

1.2 误差有三个来源,必须分开讨论

把「结果对不上」一律归为「低精度导致」,是最常见的误判。低精度算子的误差至少有 三个独立来源,量级可以差好几个数量级:

  1. 表示误差:数据存成低精度时被舍入。上界是 u = 2^-p (fp16 4.9e-4、bf16 3.9e-3、fp8 e4m3 6.3e-2、fp4 e2m1 2.5e-1)。
  2. 累加误差:乘积累加时的舍入。它取决于累加器类型与归约顺序,与输入精度无关。 本章 code/03-error-model/ 实验 C 里,同一份 fp16 输入换成 fp32 累加器,中位误差从 1.24e-4 掉到 1.24e-9。
  3. 算法误差:量化本身引入的信息损失。例如权重从 fp16 量化到 int4 之后, 无论 kernel 写得多准,输出都不可能等于量化前的结果——差别是设计决定, 不是 bug。

第 12 章讲了前两类在 fp16/bf16 下的模型;本章把它推广到 fp8/fp4/整数, 并加上第三类:怎么给「量化本身」定一个参照。

1.3 这一章的组织

沿着「数值 → 表达 → 硬件 → 验证 → 取舍」推进:

  • 第 2 节:把格式的范围与精度算出来(不是抄参数),并推出量化误差的上界;
  • 第 3 节:TileLang 怎么表达这些 dtype,五个官方示例各自的结构;
  • 第 4 节:块缩放的硬件约束——scale 放哪、怎么打包、粒度是多少;
  • 第 5 节:正确性验证方法论,重点是参照与指标的选择;
  • 第 6 节:性能与精度的权衡,落到「什么形状、什么层适合量化」;
  • 第 7 节:常见陷阱清单。

2. 数值基础

本节所有数字都由 code/01-number-formats/number_formats.py 枚举位模式算出; 读者可以在任意装了 numpy 的机器上复算。不要把这些数字当「参数表」抄下来—— 要记住的是它们怎么来的。

低精度格式阶梯浮点:有指数,值域随指数走定点:没有指数,范围靠 scale 撑缩放因子:只搬指数,不表示数据缩放 fp8/fp4(MX)缩放 fp8/fp4(MX)−1 位宽 ⇒ 精度约等于 bf16−1 位宽 ⇒ 精度约等于 fp8fp321/8/23 · u=6.0e-8 · ±3.4e38 · 83 个数量级fp161/5/10 · u=4.9e-4 · ±6.6e4 · 12.0 个数量级bf161/8/7 · u=3.9e-3 · ±3.4e38 · 78.6 个数量级fp8 e4m31/4/3 · u=6.3e-2 · ±448 · 5.4 个数量级fp8 e5m21/5/2 · u=1.3e-1 · ±57344 · 9.6 个数量级fp4 e2m11/2/1 · u=2.5e-1 · ±6 · 1.1 个数量级int8qmax=127 · u=1/(2·127)=3.9e-3 · 范围由 absmax 决定int4qmax=7 · u=1/(2·7)=7.1e-2 · 范围由 absmax 决定E8M08 位指数 + 0 位尾数 · 2^-127..2^127,没有 0
动态范围与相对精度是两个独立维度:bf16 的精度约等于 int8,范围约等于 fp32;fp8 与 int4 只是「相对精度同量级」,范围分配方式完全不同。
  • 动态范围与相对精度是两个独立维度;e4m3 的动态范围只有 5.4 个数量级,所以必须配一个 E8M0 scale。
  • fp8 的两种格式是一个显式取舍:e4m3 用范围换精度(前向、权重),e5m2 用精度换范围(反向、梯度)。
  • int4 没有指数,离群值会把整个 scale 拉大,所以它更需要细粒度 scale(per-group,甚至 per-channel + group 混合)。
MX 块缩放的生产与消费生产者(离线或单独的量化 kernel)消费者(T.tcgen05_gemm_blockscaled)取指数A/B tileSFAX: [MN, K] fp32/fp16按 gran_k 分块求每块 amaxscale = ceil_to_ue8m0(amax / 448)即 2^k,clamp 到 [1, 254]Xq = round_to_e4m3(X / scale)→ A/B tilesf_u8 = fp32 的 biased exponent 字节= log2(scale) + 127每 4 个字节拼 1 个 uint32w0 | w1<<8 | w2<<16 | w3<<24转置成 group-major 扁平数组SFA[g*M + m]TMA 每 sf_load_period 次迭代搬一个 wordsf_load_period = gran_k * 4 / block_Ktcgen05_sf_warp_transpose4x32 word 视图 → 32x4tcgen05_cp_warpx4每 128 word 块写入 4 个 TMEM 列MMA 由 SFA_ID / SFB_ID 选字节子列id = (k_start / gran_k) % 4C += (A * SFA) @ (B * SFB)
左边是量化(生产 scale),右边是 kernel(消费 scale);两边必须共享同一个粒度与同一个打包约定,否则结果直接错。
  • 三个必须写进接口的量:sf_granularity_k(示例 128,PTX 原生 32)、一个 word 覆盖的 K(4 × gran_k)、sf_load_period。
  • scale 必须是 2 的整数次幂且向上取整;这样 X / scale 一定落在 e4m3 的值域内。

2.1 浮点格式的表示与取舍:fp16 与 bf16

一个二进制浮点数由三部分构成:符号位 s、指数 e、尾数 m,值为 (-1)^s × (1.m)₂ × 2^(e - bias)。

有效位数 p 决定精度,指数位数决定范围。 16 位里两者此消彼长:

格式 符号/指数/尾数 有效位 p u = 2^-p max 有限值 min 规格化数 动态范围
fp16 1/5/10 11 4.883e-04 6.55e+04 6.104e-05 12.0 个数量级
bf16 1/8/7 8 3.906e-03 3.39e+38 1.175e-38 78.6 个数量级

这张表解释了 bf16 的全部设计动机:它把 fp32 的指数位原封不动搬过来,只砍尾数。 于是 fp32 → bf16 只需要截断低 16 位,溢出风险与 fp32 相同;代价是相对精度比 fp16 低 8 倍(2^-8 对 2^-11)。

工程上的两条直接推论:

  • 需要宽的动态范围时用 bf16:梯度的量级跨度远大于激活(有极小值也有离群值), fp16 更容易下溢到次正规区甚至 0;
  • 需要高精度时用 fp16:激活与前向权重通常已被归一化到量级接近 1 的范围, 这时 u 小 8 倍是实打实的收益。

2.2 fp8:e4m3 与 e5m2 是一个显式的取舍

fp8 只有 8 位,于是指数与尾数的争夺更激烈,标准给出了两个版本:

格式 符号/指数/尾数 有效位 p u = 2^-p max 有限值 min 规格化数 动态范围
e4m3 1/4/3 4 6.250e-02 448 0.01562 5.4 个数量级
e5m2 1/5/2 3 1.250e-01 5.734e+04 6.104e-05 9.6 个数量级

有几个 e4m3 特有的细节,写 kernel 时必须知道:

  1. e4m3 没有 inf,指数全 1 也是有限值——448 = 1.75 × 2^8 就是它的最大值。 这一点与 IEEE 惯例不同(本章引用 R7: “The e4m3 encoding does not support infinity”)。

  2. NaN 只有两个编码:0x7f 与 0xff。其余位模式都是有限数。

  3. 因为最大只有 448,e4m3 几乎总是配 scale 使用。锁定版本里的 MXFP8 量化就是这么做的:

    # _vendor/tilelang-v0.1.15/examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:556-559
    x_amax = x_view.abs().float().amax(dim=2).clamp_min(1e-4)
    sf = ceil_to_ue8m0(x_amax / 448.0)
    x_fp8 = (x_view * (1.0 / sf.unsqueeze(2))).to(torch.float8_e4m3fn)
    

    amax / 448 再向上取到 2 的幂,保证除以 scale 之后一定落在 e4m3 的值域内。

  4. e4m3 还有负零。TileLang 里为此专门有一个清理函数 fp8_remove_negative_zeros_(tilelang/utils/tensor.py:25-29): 在逐位比较的场景下,-0.0 与 +0.0 的位模式不同,会被判成不相等。

于是「e4m3 还是 e5m2」就有了清楚的判据:前向与权重用 e4m3(精度优先), 反向的梯度用 e5m2(范围优先)。determine_fp8_type() 的默认值也是 e4m3 (tilelang/language/fp8.py:9-27)。

2.3 MX / block-scaled:块粒度 + E8M0

给整个张量配一个 scale(per-tensor)太粗,给每个元素配一个(per-element)开销太大。 MX(Microscaling)格式的答案是折中:每 32 个元素共享一个 8 位指数 scale。

三个要素缺一不可:

(a)块粒度。 MX 的原生粒度是 32 个元素。这一点在两处独立文档里一致:

  • PTX 对 .kind::mxf8f6f4 的块缩放规定:每个 A 的行按 32 个 K 元素分块, scale 存在 Tensor Memory 里(Parallel Thread Execution ISA 第 32061–32130 行);
  • CATLASS 的常量 MX_SCALE_GROUP_NUM = 32,注释是 “Data count for one MX-scale factor per group” (catlass/include/catlass/catlass.hpp:48)。

(b)scale 的语义:E8M0。 8 位指数、0 位尾数,也就是只能是 2 的整数次幂:

  • 可表示值 2^-127 .. 2^127(255 个编码,0xff 留给 NaN);
  • 没有 0、没有负数——它是缩放因子,不是数据格式;
  • 相邻值之比恒为 2,所以「用 scale 缩放」不引入任何尾数误差,只搬运指数。

这条性质是 MX 能同时保住范围与精度的关键:x / 2^k 在二进制下只是指数减 k, 是精确操作。E8M0 的编码还有个实现上的便利——它正好就是 fp32 的 biased exponent 字节,所以源码里直接从位里抠:

# _vendor/tilelang-v0.1.15/examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:543-546
def ceil_to_ue8m0(x):
    bits = x.abs().float().view(torch.int32)
    exp = ((bits >> 23) & 0xFF) + (bits & 0x7FFFFF).ne(0).to(torch.int32)
    return (exp.clamp(1, 254) << 23).view(torch.float32)
  • 加 (bits & 0x7FFFFF).ne(0):尾数非 0 时指数加一,实现「向上取整到 2 的幂」;
  • clamp(1, 254):跳过 0(次正规区)与 255(NaN)。

(c)打包与索引。 4 个 scale 字节被打进 1 个 uint32,全局按 group-major 展平:

word = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
SFA[g * M + m]        # g 是「4 个字节一组」的组号,m 是行号

code/05-mx-scale-layout/mx_scale_layout.py 把这套打包/解包复刻了一遍,并证明: 字节序写反会让结果偏差 5.58(相对 L2),用行主序索引会偏差 2.47—— 都是「和真值同一个量级」的错误,属于逻辑错误而不是精度问题。

块粒度的实际取值可以比 32 粗。 锁定版本的 SM100 MXFP8 示例把粒度设成 128: 一个字节覆盖 128 个 K 元素,四个字节覆盖 512 个 K 元素,正好一个 uint32。 sf_load_period = sf_granularity_k * 4 // block_K = 128 * 4 / 128 = 4, 所以每 4 次 K 迭代搬一个 word (examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:1-47)。

对接口的含义:粒度是一个必须显式写进接口的量。NVIDIA 硬件原生 32、 TileLang 的 SM100 示例 128、昇腾 CATLASS 固定 32。 生产端与消费端取不同的粒度,结果会直接错(见 code/05-mx-scale-layout/ 第 3 行:偏差 2.77)。

2.4 int8 / int4 的量化与反量化

整数量化没有指数位,范围完全由 scale 撑起来。最常用的是对称量化(记 qmax 为整数格式的正向最大值,int8 取 127、int4 取 7):

s = absmax / qmax                        # scale
q = clip(round(x / s), -qmax-1, qmax)    # 量化(round 为就近取整)
x_hat = q * s                            # 反量化

误差是均匀分布的:量化步长等于 s,最大误差 s/2,均方根 s/√12。 相对到 absmax 就是 u_int = 1/(2·qmax),即

  • int8:1/(2·127) = 3.937e-3;
  • int4:1/(2·7) = 7.143e-2。

把这两个数与第 2.1、2.2 节的浮点表对起来看,会得到一个反直觉但很有用的结论 (code/02-quant-dequant/ 与 code/03-error-model/ 都实测到了):

对标 相对精度 结论
int8 (3.9e-3) ≈ bf16 (3.9e-3) 同量级 int8 存权重在精度上不比 bf16 差
int4 (7.1e-2) ≈ fp8 e4m3 (6.3e-2) 同量级 4bit 定点约等于 8bit 浮点

差别在范围的分配方式。int4 只有一个全局 absmax,一个离群值就会把整个 scale 拉大,让绝大多数元素只用到很粗的档位;fp8 有指数,大小值各用各的精度。 这就是「int4 更需要细粒度 scale」的根本原因。

粒度到底有多重要? code/02-quant-dequant/quant_dequant.py 在同一份 256×1024 数据上实测(相对 L2 是对全张量的均方口径):

方案 相对 L2 逐元素 max err SQNR
int8 per-tensor 4.951e-02 3.406e-01 26.1 dB
int8 per-channel 7.674e-03 3.393e-01 42.3 dB
int4 per-group-64 1.083e-01 6.049e+00 19.3 dB
MXFP8 (e4m3, blk 32) 2.685e-02 3.808e+00 31.4 dB
MXFP4 (e2m1, blk 32) 1.177e-01 1.586e+01 18.6 dB

注意第一行:per-tensor int8 的相对 L2 是 4.95e-2,比它的理论上界 3.9e-3 大一个 数量级。原因是这份数据 per-tensor 的 absmax 是 86.5,而 per-row absmax 的中位数 只有 3.44——差 25 倍。上界是相对 absmax 的,而 L2 是全体元素的均方。 换成 per-channel 之后 absmax 回到同一量级,L2 立刻降到 7.67e-3,落在上界附近。

一句话记住:量化的精度由「每多少个元素共享一个 scale」决定, 位宽只是其中一个因素。选粒度比选位宽更值得花时间。

2.5 累加精度与舍入误差的传播

第 12 章给出的模型是:以 S = Σ|a_i·b_i| 为基准,长度 K 的归约

  • 最坏情形相对误差约等于 u(各项误差同号、完全不抵消);
  • 均方根相对误差约为 u/√K(各项误差独立、随机抵消)。

本章把 u 换成每个格式的「等价 u」(浮点用 2^-p,整数用 1/(2·qmax)), 在 code/03-error-model/error_model.py 里做了三组实验。

实验 B:缩放律对浮点成立,对 fp4 失效。 实测中位数 / 预算:

格式 K=64 K=256 K=1024 K=4096
fp16 0.53 0.54 0.58 0.48
bf16 0.52 0.41 0.41 0.47
e4m3 0.47 0.45 0.47 0.44
int8 1.47 1.27 1.32 1.30

fp16/bf16/e4m3 稳定在 0.5 附近(与第 12 章的结论一致),int8 在 1.3 附近 (均匀量化比浮点更接近最坏情形)。但 e2m1(fp4)在同一实验里是 2.58: 4 位浮点的相对间距随指数变化太剧烈,u 这个「相对上界」对小数值根本不成立。 用 fp4 时不要套 fp16 的容差公式,必须在真实数据分布上实测。

实验 C:累加器是独立的一档误差。 输入完全相同(fp16),只换累加方式 (K=4096,20 次试验):

方案 相对误差中位数 相对误差最大值
fp16 顺序累加 1.238e-04 5.695e-04
fp32 累加 1.243e-09 3.431e-09
32 段 fp16 部分和 + fp32 汇总 2.063e-05 7.009e-05

第三行就是 split-K 的抽象:每段的舍入误差只与该段长度有关,跨段用 fp32 连接 不会再放大。这条结论可以直接搬到 kernel 上:K 很长时,宁可把 K 切开、 用 fp32 汇总,也不要让低精度累加器一路累到底。

累加器的选择在编译器里是有约束的,不是随手填的:

  • TCGEN5MMA(Blackwell)的 fp16/bf16 路径要求累加器必须是 fp32, 且 K 是 16 的倍数(src/op/tcgen5_meta.h:42-45);
  • 同一路径的 f8f6f4 家族要求 K 是 32 的倍数,累加器可以是 fp32 或 fp16 (src/op/tcgen5_meta.h:79-82)——后者能编译通过,但会明显变差, 这是本章第 7 节的陷阱之一;
  • WGMMA(Hopper)的 fp8 路径要求 A 不转置、B 转置,且 K 是 32 的倍数 (src/cuda/op/gemm.cc:49-50,62-63)。

3. TileLang 里的表达

3.1 dtype 系统:一个入口,三种写法

TileLang 的 dtype 不接受「自己发明的名字」。tilelang/dtypes.py 只有三行, 把语言面的定义整体再导出(_vendor/tilelang-v0.1.15/tilelang/dtypes.py:1-3):

from tilelang.language.dtypes import *
from tilelang.language.dtypes import dtype, AnyDType, get_tvm_dtype

真正的定义在 tilelang/language/dtypes.py。要点:

  • 一个 dtype 可以写成三种形态:字符串('float8_e4m3fn')、TileLang 的 dtype 对象(T.float8_e4m3fn)、框架 dtype(torch.float8_e4m3fn)。 统一入口是 get_tvm_dtype()(:349-353)。

  • 登记表决定「能不能用」。_STR_TO_TVM_DTYPE_CALL(:121-142)里列出的是 语言面认识的名字:int4 / int8 / int16 / int32 / int64、float16 / float32 / float64 / bfloat16、float8_e4m3 / float8_e4m3fn / float8_e4m3fnuz / float8_e5m2 / float8_e5m2fnuz / float8_e8m0fnu、 tfloat32。名字不在这张表里,T.dtype("...") 会直接抛 TypeError(:245-255)。

  • 低精度家族的向量形态是自动派生的:float8_e4m3x2 / x4 / x8 ...、 float4_e2m1fnx16 等(:449-560 的类声明区)。命名规则就是 <dtype>x<lanes>, 多个标量用 T.float8_e4m3x2(a, b) 打包成 Shuffle(:148-152)。

  • FP4 有两种存储,这一点很容易踩坑:

    • float4_e2m1fn:紧凑的 4 bit 打包(type_code == 17),对应 CUTLASS 的 float_e2m1_t,用于 mxf4 / mxf4nvf4 的 packed SMEM;
    • float4_e2m1_unpacked:8 bit 一个槽位(type_code == 131),只用于 tcgen05 的 f8f6f4 / mxf8f6f4 混合精度路径与 TMA 的 16U4_ALIGN16B。

    两者的判断函数是 is_float4_e2m1fn() / is_float4_e2m1_unpacked() / is_float4()(:268-308)。

  • 混合 dtype 的 GEMM 目前只允许 f8f6f4 家族: validate_gemm_ab_dtypes() 在 A、B dtype 不同时直接抛错, 除非两边都属于 is_f8f6f4_family()(:319-341)。

还有一条只在运行时才会暴露的约束:torch 自己不一定有这些 dtype。 __dtype_as_torch__(:187-238)在遇到 int4 / uint4 / float4_e2m1fn 时会 退化成 int8 / uint8 / float4_e2m1fn_x2 作为存储类型,并打一条 debug 日志; 而 float8_e8m0fnu 要求 torch >= 2.8.0,float4_e2m1fn_x2 同样要求 torch >= 2.8.0。也就是说:TileLang 认识这个 dtype,不代表你的 torch 认识。 遇到「编译过了但张量创建失败」,先查版本。

3.2 fp8 相关的辅助模块

tilelang/language/fp8.py 只有 49 行,但它是「同一份代码跑在不同后端上」的关键:

# _vendor/tilelang-v0.1.15/tilelang/language/fp8.py:9-27
def determine_fp8_type(fp8_format="e4m3", device=None) -> str:
    """Select the correct FP8 dtype string for the current platform.
    - CUDA defaults to FP8 E4M3FN / E5M2.
    - ROCm uses FNUZ except gfx950 (OCP), which prefers non-FNUZ when available.
    """

同样的 "e4m3" 请求在三个平台上会得到三个不同的字符串:

平台 determine_fp8_type("e4m3") 说明
CUDA T.float8_e4m3fn OCP 版本,就是本章第 2.2 节讲的那个
ROCm(除 gfx950) T.float8_e4m3fnuz FNUZ:没有负零、NaN 只有一个
ROCm(gfx950) T.float8_e4m3fn 新硬件回到 OCP

注意 dtype.as_torch()(language/dtypes.py:191-204)用的是同一套判据, 所以「TileLang 的 dtype 字符串」与「torch 的 dtype」在 fp8 上是平台相关的。 写跨后端代码时不要硬编码 torch.float8_e4m3fn,用 determine_torch_fp8_type()。

3.3 五个量化 GEMM 示例各自在讲什么

第 14 章第 5 节讲了它们的结构,这里补上每个示例里与数值相关的细节。

(a)examples/gemm_fp8/example_tilelang_gemm_fp8.py:只换 dtype。

# :16-35,节选
def matmul(A, B, block_M, block_N, block_K, dtype, accum_dtype=T.float32):
    A: T.Tensor((M, K), dtype)
    B: T.Tensor((N, K), dtype)
    ...
    C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
  • 累加器显式写成 T.float32——这是 fp8 的正确用法;
  • 对拍用的不是逐元素比较,而是相似度(:7-11 的 calc_diff, 形如 1 - 2<x,y>/(|x|²+|y|²)),判据是 diff < 1e-3(:53)。 这是一个「量级判据」:它只关心两个结果的方向是否一致,天然允许逐元素的量化噪声。 第 5 节会讲这种指标的适用范围与风险。

(b)examples/gemm_int4/example_tilelang_gemm_int4.py:整数域是精确的。

# :19-36,节选
A: T.Tensor((M, K), T.int4)
B: T.Tensor((N, K), T.int4)
C: T.Tensor((M, N), T.int32)
...
T.gemm(A_shared, B_shared, C_local, transpose_B=True)
  • 累加器是 int32:int4 × int4 的乘积最大 8 × 8 = 64,K 累加不会溢出 int32, 所以整数域的乘法累加是精确的,没有累加误差;
  • 因此它的对拍判据是 rtol=0, atol=0(:101)——逐位相等。 这是本章里唯一可以用精确相等的地方,因为它根本没有舍入;
  • 打包在宿主侧完成(:59-67):(a & 0x0F) | ((b & 0x0F) << 4), 两个 int4 的低 4 位拼进一个字节。解码时靠算术右移做符号扩展还原。

(c)examples/dequantize_gemm/:把反量化融合在搬入时。

README 里的原型就是最小可运行版本:

# examples/dequantize_gemm/README.md
for i, j in T.Parallel(block_N, block_K):
    B_dequantize_local[i, j] = _tir_packed_to_unsigned_convert("int", 8)(
        num_bits, B_local[i, j // 2], j % 2, dtype=in_dtype,
    )
T.gemm(B_dequantize_local, A_shared, Ct_local, transpose_B=True)

几个值得单独看的变体:

  • example_dequant_gemm_w4a8.py:10-22 的 _tir_u8_to_i4_to_i8 用了一个漂亮的 位技巧做符号扩展:先把 4 位值左移 4 位,再把它按 int8 算术右移 4 位, 就把 4 位有符号数变成了 8 位有符号数——比任何分支判断都快。
  • example_dequant_gemm_fine_grained.py:43-44 按「一次搬运 128 位」反推每个线程 该处理多少元素:local_size = 128 // bits(in_dtype)。这是 dequantize GEMM 最关键的工程参数:解码的粒度要跟访存事务对齐,否则向量化会失效。
  • examples/dequantize_gemm/quantize/ 下是 host 侧的量化工具(mxfp.py、 nvfp4.py、quantization.py),其中 nvfp4.py:180-196 的 blockscaled_chunk_kmajor_word_offset() 专门处理 CUTLASS BlockScaledBasicChunk 的 K-major 排布——scale 布局不只有一种。

(d)examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:块缩放的样子。

# :37-52 与 :134-146,节选
SFA: [(K / sf_granularity_k) / 4) * M] in uint32     # group-major packed E8M0
SFB: [(K / sf_granularity_k) / 4) * N] in uint32
T.tcgen05_gemm_blockscaled(..., k_start=k * block_K,
                           sf_a_granularity_k=sf_granularity_k,
                           sf_b_granularity_k=sf_granularity_k)

这几行里包含了全部约定:粒度(sf_granularity_k)、打包(4 个字节一个 uint32)、 布局(/4) * M 这种下标表达式就是 group-major)。 这个示例还有 1CTA / 2CTA / 2CTA persistent 三个变体,其中 2CTA 变体直接断言 sf_granularity_k == 128(:195)——粒度不是随便填的,硬件与模板都会限制它。

(e)examples/bitnet-1.58b/:端到端的 1.58 bit。

这个目录不是单个 kernel,而是一个完整的模型复现(BitNet b1.58-3B)。它的量化规则 在 utils_quant.py 里,是「三值权重 + 8 位激活」的经典写法:

# examples/bitnet-1.58b/utils_quant.py:16-30
def weight_quant(weight, num_bits=1):
    s = 1 / weight.abs().mean().clamp(min=1e-5)
    result = (weight * s).round().clamp(-1, 1) / s        # 三值:-1 / 0 / +1

def activation_quant(x, num_bits=8):
    Qn, Qp = -(2 ** (num_bits - 1)), 2 ** (num_bits - 1) - 1
    s = Qp / x.abs().max(dim=-1, keepdim=True).values.clamp(min=1e-5)
    result = (x * s).round().clamp(Qn, Qp) / s            # per-token absmax

两个细节值得记住:

  • 权重的 scale 用「平均绝对值」而不是 absmax(s = 1/mean|w|)。这是因为三值 量化只有 3 个档位,用 absmax 会让绝大多数权重落到 0;
  • 激活的 scale 是 per-token(dim=-1),因为激活的离群值通常集中在少数通道, per-tensor 会被单个离群值拖死。

这条 1.58 bit 路线的性能数字在 资料库索引 里 (A100、Llama-3B 结构):fp16 基线 177.67 → int8×int2 实现 64.18 (batch=1、in_seq=1)。这是上游样例给出的数字,不是本机实测, 条件与复现脚本都在那个目录里,本章引用 已登记。

3.4 昇腾侧的表达

昇腾适配器仓库(_vendor/tilelang-ascend-v0.1.2.000/)给低精度路径补了两样东西:

(a)dtype 名字不同。 昇腾方言里 fp8 的字符串是 e4m3_float8 / e5m2_float8, 映射到 float8_e4m3_t / float8_e5m2_t(tilelang/language/ascend.py:26、 tilelang/utils/tensor.py:26-40)。写跨后端代码时不要硬编码名字。

(b)量化 GEMM 的写法。 examples/quant_batch_matmul/example_quant_batch_matmul.py 是最完整的例子,它的数据流是:

# :49-88,节选
A_L1 = T.alloc_L1([block_M, block_K], in_dtype)        # int8
B_L1 = T.alloc_L1([block_K, block_N], in_dtype)        # int8
C_L0 = T.alloc_L0C([block_M, block_N], accum_dtype)    # int32
for bk in T.serial(k_num):
    T.copy(A[bb, bm * block_M, bk * block_K], A_L1)
    T.copy(B[bb, bk * block_K, bn * block_N], B_L1)
    T.gemm_v0(A_L1, B_L1, C_L0, init=(bk == 0))
T.copy(C_L0, workspace_1[...])
T.copy(workspace_1[...], c_ub)                          # 搬到 UB 上做 epilogue
for bm_v, bn_v in T.Parallel(block_M_2, block_N):
    c_scale[bm_v, bn_v] *= scale_ub[bn_v]               # per-channel / per-tensor scale

注意这条路径的形状:int8 输入 → int32 在 L0C 精确累加 → 搬到 UB 乘 scale → cast 到 fp16/bf16 输出。这是「位置 C:累加之后反量化」的原型,也是最省的一种做法: 整数累加精确,scale 只乘一次。它的对拍判据是 rtol=1e-2, atol=1e-2(:116)。

(c)昇腾的 fp8 GEMM 有平台门槛。 examples/gemm/example_gemm_fp8_pto.py:10-16 写得很直白:FP8 的 TMATMUL 路径需要 A5(Ascend 950)的 Cube 核, A2/A3 没有这条路径,示例会直接打印 [SKIP] 跳过。


4. 实现机制:块缩放的硬件约束

NVIDIA 侧 scale 的 TMEM 布局1. SMEM:一个 word = 4 个 E8M0 字节b0..b3 各覆盖 gran_k 个 K;128 word 一个 chunk2. tcgen05_sf_warp_transpose把 4x32 的 word 视图改写成 32x43. tcgen05_cp_warpx4一个 128 word 的 chunk → 4 个 TMEM 列4. TMEM:SFA [128 lanes, 4 columns]每个 lane 一行,4 列 = 4 个 K32 子块5. SFB 复制到全部 32 个 lane 分区SFB 的 TMEM 列数是 SFA 的 2 倍(示例 4 列 vs 8 列)6. MMA 选子列SFA_ID = SFB_ID = (k_start / gran_k) % 4
scale 住在 TMEM 而不是 SMEM;TMEM 一个 32 位单元格正好放 4 个 8 位 E8M0,所以「每 4 个 K 块打包」是指令编码决定的,不是工程偏好。
  • 指令描述符里用 SFA_ID / SFB_ID 两个 2 位字段选子列:runtime_instr_desc | (sf_a_id << 29) | (sf_b_id << 4)。
  • 粒度:PTX 原生 32;SM100 示例加粗到 128(4 个字节覆盖 4×128 = 512 个 K);昇腾 CATLASS 固定 32,没有加粗自由度。
  • 加粗合法、细过 32 不行——scale_vec::1X 只有 4 个子列可用。
反量化可以融合在哪一级A 搬入时(prologue)B 主循环里(L1→L0)C 累加之后(epilogue)做法GM(int4) --T.copy--> SMEM --解码--> 寄存器 --T.gemm--> 累加器GM --MTE--> L1(int8 + e8m0 scale) --L1→L0--> L0A/L0B,搬运指令里按粒度作用 scaleL0C/寄存器里的 int32 或 fp32 累加结果 --乘 scale--> 输出代表examples/dequantize_gemm/*(_tir_packed_to_unsigned_convert、w4a8 符号扩展)CATLASS 的 BlockMmadTla<MmadMx<...>>;tilelang/ascend 粒度固定 32 的 T.gemm_blockscaledtilelang-ascend example_quant_batch_matmul:T.gemm_v0 累加到 int32,再 c_scale *= scale_ub优点反量化只做一次,之后整条 GEMM 路径是普通 fp16 GEMMCube 直接吃低精度操作数,L1 带宽省下来整数累加精确,scale 只乘一次,误差只来自权重量化缺点 / 适用条件SMEM 要按解压后的尺寸开(4 bit → 16 bit 是 4 倍)粒度被硬件模板固定,改粒度要改模板只适用于 scale 与 K 无关(per-tensor / per-channel);per-K-block 就得回到 B
同一个「W 是低精度、要乘 scale 才能用」的问题有三个融合位置:搬入时、主循环里、累加之后;代价与适用条件由 scale 的粒度倒推。
  • 选择判据:scale 与 K 无关 → C;权重 4 bit + group scale → A;硬件提供块缩放 MMA(SM100 tcgen05、昇腾 950 Cube)→ B。
  • 位置 C 的「整数累加精确」只在累加器是整数时成立:fp8 权重配 fp16 累加器时,scale 乘法仍精确,但累加本身已经有误差。

4.1 NVIDIA:scale 住在 TMEM 里

块缩放看起来只是「多乘一个数」,但在 Blackwell 上它牵动了整条数据通路。 PTX 对 tcgen05.mma 的块缩放规定(Parallel Thread Execution ISA 第 32061–32130 行):

(A * scale_A) * (B * scale_B) + D
where `scale_A` and `scale_B` are matrices residing in Tensor Memory.

四个由此推出的硬约束:

(1)scale 必须在 TMEM,不能在 SMEM。 所以示例里有一条专门的通路把 scale 从 SMEM 搬进 TMEM:

# examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:131-134
T.tcgen05_cp_warpx4(SFA_shared[stage, :], SFA_tmem)
T.tcgen05_cp_warpx4(SFB_shared[stage, :], SFB_tmem)

tcgen05_cp_warpx4 的语义在 tilelang/language/builtin.py:1440-1471 写得很清楚: 每个 128 word 的块映射到 4 个 TMEM 列,列偏移自动前进。

(2)为什么是「每 4 个 K 块打包」。 TMEM 的一个单元格是 32 位,E8M0 是 8 位, 所以一个单元格正好放 4 个 scale。指令描述符里用两个 2 位字段选子列:

# tilelang/cuda/intrinsics/macro/tcgen05_macro_generator.py:629-632
runtime_sf_a_id = ((_sf_k_start + ki * micro_size_k) // int(sf_a_granularity_k)) % 4
runtime_sf_b_id = ((_sf_k_start + ki * micro_size_k) // int(sf_b_granularity_k)) % 4
runtime_instr_desc = base_instr_desc | (runtime_sf_a_id << 29) | (runtime_sf_b_id << 4)

% 4 就是「在 word 的 4 个字节里选第几个」。这条公式把 sf_granularity_k 与 存放布局绑死了:粒度写错,选到的字节就错。

(3)SFB 要复制到 32 个 lane 分区,SFA 不一定。 PTX 的原文是 “Scale factor for B matrix needs to be duplicated to all 32 lane partitions of tensor memory. But for the A matrix, the duplication is not necessary if the scales are copied into Tensor Memory via tcgen05.st on sm_107f architecture.” 于是示例里的 TMEM 形状是不对称的:

SFA_tmem: [128 lanes, 4 columns]
SFB_tmem: [128 lanes, 8 columns]     # 两个 128 列的 N chunk

(examples/blockscaled_gemm_sm100/mxfp8_illustrated.md 的 Scale-Factor Layout 一节) 同一份文档还说明了 tcgen05_sf_warp_transpose 的作用:把 4 x 32 的 word 视图 改写成 32 x 4,以匹配 tcgen05.cp.32x128b.warpx4 的源模式。

(4)粒度可以比 32 粗,但不能比 32 细。 加粗是合法的——把同一个值写进 4 个连续的 子块即可;但细过 32 不行,因为 scale_vec::1X 只有 4 个子列可用。

4.2 昇腾:固定粒度 32,反量化发生在 L1 → L0

昇腾侧的 MX 路径与 NVIDIA 的结构完全不同,但有一处一致:粒度是 32。

(a)CATLASS 的模板。 常量定义在 catlass/include/catlass/catlass.hpp:48:

constexpr uint32_t MX_SCALE_GROUP_NUM = 32;  // Data count for one MX-scale factor per group

它在模板里的用法可以在 block_mmad_mx_tla.hpp 看清楚:

// include/catlass/gemm/block/block_mmad_mx_tla.hpp:101,107-109
static constexpr uint32_t L1_SCALE_FACTOR_K = DispatchPolicy::L1_SCALE_FACTOR_K;
static constexpr uint32_t L1SCALEA_TILE_SIZE =
    L1_TILE_M * L1_TILE_K / MX_SCALE_GROUP_NUM * sizeof(ElementMxScaleA) * L1_SCALE_FACTOR_K;

L1_SCALE_FACTOR_K 是「L1 上一次驻留多少个 K 条带的 scale」,而 MX_SCALE_GROUP_NUM(= 32)决定每个条带需要多少个 scale。也就是说: 想加粗粒度,只能改模板参数,不能像 NVIDIA 那样在 kernel 里用不同的 sf_granularity_k 绕过去。

(b)反量化发生在搬运指令里。 昇腾的 Cube 直接吃低精度操作数, scale 在 L1 → L0 的搬运阶段就作用到每个 K 块上。CATLASS 的 MXFP8 FlashAttention 文档把这条路径画得很清楚(mxfp8_flash_attention_infer.md):

Q(fp8_e4m3)  K(fp8_e4m3)   mxScaleQ(e8m0)  mxScaleK(e8m0)
      |            |              |              |
      v            v              v              v
   GM→L1        GM→L1      GM→L1 mxScaleA     GM→L1 mxScaleB
      |            |              |              |
      +------------+--------------+--------------+
                   v
        L1→L0A / L1→L0B(反量化在这里发生)
                   v
           Cube Mmad (mx_fp8_e4m3)

同一份文档还给了两个关键数字:block size = 32、scale 格式 e8m0。 这与 NVIDIA 侧的 PTX 规定一致,说明「MX 的粒度是 32」是跨厂商的约定, 而 TileLang 的 128 是具体 kernel 的实现选择。

(c)昇腾 950 上的 dtype。 两个现成的例子:

  • examples/59_ascend950_a8w4_mx_matmul/:A 是 float8_e4m3_t、B 是 float4_e2m1x2_t(E2M1 打包)、scale 是 float8_e8m0_t (a8w4_mx.cpp:57),公式写作 C = (MxScaleA x A) * (MxScaleB x B) + Bias;
  • examples/54_ascend950_fp4_mx_matmul/:A、B 都是 float4_e2m1x2_t, L1 分块 256×256×448、L0 256×256×128——README 特意提醒「勿随意增大 L1 的 K, 否则 L1TileShape exceeding the L1 space」。

(d)TileLang 主干里的昇腾块缩放接口。 tilelang/ascend/language/gemm_op.py 把粒度写死在 32:

# tilelang/ascend/language/gemm_op.py:121-156,节选
"""Ascend block-scaled MXFP8 GEMM, shadowing the common ``T.gemm_blockscaled``.
...
the SFA region slice, and MX scales cover 32 K elements per factor.
"""
annotations["sf_a_granularity_k"] = 32
annotations["sf_b_granularity_k"] = 32

对照 NVIDIA 侧的 T.gemm_blockscaled(..., sf_a_granularity_k=..., sf_b_granularity_k=...) (tilelang/language/gemm_op.py:275-320),可以看清楚这个抽象的分层:

接口 粒度 谁决定
T.gemm_blockscaled 调用方传入 前端(target-neutral),后端决定支持与否
T.tcgen05_gemm_blockscaled 调用方传入 必须落到 SM100 的 TCGEN5MMA,否则编译失败
昇腾方言的 T.gemm_blockscaled 固定 32 昇腾后端

还有一个设计细节值得注意:没有块缩放实现的后端不会静默降级。 src/op/gemm_blockscaled.cc:94-99 里的报错信息是:

Block-scaled GEMM is not supported by the <backend> backend (target=...);
a dense GEMM lowering would silently drop the SFA/SFB scale factors.

这是对的:块缩放不是一个可选的优化,丢掉 scale 会直接改变结果。 如果你在某个后端上看到这条错误,它是在阻止你写出一个静默出错的 kernel。

4.3 反量化融合在哪一级

把「W 是低精度、要乘 scale 才能用」这件事落到实处,只有三个位置可选(图 4):

位置 做法 代表 适用条件
A. 搬入时(prologue) 在 T.copy 之后用位运算解码,再送进普通 T.gemm examples/dequantize_gemm/* scale 与 K 相关(group 沿 K)或格式需要解包(int4/fp4)
B. 主循环里(L1→L0) 搬运指令按块粒度读 scale 并作用 CATLASS MmadMx、SM100 的 tcgen05.mma.block_scale 硬件提供块缩放 MMA
C. 累加之后(epilogue) 整数/浮点累加完后乘一次 scale 昇腾 example_quant_batch_matmul scale 与 K 无关(per-tensor / per-channel)

三者的取舍不是性能偏好,而是由 scale 的粒度倒推的:

  • 位置 C 之所以便宜,是因为整数累加精确、scale 只乘一次;但它只能处理与 K 无关的 scale。一旦 scale 是 per-K-block,累加完再乘就已经晚了—— 每个 K 块必须乘自己的 scale,误差结构完全不同。
  • 位置 B 是唯一能省掉「解压 + SMEM 往返」的方案,代价是粒度被硬件与模板固定。
  • 位置 A 是通用兜底:能处理任意粒度,但 SMEM 要按解压后的尺寸开 (4 bit 解到 16 bit 就是 4 倍空间)。

位置 C 有一个容易忽略的前提:只有当累加器是整数时,「整数累加精确」才成立。 如果权重是 fp8、累加器是 fp16,那么位置 C 的 scale 乘法仍然精确, 但累加本身已经有误差了——这正是第 2.5 节实验 C 测出来的那一档。


5. 正确性验证方法论

第 12 章讲了「先定容差再谈对错」。低精度算子在这个基础上多了两个问题: 参照怎么选、指标怎么选。

数值误差的下一层:分布、参照、累加器少数离群点(不匹配 < 1e-3)整体偏大没有(拿量化前的 fp32 当参照)有fp16 / 低精度累加fp32 / int32① 正常的数值误差误差分布:少数离群点还是整体偏大?通过:用 max_mismatched_ratio 允许离群元素参照自己也做了量化吗?④ 参照选错:换量化感知的参照,不是改 kernel累加器是什么类型?⑤ 累加器精度不足:换 fp32 累加器,或分段累加回到分块与 epilogue检查 split-K 的收尾、epilogue 的 cast 位置
确认是正常数值误差之后,还要回答三件事:误差分布是少数离群点还是整体偏大、参照自己有没有做量化、累加器是什么类型。
  • 参照 = 同一个量化规则 + 同一个粒度 + 同一个累加类型(或更高);「两边一致」不等于「两边都对」——两边犯同一个错也会通过。
  • 补充信号:数值误差随 K 按 1/sqrt(K) 缩小;如果误差随 K 增大而变大,几乎一定是累加器精度不足或归约顺序有问题。
把「对不上」分成四类:先分类,不要先调容差1e-3 ~ 1e-2 量级1e0 量级,几乎每个元素都错全部元素、偏移方向一致只在某个维度上错(某些行/列/专家)对拍失败:max_diff 超阈值误差相对结果的量级?1e-2 还是 1e0① 正常的数值误差对照 u·sqrt(K) 预算:fp16 1e-3 / bf16 1e-2 / fp8 1e-1所有元素都错,还是只有一部分?② 逻辑错误:全局量纲 / 位置错scale 漏乘、反量化位置错、打包字节序错③ 逻辑错误:索引错scale 用错轴、group 索引错、行主序 vs group-major
第一步是用「误差 / 结果的量级」而不是「误差 / 容差」来判断:1e0 量级是全局性逻辑错误,1e-3 ~ 1e-2 量级才可能是正常的数值误差。
  • 先算三个指标:相对 L2(整体是不是同一个量级)、逐元素 max(有没有少数大离群点)、不匹配比例(全局错还是局部错)。
  • 低精度相对上界(u):fp16 4.9e-4、bf16 3.9e-3、int8 3.9e-3、e4m3 6.3e-2、int4 7.1e-2、fp4 e2m1 2.5e-1。

5.1 三层参照:先决定「跟什么比」

低精度算子的对拍必须先回答一个问题:参照实现自己做不做量化? 三种选择对应 三种完全不同的判据(code/04-vs-reference/reference_matching.py 把三种都跑了一遍):

参照 相比对象 测的是什么 判据
绝对参照(量化感知的 fp64/fp32 实现) 实现了同一套量化规则的参考代码 kernel 是否写对 可以很紧(本例到 1e-16)
相对参照(另一个 kernel / 框架实现) 另一个做了同样量化的实现 两个实现是否一致 松容差 + 不匹配比例
量化前参照(直接把 fp32 结果当参照) 量化之前的数据 量化损失了多少信息 只能用于评估精度,不能当对拍判据

code/04-vs-reference/ 的实测(X 是 int8、W 是 int4 per-group-64,判据为 rtol=1e-2, atol=1e-3, max_mismatched_ratio=1e-3):

参照                                      相对 L2      不匹配比例    结论
A 量化感知参照(fp64)                        3.837e-16    0.000e+00    通过
B 反量化位置错(只乘最后一组 scale)             2.097e-01    9.666e-01    失败
C 参照漏乘 scale(逻辑错误)                   9.643e-01    9.999e-01    失败
D 用量化前的 fp32 权重当参照                   1.078e-01    9.397e-01    失败(但不是 bug)
E 被测与参照 C 犯同一个错                      9.906e-13    0.000e+00    通过(假阳性)
E' 同一个错误的实现 vs 正确的 A                 2.668e+01    9.999e-01    失败

两行最关键:

  • D 行失败,但不是 bug。 94% 的元素不匹配、相对 L2 是 1.08e-1—— 这就是 int4 权重量化应有的误差。把它当判据,你只能把 atol 放到毫无作用的程度。
  • E 行通过,但也不对。 两个实现犯了同一个错(都漏了 scale),对拍当然通过; 而 E' 行证明这个实现相对正确参照差了 26.7 倍。「两边一致」不等于「两边都对」。

结论:每个量化 kernel 都要有一个绝对参照,再拿与框架实现的对拍作第二道防线。

5.2 误差指标:三个一起看,缺一不可

锁定版本里的两个对拍工具给的是同一组参数(rtol / atol / max_mismatched_ratio):

工具 位置 默认值
torch_assert_close tilelang/utils/tensor.py:205-293 rtol=1e-2, atol=1e-3, max_mismatched_ratio=1e-3
Profiler.assert_allclose tilelang/profiler/__init__.py:104-115 atol=1e-2, rtol=1e-2, max_mismatched_ratio=1e-2

这些参数各负责一件事,用错了会漏掉整类问题:

指标 能发现 发现不了
rtol / atol(逐元素) 个别元素的巨大偏差、近零值的绝对偏差 全张量统一的小偏移(每个元素都没超差,但整体错)
max_mismatched_ratio 允许极少数离群点,避免被单个元素卡死 大面积的系统性错误(比例远超阈值)
相对 L2 整体是否「同一个量级」 少数离群点(被均方稀释)
逐元素 max err 有没有大的离群点 整体的小偏差

实践中按这个顺序看:

  1. 相对 L2:判断是「量级问题」还是「噪声问题」。接近 1 就是逻辑错误。
  2. 逐元素 max err:找出离群元素,看它们是否集中在某一行/列/专家上。
  3. 不匹配比例:看错的是「全部」还是「局部」。

code/04-vs-reference/ 的输出就是按这个顺序排的,一眼就能看出 B/C/E' 是逻辑错误 (相对 L2 分别是 0.21 / 0.96 / 26.7),D 是数值误差(相对 L2 0.11,但 max err 只有 1.04)。

5.3 哪些误差是「正常的」

把本章的模型与实测汇成一张基线表。判据是「相对 L2 是否落在预期量级」, 不是「是否等于 0」:

场景 预期相对 L2 量级 依据
fp16 输入 + fp32 累加 1e-5 ~ 1e-4 第 12 章实测中位数约为 0.5 × u/√K
bf16 输入 + fp32 累加 1e-4 ~ 1e-3 同上
fp8 e4m3 输入 + fp32 累加 1e-3 ~ 1e-2 本章 code/03-error-model/ 实验 A(K=64 时 3.7e-3)
int8 per-channel + int32 累加 1e-3 ~ 1e-2 本章 code/02-quant-dequant/(7.67e-3)
int4 per-group-64 + int32 累加 5e-2 ~ 2e-1 本章 code/02-quant-dequant/(1.08e-1)
MXFP8(e4m3,32 粒度块缩放) 1e-2 ~ 5e-2 本章 code/02-quant-dequant/(2.69e-2)
MXFP4(e2m1) 1e-1 ~ 3e-1 本章 code/02-quant-dequant/(1.18e-1);模型在此失效
纯整数路径(int8×int8 → int32) 0(逐位相等) examples/gemm_int4 的 rtol=0, atol=0

最后一行值得单独强调:整数域没有舍入,所以可以要求逐位相等。 如果你的 int 路径对不上,那就一定是逻辑错误,没有任何「容差」的余地。 这条判据比任何经验值都可靠。

5.4 区分数值误差与逻辑错误

图 5 给了完整的判定树。这里给出三个最快能用的判据:

判据一:看「误差 / 结果的量级」。 如果是 1e0 量级(每个元素都错), 那是逻辑错误;如果是 1e-2 量级且与格式的 u 相符,那是数值误差。

判据二:看误差是否与某个维度相关。 把误差 reshape 回 (M, N),分别沿两个方向 取最大绝对值:

  • 误差集中在某些行/列/专家 → 索引错误(scale 用错轴、group 索引错);
  • 误差与行列无关、分布均匀 → 数值误差。

判据三:换参照再算一遍。 如果换成量化感知的 fp64 参照后误差变成 1e-15, 那原来的「误差」全部来自量化本身;如果仍然很大,那是 kernel 的问题。

一个有用的补充信号:误差与 K 的关系。数值误差随 K 按 1/√K 缩小 (第 12 章与本章实验 B);如果误差随 K 增大而变大,那几乎一定是 累加器精度不足或归约顺序有问题,而不是输入精度的问题。


6. 性能与精度的权衡

6.1 什么形状适合量化

量化的收益来自「少搬字节、少算时间、少占显存」,所以要回到第 14 章的性能账本:

  • memory-bound 的形状(算术强度低于机器平衡点): 收益几乎与位宽成线性。decode 阶段 batch=1 的 GEMV、归一化、逐元素算子都在这里。 权重从 16 bit 降到 4 bit,访存量降到 1/4,时间通常也接近 1/4。 这是权重量化(W4A16、W8A16)最有价值的场景。
  • compute-bound 的形状(大 M、大 K 的 prefill GEMM): 收益来自张量核的峰值差异。fp8 的峰值通常是 fp16 的 2 倍, 但只有当 K 足够大、且精度损失可接受时才拿得到。 激活也一起量化(W8A8、W4A8)才吃到这部分收益。
  • 容量受限的形状:MoE 的专家数、长上下文的 KV cache。 这类场景里量化往往是唯一能把模型放进显存的办法,精度是次要矛盾。

一条粗判据:先看这个算子的算术强度与显存压力,再看它的数值敏感度。 形状决定收益上限,数值敏感度决定你敢走多远。

6.2 量化对 attention 与 MoE 的影响

Attention。 三个矩阵乘各有各的敏感度:

步骤 量化对象 关键约束 依据
QK^T Q、K 需要 per-block scale;softmax 之前整体平移不改变结果,所以 scale 的绝对量级不敏感 CATLASS 的 MXFP8 FA 用 block=32 的 e8m0
P·V P P 是 softmax 的输出,值域固定在 [0,1],必须 cast 回 fp8/fp16,且常常要配一个静态量化系数 mxfp8_flash_attention_infer.md:P_fp8 = quantize(P_fp32 × pScale)
输出 O 累加仍在 fp32,Online Softmax 也在 fp32 完成 同上:“Online Softmax 在 FP32 精度下完成计算”

两个具体的坑:

  • P 的量化会放大误差:softmax 之后 P 的小值很多,低精度下这些小数被压成 0 或相邻档位,等效于对注意力的尾部做了截断。这正是要用 per-block scale 而不是 per-tensor 的原因。
  • Online Softmax 的 rescale 与累加器精度耦合:O 的累加在 fp32 完成, 但每次 rescale 都要把 O 乘上 exp(m_old - m_new),低精度累加器在这里会快速 累积误差。attention 的累加器用 fp32 几乎是硬要求。

MoE。 三条影响路径:

  1. 专家权重的量化收益最大:MoE 的参数量集中在专家上,量化后同样的显存放得下 更多专家,或同样专家数下 batch 更大。

  2. 路由权重的量化要单独考虑:路由的 top-k 选择对数值很敏感, 通常保留 fp32/fp16 不量化;一旦量化,可能选出「不同的专家」。 这种离散错误的表现不是误差变大,而是整段输出结构不同。

  3. 分组 GEMM 的 scale 布局更复杂:CATLASS 的 grouped MX 示例里, SFA 是「按拼接后的 M 做 group-major」,而 SFB 是「按 (E, N) 做 group-major」:

    SFA[sf_group * M_total + m]
    SFB[sf_group * E * N + eid * N + n]
    

    (examples/blockscaled_gemm_sm100/mxfp8_illustrated.md) 这里有三个下标维度(组、专家、行列),是 scale 布局最容易写错的地方。

6.3 一份检查清单

决定量化一个算子之前,按顺序回答:

  1. 这个算子是哪一类(memory-bound / compute-bound / 容量受限)?收益上限是多少?
  2. 量化谁(只有权重、还是权重 + 激活、还是连 KV cache 一起)?
  3. scale 的粒度(per-tensor / per-channel / per-group(多大)/ per-block (K 方向多大))?粒度与硬件模板兼容吗?
  4. scale 的布局(group-major 还是行主序、字节序、一个 word 覆盖多少 K); 与生产端(量化工具)的约定一致吗?
  5. 累加器类型(fp32 / int32)?中途有没有 cast 回低精度?
  6. 参照实现(量化感知的绝对参照 + 框架对拍)准备好了吗?
  7. 容差(rtol / atol / max_mismatched_ratio)是从 u 推出来的,还是抄的?
  8. 端到端指标(ppl / 准确率)有没有测?逐层误差都在预算内不等于模型没退化。

7. 常见陷阱

这一节把本章与 corpus 里的错误模式汇成清单,每条都给出可复现的凭据。

(1)scale 布局写错。 三种典型错误在 code/05-mx-scale-layout/mx_scale_layout.py 里都能复现,相对 L2 分别是 5.58(字节序)、2.47(行主序)、2.77(粒度不匹配)。 其中行主序的 bug 会藏起来:当 K 不超过 4 个粒度块(只有 1 个 word 组)时, 两种索引恰好等价——脚本输出末尾那个 True 就是这个情况。 对策:layout 单测必须用至少 2 个组的 shape。

(2)累加器精度不足。 最隐蔽的一种:fp8 输入配 fp16 累加器能通过编译。 src/op/tcgen5_meta.h:79-82 明确允许 f8f6f4 家族配 c_dtype.is_float16()。 它能跑,但第 2.5 节实验 C 说明这一档误差可以差 4 个数量级。 对策:fp8/fp16 输入的累加器一律写 T.float32;K 很长时再考虑分段累加。

(3)反量化融合位置错误。 把 per-K-block 的 scale 提到循环外只乘一次, 在 code/04-vs-reference/ 的 B 行可以复现(相对 L2 0.21)。这类错误的特征是 误差与 K 的位置相关:K 越大的部分错得越多。 对策:把 scale 的下标(组号)与 K 的循环变量画在一起,确认每一块乘的是自己那一组。

(4)精度验证用错参照。 code/04-vs-reference/ 的 D 行:拿量化前的 fp32 结果当参照, 会有 94% 的元素不匹配,这会逼着你把容差放到没有鉴别力。 对策:参照实现里的量化规则与 kernel 完全一致;把「量化损失」与「实现误差」 拆成两个测试。

(5)把 fp4 当成「更小的 fp8」来估容差。 第 2.5 节实验 B:e2m1 的实测误差 是 u/√K 预算的 2.58 倍,模型在这个精度下已经失效。 对策:fp4 的容差必须在真实数据分布上实测,不要套公式。

(6)E8M0 取整方向写反。 scale 必须向上取到 2 的幂 (ceil_to_ue8m0),否则 x/scale 可能超过 e4m3 的最大值 448 而溢出成 NaN (e4m3 没有 inf,溢出直接进 NaN 编码 0x7f)。 对策:量化完之后断言 max(|xq|) <= 448。

(7)用错了 fp8 的变体。 CUDA 上是 e4m3fn、ROCm 上默认是 e4m3fnuz, 两者的位模式不同(FNUZ 没有负零)。硬编码 torch.float8_e4m3fn 在 ROCm 上 会得到错误结果而不是报错。对策:用 determine_fp8_type() / determine_torch_fp8_type()。

(8)把 fp8 的 -0.0 当普通零。 逐位比较时 -0.0 != +0.0。 tilelang/utils/tensor.py:25-29 提供了 fp8_remove_negative_zeros_, 但对拍脚本自己写比较逻辑时容易忘。

(9)int4 打包时丢掉符号位。 pack_int4 用的是「低 4 位 + 补码」: (a & 0x0F) | ((b & 0x0F) << 4)。解码必须做算术右移(不是逻辑右移), 否则 -1 会变成 15。example_dequant_gemm_w4a8.py:10-22 里的 「先左移 4 位再算术右移 4 位」就是这个技巧。

(10)跨实现的粒度约定不一致。 NVIDIA 原生 32、TileLang SM100 示例 128、 昇腾 CATLASS 固定 32。生产端与消费端取不同的粒度,code/05-mx-scale-layout/ 里对应 2.77 的相对 L2。 对策:把粒度写进 checkpoint 的元数据,而不是靠「约定」。

(11)只测逐层误差,不测端到端指标。 每一层的相对 L2 都在预算内, 误差仍可能在几十层里累积到影响 ppl。examples/bitnet-1.58b/eval_ppl.py 与 eval_correctness.py 就是上游为这件事准备的脚本。


8. 本章小结

数值基础(第 2 节)。 u = 2^-p:fp32 6.0e-8、fp16 4.9e-4、bf16 3.9e-3、 fp8 e4m3 6.3e-2、fp4 e2m1 2.5e-1。整数对称量化的等价 u 是 1/(2·qmax): int8 3.9e-3(约等于 bf16)、int4 7.1e-2(约等于 fp8 e4m3)。 粒度决定误差,位宽只是其中一个因素:per-tensor int8 的实测相对 L2 (4.95e-2)比上界大一个数量级,而 per-channel 立刻回到上界附近(7.67e-3)。

MX 与块缩放。 块粒度(MX 原生 32)+ E8M0 scale(2 的整数次幂, 2^-127 .. 2^127,没有 0)+ 每 4 个字节打包进一个 uint32(group-major 展平)。 E8M0 的缩放只搬指数,是精确操作;这也是它适合做块缩放的原因。

误差传播。 第 12 章的 u/√K 对 fp16/bf16/e4m3 成立(实测比值 0.44–0.58), 对 int8 偏保守(1.27–1.47),对 fp4 失效(2.58)。累加器是独立的一档误差: 同一份 fp16 输入,fp16 累加 1.24e-4、fp32 累加 1.24e-9、 32 段 fp16 + fp32 汇总 2.06e-5。

TileLang 的表达。 dtype 有统一入口(get_tvm_dtype),fp8 的名字由 determine_fp8_type() 按平台决定;T.int4 + T.int32 是唯一可以要求 逐位相等(rtol=0, atol=0)的路径;T.gemm_blockscaled 把粒度作为显式参数, 而昇腾方言把它固定为 32。

硬件约束。 NVIDIA:scale 住在 TMEM,用 tcgen05_cp_warpx4 写入 4 个子列, SFA_ID / SFB_ID = (k_start / granularity) % 4 选字节,SFB 还要复制到 32 个 lane 分区。昇腾:粒度固定 32,反量化发生在 L1→L0。没有块缩放实现的后端会 直接编译失败,不会静默退化成普通 GEMM。

验证方法论。 先选参照(量化感知的绝对参照是必须的,因为「两边一致」不等于 「两边都对」),再看三个指标(相对 L2 判量级、逐元素 max 找离群、不匹配比例看 是全局还是局部),最后用「误差 / 结果量级」分类:1e0 是逻辑错误, 1e-2 量级且与 u 相符是正常数值误差。

取舍。 memory-bound 看带宽收益、compute-bound 看张量核峰值、容量受限看能不能 放下;attention 的 P 需要 per-block scale,MoE 的路由权重通常不能量化, 分组 GEMM 的 SFB 布局是 (E, N) group-major。

延伸阅读

习题

14 题 · 答案在提交后展开

先在纸上或输入框里写下你的答案,再展开参考答案对照。客观判断请自己打勾:这三颗按钮 只影响本机保存的学习进度。

E1概念难度 ★无需硬件概念题 不需要硬件
  1. 写出 fp16、bf16、fp8 e4m3、fp8 e5m2、fp4 e2m1 的「符号/指数/尾数」位数、 有效位数 p 与 u = 2^-p。
  2. 为什么 bf16 选「8 位指数 + 7 位尾数」,而不是像 fp16 那样「5 位指数 + 10 位尾数」? 请从「溢出/下溢」与「相对精度」两个角度各写一句。
  3. 用第 2.4 节的公式算出 int8 与 int4 对称量化的等价 u,并指出它们分别与哪个 浮点格式的相对精度同级。
  4. 一句话回答:量化误差主要由位宽决定,还是由 scale 的粒度决定?给出本章里的 一个实测证据。

E2概念难度 ★无需硬件概念题 不需要硬件

fp8 有两个版本,e4m3(最大 448,u = 6.25e-2)与 e5m2(最大 57344, u = 1.25e-1)。

  1. 为什么 e4m3 没有 inf、只有两个 NaN 编码?这对量化流程提出什么要求?
  2. 训练里「前向/权重用 e4m3、反向梯度用 e5m2」这条经验的依据是什么?
  3. 如果在 CUDA 上硬编码 torch.float8_e4m3fn,代码搬到 ROCm(非 gfx950)上会出现 什么现象?为什么说这是「静默错误」而不是「报错」?
  4. 「e4m3 有负零」这件事会在什么样的对拍代码里造成误判?锁定版本提供了哪个函数 来规避它?

E3概念难度 ★★无需硬件概念题 不需要硬件

参考第 2.3 节与图 2,回答 MX / block-scaled 的三个要素:

  1. 块粒度在 PTX 与 CATLASS 里分别是多少?给出你在 corpus / _vendor 里找到的 具体位置。
  2. E8M0 的值域是多少?它为什么「不能表示 0」?这对「整块全零」的输入意味着什么?
  3. 为什么说「用 E8M0 缩放不引入尾数误差」?请从二进制表示的角度解释。
  4. 锁定版本的 SM100 MXFP8 示例用的是 128 粒度,而不是 32。请说明: (a) 为什么 128 是合法的;(b) 这一选择与 sf_load_period 的关系; (c) 昇腾侧能不能做同样的选择。

E4概念难度 ★★无需硬件概念题 不需要硬件

一份权重矩阵 W(形状 4096×4096)要做 int8 对称量化,三种粒度可选: per-tensor、per-channel(每输出行一个 scale)、per-group-64(沿 K 每 64 个元素一个)。

  1. 分别写出三种方案需要存多少个 scale(浮点数个数)以及相对权重的开销比例。
  2. 假设 W 的行幅度差异很大(各行 absmax 相差 20 倍),三种方案的相对 L2 误差 会怎么排序?为什么?
  3. 用 code/02-quant-dequant/quant_dequant.py 复算,验证你的排序; 并解释输出里 per-tensor 的相对 L2(4.95e-2)为什么大于它的理论上界 3.9e-3。
  4. 如果换成 int4(qmax = 7),三个数字会各自变成多少量级?哪种粒度才能让 相对 L2 落在 1e-2 以内?

E5概念难度 ★★无需硬件概念题 不需要硬件
  1. 写清楚「输入舍入误差」与「累加误差」的区别,并各举一个本章的实验证据。
  2. 第 12 章的模型说,长度 K 的归约误差约为 u/√K。本章实验 B 里 fp16 的 「实测 / 预算」在 0.48–0.58 之间,而 e2m1(fp4)是 2.58。请解释后者为什么失效。
  3. split-K 把一次长归约拆成若干段。用本章实验 C 的三个数字说明它为什么能保住精度, 并指出它与「把累加器换成 fp32」在收益来源上的区别。
  4. 为什么说「fp8 输入配 fp16 累加器」是一个陷阱?给出锁定版本里允许这种组合的 源码位置,并说明它为什么能通过编译。

E6代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读 _vendor/tilelang-v0.1.15/examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py 的下列片段,回答问题。

# :37-47
SFA: [(K / sf_granularity_k) / 4) * M] in uint32
SFB: [(K / sf_granularity_k) / 4) * N] in uint32
...
k_iters = T.ceildiv(K, block_K)
sf_load_period = sf_granularity_k * 4 // block_K
sf_k_groups = T.ceildiv(T.ceildiv(K, sf_granularity_k), 4)
  1. 当 K = 4096、sf_granularity_k = 128、block_K = 128 时, k_iters、sf_load_period、sf_k_groups 分别是多少?
  2. SFA 的长度是多少(用 M 表示)?它为什么是 uint32 而不是 uint8?
  3. 第 :109-120 行的 if k % sf_load_period == 0 这个条件意味着什么? 如果把它改成每次迭代都搬 scale,功能上还正确吗?代价是什么?
  4. 第 :509-525 行的 unpack_sf_u32_1d / pack_sf_u8_to_u32_1d 互为逆操作。 请写出 pack 的表达式,并说明 packed.T.contiguous().reshape(-1) 这一步 改变了什么(即「group-major」的含义)。

E7代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读 examples/dequantize_gemm/example_dequant_gemm_w4a8.py 的下列片段:

# :10-22
def _tir_u8_to_i4_to_i8(nbit, val, pos, dtype):
    mask = tirx.const((1 << nbit) - 1, T.uint8)
    i4 = (val >> (pos.astype(T.uint8) * tirx.const(nbit, T.uint8))) & mask
    i8_shifted = tirx.reinterpret(T.int8, i4 << tirx.const(4, T.uint8))
    i8 = i8_shifted >> tirx.const(4, T.int8)
    return i8
  1. 这三行分别做了什么?为什么不能直接写 i8 = val >> (pos * 4)?
  2. 用 Python 验证:val = 0xF0、pos = 0 与 pos = 1 时结果分别是多少? (提示:模拟 uint8 的位运算与 int8 的算术右移。)
  3. examples/gemm_int4/example_tilelang_gemm_int4.py:59-67 的 pack_int4 是反方向 的操作。它在打包时有没有处理符号位?如果没有,解码端靠什么还原负数?
  4. 为什么这条路径的对拍可以用 rtol=0, atol=0,而 fp8 路径不能?

E8代码阅读难度 ★★★无需硬件代码阅读题 不需要硬件

读下面两段代码,回答 scale 在 NVIDIA 硬件上「怎么放、怎么取」。

# tilelang/cuda/intrinsics/macro/tcgen05_macro_generator.py:629-632
runtime_sf_a_id = ((_sf_k_start + ki * micro_size_k) // int(sf_a_granularity_k)) % 4
runtime_sf_b_id = ((_sf_k_start + ki * micro_size_k) // int(sf_b_granularity_k)) % 4
runtime_instr_desc = base_instr_desc | (runtime_sf_a_id << 29) | (runtime_sf_b_id << 4)
# tilelang/language/builtin.py:1440-1452(docstring 节选)
"""Copy one or more packed scale-factor chunks from shared memory to tensor memory.
...
For 1D packed ``uint32`` scale buffers, each 128-word chunk maps to
4 TMEM columns and the column offset is advanced automatically."""
  1. % 4 这个运算的物理含义是什么?为什么是 4 而不是 8 或 16?
  2. 设 sf_a_granularity_k = 128、block_K = 128、k_start = 256。 当 ki = 0,1,2,3 时(micro_size_k = 32),runtime_sf_a_id 分别是多少? 由此说明「一个 scale 字节能服务几个 K32 原子」。
  3. 如果把 sf_a_granularity_k 从 128 改成 32 而生产端仍按 128 打包,会发生什么? (用 code/05-mx-scale-layout/mx_scale_layout.py 的第 3 行结果佐证。)
  4. 第二段代码里「每 128 word 的块映射到 4 个 TMEM 列」这一句, 与第 1 问的「4」是同一个 4 吗?请解释它们的关系。

E9代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

读昇腾侧 _vendor/tilelang-ascend-v0.1.2.000/examples/quant_batch_matmul/example_quant_batch_matmul.py 的片段:

# :49-88,节选
A_L1 = T.alloc_L1([block_M, block_K], in_dtype)      # int8
B_L1 = T.alloc_L1([block_K, block_N], in_dtype)      # int8
C_L0 = T.alloc_L0C([block_M, block_N], accum_dtype)  # int32
...
for bm_v, bn_v in T.Parallel(block_M_2, block_N):
    c_scale[bm_v, bn_v] *= scale_ub[bn_v]
  1. 这份实现把反量化放在哪一级?对照图 4 的三个位置(prologue / 主循环 / epilogue), 说明它属于哪一类,以及为什么这个位置在这里是合法的。
  2. 如果把 scale 改成「沿 K 每 32 个元素一个」的 per-block scale, 这段代码还能用吗?为什么?
  3. 它的对拍判据是什么?请从「误差来源」的角度解释这个判据是不是合理。
  4. 结合第 4.2 节,说明昇腾的 MX 路径(MX_SCALE_GROUP_NUM = 32)与这份 per-channel 量化示例在「粒度」上的差别,以及各自适合什么场景。

E10动手难度 ★★★无需硬件动手题 不需要硬件

code/05-mx-scale-layout/mx_scale_layout.py 里有一个「行主序 bug 会藏起来」的现象。

  1. 直接运行脚本,记录第 2 行(行主序)的相对 L2,以及最后那句 「group-major 与 row-major 的解包结果相同」的取值。
  2. 把默认的 --k 1024 改成 --k 512 再跑一次,第 2 行的结果变成多少?为什么?
  3. 用 --k 2048 再跑一次。解释结果随 K 变化的原因(提示:看 sf_k_groups = ceil(ceil(K/gran)/4))。
  4. 给这个脚本加一条防御:当 sf_k_blocks <= 4 时打印一条警告,说明此时无法用 这个用例检测出行主序 bug。写出你的实现思路与关键代码。

E11动手难度 ★★★无需硬件动手题 不需要硬件
  1. 用 code/03-error-model/error_model.py --trials 400 跑一遍, 记录 fp16 与 int8 在四个 K 上的「实测 / 预算」。
  2. 把 EQUIVALENT_U["int8"] 分别改成 1/(2*sqrt(12)*127) 与 1/127, 再跑一次。哪一个是更好的拟合?请从「均匀量化误差的统计性质」解释。
  3. 用 code/02-quant-dequant/quant_dequant.py --rows 64 --cols 4096 跑一遍 MXFP8,观察它与默认参数下的相对 L2 有什么差别,并解释原因 (提示:块数变了,每块的 absmax 也随之变化)。
  4. 写一段 10 行以内的 numpy,验证「E8M0 缩放是精确的」: 对任意 fp32 数值 x 与任意 E8M0 scale s,x / s 的结果在 fp32 下没有舍入误差 (除指数下溢/上溢)。给出你的验证方式与结论。

E12动手难度 ★★★无需硬件动手题 不需要硬件

你要为一个「fp8 e4m3 权重 + per-channel scale + fp32 累加」的 GEMM 写对拍。

  1. 根据本章的模型,写出这个场景下预期的相对 L2 量级,并说明推导过程。
  2. 设计三个参照(量化感知的绝对参照、框架相对参照、量化前参照),分别说明 它们能证明什么、不能证明什么。
  3. 用 numpy 实现这个场景的最小版本(不需要 TileLang):随机 M×K 的 fp16 激活与 N×K 的权重,权重按 per-channel 量化到 e4m3,做 Y = X @ Wq.T, 与三个参照比较,打印相对 L2 / 逐元素 max err / 不匹配比例。
  4. 你会把 rtol / atol / max_mismatched_ratio 设成多少?给出数字与理由。
  5. 故意在 kernel 侧引入一个 bug(例如把 scale 作用在 N 的错位上), 说明输出指标会怎么变,以及你会用哪个指标先发现它。

E13分析难度 ★★★无需硬件分析题 不需要硬件

下面五组观测都来自「和 PyTorch 参考实现对拍」的场景。对每一组判断它最可能是 哪一类问题(正常数值误差 / 逻辑错误 / 参照选错 / 累加器精度不足),并给出你的 第一步动作。

  1. W 是 int4 per-group、X 是 fp16;相对 L2 = 1.1e-1,逐元素 max err = 1.0, 不匹配比例 = 0.94。
  2. W 是 int4、累加器是 int32;相对 L2 = 9.6e-1,不匹配比例 = 0.99, 差异在 N 方向上呈现「每个通道一个固定倍数」的模式。
  3. 输入是 fp16、累加器也是 fp16(K=8192);相对 L2 = 3e-4, 把累加器换成 fp32 后降到 1e-8。
  4. 相对 L2 = 5.6,且误差与 K 的位置强相关(K 越大误差越大)。
  5. 相对 L2 = 2.7e-3,不匹配比例 = 2e-4,逐元素 max err = 1.8e-2, 误差在整张张量上均匀分布,没有明显的行列结构。

E14设计难度 ★★★无需硬件设计题 不需要硬件

你要把一个 70B 级别的 MoE 模型(每层 64 个专家、路由 top-8)部署到单卡上, 显存是硬约束。回答:

  1. 你会先量化什么、后量化什么?按收益从大到小排序,并说明每一项的判据。
  2. 路由权重(gate)与专家权重应该用不同的量化策略吗?为什么?
  3. 分组 GEMM 的 scale 布局与普通 GEMM 有什么不同?参考 examples/blockscaled_gemm_sm100/mxfp8_illustrated.md 给出 SFA / SFB 的下标表达式。
  4. 你会用哪些指标来确认「没有量化坏」?逐层误差与端到端指标各占什么位置?

章节自测

8 题 · 提交后本地评分

单选题。提交后立即给出分数与逐题解析;重做会清空选择,但会保留上一次的成绩记录。

  1. 1. fp8 e4m3 有一个与 IEEE 惯例不同的细节,是哪一条?

  2. 2. 为什么本章说「4 bit 定点约等于 8 bit 浮点」?

  3. 3. 关于 MX 里的 E8M0 scale,下列哪一项正确?

  4. 4. 关于 MX 的块粒度,下列哪一项正确?

  5. 5. 反量化融合在哪一个位置时,只适用于「scale 与 K 无关」的情形?

  6. 6. 低精度对拍里「参照选错」的典型表现是什么?

  7. 7. 关于「fp8 输入配 fp16 累加器」,下列哪一项正确?

  8. 8. 为什么纯整数路径(int8×int8 → int32,或 int4 累加到 int32)可以要求逐位相等?

本章代码

13 个文件

正文里提到的每个示例都能在这里直接阅读(带语法高亮),也可以整章浏览。

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

本章写到的每个源码位置与外部资料都在下面列出:源码链接指向锁定 tag 的 GitHub 文件, 资料链接指向原文。查看全部资料索引 →

展开引用清单

第 27 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release(commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。 全章以锁定版本为唯一事实来源,不做版本漂移式的表述; 讨论 main 上更新的东西时会显式标注。

写作环境:macOS (Apple Silicon)、Python 3.11.7 + numpy 1.24.1; 没有 NVIDIA GPU,也没有昇腾 NPU。因此本章

  • 数值结论全部来自 code/01-number-formats/–code/05-mx-scale-layout/ 的 CPU 实跑(验证级别:CPU 逻辑验证);
  • 硬件结论全部来自 _vendor/ 的源码或 corpus 的厂商文档;
  • 没有任何一条性能数字来自本机实测,正文里唯一引用的性能数字(BitNet) 是上游样例给出的,见「关于性能数字的说明」。

编号约定:下表的 S* / A* / R* 与 code/06-tilelang-audit/tilelang_audit.py 的检查编号一一对应; 每张表末尾那些没有对应检查的条目编号继续往下排(S31+、A6+、R11+), 表示「本章引用过,但没有做成机器检查」。一条命令复查全部对应关系:

# 在 code/06-tilelang-audit/ 下运行;期望:通过 44 / 失败 0
python3 tilelang_audit.py

锁定的源码引用(_vendor/tilelang-v0.1.15/)

id 引用位置 用途 核验方式与状态
S1 tilelang/dtypes.py:1-3 tilelang/dtypes.py 只是 language/dtypes.py 的再导出 已核验(2026-10-01):逐行读;code/06-tilelang-audit/ 检查 S1
S2 tilelang/language/dtypes.py:121-142 语言面的 dtype 登记表 _STR_TO_TVM_DTYPE_CALL:int4 / float8_e4m3fn / float8_e8m0fnu 等 已核验(2026-10-01);code/06-tilelang-audit/ S2
S3 tilelang/language/dtypes.py:81-103 torch 扩展 dtype 列表:e4m3fn / e5m2 / e8m0fnu / float4_e2m1fnx2 已核验(2026-10-01);code/06-tilelang-audit/ S3
S4 tilelang/language/dtypes.py:187-238 __dtype_as_torch__:int4→int8 存储 已核验(2026-10-01);code/06-tilelang-audit/ S4
S5 tilelang/language/dtypes.py:213-217 float8_e8m0fnu 要求 torch ≥ 2.8.0 已核验(2026-10-01);code/06-tilelang-audit/ S5
S6 tilelang/language/dtypes.py:268-308 FP4 的 packed(type_code 17)与 unpacked(type_code 131)两种存储及其判断函数 已核验(2026-10-01);code/06-tilelang-audit/ S6
S7 tilelang/language/dtypes.py:319-341 validate_gemm_ab_dtypes:混合 dtype 只允许 f8f6f4 家族 已核验(2026-10-01);code/06-tilelang-audit/ S7
S8 tilelang/language/fp8.py:9-27 determine_fp8_type:CUDA 用 e4m3fn/e5m2,ROCm 默认 FNUZ、gfx950 例外 已核验(2026-10-01);code/06-tilelang-audit/ S8
S9 tilelang/utils/tensor.py:25-29 fp8_remove_negative_zeros_:把 fp8 的负零写回 0x00 已核验(2026-10-01);code/06-tilelang-audit/ S9
S10 examples/gemm_fp8/example_tilelang_gemm_fp8.py:7-11,16-35,53 fp8 GEMM:accum_dtype=T.float32、block_K=64、相似度判据 diff < 1e-3 已核验(2026-10-01);code/06-tilelang-audit/ S10;未运行
S11 examples/gemm_int4/example_tilelang_gemm_int4.py:19-36 T.int4 张量 + T.int32 累加 + T.gemm 已核验(2026-10-01);code/06-tilelang-audit/ S11;未运行
S12 examples/gemm_int4/example_tilelang_gemm_int4.py:59-67,101 pack_int4 的打包公式与 rtol=0, atol=0 的精确相等断言 已核验(2026-10-01);code/06-tilelang-audit/ S12;未运行
S13 资料库索引 dequantize GEMM 的最小原型(_tir_packed_to_unsigned_convert + T.gemm) 已核验(2026-10-01);code/06-tilelang-audit/ S13;未运行
S14 examples/dequantize_gemm/example_dequant_gemm_w4a8.py:10-22 int4→int8 的符号扩展技巧(左移 4 位 + 算术右移 4 位) 已核验(2026-10-01);code/06-tilelang-audit/ S14;未运行
S15 examples/dequantize_gemm/example_dequant_gemm_fine_grained.py:43-44,63-88 按 128 位事务决定每线程元素数;解码后写回 shared 再 T.gemm 已核验(2026-10-01);code/06-tilelang-audit/ S15;未运行
S16 examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:1-52 MXFP8 示例的头部约定:blockscale (1,1,128)、sf_load_period、sf_k_groups、uint32 打包注释 已核验(2026-10-01);code/06-tilelang-audit/ S16;未运行
S17 同上 :509-525 unpack_sf_u32_1d / pack_sf_u8_to_u32_1d:4 字节拼 uint32 后转置成 group-major 已核验(2026-10-01);code/06-tilelang-audit/ S17;未运行
S18 同上 :543-562 ceil_to_ue8m0 与 quantize_fp8_with_packed_ue8m0:amax/448 向上取到 2 的幂、E8M0 字节取自 fp32 指数 已核验(2026-10-01);code/06-tilelang-audit/ S18;未运行
S19 同上 :566 scale 的填充值是 127(即 2^0 = 1),不是 0 已核验(2026-10-01);code/06-tilelang-audit/ S19;未运行
S20 同上 :575-620 blockscaled_gemm_ref:按 sf_granularity_k 分块、逐块乘 scale 已核验(2026-10-01);code/06-tilelang-audit/ S20;未运行
S21 同上 :195 2CTA 变体断言 sf_granularity_k == 128 已核验(2026-10-01);code/06-tilelang-audit/ S21;未运行
S22 tilelang/language/gemm_op.py:275-320 target-neutral 的 T.gemm_blockscaled(A, B, C, SFA, SFB, k_start, sf_a_granularity_k, sf_b_granularity_k) 已核验(2026-10-01);code/06-tilelang-audit/ S22
S23 tilelang/cuda/language/gemm_op.py:313-395 T.tcgen05_gemm_blockscaled:A/B 为 FP8/FP6/FP4、SFA/SFB 为 TMEM 里的 E8M0、2CTA 的约束 已核验(2026-10-01);code/06-tilelang-audit/ S23
S24 tilelang/cuda/intrinsics/macro/tcgen05_macro_generator.py:629-632 SF ID 的推导公式 (k_start / granularity) % 4 与指令描述符的位域拼接 已核验(2026-10-01);code/06-tilelang-audit/ S24
S25 tilelang/language/builtin.py:1440-1471 tcgen05_cp_warpx4:每个 128 word 的块映射到 4 个 TMEM 列 已核验(2026-10-01);code/06-tilelang-audit/ S25
S26 src/op/gemm_blockscaled.cc:94-99 没有块缩放实现的后端 LOG(FATAL),明确不会退化成丢掉 scale 的普通 GEMM 已核验(2026-10-01);code/06-tilelang-audit/ S26
S27 src/op/tcgen5_meta.h:283-297 MX 指令描述符的 dtype 编码:E4M3=0、E5M2=1、E2M1=5 已核验(2026-10-01);code/06-tilelang-audit/ S27
S28 src/op/tcgen5_meta.h:42-45 TCGEN5MMA 的 fp16/bf16 路径:累加器必须是 fp32,K 是 16 的倍数 已核验(2026-10-01);code/06-tilelang-audit/ S28
S29 src/op/tcgen5_meta.h:79-82 f8f6f4 家族:K 是 32 的倍数,累加器可为 fp32 或 fp16 已核验(2026-10-01);code/06-tilelang-audit/ S29
S30 src/cuda/op/gemm.cc:49-50,62-63 WGMMA 的 fp8 路径要求 !transA && transB && K%32==0 已核验(2026-10-01);code/06-tilelang-audit/ S30
S31 tilelang/language/dtypes.py:29 AnyDType = ir.Type | str | type | torch.dtype | dtype(dtype 的三种写法) 已核验(2026-10-01);未做成机器检查
S32 tilelang/language/dtypes.py:148-152 __dtype_call__:多参数打包成 Shuffle(T.bfloat16x2(a, b)) 已核验(2026-10-01);未做成机器检查
S33 tilelang/language/dtypes.py:245-255 __dtype_new__:未登记的名字抛 TypeError 已核验(2026-10-01);未做成机器检查
S34 tilelang/language/dtypes.py:349-353 get_tvm_dtype:dtype 的统一入口 已核验(2026-10-01);未做成机器检查
S35 tilelang/language/dtypes.py:449-560 低精度家族的向量形态声明(float8_e4m3x2…float4_e2m1_unpacked / bfloat16 / tfloat32) 已核验(2026-10-01);未做成机器检查
S36 tilelang/language/fp8.py:30-40 determine_torch_fp8_type:同一套判据映射到 torch dtype 已核验(2026-10-01);未做成机器检查
S37 tilelang/utils/tensor.py:205-293 torch_assert_close(rtol=1e-2, atol=1e-3, max_mismatched_ratio=1e-3) 的完整实现(含 fp8 的 cast 处理) 已核验(2026-10-01);未做成机器检查
S38 tilelang/profiler/__init__.py:104-115 Profiler.assert_allclose(atol=1e-2, rtol=1e-2, max_mismatched_ratio=1e-2) 已核验(2026-10-01);未做成机器检查
S39 examples/blockscaled_gemm_sm100/gemm_mxfp8_blockscaled_1d1d.py:63-72,108-146 SMEM 里每行一个 uint32;TMEM 的 SFA_tmem [block_M,4] 与 SFB_tmem [block_M,8];每 4 次迭代搬一次 scale 已核验(2026-10-01);未做成机器检查;未运行
S40 examples/blockscaled_gemm_sm100/mxfp8_illustrated.md SM100 MXFP8 的数据通路说明:每 128 个 K 元素一个 ue8m0、word 打包公式、TMEM 形状、SF ID 的推导、grouped 的 SFB[sf_group*E*N + eid*N + n] 已核验(2026-10-01):逐段读
S41 tilelang/language/builtin.py:1473-1482 tcgen05_sf_warp_transpose:按 128 word 分块做 warp 级转置 已核验(2026-10-01);未做成机器检查
S42 src/cuda/op/gemm_blockscaled.cc:46-90 CUDA 侧的块缩放指令选择:SM100 的 TCGEN05(A/B 在 SMEM、C 在 TMEM)或 SM120 的 mma.sync 已核验(2026-10-01);未做成机器检查
S43 examples/dequantize_gemm/quantize/nvfp4.py:180-196 blockscaled_chunk_kmajor_word_offset:CUTLASS BlockScaledBasicChunk 的 K-major 词坐标 已核验(2026-10-01);code/06-tilelang-audit/ N2;未运行
S44 examples/bitnet-1.58b/utils_quant.py:16-30 三值权重(`1/mean w
S45 资料库索引 上游给出的 A100 性能与显存数字(见「关于性能数字的说明」) 已核验(2026-10-01):文本存在;未复现

负向断言(code/06-tilelang-audit/ N1):主干 tilelang 包下没有 quantize 子包—— v0.1.15 的量化工具只出现在 examples/dequantize_gemm/quantize/ 与 testing/python/quantize/ 两个示例目录里,以及 examples/dequantize_gemm/quantize/nvfp4.py 这个文件。 核验方式:在 _vendor/tilelang-v0.1.15/ 下执行 ls tilelang/quantize 会报 No such file or directory; code/06-tilelang-audit/ 的 N1 就是这个断言,N2 是对照组(N2 引用的文件存在)。


锁定的源码引用(_vendor/tilelang-ascend-v0.1.2.000/)

id 引用位置 用途 核验方式与状态
A1 tilelang/ascend/language/gemm_op.py:110-160 昇腾方言的 T.gemm_blockscaled:MX 粒度写死为 32 已核验(2026-10-01);code/06-tilelang-audit/ A1
A2 tilelang/language/ascend.py:26 fp8 的昇腾 dtype 名 e4m3_float8 → float8_e4m3_t 已核验(2026-10-01);code/06-tilelang-audit/ A2
A3 examples/quant_batch_matmul/example_quant_batch_matmul.py:25-116 per-tensor / per-channel 量化 GEMM:int8 → int32 L0C 累加 → UB 乘 scale → cast;对拍判据 rtol=1e-2, atol=1e-2 已核验(2026-10-01);code/06-tilelang-audit/ A3、A4;未运行
A4 examples/quant_batch_matmul/example_quant_matmul.py:25-116 同一实现的非 batch 版本(对照) 已核验(2026-10-01);未运行
A5 examples/gemm/example_gemm_fp8_pto.py:10-16,35-93 昇腾 FP8 GEMM 需要 A5(Ascend 950)的 Cube 核,A2/A3 直接 [SKIP] 已核验(2026-10-01);code/06-tilelang-audit/ A5;未运行
A6 tilelang/utils/tensor.py:26-40 e4m3_float8 / e5m2_float8 与 torch dtype 的双向映射 已核验(2026-10-01);未做成机器检查

corpus 资料引用(资料库索引)

抓取日期:2026-09-30(Asia/Shanghai),见 corpus 的 资料库索引。

id 本地路径 上游链接 用途 核验状态
R1 华为昇腾与 CANN · 资料清单 https://gitcode.com/cann/catlass MX_SCALE_GROUP_NUM = 32——MX 粒度在昇腾侧的唯一定义 已核验(2026-10-01);code/06-tilelang-audit/ R1
R2 华为昇腾与 CANN · 资料清单 同上 L1_SCALE_FACTOR_K 与 L1SCALEA/L1SCALEB_TILE_SIZE 的公式:scale 在 L1 的深度是模板参数 已核验(2026-10-01);code/06-tilelang-audit/ R2
R3 资料库索引 与 a8w4_mx.cpp:57 同上 A8W4 MX 示例:float8_e4m3_t × float4_e2m1x2_t、scale 为 float8_e8m0_t、公式 C = (MxScaleA x A) * (MxScaleB x B) + Bias 已核验(2026-10-01);code/06-tilelang-audit/ R3;未编译、未运行
R4 华为昇腾与 CANN · 资料清单 同上 MXFP8 FlashAttention:block size=32、float8_e8m0_t scale、在 L1→L0 阶段反量化、P 需要静态量化系数、Online Softmax 在 FP32 完成 已核验(2026-10-01);code/06-tilelang-audit/ R4
R5 Parallel Thread Execution ISA 第 32061–32134 行 https://docs.nvidia.com/cuda/parallel-thread-execution/index.html tcgen05.mma 的块缩放:(A*scale_A)*(B*scale_B)+D、scale 在 Tensor Memory、SFB 复制到 32 个 lane 分区 已核验(2026-10-01);code/06-tilelang-audit/ R5
R6 同上 :32136-32300 同上 Scale Factor A/B ID 的布局与子列选择(1/2/4 字节对齐) 已核验(2026-10-01);code/06-tilelang-audit/ R6
R7 同上 :1196-1252 同上 格式定义:bf16(8 指数 7 尾数)、e4m3(无 inf、NaN 只有 0x7f/0xff)、e5m2、e2m1、ue8m0(8 位指数 0 位尾数) 已核验(2026-10-01);code/06-tilelang-audit/ R7
R8 同上 :23561-23645 同上 mma.sync 的块缩放:.kind::mxf8f6f4 配 .ue8m0 与 .scale_vec::1X,scale_A/scale_B 的形状表 已核验(2026-10-01)
R9 同上 :32620-32660 同上 tcgen05.mma 的语法,含 .kind.block_scale{.scale_vectorsize} 与 [scale-A-tmem]/[scale-B-tmem] 操作数 已核验(2026-10-01)
R10 资料库索引 https://gitcode.com/cann/catlass MXFP4 示例:L1 分块 256×256×448、L0 256×256×128 与 L1 容量约束的提醒 已核验(2026-10-01);未编译、未运行
R11 Blackwell Tuning Guide https://docs.nvidia.com/cuda/blackwell-tuning-guide/index.html Blackwell 调优指南。注:本 corpus 快照的正文只到第 1 节(架构概述),不含 tcgen05 / 块缩放的细节 已核验(2026-10-01):全文 14.7 KB,grep -i "tcgen05|block.scal" 无命中。因此本章的 Blackwell 细节一律引用 R5–R7 的 PTX ISA
R12 arXiv:2407.08608 https://arxiv.org/abs/2407.08608 FlashAttention-3:低精度 attention 的设计动机与 FP16/FP8 的数值误差对比 已核验(2026-10-01):本地为全文
R13 T-MAC: CPU Renaissance via Table Lookup for Low-Bit… https://arxiv.org/abs/2407.00088 T-MAC / BitBLAS 系:TileLang 的低精度血统(低比特算子生成,而非通用编译器研究) 已核验(2026-10-01)

本章可复算的产出(code/)

id 位置 内容 验证级别
C1 code/01-number-formats/number_formats.py 位模式枚举:各格式的 u、三个边界、E8M0 值域、E2M1 码本 CPU 逻辑验证(实跑)
C2 code/02-quant-dequant/quant_dequant.py 五种量化方案的往返误差(相对 L2 / max err / SQNR) CPU 逻辑验证(实跑)
C3 code/03-error-model/error_model.py u/√K 缩放律、参照选择、累加器精度三组实验 CPU 逻辑验证(实跑)
C4 code/04-vs-reference/reference_matching.py 六种参照的对比(含两种会导致误判的写法) CPU 逻辑验证(实跑)
C5 code/05-mx-scale-layout/mx_scale_layout.py scale 打包/解包的复刻与四种布局错误 CPU 逻辑验证(实跑)
C6 code/06-tilelang-audit/tilelang_audit.py 44 条源码事实的静态核对 CPU 逻辑验证(实跑,44/44 通过)

关于性能数字的说明

本章只出现一处「性能数字」,并且明确标注了来源:

数字 来源 条件 状态
BitNet b1.58-3B(Llama-3B 结构):fp16 基线 177.67 vs int8×int2 实现 64.18 资料库索引 的 “BitBLAS Results / Performance” 表 设备 A100、batchsize=1、in_seq=1 上游样例给出的数字,本机未复现(没有 GPU)

除此之外,本章的全部数字分两类:

没有任何一条来自加速器实测。


本章资料缺口

以下内容在写作时未能在 corpus 或锁定版本里找到足够的第一手材料。 它们不影响本章任何结论的成立,但会影响后续章节的取材:

  1. TileLang 官方文档里没有低精度/量化的专门页面。 TileLang 官方文档 · 资料清单 只有一页 dtype 清单(列出 float8/6/4 家族), TileLang 官方文档 · 资料清单 里只有 T.gemm_blockscaled 的一句签名说明。 本章因此完全依赖源码与示例,而不是官方教程。
  2. R11(Blackwell Tuning Guide)的本地快照不完整,只到架构概述一节; “block scaling” 的实际内容在 PTX ISA(R5–R7)里。 如果后续要写硬件章节,建议补抓完整的 Blackwell tuning guide 与 CUTLASS 的 block-scaled 文档。
  3. 昇腾侧的 MXFP8/MXFP4 缺少端到端的数值误差公开数据。 CATLASS 的示例只给「Compare success」这样的通过/失败判据, 没有给出误差量级;本章的昇腾数值结论因此只引用模板参数与数据流, 不给误差数字。
  4. 没有 MXINT8 / MXINT4 的硬件资料。 OCP MX 规范里包含整数变体, 但锁定版本与 corpus 里都没有对应实现,本章因此只讲 fp8/fp4 的 MX 路径。

本章共 11 个二级小节;右上角目录在桌面端会跟随滚动高亮。