"""
=============================================================================
CS349E · 前置讲 P3 · 02_floating_point_formats.py —— 浮点格式：从 FP32 到 FP4，比特怎样分给"范围"和"精度"
=============================================================================

【这个脚本要回答的问题】
    浮点数 = 符号 × 2^指数 × 1.尾数。指数决定"范围"（能表示多大、多小的数），尾数决定"精度"（相邻两个数离多近）。
    一个格式的全部性格，都在"指数几位、尾数几位"这两个数里。本脚本：

      演示 1：拆开 FP32——1.0、0.1、1/3、16,777,217、次正规数、Inf、NaN 在比特里长什么样？
      演示 2：格式一览表：FP32 / TF32 / FP16 / BF16 / FP8 / FP6 / FP4 的范围、精度、能表示几个数。
      演示 3：数轴上的小格式：FP4 只有 15 个数；FP8 E4M3 每个"2 的幂区间"里有 8 个数——间隔随数值翻倍。
      演示 4：验证我们的编码器：和 numpy（FP16 / FP32）、位运算（BF16）、PyTorch（FP8）逐位比较。
      演示 5：范围 vs 精度的实战：同一组数在各格式里会上溢、下溢还是被舍入？梯度为什么在 FP16 里会"消失"、
              loss scaling 怎样救回来——这正是后来"缩放因子"思想的起点。

【怎么读输出】全部是确定性的计算，每次运行结果相同（演示 4 的 FP8 对照需要 PyTorch，没装会跳过）。

【运行】python3 02_floating_point_formats.py      （约 1 秒）
【依赖】numpy；可选 torch；从 ../lecture01 导入 console；同目录的 numfmt。
"""

import os
import sys
from decimal import Decimal

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

from console import print_header, print_observations, print_table  # noqa: E402
from numfmt import (BF16, FORMATS, FP4_E2M1, FP6_E2M3, FP6_E3M2, FP8_E4M3,  # noqa: E402
                    FP8_E5M2, FP16, FP32, bf16_bit_trick, bits_str)


def fmt_g(x, digits=4):
    """把一个数写成紧凑的科学计数法（0、Inf、NaN 原样）。"""
    if x == 0 or not np.isfinite(x):
        return str(x)
    return f"{x:.{digits}g}"


# ---------------------------------------------------------------------------
# 演示 1：拆开 FP32
# ---------------------------------------------------------------------------
def demo1_anatomy():
    """
    把几个典型的数编码成 FP32，打印三个字段和"实际存下来的那个数"的精确十进制展开。

    【读表】E 是指数字段（无符号，减去偏置 127 才是真正的指数）；M 是尾数字段（23 位，前面隐含"1."）。
    【Python 小知识】Decimal(x) 把一个 float 的二进制值**精确地**写成十进制（float 本身是二进制分数，
        所以 0.1 存下来的其实是 0.100000001490116119384765625……）。
    """
    print_header("演示 1：拆开 FP32——符号 | 指数（8 位）| 尾数（23 位）")
    cases = [("1.0", 1.0), ("−2.5", -2.5), ("0.1", 0.1), ("1/3", 1 / 3), ("16,777,217 = 2^24 + 1", 16777217.0),
             ("1e−40（次正规）", 1e-40), ("3.4e38", 3.4e38), ("1e39（溢出）", 1e39), ("NaN", float("nan"))]
    rows = []
    for label, v in cases:
        with np.errstate(over="ignore"):
            code = int(FP32.encode(v))
        S, E, M = FP32.fields(code)
        stored = float(FP32.decode(code))
        if E == 255:
            kind = "Inf" if M == 0 else "NaN"
        elif E == 0:
            kind = "次正规" if M else "零"
        else:
            kind = f"正规，指数 {E - 127}"
        exact = str(Decimal(stored)) if np.isfinite(stored) else str(stored)
        if len(exact) > 34:
            exact = exact[:31] + "…"
        rows.append([label, bits_str(code, 32, (1, 8, 23)), kind, exact])
    print_table(["输入", "比特（S | E | M）", "类型", "实际存下来的值"], rows)
    print_observations([
        "1.0 = 2^0 × 1.0：指数字段是 127（偏置），尾数全 0。−2.5 = −2^1 × 1.25：符号位 1、指数字段 128、尾数 .01。",
        "0.1 和 1/3 在二进制里是无限循环小数，存下来的只是最接近的那个 FP32 数：0.1 实际存的是 0.10000000149……"
        "——所以浮点运算'几乎总是'有舍入误差，不是 bug。",
        "2^24 + 1 存不下：FP32 只有 24 位有效数字（23 位尾数 + 隐含的 1），2^24 以上相邻两个数相差 2。",
        "比最小正规数 2^−126 还小的数用'次正规数'表示（指数字段 0，没有隐含的 1），精度逐渐降低，直到变成 0。"
        "超过最大值的数变成 Inf；0/0、Inf − Inf 之类得到 NaN。",
    ])


