跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 18 章 端到端 II:推理与部署

/code/18-e2e-inference-serving/

03-decode-step-share/decode_step_share.py8651 字节
#!/usr/bin/env python3
"""第 18 章 · 一次 decode step 里各算子的耗时占比(纯标准库,CPU 可跑)。

这是本章第 4 节那张表的**计算过程**。它把第 16 章的「瓶颈类型」结论
(`chapters/16-operators-moe-mla/code/01-cost-ledger/decode_step_ledger.py`
的 ``decode_step_summary()``)落到一个可以在任意机器上复算的 roofline 模型上。

模型
----

对每个算子分别算:

* ``flops``:乘加次数;纯搬运算子记 0。
* ``bytes``:权重流量 + 激活/中间结果流量(理想情况下的下界)。
* ``t_mem = bytes / BW``,``t_compute = flops / peak``,
  ``t = max(t_mem, t_compute)``——**假设访存与计算完美重叠**的下界。
* ``share = t / Σt``。

判类用第 02 / 14 / 16 章同一套口径:``AI = flops / bytes`` 与机器平衡点比较。

这是一个**下界模型**,不是测量:

* 真实 kernel 有启动开销、同步、尾部效应、cache 命中,每一项都会让
  「小算子」的占比比这里更高;
* quantized / fused 实现会改变权重流量与算子个数,占比随之变化;
* 多卡并行时的通信完全不在这里的账里。

所以本脚本的输出只用于回答「哪个算子值得优化」,不能当成 benchmark 结果。

用法
----

    python3 decode_step_share.py
    python3 decode_step_share.py --batch 32 --context 8192
    python3 decode_step_share.py --json
"""

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass

# ---------------------------------------------------------------------------
# 机器与模型
# ---------------------------------------------------------------------------

#: H100 SXM5 公开规格(非实测),与第 02 / 14 / 16 章同源。
PEAK_FLOPS = 1000e12
BANDWIDTH = 3.0e12
BALANCE = PEAK_FLOPS / BANDWIDTH  # 约 333 FLOP/Byte

#: Qwen2.5-7B;字段来源同 `02-kv-ledger`。
CFG = {
    "n_layers": 28,
    "n_q_heads": 28,
    "n_kv_heads": 4,
    "head_dim": 128,
    "hidden_size": 3584,
    "intermediate_size": 18944,
    "vocab_size": 152064,
    "dtype_bytes": 2,
}


@dataclass
class Op:
    """一个算子的账。"""

    name: str
    flops: float
    bytes_: float
    note: str = ""

    @property
    def ai(self) -> float:
        return float("inf") if self.bytes_ <= 0 else self.flops / self.bytes_

    @property
    def seconds(self) -> float:
        return max(self.bytes_ / BANDWIDTH, self.flops / PEAK_FLOPS)

    @property
    def bound(self) -> str:
        return "compute" if self.ai >= BALANCE else "memory"


def gemm_ops(name: str, m: int, n: int, k: int, b: int,
             note: str = "", matrices: int = 1) -> Op:
    """``matrices`` 个同形状 GEMM/GEMV:权重各读一次、激活共用、输出各写一次。"""
    flops = 2.0 * m * n * k * matrices
    byts = float(b) * (m * k + matrices * k * n + matrices * m * n)
    return Op(name, flops, byts, note)


def build_ops(batch: int, context: int) -> list[Op]:
    """一层解码 + 最终 lm_head 的算子清单。"""
    b = CFG["dtype_bytes"]
    d = CFG["hidden_size"]
    i = CFG["intermediate_size"]
    h_q, h_kv, hd = CFG["n_q_heads"], CFG["n_kv_heads"], CFG["head_dim"]
    layers = CFG["n_layers"]
    t = batch  # decode:这一步只有 1 个 token / 请求

    per_layer: list[Op] = []

    # QKV 投影:拆成 q / k / v 三块,权重形状不同(GQA)。
    per_layer.append(gemm_ops("QKV 投影 (q)", t, d, d, b))
    per_layer.append(gemm_ops("QKV 投影 (k,v)", t, 2 * h_kv * hd, d, b,
                              "GQA:KV 头只有 4 个"))
    # 输出投影
    per_layer.append(gemm_ops("输出投影 (o)", t, d, d, b))

    # Attention:QK^T 与 PV;访存是读 K 和 V 各一份
    attn_flops = 4.0 * t * h_q * context * hd
    attn_bytes = 2.0 * t * h_kv * context * hd * b
    per_layer.append(Op("decode attention", attn_flops, attn_bytes,
                        "KV 流量随 context 线性增长"))

    # MLP:gate / up / down
    per_layer.append(gemm_ops("MLP gate+up", t, i, d, b, matrices=2))
    per_layer.append(gemm_ops("MLP down", t, d, i, b))

    # Norm + RoPE:纯访存
    act_bytes = float(t) * d * b
    per_layer.append(Op("RMSNorm + RoPE(2 个 norm)", 0.0, 4.0 * act_bytes,
                        "读 + 写各两遍"))

    ops: list[Op] = []
    for op in per_layer:
        ops.append(Op(f"{op.name} × {layers} 层", op.flops * layers,
                      op.bytes_ * layers, op.note))

    # 最终分类头(每步只算一次)
    ops.append(gemm_ops("LM head / logits", t, CFG["vocab_size"], d, b,
                        "每步只算一次,但权重很大"))
    return ops


def fmt_bytes(n: float) -> str:
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if abs(n) < 1024.0:
            return f"{n:.2f} {unit}"
        n /= 1024.0
    return f"{n:.2f} PiB"


