AkiYang推理工程手记

AISBench 的 whole、native、TPOT 和 ITL,到底在量什么?

版本与源码依据
首次提交 最近更新 文章修改记录 ↗
系列阅读 · SGLang与AISBench压测实战展开篇目
本页目录

有一组结果乍看很奇怪:TPOT 从 8.8 ms 变成了 9.4 ms,输出吞吐却从 179.04 涨到 236.83 token/s/卡。另一轮报告里,TPOT 平均只有 10.4 ms,ITL 平均却达到 58.6 ms。

这两件事都发生在 DeepSeek-V4-Flash、Ascend 910C 的真实 PD 压测中。前一组是 16K 输入、零缓存下的 Prefill 合批对照;后一组来自 128K 输入、约 90% 前缀命中的测试。它们各有自己的负载,放在这里是为了解释指标,不能拼成一条性能提升曲线。

读这类报告,最容易出错的地方,是把几个带着“时间”或“吞吐”字样的数字,当成同一件事的不同写法。实际上,它们用了不同的样本、不同的分母,有些还处在不同的观察层。

本文按我们使用的 AISBench 固定提交 e00d1f7 解释。这里的 native 沿用实验记录中的叫法,指原生 StablePerfMetricCalculator 的结果;源码中的阶段名是 stable,whole 对应默认计算器的 total。它不是另一种服务端推理模式。文中的真实数字和完整统计行保存在脱敏证据中。

一、先沿一条请求走完时间线

一条流式请求,在客户端留下的是一串时间点:

t0              t1          t2          t3        …        tm
准备发送 POST    首个记点事件  后续事件    后续事件             最后记点事件
|---------------|-----------|-----------|-------------------|
     TTFT                 首个事件之后的流式阶段
|---------------------------------------------------------|
                         E2E

这版 AISBench 在发起 HTTP POST 前记录 t0。之后每解析一个通过过滤的 SSE 数据事件,先记时间,再交给响应解析函数;空行、注释和 [DONE] 不在这些后续记点中。因而这里的 tm 是最后一个被记录事件的时间,并非脚本退出、连接关闭或服务端算子结束的时间。对应源码:stream_infer。

这个位置也决定了延迟覆盖哪些工作:网络传输、服务侧排队、Prefill、PD 交接以及 Decode,都会影响客户端看到的时间。客户端在进入这段发送流程以前等待并发槽位的时间,则没有自动加进这条请求的 t0 → tm。

因此,TTFT 很长,说明客户端等待首个记点事件很久。要进一步区分是 Prefill 算得慢、服务排队还是 PD 交接,需要服务侧证据;单凭这个数拆不出各阶段耗时。

还有一个细节会影响后面的全部解释:首个记点事件未必已经带有正文 token。 角色、reasoning 或其他数据事件也可能被记点;尾部 usage、metadata-only 事件也可能延长时间线。具体哪些事件出现,取决于服务回包。关于如何保留这些字段又沿用原计时,见流式计数与计时一文。

二、TPOT 摊的是 token,ITL 数的是事件间隔

设一条请求实际生成 L 个输出 token。这版汇总器的计算可以写成:

指标 单请求计算方式 读数含义
E2E / E2EL tm − t0 从发送前记点到最后记点事件
TTFT t1 − t0 到首个记点事件的等待
TPOT (tm − t1) / (L − 1),L > 1 把首事件后的时间摊到其余输出 token
ITL t2−t1, t3−t2, …, tm−t(m−1) 相邻已记点流式事件的间隔

这些公式来自 DefaultPerfSummarizer._calc_perf_data。token 数优先使用可用的服务返回值,缺失时相关分支会用 tokenizer 补算。压测配置里的最大输出长度,不能直接代替这里的实际 L。

用一个人为构造的小例子就能看清差别。请求在 0 ms 记点,随后在 100、160、220 ms 收到三个记点事件,实际输出 13 token:

E2E  = 220 ms
TTFT = 100 ms
TPOT = (220 − 100) / (13 − 1) = 10 ms
ITL  = [60 ms, 60 ms]

一条事件可以带多个 token。投机解码接受一段 token 后,服务还可能经过文本解码和输出缓冲才发出事件。于是,平均每个输出 token 摊到 10 ms,同时客户端每隔 60 ms 收到一次事件,完全可以成立。

真实 128K 那轮的 native 行也是如此:

指标 Average P90
TPOT 10.4 ms 11.0 ms
ITL 58.6 ms 76.7 ms
E2E 22.8695 s 24.5766 s

这里的 ITL 更接近客户端看到的“下一次更新还要等多久”。TPOT 则把一段时间按输出 token 数摊平。ITL/TPOT 的比值可能与一次更新携带的 token 数有关,但事件聚合、尾部 metadata 和统计权重都参与了这个比值,用它倒推 AL 或接受率会把这些因素混在一起。

这也解释了为什么不该为了让 ITL 好看,就删掉无正文事件。删事件会改变间隔的样本数,还可能同时改掉 tm,继而改变 E2E 和 TPOT。想另外计算“相邻正文更新间隔”,可以加一个有明确名称的新指标,并保留原始 AISBench 结果。

