跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

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

/code/27-low-precision-quantization/

03-error-model/error_model.py9417 字节
#!/usr/bin/env python3
"""第 27 章 · 示例 3:低精度 GEMM 的误差预算与实测(CPU 逻辑验证)。

第 12 章用 fp16/bf16 建立了容差模型:

    以 S = sum|a_i * b_i| 为基准,输入舍入带来的相对误差
        最坏情形 = u = 2^-p,  均方根 = u / sqrt(K)

本章要把这个模型推广到 fp8 / fp4 / int8 / int4,并回答三个问题:

1. **输入量化误差** 与 **累加误差** 是谁在主导?因此对拍时该选哪个参照?
2. K 增大时误差为什么反而变小(u/sqrt(K)),什么情况下会破坏这个规律?
3. 累加器用 fp16 而不是 fp32,误差会放大多少?split-K 这种「分段累加」为什么能把
   误差拉回来?

脚本对同一批随机数据做三组实验:

* 实验 A:各种格式量化输入 + fp32 累加,分别以「原始值」和「量化后的值」为参照,
  看两种参照下的误差差多少(这就是「参照选错」的量化版本);
* 实验 B:K = 64 .. 8192,验证 u/sqrt(K) 的缩放律;
* 实验 C:K = 4096,比较 fp16 累加、fp32 累加、以及 split 成 32 段后的累加。

依赖:Python 3.9+ 与 numpy。不需要 GPU/NPU。

用法:

    python3 error_model.py
    python3 error_model.py --trials 400 --lengths 64,256,1024,4096

作者实际做到的验证级别:**CPU 逻辑验证**(写作机 macOS / Apple Silicon、
Python 3.11.7 + numpy 1.24.1 上实际运行过,退出码 0;输出见同目录 README.md)。
"""

from __future__ import annotations

import argparse
import math

import numpy as np


def e4m3_codebook() -> np.ndarray:
    values: set[float] = set()
    for exponent in range(16):
        for mantissa in range(8):
            if exponent == 0xF and mantissa == 0x7:
                continue
            if exponent == 0:
                values.add((mantissa / 8.0) * 2.0 ** (1 - 7))
            else:
                values.add((1.0 + mantissa / 8.0) * 2.0 ** (exponent - 7))
    return np.asarray(sorted(values), dtype=np.float64)


def e2m1_codebook() -> np.ndarray:
    values: set[float] = set()
    for exponent in range(4):
        for mantissa in range(2):
            if exponent == 0:
                values.add((mantissa / 2.0) * 2.0 ** 1)
            else:
                values.add((1.0 + mantissa / 2.0) * 2.0 ** (exponent - 1))
    return np.asarray(sorted(values), dtype=np.float64)


def round_to_codebook(magnitude: np.ndarray, codebook: np.ndarray) -> np.ndarray:
    idx = np.clip(np.searchsorted(codebook, magnitude, side="left"), 1, len(codebook) - 1)
    lower = codebook[idx - 1]
    upper = codebook[idx]
    return np.where(magnitude - lower <= upper - magnitude, lower, upper)


def to_bf16(x: np.ndarray) -> np.ndarray:
    """按「截断 fp16 低位」近似 bf16 的 round-to-nearest(与第 12 章一致)。"""

    as_u32 = x.astype(np.float32).view(np.uint32)
    rounded = (as_u32 + np.uint32(0x8000)) & np.uint32(0xFFFF0000)
    return rounded.view(np.float32).astype(np.float64)


def quantize(name: str, x: np.ndarray) -> np.ndarray:
    """把 float64 输入量化成某种低精度格式,再还原成本脚本内部的 float64 表示。"""

    if name == "fp16":
        return x.astype(np.float16).astype(np.float64)
    if name == "bf16":
        return to_bf16(x)
    if name == "e4m3":
        magnitude = round_to_codebook(np.abs(x), e4m3_codebook())
        return np.copysign(magnitude, x)
    if name == "e2m1":
        magnitude = round_to_codebook(np.abs(x), e2m1_codebook())
        return np.copysign(magnitude, x)
    if name in ("int8", "int4"):
        bits = 8 if name == "int8" else 4
        qmax = 2 ** (bits - 1) - 1
        absmax = float(np.max(np.abs(x)))
        scale = absmax / qmax if absmax > 0 else 1.0
        return np.clip(np.rint(x / scale), -qmax - 1, qmax) * scale
    raise ValueError(name)


