跳转至
手写 mini-sglang 阶段四 · 更快、更大

基准测试与消融

所有功能都写完了,最后要回答"每个优化到底值多少"。这一章给出两个压测工具——离线吞吐测试和在线的流式压测客户端——以及一组消融实验的做法:关掉某一项优化,看吞吐和延迟怎么变。CPU 上我们跑了两个趋势明显的消融;需要 GPU 的那些给出命令和要观察的指标。

自测:能答上来就可以跳过本章

  1. 离线吞吐测试和在线压测分别回答什么问题?
  2. TTFT、TPOT 各受哪些因素影响?哪项优化主要改善 TTFT、哪项主要改善 TPOT?
  3. 为什么 CPU 上"批大小从 1 到 16"的吞吐提升远小于 GPU?
  4. 想量化重叠调度的收益,应该用大模型还是小模型?
自测参考答案(先自己答,再展开对照)
  1. 离线吞吐测试:一次性把所有请求都给引擎,衡量满负载下每秒能生成多少 token(引擎本身有多快);在线压测:按某个请求速率发送请求,衡量这个负载下的 TTFT、TPOT 和尾延迟(能不能满足 SLO)。
  2. TTFT:排队时间、prefill 的计算量(提示词长度、前缀命中率)、调度策略;TPOT:每一步 decode 的时间(batch 大小、KV 长度、kernel 效率、CPU 开销)。前缀缓存、分块 prefill 主要改善 TTFT;CUDA Graph、重叠调度、更好的注意力 kernel 主要改善 TPOT。
  3. CPU 的算力和带宽之比与 GPU 不同:CPU 上 batch 为 1 时计算就已经占了相当大的比例,不像 GPU 那样几乎全在读权重,所以增大 batch 带来的"免费"吞吐提升小得多。
  4. 小模型:重叠调度藏的是固定的 CPU 开销,模型越小、每一步的 GPU 时间越短,这部分开销占比越大,收益越明显;大模型上 GPU 计算时间长,CPU 开销本来就被淹没了。

本章要写的文件:benchmark/offline.py、benchmark/client.py。

到这一章为止的文件:82 个(新建 3,修改 0,提前引入 0)
minisgl/
├── __init__.py
├── core.py
├── env.py
├── __main__.py
├── shell.py
├── attention/  (6 个文件,不变)
├── benchmark/
│   ├── __init__.py  ← 本章新建
│   ├── offline.py  ← 本章新建
│   ├── client.py  ← 本章新建
├── distributed/  (3 个文件,不变)
├── engine/  (5 个文件,不变)
├── kernel/  (3 个文件,不变)
├── kvcache/  (5 个文件,不变)
├── layers/  (9 个文件,不变)
├── llm/  (2 个文件,不变)
├── message/  (5 个文件,不变)
├── models/  (7 个文件,不变)
├── moe/  (4 个文件,不变)
├── scheduler/  (9 个文件,不变)
├── server/  (4 个文件,不变)
├── tokenizer/  (4 个文件,不变)
├── utils/  (8 个文件,不变)
这一步的 main:examples/ch21_benchmark.py——它只用到上面这些文件;python tools/steps.py check 会逐章搭出这棵树、跑这个 main。

离线吞吐