# ---------------------------------------------------------------------------
# 演示 2：格式一览表
# ---------------------------------------------------------------------------
def count_finite(fmt):
    """
    数出格式能表示的不同有限值的个数（+0 和 −0 算一个）。

    【思路】16 位及以下直接枚举全部编码；更宽的用公式：每个符号有 (2^E − 1 或 2^E) × 2^M 个编码，
        减去特殊值，再把 ±0 合并。
    """
    if fmt.bits <= 16:
        v = fmt.all_values()
        return len(np.unique(v[np.isfinite(v)]))
    per_sign = ((1 << fmt.exp_bits) - 1) * (1 << fmt.man_bits)     # ieee：E 全 1 的编码都是特殊值
    return 2 * per_sign - 1


def demo2_format_table():
    """
    每种格式的关键参数，全部由 FloatFormat 的定义算出来（和 IEEE 754 / OCP 规范的表格一致，见 numfmt 自检）。

    【列的含义】
        最大值 / 最小正规数 / 最小正数：范围；
        机器精度 eps = 2^−M：1 附近相邻两个数的距离；"十进制有效数字"≈ (M + 1) × log10(2)；
        动态范围（binade 数）= log2(最大值 / 最小正数)：数轴上一共跨了几个"2 的幂区间"。
    """
    print_header("演示 2：格式一览表——比特怎样分给范围（指数）和精度（尾数）")
    rows = []
    for f in FORMATS:
        digits = (f.man_bits + 1) * np.log10(2)
        binades = np.log2(f.max_value / f.min_subnormal)
        rows.append([f.name, f.bits, f"E{f.exp_bits}M{f.man_bits}", f.bias, fmt_g(f.max_value),
                     fmt_g(f.min_normal), fmt_g(f.min_subnormal), fmt_g(f.eps, 3), f"{digits:.1f}",
                     f"{binades:.0f}", f"{count_finite(f):,}"])
    print_table(["格式", "位数", "结构", "偏置", "最大值", "最小正规数", "最小正数", "eps",
                 "十进制位数", "跨几个 2 的幂", "有限值个数"], rows)
    print_observations([
        "FP16 和 BF16 都是 16 位，但分法相反：FP16 用 5 位指数 + 10 位尾数（精度高、最大只有 65,504），"
        "BF16 用 8 位指数 + 7 位尾数（范围和 FP32 一样、精度只有约 2.4 位十进制）。训练从 FP16 转向 BF16，"
        "就是因为范围比精度更难对付（演示 5）。",
        "TF32 不是存储格式，而是 NVIDIA Tensor Core 内部的计算格式：FP32 的 8 位指数 + FP16 的 10 位尾数。",
        "FP8 的两种格式又是一对取舍：E4M3 精度高（最大 448），E5M2 范围大（最大 57,344）。OCP 规范建议"
        "权重和激活用 E4M3、梯度用 E5M2（DeepSeek-V3 则在所有张量上都用 E4M3，靠细粒度缩放弥补范围）。",
        "FP4 E2M1 一共只有 15 个不同的数（±0 合并后），最大值 6——不缩放根本没法用（05 脚本：块缩放）。",
        "E4M3 的最大值是 448 而不是 240：它不要 Inf，只保留一个 NaN 编码，把多出来的 7 个编码用来表示 256–448"
        "（Micikevicius et al. 2022 的设计）。",
    ])


