"""
=============================================================================
CS349E · 前置讲 P1 · 04_latency_bandwidth_little.py —— 延迟、带宽与 Little 定律；内存技术
=============================================================================

【这个脚本要回答的问题】
    03 脚本发现：同样是访问内存，有依赖的访问每次要等 120 ns，没有依赖的随机访问平均 4 ns 就能完成一次，
    顺序复制更是一秒能搬上百 GB。延迟和带宽之间到底是什么关系？要拿到更高的带宽需要什么？

      演示 1：Little 定律——"在途的数据量 = 带宽 × 延迟"。CPU 内存、H100 的 HBM、芯片间网络各需要多少在途请求？
      演示 2：实测——用 1、2、4、8、12 个线程同时复制数组，在途请求越多，带宽越高，直到内存系统饱和。
      演示 3：内存技术——带宽 = 引脚数 × 每根引脚的速率。DDR、LPDDR、HBM、HBF 各是什么取舍；
              从 GPU 的总带宽反推每根引脚跑多快。
      演示 4：SRAM 和 DRAM 的密度——把一个 70B 模型全放进 SRAM 要多大的硅片？
      演示 5：DRAM 的行缓冲——顺序访问和随机访问为什么在 DRAM 内部也不一样。

【怎么读输出】演示 2 是墙钟测量（只看趋势）；其余是公开规格上的算术，每次相同。

【运行】python3 04_latency_bandwidth_little.py          （约 1 秒）
        python3 04_latency_bandwidth_little.py --quick  （更快，数组更小）
【依赖】numpy；从 ../lecture01 导入 console、hw_specs、llm_math。
"""

import os
import sys
from concurrent.futures import ThreadPoolExecutor

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_bytes  # noqa: E402
from hw_specs import H100, B300, RUBIN  # noqa: E402
from llm_math import LLAMA31_70B  # noqa: E402
from arch_lab import littles_law_inflight, dram_row_hit_rate, seq_trace, random_trace, best_time  # noqa: E402

QUICK = "--quick" in sys.argv


# ---------------------------------------------------------------------------
# 演示 1：Little 定律
# ---------------------------------------------------------------------------
def demo1_little():
    """
    Little 定律：L = λ · W。系统里平均的"在里面的东西"= 到达速率 × 每个东西停留的时间。

    【换到内存上】要维持带宽 β（字节/秒），而每个请求要 latency 秒才回来，任意时刻必须有 β × latency
        字节的请求已经发出、还没回来。发得不够多，带宽就上不去——不管内存本身有多快。
    【延迟的取值】都是**数量级假设**：CPU 内存约 120 ns（03 脚本实测），H100 的 HBM 约 500 ns
        （Gimlet 博客引用 Jarmusch et al. 的测量：375–500 ns），芯片间互连取 1–5 µs 量级。
    """
    print_header("演示 1：Little 定律——要跑满带宽，同时得有多少数据'在路上'？")
    cases = [
        ("一个 CPU 核（单线程复制，03 实测）", 130e9, 120e-9, 128),
        ("一块 H100 的 HBM", H100.mem_bw, 500e-9, 128),
        ("  其中每个 SM（÷132）", H100.mem_bw / 132, 500e-9, 128),
        ("一块 Blackwell Ultra 的 HBM（延迟同样假设 500 ns）", B300.mem_bw, 500e-9, 128),
        ("H100 的 NVLink（单向 450 GB/s，假设 2 µs）", H100.link_bw, 2e-6, 4096),
    ]
    rows = []
    for name, bw, lat, req in cases:
        inflight = littles_law_inflight(bw, lat)
        rows.append([name, fmt_bytes(bw) + "/s", f"{lat * 1e9:,.0f} ns", fmt_bytes(inflight),
                     f"{req} B", f"{inflight / req:,.0f}"])
    print_table(["系统", "带宽", "延迟（假设）", "需要在途的字节", "每个请求", "需要同时在途的请求数"], rows)
    per_sm = littles_law_inflight(H100.mem_bw / 132, 500e-9) / 128
    print_observations([
        "一个 CPU 核要拿到 130 GB/s，就得同时有约 15 KB、一百多个 cache line 的请求在路上——靠的是乱序执行和硬件预取器。",
        f"一块 H100 要跑满 3.35 TB/s，同时要有约 1.7 MB 在途；摊到每个 SM 约 {per_sm:.0f} 个 128 字节的请求。"
        "一个 warp 一次读 128 字节，而一个 SM 最多只能驻留 64 个 warp——所以每个 warp 还得一次发好几个读请求"
        "（或者每个线程一次读 16 字节）。这就是 GPU 需要成千上万个线程、需要'向量化读取'的根本原因（P2 §7）。",
        "带宽越涨、延迟不变，需要的在途量就越大：Blackwell Ultra 要约 4 MB。'延迟不降、只涨带宽'对软件的并行度要求越来越高。",
        "网络上的请求大得多（这里按 4 KB 一个算），但延迟是微秒级，同样需要很多请求同时在路上——第 4 讲的通信与计算重叠就是为了这个。",
    ])