minisgl/benchmark/offline.py · run
def run(model: str, num_seqs: int, max_input_len: int, max_output_len: int,
        dtype: torch.dtype = torch.bfloat16, **llm_kwargs) -> dict:
    seed(0)
    llm = LLM(model, dtype=dtype, **llm_kwargs)
    prompts = [[randint(0, 10000) for _ in range(randint(max_input_len // 10, max_input_len))]
               for _ in range(num_seqs)]
    params = [SamplingParams(temperature=0.6, ignore_eos=True,
                             max_tokens=randint(max_output_len // 10, max_output_len))
              for _ in range(num_seqs)]
    llm.generate(["Benchmark: "], SamplingParams(max_tokens=4))  # 预热
    t = time.perf_counter()
    outputs = llm.generate(prompts, params)
    t = time.perf_counter() - t
    total = sum(len(o["token_ids"]) for o in outputs)
    llm.shutdown()
    return {"output_tokens": total, "seconds": t, "throughput": total / t}
与官方的 benchmark/offline/bench.py(源自 nano-vllm)相同的做法:随机生成若干请求,输入、输出长度各自在区间内均匀随机,ignore_eos=True 保证每个请求都生成满 max_tokens,统计输出 token 的吞吐。它衡量的是满负载下引擎能跑多快,不关心单个请求的延迟。

python -m minisgl.benchmark.offline --model Qwen/Qwen3-0.6B --num-seqs 256 \
    --max-input-len 1024 --max-output-len 1024 --page-size 256 --cuda-graph-max-bs 256

在线压测

minisgl/benchmark/client.py · one_request
async def one_request(client: httpx.AsyncClient, url: str, prompt: str, max_tokens: int) -> RequestResult:
    body = {"model": "m", "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens,
            "temperature": 0.0, "ignore_eos": True, "stream": True}
    res = RequestResult()
    start = time.perf_counter()
    async with client.stream("POST", url, json=body) as resp:
        async for line in resp.aiter_lines():
            if not line.startswith("data: ") or line == "data: [DONE]":
                continue
            delta = json.loads(line[6:])["choices"][0]["delta"].get("content")
            if delta:
                now = time.perf_counter() - start
                if not res.chunk_times:
                    res.ttft = now
                res.chunk_times.append(now)
                res.output_chars += len(delta)
    res.latency = time.perf_counter() - start
    return res
在线压测模拟真实的用户:请求按泊松过程到达(相邻请求的间隔服从指数分布),流式接收,记录每个请求的 TTFT(发出到收到第一个片段)、TPOT(之后每个片段的平均间隔)和端到端延迟,报告均值、中位数和 P99。它回答的是在某个请求速率下,用户体验如何。

python -m minisgl --model Qwen/Qwen3-0.6B &
python -m minisgl.benchmark.client --num-requests 256 --rate 8 --max-tokens 256

逐步提高 --rate,画出"请求速率 - P99 TTFT / P99 TPOT"曲线,找到满足延迟目标(SLO)的最大速率,就是这台机器的容量(方法见推理系统手册的压测与容量规划一章)。

CPU 上的两个消融

examples/ch21_benchmark.py
"""第 21 章:在 CPU 上做两个消融——连续批处理的批大小,以及前缀缓存。

CPU 上的绝对数字没有参考价值,但趋势与 GPU 上一致。GPU 上的测法见正文。
"""

import time
from random import randint, seed

import torch
from minisgl.core import SamplingParams
from minisgl.llm import LLM

PATH = "models/Qwen3-0.6B"
seed(0)
prompts = [[randint(100, 10000) for _ in range(randint(32, 64))] for _ in range(16)]
params = SamplingParams(max_tokens=32, ignore_eos=True, temperature=0.6)

print("一、最大并发请求数(16 个请求,输入 32~64、输出 32 个 token)")
for max_running in (1, 4, 16):
    llm = LLM(PATH, dtype=torch.bfloat16, max_running_req=max_running, num_page_override=4096,
              max_seq_len_override=256)
    llm.generate([[1, 2, 3]], SamplingParams(max_tokens=2))  # 预热
    t = time.perf_counter()
    out = llm.generate(prompts, params)
    dt = time.perf_counter() - t
    n = sum(len(o["token_ids"]) for o in out)
    print(f"  max_running_req={max_running:<3} 用时 {dt:6.2f}s  输出吞吐 {n / dt:7.1f} token/s")
    llm.shutdown()

print("二、前缀缓存(16 个请求共享 400 个 token 的前缀,各自再加 16 个 token)")
shared = [randint(100, 10000) for _ in range(400)]
prompts2 = [shared + [randint(100, 10000) for _ in range(16)] for _ in range(16)]
for cache in ("naive", "radix"):
    llm = LLM(PATH, dtype=torch.bfloat16, max_running_req=16, num_page_override=16384,
              max_seq_len_override=512, cache_type=cache)
    llm.generate([[1, 2, 3]], SamplingParams(max_tokens=2))
    computed = []
    orig = llm._forward
    llm._forward = lambda fi: (computed.append(len(fi.batch.positions)) if fi.batch.is_prefill else None,
                               orig(fi))[1]
    t = time.perf_counter()
    llm.generate(prompts2[:1], SamplingParams(max_tokens=1))  # 第一个请求先把前缀算好
    llm.generate(prompts2[1:], SamplingParams(max_tokens=8, ignore_eos=True))
    dt = time.perf_counter() - t
    print(f"  {cache:<5} prefill 计算 {sum(computed):>5} 个 token,总用时 {dt:5.2f}s")
    llm.shutdown()
运行结果
一、最大并发请求数(16 个请求,输入 32~64、输出 32 个 token)
  max_running_req=1   用时  24.47s  输出吞吐    20.9 token/s
  max_running_req=4   用时  15.02s  输出吞吐    34.1 token/s
  max_running_req=16  用时  11.49s  输出吞吐    44.6 token/s
二、前缀缓存(16 个请求共享 400 个 token 的前缀,各自再加 16 个 token)
  naive prefill 计算  6656 个 token,总用时 18.62s
  radix prefill 计算   656 个 token,总用时  4.19s
连续批处理:同样 16 个请求,一次只允许 1 个请求运行时,吞吐只有约 21 token/s;允许 16 个同时运行时约为两倍。在 GPU 上这个差距会大得多:decode 是访存受限的,批大小从 1 到 16,每步的时间几乎不变(读一遍权重的时间占主导),吞吐接近线性增长;而 CPU 的算力相对带宽弱得多,批大小一增加,计算就成了瓶颈,收益被压缩。

前缀缓存:16 个请求共享 400 个 token 的前缀时,naive 缓存要把前缀算 16 遍(6656 个 token);Radix Cache 只算第一遍,之后每个请求只算自己的 16 个 token 加上最后一个前缀 token(656 个),总用时从约 18 秒降到约 4 秒。前缀缓存的收益与"共享前缀的长度 × 请求数"成正比,在多轮对话、Agent、少样本提示等场景里非常可观。

GPU 上的消融清单

消融 命令 主要看什么
重叠调度 MINISGL_DISABLE_OVERLAP_SCHEDULING=1 离线吞吐;小模型、大批量时差距最明显
CUDA Graph --cuda-graph-max-bs 0 TPOT;批大小越小差距越大
前缀缓存 --cache-type naive 共享前缀负载下的 TTFT 与吞吐
注意力后端 --attn fi / --attn fa / --attn fa,fi prefill 吞吐(FA3 在 Hopper 上强)、decode TPOT(FlashInfer 强)
分块大小 --max-prefill-length 2048 / 16384 长提示词下的 TTFT 与 decode 的 TPOT 尖峰
page size --page-size 1 / 16 / 256 前缀命中率(页越大越粗)与管理开销

做消融时每次只改一项,其余保持不变;每个配置跑三次取中位数。想知道时间具体花在哪,用 Nsight Systems 抓一段时间线:CPU 调度与 GPU kernel 之间的空隙,就是重叠调度和 CUDA Graph 要消灭的东西(方法见推理系统手册的 Profiling 一章)。

官方实现

  • 离线:benchmark/offline/bench.py(256 个请求、输入输出各 100~1024,page_size=256、cuda_graph_max_bs=256)
  • 在线:benchmark/online/bench_qwen.py,回放阿里云百炼公开的 Qwen 请求轨迹;客户端 benchmark/client.py 约 500 行,支持更多统计
  • 官方 README 给出了 H200 上的离线吞吐与在线延迟结果(在线测试与 SGLang 对比)

测试

tests/test_ch21_benchmark.py · test_offline_benchmark_runs
def test_offline_benchmark_runs():
    r = run(QWEN3, num_seqs=6, max_input_len=40, max_output_len=16, dtype=torch.float32,
            max_running_req=8, num_page_override=1024, max_seq_len_override=128)
    assert r["output_tokens"] > 6 and r["throughput"] > 0

怎么讲清楚

讲基准测试:离线吞吐测满负载下引擎每秒能处理多少 token,在线压测测某个请求速率下的 TTFT、TPOT 和尾延迟(P99),两者衡量的东西不同。TTFT 受排队、prefill 计算量(提示词长度、前缀命中)影响,分块 prefill、前缀缓存、PD 分离主要改善它;TPOT 受 decode 一步的时间影响,CUDA Graph、重叠调度、量化、投机解码主要改善它。做消融每次只改一项;重叠调度和 CUDA Graph 的收益在小模型、小 batch 时最大(CPU 开销占比高),要用小模型来量化它们。CPU 上批大小从 1 到 16 吞吐提升有限,因为 CPU 的算力早已跑满,不像 GPU 那样受访存限制。

练习

  1. 用 benchmark/client.py 在 CPU 上压一个 --max-running-requests 4 的服务,把 --rate 从 0.5 逐步提高到 4,观察 TTFT 的变化,解释拐点出现的位置。
  2. 设计一个"多轮对话"负载:每个用户连续发 5 轮,每轮的提示词包含之前所有轮次。比较 radix 与 naive 在这个负载下的 TTFT。
  3. 在 GPU 上复现官方的离线测试,分别关闭重叠调度和 CUDA Graph,记录吞吐。哪一项对 Qwen3-0.6B 影响更大?对 Qwen3-14B 呢?为什么?
参考答案
  1. 当到达速率超过服务能力(max_running_req 个并发请求能提供的吞吐)时,请求开始在等待队列里堆积,TTFT 从"prefill 时间"跳升为"排队时间 + prefill 时间",并随时间持续增长——这是排队论里利用率接近 1 时延迟爆炸的现象。
  2. 多轮对话下,每轮的提示词前缀就是上一轮的完整对话,Radix Cache 能命中几乎全部前缀,TTFT 只取决于新增部分;naive 每轮都从头 prefill,TTFT 随轮次线性增长。
  3. 小模型每步 GPU 时间很短,CPU 开销和 kernel 发射开销占比大,两项优化的收益都明显;大模型每步 GPU 时间长,开销占比小,收益变小。具体哪项更大取决于批大小:批越小,kernel 发射开销(CUDA Graph 消除的)占比越大。

小结

  • 离线吞吐衡量满负载下的引擎速度,在线压测衡量某个请求速率下的 TTFT、TPOT 与尾延迟。
  • CPU 上可以观察到连续批处理和前缀缓存的收益;GPU 上前者更明显。
  • 消融每次只改一项;重叠调度与 CUDA Graph 的收益在小模型、小批量时最大。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题