# ---------------------------------------------------------------------------
# 演示 3：数轴上的小格式
# ---------------------------------------------------------------------------
def demo3_number_line():
    """
    列出 FP4、FP6 的全部非负值，以及 FP8 E4M3 每个 2 的幂区间里有几个数、间隔多大。

    【要看出来的规律】在每个区间 [2^e, 2^(e+1)) 里，能表示的数是等间隔的 2^M 个，间隔 2^(e−M)：
        数越大，间隔越大，但"间隔 / 数值"大致不变——浮点数的相对精度是均匀的。
        0 附近的次正规数是等间隔的（间隔 = 最小正数），把 0 和最小正规数之间的空隙填平。
    """
    print_header("演示 3：数轴上的小格式——数越大，间隔越大")
    for f in (FP4_E2M1, FP6_E2M3, FP6_E3M2):
        v = f.all_values()
        pos = sorted(set(np.abs(v).tolist()))
        print(f"{f.describe()} 的全部非负值（{len(pos)} 个）：")
        print("  " + ", ".join(f"{x:g}" for x in pos))
    print()
    v = FP8_E4M3.all_values()
    pos = np.unique(np.abs(v[np.isfinite(v)]))
    rows = []
    sub = pos[(pos > 0) & (pos < FP8_E4M3.min_normal)]
    rows.append(["次正规区 (0, 2^−6)", len(sub), fmt_g(sub[1] - sub[0]), "—"])
    for e in (-6, -3, 0, 3, 6, 8):
        lo, hi = 2.0 ** e, 2.0 ** (e + 1)
        seg = pos[(pos >= lo) & (pos < hi)]
        gap = seg[1] - seg[0]
        rows.append([f"[2^{e}, 2^{e + 1}) = [{fmt_g(lo)}, {fmt_g(hi)})", len(seg), fmt_g(gap),
                     f"{gap / seg[-1] * 100:.1f}% – {gap / seg[0] * 100:.1f}%"])
    print_table(["FP8 E4M3 的区间", "区间里有几个数", "相邻间隔", "间隔 / 数值"], rows)
    print_observations([
        "FP4 E2M1 的 8 个非负值 0, 0.5, 1, 1.5, 2, 3, 4, 6：1 和 2 之间间隔 0.5，2 到 4 之间间隔 1，4 到 6 之间间隔 2。",
        "FP8 E4M3 的每个区间都有 8 个数（3 位尾数），间隔从 2^−9 一路翻倍到 32，但'间隔 / 数值'始终在 6.7%–12.5% 之间："
        "这就是'相对精度均匀'。最后一个区间 [256, 512) 只有 7 个数（512 前面那个编码留给了 NaN），所以最大值是 448。",
        "这也说明了量化时'缩放因子'的作用：把一组数整体乘以一个因子，让它们落在格式'最密'、又不溢出的那段数轴上。",
    ])