def print_weight_check() -> None:
    """自检:账本里的权重字节必须能对上模型卡的总参数量。"""
    b = CFG["dtype_bytes"]
    d = CFG["hidden_size"]
    i = CFG["intermediate_size"]
    h_kv, hd = CFG["n_kv_heads"], CFG["head_dim"]
    v = CFG["vocab_size"]
    layers = CFG["n_layers"]

    per_layer = d * d + 2 * d * (h_kv * hd) + d * d + 2 * d * i + i * d
    embed = 2 * v * d  # token embedding + lm_head
    total_params = per_layer * layers + embed
    print("=" * 96)
    print("自检:账本里出现的矩阵,参数总量必须等于模型卡给的 7.61B")
    print("=" * 96)
    print(f"  每层参数 {per_layer / 1e6:.2f} M × {layers} 层 + "
          f"嵌入/头 {embed / 1e6:.2f} M = {total_params / 1e9:.3f} B"
          f"(模型卡 7.61 B,相对误差 {abs(total_params / 7.61e9 - 1):.4%})")
    print(f"  其中账本里参与的「权重流量」是除 token embedding 外的部分:"
          f"{(total_params - v * d) * b / 1e9:.2f} GB")
    print("  token embedding 只在第一步按 token 查表,decode 每步的流量可忽略,")
    print("  但它照样占显存——这是「显存账本」和「带宽账本」不是一回事的例子。\n")


def report(batch: int, context: int) -> list[dict[str, object]]:
    ops = build_ops(batch, context)
    total = sum(op.seconds for op in ops)
    rows: list[dict[str, object]] = []
    for op in ops:
        rows.append({
            "operator": op.name,
            "t_ms": op.seconds * 1e3,
            "share": op.seconds / total,
            "ai": op.ai,
            "bound": op.bound,
            "bytes": op.bytes_,
            "flops": op.flops,
            "note": op.note,
        })
    rows.sort(key=lambda r: float(r["t_ms"]), reverse=True)
    return rows


def print_table(batch: int, context: int) -> None:
    rows = report(batch, context)
    total = sum(float(r["t_ms"]) for r in rows)
    print("=" * 96)
    print(f"一次 decode step 的算子账本:batch={batch}, context={context}, "
          f"bf16, H100 公开规格")
    print("=" * 96)
    print(f"{'算子':<34} | {'FLOP':>10} | {'字节':>11} | {'AI':>8} | "
          f"{'下界时间':>10} | {'占比':>7} | 受限")
    print("-" * 96)
    for r in rows:
        print(f"{str(r['operator']):<34} | {float(r['flops']):>10.3e} | "
              f"{fmt_bytes(float(r['bytes'])):>11} | {float(r['ai']):>8.1f} | "
              f"{float(r['t_ms']):>8.4f}ms | {float(r['share']):>6.1%} | "
              f"{str(r['bound']):>7}-bound")
    print("-" * 96)
    print(f"{'合计(完美重叠的下界)':<34} |            |             | "
          f"         | {total:>8.4f}ms | 100.0% |")
    print(f"\n  机器平衡点 {BALANCE:.0f} FLOP/Byte;"
          f"表中的 AI 全部远低于它 → 整个 decode step 是 memory-bound。")
    print("  占比最大的两项是权重流量(投影与 MLP)与 KV 流量(attention);")
    print("  前者与 batch 无关、被 batch 摊薄,后者随 context 线性增长——")
    print("  所以「加 batch」和「加 context」会改变这张表,方向相反。\n")


def print_sensitivity() -> None:
    print("=" * 96)
    print("占比怎么随 batch / context 变(同一套账本)")
    print("=" * 96)
    print(f"{'batch':>6} | {'context':>8} | {'attention 占比':>14} | "
          f"{'权重类算子占比':>16} | {'单步下界(ms)':>13}")
    for batch, ctx in ((1, 8192), (1, 131072), (8, 8192), (32, 8192),
                       (32, 131072), (128, 8192)):
        rows = report(batch, ctx)
        total = sum(float(r["t_ms"]) for r in rows)
        attn = sum(float(r["share"]) for r in rows
                   if "attention" in str(r["operator"]))
        weightish = 1.0 - attn - sum(float(r["share"]) for r in rows
                                     if "Norm" in str(r["operator"]))
        print(f"{batch:>6} | {ctx:>8} | {attn:>13.1%} | {weightish:>15.1%} | "
              f"{total:>11.4f}")
    print("\n  读法:attention 的占比只由 context(和并发序列的总 KV 长度)决定;")
    print("  权重类算子的绝对时间不随 batch 变,但被 batch 摊薄。")
    print("  这就是 continuous batching 能提高吞吐的根本原因(见 `04-serving-schedule-sim`)。")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--batch", type=int, default=1)
    parser.add_argument("--context", type=int, default=8192)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    if args.json:
        payload = {
            "balance": BALANCE,
            "batch": args.batch,
            "context": args.context,
            "rows": report(args.batch, args.context),
            "sensitivity": [
                {"batch": b, "context": c,
                 "rows": report(b, c)}
                for b, c in ((1, 8192), (1, 131072), (32, 8192), (32, 131072))
            ],
        }
        print(json.dumps(payload, indent=2, ensure_ascii=False))
        return 0

    print_weight_check()
    print_table(args.batch, args.context)
    print_sensitivity()
    print("提醒:以上是**下界模型**。真实占比会被 kernel 启动、融合程度、"
          "\n      量化方案和并行策略改变;要拿真实占比请用第 12 章的 profiler。")
    return 0


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

← 浏览本章全部代码