跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

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

/code/18-e2e-inference-serving/

04-serving-schedule-sim/serving_schedule_sim.py7773 字节
#!/usr/bin/env python3
"""第 18 章 · static batching 与 continuous batching 的调度仿真(纯标准库,CPU 可跑)。

这份脚本不测任何真实硬件,它把「调度」这件事单独拎出来做离散事件仿真,
回答一个问题:

    在同样的算力与同样的请求流下,为什么 continuous batching 的吞吐
    能比 static batching 高出一大截?

模型(故意做得非常小,便于手算复核)
----------------------------------

* 一个「槽位(slot)」是一份 KV cache 的容量,也就是一条并发序列。
* 请求按到达时刻进入队列;每个请求有 [(prompt 长度, 输出长度)]。
* 一个 decode step 的耗时固定为 ``STEP_MS``,与这一批里有几条序列无关
  —— 这正是「decode 是 memory-bound、权重流量被 batch 摊薄」在调度层的体现。
* prefill 的耗时正比于本步要处理的 prompt token 数(compute-bound)。

两种策略
--------

* **static**:凑满 ``slots`` 条请求就一起跑到底;早做完的序列空占槽位
  直到整批结束(GPU 在 decode 步里仍然要陪跑)。
* **continuous**:每个 decode step 结束时把已完成的序列踢出去,并从队列
  里补进新序列(这就是 iteration-level scheduling)。

输出:总完成时间、总输出 token 数、吞吐(token/s)、平均与 p95 延迟。

**这是仿真,不是测量**:真实系统里 decode step 的耗时并非严格常数,
prefill 与 decode 还会混步(chunked prefill),调度器还会考虑公平性与
优先级。模型的用处是让「调度决定吞吐」这件事变得可复算。

用法
----

    python3 serving_schedule_sim.py
    python3 serving_schedule_sim.py --slots 8 --step-ms 8 --seed 7
"""

from __future__ import annotations

import argparse
import copy
import random
from dataclasses import dataclass, field


@dataclass
class Request:
    rid: int
    prompt: int
    output: int
    arrive_ms: float
    start_ms: float = -1.0
    first_token_ms: float = -1.0
    done_ms: float = -1.0
    produced: int = 0

    @property
    def ttft(self) -> float:
        return self.first_token_ms - self.arrive_ms

    @property
    def e2e(self) -> float:
        return self.done_ms - self.arrive_ms

    @property
    def tpot(self) -> float:
        return (self.done_ms - self.first_token_ms) / max(1, self.output - 1)


@dataclass
class Result:
    name: str
    makespan_ms: float
    tokens: int
    requests: list[Request] = field(default_factory=list)

    @property
    def throughput(self) -> float:
        return self.tokens / (self.makespan_ms / 1e3)

    def latency(self) -> tuple[float, float, float, float]:
        e2e = sorted(r.e2e for r in self.requests)
        ttft = sorted(r.ttft for r in self.requests)
        p95 = e2e[min(len(e2e) - 1, int(0.95 * len(e2e)))]
        return e2e[0], sum(e2e) / len(e2e), p95, sum(ttft) / len(ttft)


def make_requests(n: int, seed: int) -> list[Request]:
    """请求流:输出长度离散度很大,这是真实负载的常态。"""
    rng = random.Random(seed)
    out: list[Request] = []
    t = 0.0
    for i in range(n):
        prompt = rng.choice((128, 256, 512, 1024))
        # 大部分请求几十个 token,少数请求几百个:长尾是 static batching 的杀手
        output = rng.choice((16, 24, 32, 48, 64, 96, 128, 256, 512))
        out.append(Request(i, prompt, output, arrive_ms=t))
        t += rng.uniform(2.0, 12.0)
    return out


def run_static(reqs: list[Request], slots: int, step_ms: float,
               prefill_ms_per_tok: float) -> Result:
    """凑满一批一起跑;早完成的序列空占槽位。"""
    pending = list(reqs)
    clock = 0.0
    done: list[Request] = []
    while pending:
        batch = pending[:slots]
        pending = pending[slots:]
        clock = max(clock, max(r.arrive_ms for r in batch))
        # prefill 阶段:这一批的 prompt 一次性处理
        max_prompt = max(r.prompt for r in batch)
        clock += max_prompt * prefill_ms_per_tok
        for r in batch:
            r.start_ms = clock - max_prompt * prefill_ms_per_tok
            r.first_token_ms = clock
        max_out = max(r.output for r in batch)
        for _ in range(max_out):
            clock += step_ms
            for r in batch:
                if r.produced < r.output:
                    r.produced += 1
                if r.produced == r.output and r.done_ms < 0:
                    r.done_ms = clock
        done.extend(batch)
    return Result("static", clock, sum(r.output for r in done), done)


