"""
=============================================================================
CS349E · 第 14 讲 · 03_measurement_pitfalls.py —— 测速度的陷阱：噪声、挑赢家、cache、问题太小、基线、汇总
=============================================================================

【这个脚本要回答的问题】
    02 脚本讲的是"候选骗评估器"。还有一类问题，候选一点都没作弊，评估器自己就会给出误导的数字：
      演示 1：噪声——同一份代码和自己比，"加速比"是多少？fast_1 会给它几分？
      演示 2：挑赢家（winner's curse）——16 个一模一样的候选里挑量得最快的那个，它"快了"多少？
      演示 3：cache 的冷热——同一个内核，紧接着自己再跑、跑在参考实现之后、跑在清空 cache 之后，时间差几倍？
      演示 4：问题太小——逐元素内核的加速比随问题大小怎样变化（KernelBench v0.1 为什么把题目放大到 1–15 ms）。
      演示 5：基线的精度——参考实现用更高的精度时，"加速比"从哪里来（KernelBench-Verified 的 TF32 问题）。
      演示 6：汇总——同一组加速比，算术平均、几何平均、中位数、"不低于 1"的几何平均差多少。

【关于数字】全部是本机 numpy 的墙钟时间，每次运行会变化；观察按这次运行的数字生成。
【运行】python3 03_measurement_pitfalls.py      （约 10 秒）
【依赖】numpy；本目录的 kbench；../lecture01 的 console。
"""

import math
import os
import sys
import time

import numpy as np

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

from kbench import LIBRARY, TASK, TASKS, check_output, evaluate, load_candidate  # noqa: E402
from console import print_header, print_observations, print_table  # noqa: E402

F32 = np.float32


def compiled(task_name, cand_name):
    """取出候选库里某个候选的 forward / init。"""
    c = next(c for c in LIBRARY if c.task == task_name and c.name == cand_name)
    fwd, init, _ = load_candidate(c.src, {"np": np, "math": math})
    return fwd, init


def alloc(shape, dtype=F32):
    return np.empty(shape, dtype)


def pair(f_ref, f_cand, n=10, before=None):
    """
    交替计时参考和候选（A B A B ……），各取中位数，返回 (参考秒数, 候选秒数)。

    【参数】before：每次计时前调用的函数（比如清 cache），不计时
    【为什么交替】机器的状态（频率、温度、后台进程）会慢慢漂移；交替测让两边受到同样的漂移。
    """
    tr, tc = [], []
    for i in range(n + 2):
        if before:
            before()
        t0 = time.perf_counter()
        f_ref()
        t1 = time.perf_counter()
        if before:
            before()
        t2 = time.perf_counter()
        f_cand()
        t3 = time.perf_counter()
        if i >= 2:
            tr.append(t1 - t0)
            tc.append(t3 - t2)
    return float(np.median(tr)), float(np.median(tc))


def demo1_noise():
    print_header("演示 1：噪声——同一份代码和自己比（L1_matmul，1024³，每次 10 对交替计时取中位数）")
    t = TASK["L1_matmul"]
    ref = t.ref_fn()
    fwd, _ = compiled("L1_matmul", "same")
    x = t.make_inputs(np.random.default_rng(0))
    sp = []
    for _ in range(40):
        a, b = pair(lambda: ref(x, {}, None), lambda: fwd(x, {}, alloc))
        sp.append(a / b)
    sp = np.array(sp)
    q = np.percentile(sp, [0, 10, 50, 90, 100])
    print(f"40 次测量的\"加速比\"：最小 {q[0]:.3f}，10% 分位 {q[1]:.3f}，中位数 {q[2]:.3f}，90% 分位 {q[3]:.3f}，最大 {q[4]:.3f}")
    gt1 = float(np.mean(sp > 1.0))
    gt105 = float(np.mean(sp > 1.05))
    print(f"\"加速比 > 1\"的比例 = {gt1:.2f}；\"> 1.05\"的比例 = {gt105:.2f}")
    print_observations([
        f"候选和参考是同一行代码，\"加速比\"却在 {q[0]:.3f}–{q[4]:.3f} 之间变化。fast_1 的阈值正好落在噪声中间："
        f"这次运行里，一个什么都没改的\"内核\"有 {gt1:.0%} 的机会拿到 fast_1 的分。",
        "所以 p = 1 的 fast_p 对\"和参考差不多\"的候选是一个抛硬币的判决。对策：阈值留余量（例如 p = 1.05）、"
        "多测几次、报告置信区间；KernelBench 报告它的计时变异系数 < 3%（L40S 上 100 次取平均）。",
    ])
    return sp