三、Average 和 P90 的样本也不一样

对 TTFT、TPOT、E2E,AISBench 先得到每条成功请求的一个数,再计算分布。例如 TPOT P90,是这些单请求平均 TPOT的第 90 百分位。

ITL 的路径不同:每条请求产生一组间隔,计算器把这些数组拼起来,再算整体 Average 和 P90。拥有更多流式事件的请求,会贡献更多 ITL 样本。BasePerfMetricCalculator 中的 np.concatenate 和 np.percentile 把这件事写得很直接。

假设请求 A 有两个间隔 [50, 50] ms,请求 B 只有一个间隔 [1] ms。AISBench 合并后的 ITL 平均值是:

(50 + 50 + 1) / 3 = 33.67 ms

如果先分别求请求平均值,再让两条请求等权,得到的却是 (50 + 1) / 2 = 25.5 ms。二者回答的问题不同,报告里需要说明使用哪一种。

ITL 行的 N 还有一处容易误读:这版代码把它填成具有非零 Decode 间隔的请求数,而不是拼接后的间隔总数。因此,看到 ITL 的 N=549,不能认定 P90 只用了 549 个间隔。要做事件级审计,得另外统计实际间隔数量。

原默认统计项是 Average、Min、Max、Median、P75、P90、P99。我们保留的原表没有 P95,就应写“原表未提供”;补 P95 要从原样本重新算,无法从 P90、P99 推回来。P90 本身也不是达标率:如果要报告“TPOT < 20 ms 的请求比例”,应按这条条件逐请求计数,并单独给出分母。

同样,P90(E2E) 不能由 P90(TTFT) + 1023 × P90(TPOT) 拼出来。落在三个分布第 90 百分位的位置,未必是同一条请求。

四、whole 看完整一轮,native 怎样选出稳定段

默认计算器把有效时间明细中的最早开始、最晚结束作为 whole 的时间边界。本文展示的轮次全部成功,因而 whole 可以直接理解为:从这轮第一条正式请求开始,到最后一条正式请求的最后记点事件结束。

native 多了一个选样步骤。固定版本的 StablePerfMetricCalculator 会把所有请求的开始、结束排成事件序列,累加客户端在途请求数:

  1. 请求开始使在途数达到配置上限 C,记一次“到达 C”。
  2. 第二次通过请求开始到达 C 的时刻,作为稳定段起点。
  3. 最后一个处理后在途数等于 C 的事件时刻,作为终点。
  4. 开始时间落在这个闭区间内的请求,进入 native 样本。

第二次到达 C,不是“第二条请求”,也不是“第二轮预热”。中途并发有波动,区间仍然可能覆盖它;如果找不到第二次到达 C,原生选择器会报找不到稳定阶段。

更值得留意的是后半段计算:时间分母使用选出的稳定区间长度,token 和延迟使用入选请求的完整结果。 它没有按每个 token 的发送时刻,把区间外的输出裁掉。

原生稳定区间与入选请求的边界示意

图中是 C=2 的教学时间线。第一次到达 C 在 1 s,第二次在 3.1 s,最后一次在 7.1 s。选择区间是 [3.1, 7.1],时长 4 s;C、D、E 三条请求入选,E 到 12 s 才结束。若三条各输出 100 token,原生算法计入 300 token,再除以 4 s,得到 75 token/s。这个数包含了 E 在区间结束以后完成的输出。

所以,这版 native 是原生稳定段选样口径,并不等同于设备在任意连续 4 秒中实际吐出的 token 数。区间越短,边界请求占比越大,解释成绩时越要留意。我们当时把“至少 128 条入选请求且区间至少 60 秒”作为长窗口确认的最低要求,这是实验约定,AISBench 没有替所有场景规定这个阈值。

whole 与 native 最好成对保留。前者呈现整轮包含爬升、收尾的完成情况,后者帮助观察进入高负载区间后的表现;还应一起看并发曲线、入选数和持续时间。需要严格的按墙钟窗口产出统计时,必须有输出事件或 token 与时间的对应记录,另算并命名,不能只给这列换个标题。

五、配置 C、系统并发和 QPS,怎样放在一张表里

配置 C=90 表示客户端允许的并发上限。它不等于 90 条请求一直在服务端运行,更不等于 Decode batch=90。

默认计算器的 Concurrency 使用:

平均客户端在途数 = 入选请求 E2E 之和 / 统计时长

对于完整请求区间的 whole,这是“每条请求占据多长时间”的面积除以总时间。请求在服务端排队时也仍处于客户端在途状态。P、D 内部的 running、waiting、batch size 则各有自己的观察位置。

native 又有一层实现细节:它仍然用入选请求的完整 E2E 求和、除以稳定区间时长,最后以 C 封顶。上一节的教学例子得到 8.7/4=2.175,表里最终显示 2。因而 native 的 Concurrency=C,可能包含这个封顶行为,不能仅凭这一格断定区间内始终满并发。

