基准测试与消融¶
所有功能都写完了,最后要回答"每个优化到底值多少"。这一章给出两个压测工具——离线吞吐测试和在线的流式压测客户端——以及一组消融实验的做法:关掉某一项优化,看吞吐和延迟怎么变。CPU 上我们跑了两个趋势明显的消融;需要 GPU 的那些给出命令和要观察的指标。
自测:能答上来就可以跳过本章
- 离线吞吐测试和在线压测分别回答什么问题?
- TTFT、TPOT 各受哪些因素影响?哪项优化主要改善 TTFT、哪项主要改善 TPOT?
- 为什么 CPU 上"批大小从 1 到 16"的吞吐提升远小于 GPU?
- 想量化重叠调度的收益,应该用大模型还是小模型?
自测参考答案(先自己答,再展开对照)
- 离线吞吐测试:一次性把所有请求都给引擎,衡量满负载下每秒能生成多少 token(引擎本身有多快);在线压测:按某个请求速率发送请求,衡量这个负载下的 TTFT、TPOT 和尾延迟(能不能满足 SLO)。
- TTFT:排队时间、prefill 的计算量(提示词长度、前缀命中率)、调度策略;TPOT:每一步 decode 的时间(batch 大小、KV 长度、kernel 效率、CPU 开销)。前缀缓存、分块 prefill 主要改善 TTFT;CUDA Graph、重叠调度、更好的注意力 kernel 主要改善 TPOT。
- CPU 的算力和带宽之比与 GPU 不同:CPU 上 batch 为 1 时计算就已经占了相当大的比例,不像 GPU 那样几乎全在读权重,所以增大 batch 带来的"免费"吞吐提升小得多。
- 小模型:重叠调度藏的是固定的 CPU 开销,模型越小、每一步的 GPU 时间越短,这部分开销占比越大,收益越明显;大模型上 GPU 计算时间长,CPU 开销本来就被淹没了。
本章要写的文件:benchmark/offline.py、benchmark/client.py。
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 个文件,不变)
examples/ch21_benchmark.py——它只用到上面这些文件;python tools/steps.py check 会逐章搭出这棵树、跑这个 main。
离线吞吐¶
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
在线压测¶
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
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 上的两个消融¶
"""第 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 个请求共享 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 对比)
测试¶
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 那样受访存限制。
练习¶
- 用
benchmark/client.py在 CPU 上压一个--max-running-requests 4的服务,把--rate从 0.5 逐步提高到 4,观察 TTFT 的变化,解释拐点出现的位置。 - 设计一个"多轮对话"负载:每个用户连续发 5 轮,每轮的提示词包含之前所有轮次。比较 radix 与 naive 在这个负载下的 TTFT。
- 在 GPU 上复现官方的离线测试,分别关闭重叠调度和 CUDA Graph,记录吞吐。哪一项对 Qwen3-0.6B 影响更大?对 Qwen3-14B 呢?为什么?
参考答案
- 当到达速率超过服务能力(
max_running_req个并发请求能提供的吞吐)时,请求开始在等待队列里堆积,TTFT 从"prefill 时间"跳升为"排队时间 + prefill 时间",并随时间持续增长——这是排队论里利用率接近 1 时延迟爆炸的现象。 - 多轮对话下,每轮的提示词前缀就是上一轮的完整对话,Radix Cache 能命中几乎全部前缀,TTFT 只取决于新增部分;naive 每轮都从头 prefill,TTFT 随轮次线性增长。
- 小模型每步 GPU 时间很短,CPU 开销和 kernel 发射开销占比大,两项优化的收益都明显;大模型每步 GPU 时间长,开销占比小,收益变小。具体哪项更大取决于批大小:批越小,kernel 发射开销(CUDA Graph 消除的)占比越大。
小结¶
- 离线吞吐衡量满负载下的引擎速度,在线压测衡量某个请求速率下的 TTFT、TPOT 与尾延迟。
- CPU 上可以观察到连续批处理和前缀缓存的收益;GPU 上前者更明显。
- 消融每次只改一项;重叠调度与 CUDA Graph 的收益在小模型、小批量时最大。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题