# 每个格式的「等价 u」:浮点格式用 2^-p;均匀量化的整数格式用 1/(2*qmax)。
EQUIVALENT_U = {
    "fp16": 2.0**-11,
    "bf16": 2.0**-8,
    "e4m3": 2.0**-4,
    "e2m1": 2.0**-2,
    "int8": 1.0 / (2.0 * 127.0),
    "int4": 1.0 / (2.0 * 7.0),
}

LAYOUT = {
    "fp16": "1/5/10",
    "bf16": "1/8/7",
    "e4m3": "1/4/3",
    "e2m1": "1/2/1",
    "int8": "定点 8 位",
    "int4": "定点 4 位",
}


def experiment_a(length: int, trials: int, seed: int) -> list[tuple[str, float, float, float, float]]:
    """返回 [(格式, 对原始值的相对误差中位数, 对量化值的相对误差中位数, 最大值, RMS 预算)]。"""

    rng = np.random.default_rng(seed)
    rows: dict[str, list[tuple[float, float]]] = {name: [] for name in EQUIVALENT_U}
    for _ in range(trials):
        x = rng.uniform(-1.0, 1.0, size=length)
        y = rng.uniform(-1.0, 1.0, size=length)
        for name in EQUIVALENT_U:
            xq = quantize(name, x)
            yq = quantize(name, y)
            # 参照一:原始值的 fp64 精确点积(包含输入量化误差)
            exact_raw = float(np.dot(x, y))
            # 参照二:量化后值的 fp64 精确点积(只含累加误差)
            exact_quantized = float(np.dot(xq, yq))
            # 被测:量化输入 + fp32 累加
            got = float(np.dot(xq.astype(np.float32), yq.astype(np.float32)))
            denom = float(np.sum(np.abs(xq * yq))) or 1.0
            rows[name].append((abs(got - exact_raw) / denom, abs(got - exact_quantized) / denom))

    out: list[tuple[str, float, float, float, float]] = []
    for name, samples in rows.items():
        arr = np.asarray(samples)
        budget = EQUIVALENT_U[name] / math.sqrt(length)
        out.append(
            (
                name,
                float(np.median(arr[:, 0])),
                float(np.median(arr[:, 1])),
                float(np.max(arr[:, 1])),
                budget,
            )
        )
    return out


def experiment_b(lengths: list[int], trials: int, seed: int, names: list[str]) -> None:
    print("\n== 实验 B:误差随 K 的缩放(u/sqrt(K) 是否成立) ==")
    print("表中是「实测中位数 / 解析预算」,只看数量级;基准仍是 S = sum|a_i b_i|。")
    header = f"{'格式':<8}" + "".join(f"{('K=' + str(k)):<12}" for k in lengths)
    print(header)
    for name in names:
        cells = []
        for length in lengths:
            rows = experiment_a(length, trials, seed)
            measured = {row[0]: row[1] for row in rows}[name]
            budget = EQUIVALENT_U[name] / math.sqrt(length)
            cells.append(f"{measured / budget:<12.2f}")
        print(f"{name:<8}" + "".join(cells))