# ---------------------------------------------------------------------------
# 演示 2：实测——线程越多，在途请求越多
# ---------------------------------------------------------------------------
def demo2_threads(latency_ns=120.0):
    """
    k 个线程各自复制大数组的 1/k。numpy 的 copyto 在复制大数组时会释放 GIL，所以多个线程能真正同时访问内存。

    【参数】latency_ns：用来估算"在途字节"的内存延迟（取 03 脚本在编写机器上测得的约 120 ns）
    【Python 小知识】GIL（全局解释器锁）让同一时刻只有一个线程执行 Python 字节码；但 numpy 在做大块计算/复制时
        会暂时释放它，所以这里的多线程是有效的。ThreadPoolExecutor(k).map(f, items) 用 k 个线程并发执行 f。
    """
    print_header("演示 2：实测——用更多线程复制大数组（每个线程负责一段）")
    n = (16 if QUICK else 64) * 2 ** 20
    src = np.ones(n, dtype=np.float32)
    dst = np.empty_like(src)
    rows = []
    base = None
    for k in (1, 2, 4, 8, 12):
        bounds = np.linspace(0, n, k + 1).astype(int)

        def work(i):
            np.copyto(dst[bounds[i]:bounds[i + 1]], src[bounds[i]:bounds[i + 1]])

        with ThreadPoolExecutor(k) as ex:
            t = best_time(lambda: list(ex.map(work, range(k))), repeats=3 if QUICK else 5)
        bw = 2 * src.nbytes / t
        base = base or bw
        rows.append([k, f"{bw / 1e9:.0f} GB/s", f"{bw / base:.2f}×", fmt_bytes(bw * latency_ns * 1e-9)])
    print_table(["线程数", "复制带宽", "相对 1 个线程", f"在途字节（按 {latency_ns:.0f} ns 估算）"], rows)
    print_observations([
        "线程越多，同时发出的内存请求越多，带宽越高——但增长越来越慢，最后饱和：内存系统本身的上限到了。",
        "单个核拿不满整个芯片的内存带宽，这在 CPU 上很常见：一个核能同时追踪的缺失请求数有上限。",
        "GPU 把这件事推到极端：不靠一个核的'聪明'（乱序、预取），而是靠成千上万个线程同时发请求。",
    ])