看真实 C90 结果,几个数就能对上了。这轮采用 W8A8、真实 P8+D8,共 16 张物理卡;高 AL 复制负载,正文 131072 token、实际 Chat 输入 131076、每条输出 1024,逐条缓存命中 118784/131076=90.6222%,配置请求频率 6 req/s:

区间 发送 / 纳入请求 C AIS Concurrency 时长(s) QPS E2E均值(s) 总输出吞吐(token/s) 每PD16卡(token/s)
whole 640 / 640 90 82.8179 179.5766 3.5639 23.2377 3649.4736 228.0921
native 640 / 549 90 87.4978 143.4935 3.8260 22.8695 3917.7796 244.8612

whole 的 3.5639 × 23.2377 ≈ 82.8179,正好解释了平均在途数。这里是同一组请求求和得到的算术关系,用展示值重算会有舍入误差。

配置频率 6 req/s 是负载发起侧的目标参数,3.5639 req/s 是整轮成功完成数除以统计时长。并发限制、请求驻留时间、负载爬升和收尾都参与后者。单独看到 QPS 小于配置频率,还不足以判断是哪一个服务阶段限制了吞吐。

640 是整轮发送量,549 是 native 纳入量。whole AL=5.7760 使用整轮输出与 verify 计数;native 没有独立接受计数分母,就保留未观测。实际生成 proposal 的严格接受率、draft/verify 阶段耗时也未在这份结果中采到,不通过 AL 补造。

六、两个“输出 token/s”,为何能差近百倍

统计表里的 OutputTokenThroughput 分布,来自每条请求的:

单请求速率 = 该请求输出 token 数 / 该请求 E2E

summary 中的 Output Token Throughput 则是:

系统输出吞吐 = 所有入选请求的输出 token 总数 / 统计时长

在上面的 whole 结果中,单请求速率 Average 为 44.361 token/s,系统输出吞吐却是 3649.4736 token/s。服务同时处理许多请求,两者自然不在一个数量级。

前者还包含 TTFT。它也不等于 1000/TPOT:TPOT 去掉了首事件等待,分母是 L−1;单请求速率使用完整 E2E,分子是 L。对各请求速率求平均,与“平均输出长度 / 平均 E2E”也不是同一种平均法。

开头那组 16K 对照就发生在这个层次。两臂保持 N640/C128/6 req/s、实际输入 16388、输出 1024、cache=0,以及相同静态布局、Compact6、自动 SPS 等设置,只将 P 端 Prefill 合批槽位从 1 改到 2:

原生稳定段指标 Pcap1 Pcap2
TPOT平均(ms) 8.8 9.4
输出吞吐(token/s/PD16卡) 179.0441 236.8268

更有效的 Prefill 合批改善了整条流水线供给,系统吞吐提高约 32.27%,即使单请求 TPOT 略有上升。完整机制见《Decode 已经很快了,吞吐为什么上不去?》。把优化目标只设为最低 TPOT,就容易错过这种收益。

“单卡”还要保留除数。我们用系统吞吐除以 P8+D8 的 16 张物理卡。若另外除以 D 端 8 张卡,应标为 D8 归一化值;它会翻倍,但整套 PD 系统没有因此多生成一个 token。

输入+输出吞吐同样需要结合缓存理解:这里的输入计数包含命中的前缀,数值描述请求承载的逻辑 token 量,不是实际新做的 Prefill token 量,也不是设备访存或通信带宽。

七、拿到报告后,我会按这个顺序读

我先看场景那一行:模型、设备数、实际输入输出、缓存、采样与投机配置。接着确认发送多少条、成功多少条、哪一列是 whole、哪一列是 native,以及对应的请求数和时间分母。短测可以帮忙筛选,长窗口结果才承担持续吞吐的结论。

然后把 TTFT、TPOT、ITL、E2E 放回同一条请求时间线,再看它们的分布。TPOT 低而 ITL 高时,先检查一次事件携带多少输出、是否有缓冲和尾部事件;TTFT 拉长而 TPOT 平稳时,去找首事件以前的队列和 Prefill/PD 证据。P90 用来观察尾部请求或尾部间隔,阶段瓶颈仍然要沿执行链定位。

最后才比较吞吐:同负载、同统计口径,整机对整机,PD16 对 PD16。whole 与 native 都摆出来,有失败则连同失败数和原始错误保留。失败没有有效时间点时,也不能从成功请求的时间分布里还原它等待了多久。

这篇附了一个只用 Python 标准库的离线算例,可以重算 TPOT/ITL、两种平均法和 native 的选样边界。它不用模型服务。理解这些分母以后,一份报告里的不同数字才会接上;下一轮该调参数、查队列,还是先修统计,也就有了具体依据。

返回阅读地图 →
读到这里

从一条请求,看见整个系统。

返回文章目录

本页目录

搜索文章与 Skills

↑ ↓ 选择 · Enter 打开 · Esc 关闭 · Ctrl / ⌘ K 搜索

工程图