def demo2_winners_curse():
    print_header("演示 2：挑赢家——16 个一模一样的候选，挑量得最快的那个（重复 8 次实验）")
    t = TASK["L1_matmul"]
    ref = t.ref_fn()
    fwd, _ = compiled("L1_matmul", "same")
    x = t.make_inputs(np.random.default_rng(1))
    out = {}
    for reps in (1, 6):
        picked, remeasured = [], []
        for _ in range(8):
            sps = []
            for _ in range(16):
                a, b = pair(lambda: ref(x, {}, None), lambda: fwd(x, {}, alloc), n=reps)
                sps.append(a / b)
            picked.append(max(sps))
            again = []
            for _ in range(5):
                a, b = pair(lambda: ref(x, {}, None), lambda: fwd(x, {}, alloc), n=6)
                again.append(a / b)
            remeasured.append(float(np.median(again)))
        out[reps] = (float(np.mean(picked)), float(np.mean(remeasured)))
    print_table(["挑候选时每个测几对", "挑出来的\"赢家\"（选它时量到的）", "赢家重新测 5 次的中位数"],
                [[f"{k} 对", f"{v[0]:.3f}×", f"{v[1]:.3f}×"] for k, v in out.items()])
    picked1, re1 = out[1]
    picked6, re6 = out[6]
    print_observations([
        f"16 个候选其实一模一样（8 次实验的平均）：每个只测 1 对时，挑出来的\"赢家\"\"快了\" {picked1 - 1:.1%}；每个测 6 对取中位数时 {picked6 - 1:.1%}；"
        f"重新测一遍，都回到 {min(re1, re6):.3f}–{max(re1, re6):.3f}×。这就是\"赢家的诅咒\"：从很多带噪声的测量里挑最大值，挑出来的值系统性地偏高；"
        "测得越粗糙（噪声越大），偏得越多。",
        "重复采样（best-of-k）、进化搜索、自动调优都在\"挑最快的\"。k 越大，这个偏差越大。对策：用一组测量挑候选，"
        "用**另一组独立的测量**报告它的速度（和机器学习里\"在验证集上选模型、在测试集上报告\"是同一个道理）。"
        "第 13 讲的 tune() 也是这样做的：按带噪声的测量挑，用无噪声的时间评价。",
    ])