# ---------------------------------------------------------------------------
# 演示 3：内存技术
# ---------------------------------------------------------------------------
def demo3_memory_tech():
    """
    带宽 = 接口位宽（引脚数）× 每根引脚每秒传几比特 ÷ 8。

    【数字来源】Ma & Patterson（IEEE Computer 2026）表 1（各代 HBM：每引脚速率、引脚数、每堆栈带宽；
        各代 NVIDIA GPU 的堆栈数）和表 3（HBF / HBM4 / DDR5 / LPDDR5 / 闪存的粗略对比）。
    """
    print_header("演示 3：内存技术——带宽 = 引脚数 × 每引脚速率")
    hbm = [("HBM", 2013, 1.0, 1024, 4), ("HBM2", 2016, 2.4, 1024, 8), ("HBM2E", 2019, 3.6, 1024, 24),
           ("HBM3", 2022, 6.4, 1024, 24), ("HBM3E", 2023, 9.8, 1024, 48), ("HBM4", 2026, 8.0, 2048, 64)]
    rows = []
    for name, year, gbps, pins, cap in hbm:
        rows.append([name, year, gbps, pins, f"{gbps * pins / 8:,.0f} GB/s", f"{cap} GiB"])
    print_table(["代际", "年份", "每引脚 Gb/s", "引脚数", "每堆栈带宽", "每堆栈最大容量"], rows)

    print("\n从 GPU 的总带宽反推每根引脚的速率（堆栈数取自 Ma & Patterson 表 1）：")
    rows = []
    for chip, stacks, pins in ((H100, 5, 1024), (B300, 8, 1024), (RUBIN, 8, 2048)):
        per_pin = chip.mem_bw * 8 / (stacks * pins) / 1e9
        rows.append([chip.name, stacks, pins, fmt_bytes(chip.mem_bw) + "/s", f"{per_pin:.2f} Gb/s"])
    print_table(["GPU", "HBM 堆栈数", "每堆栈引脚", "总带宽", "反推：每引脚速率"], rows)

    print("\n几种内存的粗略对比（Ma & Patterson 表 3，单个模块/堆栈）：")
    tech = [("HBF 堆栈（高带宽闪存）", 512, 1638, 80, "1,000s", "低"),
            ("HBM4-6400 堆栈", 48, 1638, 40, "10–100", "高"),
            ("DDR5-6400 64GB 模块", 64, 51, 12, "10–100", "高"),
            ("LPDDR5-6400 16GB 模块", 16, 51, 3, "10–100", "高")]
    rows = [[n, f"{c} GB", f"{b:,} GB/s", f"{w} W", f"{b / w:.0f}", f"{c / w:.1f}", lat, endu]
            for n, c, b, w, lat, endu in tech]
    print_table(["类型", "容量", "带宽", "功耗", "GB/s 每瓦", "GB 每瓦", "读延迟 ns", "写入寿命"], rows)
    print_observations([
        "HBM 的秘诀是**宽**：1024 根（HBM4 是 2048 根）引脚，而一条 DDR5 通道只有 64 位宽。这么多引脚只有在"
        "把内存堆叠起来、和计算芯片放在同一个封装里（用硅中介层连接）时才做得到。",
        "反推出 H100 每根引脚约 5.2 Gb/s，低于 HBM3 的上限 6.4；Blackwell Ultra 约 7.8 Gb/s；Rubin 的 22 TB/s 反推出"
        "约 10.7 Gb/s，高于表里 HBM4 的 8 Gb/s——说明它用的是更高速率的 HBM4（表 1 是 JEDEC 标准的数字）。",
        "按每瓦带宽算，HBM 比 DDR5 高约 10 倍；按每瓦容量算，DDR5 反而更好。没有'最好'的内存，只有'适合哪种数据'："
        "频繁读的权重和 KV 放 HBM，大而冷的数据放 DDR / 闪存。Ma & Patterson 提议的 HBF 就是想要'HBM 的带宽 + 闪存的容量'，"
        "代价是写入寿命低、读延迟是微秒级——所以只适合放推理时不变的权重。",
    ])


