"""
=============================================================================
CS349E · 前置讲 P2 · 05_amdahl_and_launch_overhead.py —— Amdahl 定律与内核启动开销
=============================================================================

【这个脚本要回答的问题】
    GPU 有上万个运算单元，可程序里总有不能并行的部分：CPU 端的调度、每次启动内核的固定开销、
    内核之间的空隙……这些"串行部分"在大规模并行下会被放大。本脚本：

      演示 1：Amdahl 定律 vs Gustafson 定律——同样的串行比例，"固定问题规模"和"问题跟着变大"结论完全不同。
      演示 2：LLM decode 的内核启动开销——一个 token 要启动几百个小内核，每个几微秒的开销加起来有多大？
              （小模型更严重：这正是第 11 讲 megakernel 的动机。）
      演示 3：在你的电脑上看到同样的现象——每次 numpy 调用都有固定开销，很多次小调用 vs 一次大调用。

【怎么读输出】演示 1、2 是公式（演示 2 的每层内核数和每次启动开销是**标明的假设值**），每次相同；
    演示 3 是墙钟测量，只看数量级。

【运行】python3 05_amdahl_and_launch_overhead.py          （约 2 秒）
        python3 05_amdahl_and_launch_overhead.py --quick  （约 1 秒）
【依赖】numpy；本目录的 simt_lab；../lecture01 的 console、hw_specs、llm_math。
"""

import os
import sys

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, fmt_time  # noqa: E402
from hw_specs import H100  # noqa: E402
from llm_math import ModelConfig, LLAMA31_8B, decode_step  # noqa: E402
from simt_lab import amdahl, gustafson  # noqa: E402

QUICK = "--quick" in sys.argv

# Llama-3.2-1B 的公开结构（Meta 模型卡 / config.json）：16 层、隐藏维 2048、32 个 query 头、8 个 KV 头、每头 64 维、
# MLP 中间维 8192、词表 128,256，词嵌入与输出层共享权重。
LLAMA32_1B = ModelConfig("Llama-3.2-1B", n_layers=16, d_model=2048, n_heads=32, n_kv_heads=8,
                         d_head=64, d_ff=8192, vocab=128256, tied=True)


# ---------------------------------------------------------------------------
# 演示 1：Amdahl vs Gustafson
# ---------------------------------------------------------------------------
def demo1_amdahl():
    """
    Amdahl：问题规模固定，串行比例 s 的部分不能加速 → 加速比 ≤ 1/s。
    Gustafson：处理器越多，人们就解更大的问题（并行部分跟着变大，串行部分的时间不变）→ 加速比 ≈ s + (1−s)·n。
    【n 的取值】8（一台机器的 CPU 核）、132（H100 的 SM 数）、16,896（H100 的 FP32 lane 数）、
        135,168（8 块 H100 的 FP32 lane 数）。
    """
    print_header("演示 1：Amdahl 定律（固定规模）vs Gustafson 定律（规模随之变大）")
    ns = (8, 132, 16_896, 135_168)
    rows = []
    for s in (0.01, 0.001, 0.0001):
        rows.append([f"{s * 100:g}%", "Amdahl"] + [f"{amdahl(s, n):,.0f}" for n in ns] + [f"{1 / s:,.0f}"])
        rows.append(["", "Gustafson"] + [f"{gustafson(s, n):,.0f}" for n in ns] + ["∞"])
    print_table(["串行比例 s", "定律"] + [f"n = {n:,}" for n in ns] + ["n → ∞"], rows)
    print_observations([
        "Amdahl：只要有 1% 不能并行，n 再大加速比也不超过 100；在 H100 的 1.7 万个 lane 上，0.1% 的串行部分就让加速比停在约 944。",
        "Gustafson：如果问题规模随处理器数一起变大（比如更大的批、更长的序列），串行部分的占比越来越小，加速比几乎线性。",
        "两个定律不矛盾，回答的是不同的问题：'同一个任务能快多少'（延迟）vs '同样时间能多做多少'（吞吐）。"
        "LLM 推理两者都要：单个请求的 decode 延迟受 Amdahl 限制，批处理的吞吐更像 Gustafson。",
    ])


# ---------------------------------------------------------------------------
# 演示 2：LLM decode 的内核启动开销
# ---------------------------------------------------------------------------
KERNELS_UNFUSED = ["RMSNorm", "QKV 投影", "RoPE", "写 KV cache", "注意力", "输出投影", "残差相加",
                   "RMSNorm", "gate/up 投影", "SiLU × 乘", "down 投影", "残差相加"]
KERNELS_FUSED = ["残差+RMSNorm", "QKV 投影", "RoPE+写 KV", "注意力", "输出投影", "残差+RMSNorm",
                 "gate/up 投影+SiLU×乘", "down 投影"]