def demo3_cache():
    print_header("演示 3：cache 的冷热——L2_matmul_div_sum_scale 的\"换算法\"候选（矩阵-向量乘法，读 8.4 MB 的输入）")
    t = TASK["L2_matmul_div_sum_scale"]
    rng = np.random.default_rng(0)
    params = t.make_params(rng, False)
    ref = t.ref_fn()
    fwd, init = compiled("L2_matmul_div_sum_scale", "algebraic")
    pc = init(dict(params))
    x = t.make_inputs(rng)
    big = np.ones(256 * 1024 * 1024 // 4, F32)

    def flush():
        np.add(big, 1.0, out=big)       # 写一遍 256 MB：把 cache 里的东西都挤出去

    def timed(f, before=None, n=20):
        ts = []
        for _ in range(n + 3):
            if before:
                before()
            t0 = time.perf_counter()
            f()
            ts.append(time.perf_counter() - t0)
        return float(np.median(ts[3:]))

    run_ref = lambda: ref(x, params, None)          # noqa: E731
    run_c = lambda: fwd(x, pc, alloc)               # noqa: E731
    rows = []
    # (状态, 参考计时前做什么, 候选计时前做什么)
    conds = [("紧接着自己再跑（热）", None, None), ("和对方交替跑", run_c, run_ref),
             ("每次先写一遍 256 MB（冷）", flush, flush)]
    res = {}
    for name, b_ref, b_c in conds:
        tr = timed(run_ref, b_ref)
        tc = timed(run_c, b_c)
        res[name] = (tr, tc)
        rows.append([name, f"{tr * 1e3:.2f} ms", f"{tc * 1e6:.1f} µs", f"{tr / tc:.0f}×"])
    print_table(["计时前的状态", "参考", "候选", "加速比"], rows)
    hot, alt, cold = (res[c[0]][1] for c in conds)
    print_observations([
        f"同一个候选，热的时候 {hot * 1e6:.0f} µs，和参考交替跑时 {alt * 1e6:.0f} µs，冷的时候 {cold * 1e6:.0f} µs，最多差 {cold / hot:.1f} 倍；"
        "参考实现大得多（读 16.8 MB 的权重、算一个完整的矩阵乘法），受 cache 的影响小。所以\"加速比\"随计时的方式变化好几倍——"
        "01 脚本里朴素和加固的评估器在这道题上量到的倍数差很多，主要就是这个原因（朴素：同一组输入反复跑；加固：和参考交替跑，"
        "参考的权重和输出把候选的输入挤出了 cache）。",
        "GPU 上一样：Triton 的 do_bench 每次计时前清零一个 256 MB 的缓冲区，把 L2 清空，测的是\"冷\"的时间。"
        "冷和热哪个\"对\"取决于部署：在真实模型里，一个小张量往往是上一个内核刚写的，还在 L2 里；大的权重通常不在。"
        "评估时要写清楚测的是哪一种，参考和候选要用同一种。",
    ])


def demo4_size():
    print_header("演示 4：问题太小——GELU：参考（含 x**3）vs 融合的就地版本，随元素个数变化")
    rg = TASK["L1_gelu"].ref_fn()
    fg, _ = compiled("L1_gelu", "fused_inplace")
    rng = np.random.default_rng(0)
    rows, sps = [], []
    for n in (64, 1024, 16384, 262144, 4194304):
        x = (rng.standard_normal(n).astype(F32).reshape(1, -1),)
        a, b = pair(lambda: rg(x, {}, None), lambda: fg(x, {}, alloc), n=30)
        sps.append(a / b)
        rows.append([f"{n:,}", f"{a * 1e6:.1f} µs", f"{b * 1e6:.1f} µs", f"{a / b:.2f}×", f"{n / b / 1e9:.2f} G 元素/s"])
    print_table(["元素个数", "参考", "融合", "加速比", "融合版的吞吐"], rows)
    print_observations([
        f"64 个元素时加速比只有 {sps[0]:.2f}×，几百万个元素时 {sps[-1]:.2f}×。小问题上，时间主要花在每个 numpy 调用的固定开销上（几百纳秒到一微秒），"
        "和算多少数无关；两个版本调用的次数差不多，所以\"一样快\"。",
        "GPU 上的同一件事是内核启动开销（几微秒，第 11、12 讲）。KernelBench v0.1 发现几十道 Level 1/2 的题在 H100 上只要几微秒，"
        "启动开销盖过了内核本身的差别，于是把题目放大（例如 ReLU 从 0.013 ms 放大到 6.97 ms），让参考实现落在 1–15 ms。",
    ])


def demo5_baseline():
    print_header("演示 5：基线的精度——参考实现用 FP64 累加（\"严格\"基线）vs 用 FP32（\"现实\"基线）")
    rng = np.random.default_rng(0)
    A = rng.standard_normal((1024, 1024), dtype=F32)
    B = rng.standard_normal((1024, 1024), dtype=F32)
    strict = lambda: (A.astype(np.float64) @ B.astype(np.float64)).astype(F32)   # noqa: E731
    real = lambda: A @ B                                                          # noqa: E731
    cand = lambda: A @ B                                                          # noqa: E731
    t_strict, t_c1 = pair(strict, cand, n=10)
    t_real, t_c2 = pair(real, cand, n=10)
    ok, msg = check_output(cand(), strict(), True)
    err = float(np.abs(cand().astype(np.float64) - strict().astype(np.float64)).max())
    print_table(["基线", "基线时间", "候选（FP32 的库函数）", "加速比"],
                [["FP64 累加（严格）", f"{t_strict * 1e3:.2f} ms", f"{t_c1 * 1e3:.2f} ms", f"{t_strict / t_c1:.2f}×"],
                 ["FP32（现实）", f"{t_real * 1e3:.2f} ms", f"{t_c2 * 1e3:.2f} ms", f"{t_real / t_c2:.2f}×"]])
    print(f"候选相对严格基线的最大误差 {err:.2e}，容差检查：{'通过' if ok else '不通过：' + msg}")
    print_observations([
        f"候选只是\"用了更低的精度\"，什么内核都没写，相对严格基线就有 {t_strict / t_c1:.1f}× 的\"加速比\"，而且远在 1e-2 的容差以内。",
        "GPU 上的对应：PyTorch 默认不让 FP32 矩阵乘法用 TF32 Tensor Core（allow_tf32 默认关闭），参考实现跑在慢得多的 FP32 CUDA core 上；"
        "LLM 写的内核只要用了 TF32 / BF16 的 Tensor Core，就能拿到看起来很大的加速比。KernelBench-Verified（2026）把基线改成打开 TF32、"
        "再加上隐藏的多分布测试集，报告最好的模型（单轮生成）的几何平均加速比从 1.43× 降到 0.88×。",
        "容差 1e-2 本身就允许降精度。降精度是合法的优化吗？要看任务的要求：如果下游能接受 BF16，就应该和**同样用 BF16 的**基线比。",
    ])


def demo6_aggregate():
    print_header("演示 6：汇总——9 道题的加速比（每题交候选库里第一个候选，加固的评估器）")
    sps = []
    for t in TASKS:
        c = next(c for c in LIBRARY if c.task == t.name)
        r = evaluate(t, c.src, "hardened", cand_name=c.name)
        sps.append(r.speedup if r.correct else math.nan)
    s = np.array([v for v in sps if not math.isnan(v)])
    floored = np.maximum(s, 1.0)
    rows = [["算术平均", f"{s.mean():.2f}×"], ["几何平均", f"{math.exp(np.log(s).mean()):.2f}×"],
            ["中位数", f"{np.median(s):.2f}×"], ["几何平均，每题不低于 1（慢了就用参考，METR 的算法）", f"{math.exp(np.log(floored).mean()):.2f}×"],
            ["去掉最大的一个之后的算术平均", f"{np.sort(s)[:-1].mean():.2f}×"], ["fast_1", f"{np.mean(s > 1):.2f}"]]
    print("各题加速比：" + "、".join(f"{t.name} {v:.2f}×" for t, v in zip(TASKS, sps)))
    print_table(["汇总方式", "结果"], rows)
    print_observations([
        f"一道\"换算法\"的题（{max(s):.0f}×）就把算术平均拉到 {s.mean():.1f}×，去掉它只剩 {np.sort(s)[:-1].mean():.2f}×——算术平均几乎只反映最大的那一个。"
        "加速比是比值，应该用几何平均（\"平均每道题快了几倍\"），或者报告中位数和分布。",
        f"\"不低于 1\"（METR 的做法：所有尝试都比参考慢时就用参考）把 {int(np.sum(s < 1))} 道变慢的题记成 1，几何平均从 "
        f"{math.exp(np.log(s).mean()):.2f}× 变成 {math.exp(np.log(floored).mean()):.2f}×。这个约定在\"能退回参考\"的部署场景下是合理的，"
        "但它看不到退步；比较两个方法时要写清楚用了哪种汇总。",
        "fast_p、几何平均、中位数回答的是不同的问题（有多少题变快了 / 平均快多少 / 典型的题快多少）。一份好的报告会同时给出几个，再加上分布。",
    ])


if __name__ == "__main__":
    demo1_noise()
    demo2_winners_curse()
    demo3_cache()
    demo4_size()
    demo5_baseline()
    demo6_aggregate()