# ---------------------------------------------------------------------------
# 演示 4：SRAM 的密度
# ---------------------------------------------------------------------------
def demo4_sram_density():
    """
    SRAM 每比特 6 个晶体管，DRAM 每比特 1 个晶体管 + 1 个电容，所以同样面积 SRAM 存得少得多。

    【数字来源】Gimlet Labs 博客 *The emerging role of SRAM-centric chips in AI inference* 的表格
        （每平方毫米多少 MB 的 SRAM）。光罩极限约 858 mm²（26 mm × 33 mm）。
    """
    print_header(f"演示 4：把 {LLAMA31_70B.name}（FP8，{fmt_bytes(LLAMA31_70B.weight_bytes('fp8'))}）全放进 SRAM 要多大面积？")
    w_mb = LLAMA31_70B.weight_bytes("fp8") / 1e6
    chips = [("B200 的 L2 cache（双 die）", 126, 1600, "4 nm"), ("Groq LPU v1", 230, 725, "14 nm"),
             ("d-Matrix Corsair", 256, 400, "6 nm"), ("Cerebras WSE-3（整片晶圆）", 44_000, 46_255, "5 nm")]
    rows = []
    for name, sram_mb, area, node in chips:
        density = sram_mb / area
        need_area = w_mb / density
        rows.append([name, node, f"{sram_mb:,} MB", f"{area:,} mm²", f"{density:.2f}", f"{need_area:,.0f} mm²",
                     f"{need_area / 858:,.0f}"])
    print_table(["芯片", "工艺", "SRAM", "芯片面积", "MB/mm²", "放下 70B 需要的面积", "≈ 几个光罩极限的芯片"], rows)
    print_observations([
        "即使是 SRAM 密度最高的整片晶圆（Cerebras），放下一个 70B 的 FP8 模型也要约 1.6 片晶圆那么大的面积；"
        "按其他芯片的 SRAM 密度，要上百到上千个光罩极限大小的芯片。",
        "而一个 12 层的 HBM3E 堆栈就有 36 GB——DRAM 的密度高一个数量级以上。这就是为什么'把模型全放进 SRAM'的芯片"
        "需要很多颗芯片一起用（第 1 讲 §4.7），而 GPU 用 HBM 放权重、只在片上放'正在算的那一小块'。",
        "Ma & Patterson 也指出：Cerebras 和 Groq 十年前用满片 SRAM 是合理的，但模型很快就超过了片上容量，两家后来都加了外部 DRAM。",
    ])


# ---------------------------------------------------------------------------
# 演示 5：DRAM 的行缓冲
# ---------------------------------------------------------------------------
def demo5_row_buffer():
    """
    DRAM 内部：每个 bank 一次只能"打开"一行（几 KB）。在已打开的行里读数据（row hit）快，换一行（row miss）要先关旧行、
    再开新行，慢好几倍、也更耗电。
    """
    print_header("演示 5：DRAM 的行缓冲——同样读 64 字节，命中已打开的行和换一行差多少")
    n = 20_000
    patterns = [("顺序读（每次 64 B）", seq_trace(n, 64)),
                ("跨 4 KB 读", seq_trace(n, 4096)),
                ("1 GB 范围内随机读", random_trace(n, 1 << 30, 64, seed=0))]
    rows = []
    for name, tr in patterns:
        rows.append([name, f"{dram_row_hit_rate(tr) * 100:.1f}%"])
    print_table(["访问模式", "行命中率（8 KB 行、16 个 bank 的玩具模型）"], rows)
    print_observations([
        "顺序访问几乎总落在已经打开的行里；随机访问几乎每次都要换行。DRAM 自己也'喜欢'顺序访问。",
        "这是又一个'顺序快、随机慢'的来源：cache line、预取器、DRAM 行缓冲，层层都在奖励局部性。"
        "GPU 的合并访存（P2 §5）、第 7 讲 PagedAttention 选择多大的页，背后都有这层考虑。",
    ])


if __name__ == "__main__":
    demo1_little()
    demo2_threads()
    demo3_memory_tech()
    demo4_sram_density()
    demo5_row_buffer()