def demo2_decode_launches(ctx=1024):
    """
    一步 decode（batch = 1）要启动多少个内核？每个内核的启动开销加起来占多少时间？

    【假设】
        - 每层的内核列表：未融合 12 个、适度融合 8 个（列在下面，是有代表性的划分，不对应某个具体引擎）；
          最后还有 3 个（最终 RMSNorm、输出层、采样）。megakernel 把整个前向放进 1 个内核。
        - 每次启动的开销 t_launch（CPU 端准备参数、驱动提交、GPU 开始执行之间的空隙）：扫描 2、4、8 µs。
        - GPU 真正的工作时间取 roofline 下界（第 1 讲 llm_math.decode_step，H100、BF16）。
    【两种合成方式】
        - 不重叠（上界）：总时间 = GPU 工作时间 + 内核数 × t_launch
        - 完全重叠（下界）：CPU 提前把内核排进队列，总时间 = max(GPU 工作时间, 内核数 × t_launch)
        真实系统在两者之间。
    """
    print_header(f"演示 2：一步 decode（batch = 1，ctx = {ctx}，H100，BF16）要启动多少个内核？")
    print("未融合（每层 12 个）：" + "、".join(KERNELS_UNFUSED))
    print("适度融合（每层 8 个）：" + "、".join(KERNELS_FUSED))
    rows = []
    for model in (LLAMA31_8B, LLAMA32_1B):
        t_gpu = decode_step(model, H100, 1, ctx)["t_step"]
        for label, per_layer in (("未融合", len(KERNELS_UNFUSED)), ("适度融合", len(KERNELS_FUSED)), ("megakernel", None)):
            n_k = 1 if per_layer is None else model.n_layers * per_layer + 3
            for t_launch in (2e-6, 4e-6, 8e-6):
                serial = t_gpu + n_k * t_launch
                overlap = max(t_gpu, n_k * t_launch)
                rows.append([model.name, fmt_time(t_gpu), label, n_k, f"{t_launch * 1e6:.0f} µs",
                             fmt_time(serial), f"{(serial - t_gpu) / serial * 100:.0f}%",
                             fmt_time(overlap), f"{1 / serial:.0f}–{1 / overlap:.0f}"])
    print_table(["模型", "GPU 工作下界", "内核划分", "内核数/步", "每次启动开销", "不重叠：每步", "开销占比",
                 "完全重叠：每步", "tok/s（两种合成）"], rows)
    print_observations([
        "8B 模型 batch = 1 时，GPU 光读权重就要约 4.5 ms；几百次启动、每次几微秒，不能和 GPU 工作重叠时，"
        "开销占每步时间的 10%–41%——已经不能忽略。",
        "1B 模型的权重只有约 2.5 GB，GPU 工作时间不到 1 ms，而内核数还有 130–200 个：不重叠时开销占每步的 26%–68%；"
        "启动开销 8 µs 时，即使完全重叠，CPU 发内核的速度也跟不上 GPU（每步从 0.75 ms 变成 1–1.6 ms）。"
        "模型越小、批越小，这个问题越严重。",
        "三种对策（第 11、12 讲）：(1) 算子融合——减少内核个数；(2) CUDA Graphs——把一整串内核录下来一次性提交，"
        "大幅降低每个内核的 CPU 端开销；(3) megakernel——把整个前向写成一个常驻的内核，彻底消灭内核边界。"
        "第 11 讲的阅读材料（Hazy Research，Llama-1B 的低延迟 megakernel）正是针对这里的 1B 模型。",
        "每次启动开销 2–8 µs、每层内核数都是假设值；真实数字取决于驱动、CPU、框架。结论的方向（小模型、小批时开销占比高）不依赖具体数值。",
    ])


# ---------------------------------------------------------------------------
# 演示 3：numpy 的每次调用开销
# ---------------------------------------------------------------------------
def demo3_numpy_overhead():
    """
    每次 numpy 调用都有一段固定开销（Python 解释器、参数检查、分配输出……），和 GPU 的内核启动开销是同一类东西。

    【实测】(1) 不同大小的 np.add 每次调用多久；(2) 同样的总工作量，切成很多次小调用 vs 一次大调用。
    """
    print_header("演示 3：在你的电脑上——每次 numpy 调用的固定开销")
    import time

    def best(fn, reps):
        fn()
        b = float("inf")
        for _ in range(3):
            t0 = time.perf_counter()
            for _ in range(reps):
                fn()
            b = min(b, (time.perf_counter() - t0) / reps)
        return b

    rows = []
    t_small = None
    for n in (1, 64, 4096, 262_144, 4_194_304):
        a = np.ones(n, dtype=np.float32)
        o = np.empty_like(a)
        reps = max(3, (20_000 if not QUICK else 2_000) // max(1, n // 4096))
        t = best(lambda: np.add(a, a, out=o), reps)
        t_small = t_small or t
        rows.append([f"{n:,}", fmt_time(t), f"{t / n * 1e9:.3f} ns", f"{min(1.0, t_small / t) * 100:.0f}%"])
    print_table(["元素个数", "每次调用", "每个元素", "固定开销占比（≈ 最小调用时间 / 本次时间）"], rows)

    total = 1 << 18
    pieces = 128
    a = np.ones(total, dtype=np.float32)
    o = np.empty_like(a)
    chunk = total // pieces
    t_many = best(lambda: [np.add(a[k * chunk:(k + 1) * chunk], a[k * chunk:(k + 1) * chunk],
                                  out=o[k * chunk:(k + 1) * chunk]) for k in range(pieces)], 20)
    t_one = best(lambda: np.add(a, a, out=o), 200)
    print(f"\n同样把 {total:,} 个元素相加：切成 {pieces} 次调用 {fmt_time(t_many)}，一次调用 {fmt_time(t_one)}，"
          f"相差 {t_many / t_one:.1f} 倍。")
    print_observations([
        "很小的数组，一次调用的时间几乎全是固定开销（约零点几微秒）；数组到几十万个元素以后，固定开销才被摊薄。",
        "同样的总工作量，切成 128 次小调用比一次大调用慢好几倍——和'几百个小内核 vs 一个 megakernel'是同一件事。",
        "GPU 上的情况更极端：GPU 处理一个小内核可能只要几微秒，而启动它的固定开销也是几微秒。"
        "这就是为什么推理引擎要融合算子、用 CUDA Graphs，以及第 11 讲要讲 megakernel。",
    ])


if __name__ == "__main__":
    demo1_amdahl()
    demo2_decode_launches()
    demo3_numpy_overhead()