def run_continuous(reqs: list[Request], slots: int, step_ms: float,
                   prefill_ms_per_tok: float) -> Result:
    """每个 decode step 结束就换人(iteration-level scheduling)。"""
    pending = list(reqs)
    clock = 0.0
    running: list[Request] = []
    done: list[Request] = []

    # 第一步:把队列里能塞进去的请求做 prefill(这里把 prefill 也串在时间轴上)
    while pending or running:
        while pending and len(running) < slots:
            r = pending.pop(0)
            clock = max(clock, r.arrive_ms)
            clock += r.prompt * prefill_ms_per_tok
            r.start_ms = clock - r.prompt * prefill_ms_per_tok
            r.first_token_ms = clock
            r.produced = 1
            if r.produced >= r.output:
                r.done_ms = clock
                done.append(r)
            else:
                running.append(r)
        if not running:
            continue
        # 一个 decode step:所有在跑的序列各出 1 个 token
        clock += step_ms
        still: list[Request] = []
        for r in running:
            r.produced += 1
            if r.produced >= r.output:
                r.done_ms = clock
                done.append(r)
            else:
                still.append(r)
        running = still

    return Result("continuous", clock, sum(r.output for r in done), done)


def print_result(res: Result) -> None:
    p_min, p_mean, p95, ttft_mean = res.latency()
    print(f"\n[{res.name}]")
    print(f"  总耗时 (makespan)      : {res.makespan_ms:>10.1f} ms")
    print(f"  输出 token 总数        : {res.tokens:>10d}")
    print(f"  吞吐                    : {res.throughput:>10.1f} token/s")
    print(f"  E2E 延迟 min/mean/p95  : {p_min:>8.1f} / {p_mean:>8.1f} / "
          f"{p95:>8.1f} ms")
    print(f"  TTFT mean              : {ttft_mean:>10.1f} ms")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--requests", type=int, default=64)
    parser.add_argument("--slots", type=int, default=8)
    parser.add_argument("--step-ms", type=float, default=8.0,
                        help="一个 decode step 的固定耗时(仿真参数)")
    parser.add_argument("--prefill-us-per-tok", type=float, default=12.0,
                        help="prefill 每个 prompt token 的耗时(微秒)")
    parser.add_argument("--seed", type=int, default=20261001)
    args = parser.parse_args()

    prefill_ms_per_tok = args.prefill_us_per_tok / 1e3
    reqs = make_requests(args.requests, args.seed)

    print("=" * 78)
    print("仿真参数(人为设定,不是测量值)")
    print("=" * 78)
    print(f"  请求数                : {args.requests}")
    print(f"  槽位数(并发上限)    : {args.slots}")
    print(f"  decode step 固定耗时  : {args.step_ms} ms(与 batch 大小无关)")
    print(f"  prefill 每 token      : {args.prefill_us_per_tok} us")
    print(f"  输出长度分布          : {sorted({r.output for r in reqs})}")
    print(f"  输出长度标准差/均值   : "
          f"{_cv([r.output for r in reqs]):.2f}(离散度越大,static 越吃亏)")

    # 两份独立的请求对象:run_* 会就地写 ttft / done_ms,共用对象会互相覆盖
    static = run_static(copy.deepcopy(reqs), args.slots, args.step_ms,
                        prefill_ms_per_tok)
    cont = run_continuous(copy.deepcopy(reqs), args.slots, args.step_ms,
                          prefill_ms_per_tok)
    print_result(static)
    print_result(cont)
    print("\n" + "=" * 78)
    print("对比")
    print("=" * 78)
    speed = static.makespan_ms / cont.makespan_ms
    thr = cont.throughput / static.throughput
    print(f"  总耗时缩短            : {speed:.2f}×")
    print(f"  吞吐提升              : {thr:.2f}×")
    _, sm, sp95, _ = static.latency()
    _, cm, cp95, _ = cont.latency()
    print(f"  平均 E2E 延迟         : {sm:.1f} ms → {cm:.1f} ms")
    print(f"  p95 E2E 延迟          : {sp95:.1f} ms → {cp95:.1f} ms")
    print("\n  读法:收益全部来自「不让早完成的序列占着槽位」和「不让新请求"
          "\n  等到整批结束」。代价是调度器要能处理变长 batch、要按块管理 KV、"
          "\n  还要处理 prefill 与 decode 混步——这就是下一节 varlen 的由来。")
    return 0


def _cv(values: list[int]) -> float:
    """变异系数(标准差 / 均值)。"""
    mean = sum(values) / len(values)
    var = sum((v - mean) ** 2 for v in values) / len(values)
    return (var ** 0.5) / mean


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

← 浏览本章全部代码