# ---------------------------------------------------------------------------
# 演示 4：验证编码器
# ---------------------------------------------------------------------------
def demo4_verify(seed=0):
    """
    把 numfmt 的编码和独立的实现逐位比较，证明 FloatFormat.round / encode 是对的。

    【对照组】FP16 / FP32：numpy 的类型转换（调用 CPU 的硬件 / C 库实现）；
        BF16：bf16_bit_trick（工业界常用的位运算写法）；
        FP8 E4M3 / E5M2：PyTorch 的 float8_e4m3fn / float8_e5m2。注意 PyTorch 2.x 转 E4M3 时**饱和**
        （超过 448 的变成 448），转 E5M2 时**不饱和**（溢出成 Inf），我们用对应的 saturate 参数对照。
    【数据】跨越很多数量级的随机数（包括次正规数和溢出），加上几个舍入的"平局"边界值。
    """
    print_header("演示 4：验证我们的编码器——和独立实现逐位比较")
    rng = np.random.default_rng(seed)
    n = 100_000
    wide = rng.standard_normal(n) * np.exp2(rng.integers(-30, 20, n))
    ties = np.array([65504.0, 65519.99, 65520.0, 2.0 ** -24 * 0.5, 2.0 ** -24 * 1.5, 1 + 2.0 ** -11, 1 + 3 * 2.0 ** -11])
    x16 = np.concatenate([wide, ties])
    rows = []
    with np.errstate(over="ignore"):
        ref = x16.astype(np.float16).view(np.uint16).astype(np.int64)
    rows.append(["FP16", "numpy astype(float16)", f"{x16.size:,}", int(np.sum(FP16.encode(x16) != ref))])
    x32 = rng.standard_normal(n) * np.exp2(rng.integers(-140, 120, n).astype(float))
    with np.errstate(over="ignore"):
        ref = x32.astype(np.float32).view(np.uint32).astype(np.int64)
    rows.append(["FP32", "numpy astype(float32)", f"{x32.size:,}", int(np.sum(FP32.encode(x32) != ref))])
    f32 = (rng.standard_normal(n) * np.exp2(rng.integers(-20, 20, n))).astype(np.float32)
    mism = int(np.sum(BF16.round(f32) != bf16_bit_trick(f32).astype(np.float64)))
    rows.append(["BF16", "位运算 (u + 0x7FFF + lsb) >> 16", f"{f32.size:,}", mism])
    try:
        import torch
        t = torch.tensor(np.concatenate([f32, np.float32([447, 449, 464, 465, 480, 1e6])]))
        v = t.numpy().astype(np.float64)
        ref = t.to(torch.float8_e4m3fn).view(torch.uint8).numpy().astype(np.int64)
        rows.append(["FP8 E4M3（饱和）", f"PyTorch {torch.__version__} float8_e4m3fn", f"{v.size:,}",
                     int(np.sum(FP8_E4M3.encode(v, saturate=True) != ref))])
        ref = t.to(torch.float8_e5m2).view(torch.uint8).numpy().astype(np.int64)
        rows.append(["FP8 E5M2（溢出成 Inf）", f"PyTorch {torch.__version__} float8_e5m2", f"{v.size:,}",
                     int(np.sum(FP8_E5M2.encode(v) != ref))])
    except ImportError:
        rows.append(["FP8", "（没有安装 PyTorch，跳过）", "—", "—"])
    print_table(["格式", "对照的独立实现", "比较的数", "不一致的个数"], rows)
    print_observations([
        "所有格式都是 0 个不一致：numfmt 的舍入（就近、平局取偶）、次正规数和溢出处理，与硬件 / 主流框架逐位相同。",
        "所以后面用 numfmt 模拟 FP8 / FP6 / FP4（numpy 没有这些类型）时，可以相信它给出的就是真实硬件会得到的数。",
        "一个细节：同样是'转成 FP8'，PyTorch 对 E4M3 截到 448，对 E5M2 却给 Inf——OCP 规范两种方式（SAT / OVF）都允许，"
        "框架各自做了选择。用低精度时要查清楚溢出是怎么处理的。",
    ])


# ---------------------------------------------------------------------------
# 演示 5：范围 vs 精度的实战
# ---------------------------------------------------------------------------
def describe_rounding(fmt, v):
    """舍入一个数并用一句话描述结果：精确 / 相对误差 / 下溢成 0 / 上溢 / 饱和。"""
    with np.errstate(over="ignore"):
        r = float(fmt.round(v))
    if np.isnan(r):
        return "NaN（溢出）"
    if np.isinf(r):
        return "Inf（溢出）"
    if r == 0 and v != 0:
        return "0（下溢）"
    if r == v:
        return "精确"
    return f"{fmt_g(r, 6)}（{abs(r - v) / abs(v) * 100:.2g}%）"