def experiment_c(length: int, trials: int, seed: int) -> None:
    """累加器精度:fp16 顺序累加 / fp32 累加 / 分段后 fp32 汇总。"""

    rng = np.random.default_rng(seed + 1)
    fp16_seq, fp32_acc, split32 = [], [], []
    for _ in range(trials):
        x = rng.uniform(-1.0, 1.0, size=length).astype(np.float16)
        y = rng.uniform(-1.0, 1.0, size=length).astype(np.float16)
        products = (x.astype(np.float64) * y.astype(np.float64))
        exact = float(np.sum(products))
        denom = float(np.sum(np.abs(products))) or 1.0

        # (1) 全程 fp16 顺序累加:cumsum 在 fp16 里逐步舍入,等价于串行累加器
        acc16 = np.cumsum(x.astype(np.float16) * y.astype(np.float16), dtype=np.float16)[-1]
        fp16_seq.append(abs(float(acc16) - exact) / denom)

        # (2) fp32 累加器,一次到底
        acc32 = np.sum(x.astype(np.float32) * y.astype(np.float32), dtype=np.float32)
        fp32_acc.append(abs(float(acc32) - exact) / denom)

        # (3) 分成 32 段,每段先用 fp16 累加,再把 32 个部分和用 fp32 相加(split-K 的抽象)
        chunks = x.astype(np.float16) * y.astype(np.float16)
        partials = np.asarray(
            [float(np.cumsum(chunk, dtype=np.float16)[-1]) for chunk in np.array_split(chunks, 32)],
            dtype=np.float32,
        )
        split32.append(abs(float(np.sum(partials, dtype=np.float32)) - exact) / denom)

    print(f"\n== 实验 C:累加器精度(K={length},{trials} 次试验,与 fp64 比较) ==")
    print(f"{'方案':<34}{'相对误差中位数':<18}{'相对误差最大值'}")
    for label, samples in (
        ("fp16 输入 + fp16 顺序累加", fp16_seq),
        ("fp16 输入 + fp32 累加", fp32_acc),
        ("fp16 输入 + 32 段 fp16 部分和 + fp32 汇总", split32),
    ):
        arr = np.asarray(samples)
        print(f"{label:<36}{np.median(arr):<20.3e}{np.max(arr):.3e}")
    print("结论:输入完全一样,只换累加器,中位数误差就从 1.2e-4 掉到 1.2e-9——")
    print("      这一档差距不是「输入精度」造成的,只能靠 fp32 累加器或分段累加解决。")
    print("      32 段 fp16 部分和 + fp32 汇总落在中间(2.1e-5):每段内部的舍入误差只")
    print("      跟段长有关,段间用 fp32 连接不会再放大。这就是 split-K 保精度的由来。")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--lengths", default="64,256,1024,4096")
    parser.add_argument("--trials", type=int, default=200)
    parser.add_argument("--seed", type=int, default=20261001)
    args = parser.parse_args()
    lengths = [int(item) for item in args.lengths.split(",") if item.strip()]

    print("== 等价 u:每个格式「单元素相对误差」的上界 ==")
    print(f"{'格式':<8}{'布局':<12}{'等价 u':<14}{'说明'}")
    notes = {
        "fp16": "2^-11,浮点",
        "bf16": "2^-8,浮点",
        "e4m3": "2^-4,浮点码本",
        "e2m1": "2^-2,浮点码本",
        "int8": "1/(2*127),均匀量化",
        "int4": "1/(2*7),均匀量化",
    }
    for name, u in EQUIVALENT_U.items():
        print(f"{name:<10}{LAYOUT[name]:<14}{u:<16.3e}{notes[name]}")

    print(f"\n== 实验 A:K={lengths[0]},两种参照下的相对误差 ==")
    print("「参照一」= 原始值的 fp64 点积,误差含输入量化;「参照二」= 量化后值的 fp64 点积,误差只有累加。")
    print(f"{'格式':<8}{'中位数(对原始值)':<20}{'中位数(对量化值)':<20}{'最大(对量化值)':<20}{'预算 u/sqrt(K)':<18}{'比值'}")
    for name, rel_raw, rel_quant, rel_quant_max, budget in experiment_a(lengths[0], args.trials, args.seed):
        print(
            f"{name:<10}{rel_raw:<22.3e}{rel_quant:<22.3e}{rel_quant_max:<22.3e}{budget:<20.3e}{rel_raw / budget:<.2f}"
        )
    print("两个参照差 4 个数量级以上。fp16 是 3.3e-5 对 5.9e-9;e4m3 / e2m1 的「对量化值」")
    print("误差直接是 0——4 位尾数的乘积只有 8 位有效位,fp32 能精确表示,累加也不丢位。")
    print("**对拍时把「量化前的 fp32 结果」当参照,会把正常的量化误差判成逻辑错误;")
    print("正确做法是拿「按同样的量化与反量化规则生成的参考实现」对比。**")
    print("比值那一列的读法:fp16/bf16/e4m3 在 0.5 附近(第 12 章的结论),int8 是 1.3~1.5")
    print("(均匀量化比浮点更接近最坏情形),e2m1 却到了 2.6 —— 4 位浮点的相对间距随指数")
    print("变化太剧烈,'相对误差上界 u' 对小数值不成立,模型在 fp4 上失效。")

    experiment_b(lengths, args.trials, args.seed, names=["fp16", "bf16", "e4m3", "int8"])
    experiment_c(4096, max(20, args.trials // 20), args.seed)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

← 浏览本章全部代码