def demo5_range_vs_precision(n=200_000, seed=0):
    """
    第一部分：几个典型的数，在 FP32 / FP16 / BF16 / E4M3 / E5M2 里各变成什么（不做任何缩放）。
    第二部分：一组"像梯度"的数（对数正态，绝对值大多在 1e−9 到 1e−3 之间），在 FP16 里有多少会下溢成 0？
        乘以 2^k（loss scaling）后再转 FP16 又如何？乘得太大会怎样？

    【loss scaling 是什么】混合精度训练（Micikevicius et al. 2018）里，先把 loss 乘以一个大数 S，
        反向传播出来的梯度也都被放大 S 倍，避免在 FP16 里下溢；更新权重前再除以 S。
        它是"用一个缩放因子把数挪到格式能表示的范围里"的最早的大规模应用，块缩放（MX、NVFP4）是它的细粒度版本。
    """
    print_header("演示 5：范围 vs 精度——上溢、下溢、舍入，以及 loss scaling")
    fmts = [FP32, FP16, BF16, FP8_E4M3, FP8_E5M2]
    cases = [("1e−9", 1e-9), ("3e−5", 3e-5), ("0.1", 0.1), ("1 + 1/300", 1 + 1 / 300), ("300.5", 300.5),
             ("70,000", 7e4), ("1e10", 1e10)]
    rows = [[label] + [describe_rounding(f, v) for f in fmts] for label, v in cases]
    print_table(["数"] + [f.name for f in fmts], rows)

    rng = np.random.default_rng(seed)
    g = rng.lognormal(mean=np.log(1e-6), sigma=2.5, size=n) * rng.choice([-1.0, 1.0], n)
    print(f"\n{n:,} 个'像梯度'的数：|g| 的中位数 {np.median(np.abs(g)):.1e}，"
          f"1% 分位 {np.quantile(np.abs(g), 0.01):.1e}，99% 分位 {np.quantile(np.abs(g), 0.99):.1e}，最大 {np.abs(g).max():.1e}")
    rows = []
    for k in (0, 8, 16, 24, 32):
        s = 2.0 ** k
        with np.errstate(over="ignore", invalid="ignore"):
            r = FP16.round(g * s)
        under = np.mean((r == 0) & (g != 0))
        sub = np.mean((np.abs(r) > 0) & (np.abs(r) < FP16.min_normal))
        over = np.mean(np.isinf(r))
        ok = np.isfinite(r) & (r != 0)
        rel = np.median(np.abs(r[ok] / s - g[ok]) / np.abs(g[ok]))
        rows.append([f"2^{k}", f"{under * 100:.1f}%", f"{sub * 100:.1f}%", f"{over * 100:.2f}%", f"{rel:.1e}"])
    rb = BF16.round(g)
    rows.append(["BF16，不缩放", f"{np.mean((rb == 0) & (g != 0)) * 100:.1f}%", "0.0%", "0.00%",
                 f"{np.median(np.abs(rb - g) / np.abs(g)):.1e}"])
    print_table(["FP16 前乘以", "下溢成 0", "落在次正规区", "上溢成 Inf", "其余数的相对误差（中位数）"], rows)
    print_observations([
        "同一个数在不同格式里的命运完全不同：1 + 1/300 在 BF16 里直接变成 1（7 位尾数分辨不出 0.3% 的差别），"
        "在 FP16 里还看得出来；70,000 在 FP16 里溢出，在 BF16 里只是舍入；1e−9 在 FP16 和两种 FP8 里都变成 0。",
        "不缩放时，这组'梯度'在 FP16 里大部分下溢成 0 或掉进精度很差的次正规区——这就是早期 FP16 训练会'梯度消失'的原因。",
        "乘以 2^16 后，下溢消失、次正规只剩 0.3%，上溢还没出现；乘到 2^24 已有 0.04% 的数溢出成 Inf，2^32 时是 13.77%。"
        "缩放因子要'刚刚好'：既把小数抬上来，又不让大数冲出去。动态 loss scaling 就是在训练中不断试探这个值。",
        "BF16 不缩放也没有下溢：它的范围和 FP32 一样。代价是精度低 8 倍——这是今天训练普遍用 BF16 的原因。",
        "FP8 的范围比 FP16 还窄得多，所以 FP8 训练和推理离不开缩放因子，而且是越来越细的缩放（每张量 → 每块 128 → 每 16 个数），"
        "见 05 脚本和第 3 讲。",
    ])


if __name__ == "__main__":
    demo1_anatomy()
    demo2_format_table()
    demo3_number_line()
    demo4_verify()
    demo5_range_vs_precision()
