Skip to content

Commit 4b91ff9

Browse files
authored
[Benchmark] measure real decode speed when the server batches stream chunks (PaddlePaddle#8113)
A server-side reasoning / tool-call parser only emits an SSE chunk once it has user-visible text, so decode steps whose text is still buffered are invisible to the client while their tokens are already counted in usage.completion_tokens. Taking TTFT from the first delta-carrying chunk then charges those tokens to TTFT and shrinks the decode window, which inflates the reported decode speed. - count a chunk whose delta is empty but whose usage.completion_tokens grew as a real token arrival, so TTFT/ITL see every generated token - credit the tokens that only show up in the trailing usage chunk (e.g. tool-call closing markers) to ITL, so the numerator and the denominator cover the same tokens - derive per-request decode speed from len(itl)/sum(itl); the previous (output_tokens - 1) form assumes the first chunk carries exactly one token, which is false under speculative decoding - drop the "TPOT < 1ms is unreliable" filter and its duplicated report block: with the window measured correctly there is nothing to filter
1 parent 947f323 commit 4b91ff9

2 files changed

Lines changed: 45 additions & 75 deletions

File tree

benchmarks/backend_request_func.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,9 @@ async def async_request_eb_openai_chat_completions(
352352
payload["stream_options"] = {
353353
"include_usage": True,
354354
"continuous_usage_stats": True,
355+
# sglang 扩展:解析器攒批时补发只带 usage 的空 delta 包,否则这些
356+
# token 会被算进 TTFT,解码速度被高估。不支持的服务端会忽略该字段。
357+
"step_usage_chunks": "first_token",
355358
}
356359
if request_func_input.json_data:
357360
json_data = request_func_input.json_data
@@ -496,7 +499,18 @@ async def async_request_eb_openai_chat_completions(
496499
reason_content = choices[0]["delta"].get("reasoning_content")
497500
tool_calls = choices[0]["delta"].get("tool_calls")
498501
completion_token_ids = choices[0]["delta"].get("completion_token_ids", [])
502+
# 服务端已生成的累计 token 数:优先用 usage(continuous_usage_stats),
503+
# 否则退化为按 token_ids 长度推断。
504+
cur_completion_tokens = (data.get("usage") or {}).get("completion_tokens")
505+
if cur_completion_tokens is None and completion_token_ids:
506+
cur_completion_tokens = len(output.output_ids) + len(completion_token_ids)
499507
has_token_chunk = bool(content or reason_content or tool_calls or completion_token_ids)
508+
# reasoning/tool-call parser 攒批时 delta 为空,服务端只发 usage 心跳包
509+
# (sglang 侧开关 stream_options.step_usage_chunks)。token 数增长
510+
# 就是一次真实的 token 到达,必须计入 TTFT/ITL,否则被攒批吞掉的 token
511+
# 会被算进 TTFT,解码区间被压缩、解码速度虚高。
512+
if not has_token_chunk and cur_completion_tokens is not None:
513+
has_token_chunk = cur_completion_tokens > last_output_len
500514
if tool_calls:
501515
for tc in tool_calls:
502516
idx = tc.get("index", 0)
@@ -536,10 +550,6 @@ async def async_request_eb_openai_chat_completions(
536550
output.prompt_len = 0
537551

538552
# 首 token 也要更新 last_output_len,用于后续 burst 摊分
539-
cur_completion_tokens = (data.get("usage") or {}).get("completion_tokens")
540-
if cur_completion_tokens is None and completion_token_ids:
541-
# 没有 usage 时退化为按 token_ids 长度推断
542-
cur_completion_tokens = len(output.output_ids) + len(completion_token_ids)
543553
if cur_completion_tokens is not None:
544554
last_output_len = cur_completion_tokens
545555
else:
@@ -550,10 +560,6 @@ async def async_request_eb_openai_chat_completions(
550560
# buffer burst 修正:如果服务端把多个 token 合并到同一个流式 chunk 里,
551561
# 直接把整段间隔记成单个 ITL 会高估解码间隔。这里参考 sglang 官方
552562
# bench_serving 的做法:用真实 token 增量摊分该 chunk 的等待时间。
553-
cur_completion_tokens = (data.get("usage") or {}).get("completion_tokens")
554-
if cur_completion_tokens is None and completion_token_ids:
555-
cur_completion_tokens = len(output.output_ids) + len(completion_token_ids)
556-
557563
chunk_gap = timestamp - most_recent_timestamp
558564
if cur_completion_tokens is not None:
559565
num_new_tokens = cur_completion_tokens - last_output_len
@@ -596,6 +602,16 @@ async def async_request_eb_openai_chat_completions(
596602
prompt_tokens_details = usage.get("prompt_tokens_details") or {}
597603
if output.prompt_len == 0:
598604
output.prompt_len = prompt_tokens_details.get("cached_tokens", 0)
605+
# 收尾 usage 包:把最后一批"生成了但没随 delta 下发"的 token 补进 ITL,
606+
# 否则分子(总 token)覆盖不到分母(ITL 区间),解码速度会被高估。
607+
tail_tokens = output.output_tokens - last_output_len
608+
if tail_tokens > 0 and ttft > 0.0:
609+
tail_gap = timestamp - most_recent_timestamp
610+
if tail_gap > 0:
611+
output.itl.extend([tail_gap / tail_tokens] * tail_tokens)
612+
last_output_len = output.output_tokens
613+
most_recent_timestamp = timestamp
614+
token_timestamps.append(wall_timestamp)
599615

600616
last_chunk_timestamp = timestamp
601617

benchmarks/benchmark_serving.py

Lines changed: 21 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,8 @@ class BenchmarkMetrics:
6565
input_throughput: float
6666
output_throughput: float
6767
total_token_throughput: float
68-
# 解码速度(过滤 TPOT<1ms 的请求后)
69-
s_decode_filtered_mean: float # tok/s
70-
s_decode_filtered_median: float # tok/s
71-
n_decode_total: int # 参与统计的总请求数
72-
n_decode_filtered: int # 被过滤的请求数(TPOT<1ms)
73-
n_decode_reliable: int # 可信请求数(TPOT>=1ms)
68+
# 解码速度:首 token 之后每秒真实到达的 token 数,全量统计不过滤
69+
n_decode_total: int # 参与统计的请求数
7470
mean_s_decode: float
7571
median_s_decode: float
7672
std_s_decode: float
@@ -259,10 +255,19 @@ def calculate_metrics(
259255
else:
260256
# sglang等无arrival_time场景fallback
261257
if outputs[i].output_tokens > 1:
262-
decode_time = outputs[i].latency - outputs[i].ttft
258+
# ITL 条目数 = 首个可见 chunk 之后真实到达的 token 数(按服务端 usage 增量摊分),
259+
# sum(itl) = 对应的完整解码区间。用 len/sum 而不是 (output_tokens-1)/sum:
260+
# 首个 chunk 在 MTP 下可能一次带 1~3 个 token,减 1 会有系统偏差。
261+
# 也不用 latency-ttft:解析器攒批时被吞掉的 token 会被计进 TTFT,解码速度虚高。
262+
if outputs[i].itl:
263+
decode_time = sum(outputs[i].itl)
264+
decode_tokens = len(outputs[i].itl)
265+
else:
266+
decode_time = outputs[i].latency - outputs[i].ttft
267+
decode_tokens = outputs[i].output_tokens - 1
263268

264269
if decode_time > 0:
265-
s_decodes.append((outputs[i].output_tokens - 1) / decode_time)
270+
s_decodes.append(decode_tokens / decode_time)
266271
else:
267272
s_decodes.append(0)
268273
else:
@@ -309,25 +314,10 @@ def calculate_metrics(
309314
total_input += prefill_only_input
310315
print(f"零输出(prefill-only)请求: {prefill_only_reqs} 条, " f"补入 ITPS 的输入 token: {prefill_only_input}")
311316

312-
# === 解码速度过滤:TPOT < 1ms 的请求视为不可信(引擎批量flush伪象) ===
313-
MIN_TPOT_S = 0.001 # 1ms
314-
reliable_s_decodes = []
315-
n_decode_total = 0
316-
n_decode_filtered = 0
317-
for o in outputs:
318-
if not o.success or o.output_tokens <= 1:
319-
continue
320-
decode_time = sum(o.itl) if o.itl else 0
321-
if decode_time <= 0:
322-
continue
323-
n_decode_total += 1
324-
tokens = o.output_tokens - 1
325-
tpot = decode_time / tokens
326-
if tpot >= MIN_TPOT_S:
327-
reliable_s_decodes.append(tokens / decode_time)
328-
else:
329-
n_decode_filtered += 1
330-
n_decode_reliable = len(reliable_s_decodes)
317+
# 解码速度只有一份口径:上面循环里按 len(itl)/sum(itl) 逐请求算好的 s_decodes。
318+
# ITL 由服务端 usage 的 token 增量摊分得到(含被解析器攒批吞掉的 token 与收尾 token),
319+
# 所以 sum(itl) 就是真实解码区间,不需要"TPOT<1ms 视为不可信"这类兜底过滤。
320+
n_decode_total = len([s for s in s_decodes if s > 0])
331321

332322
metrics = BenchmarkMetrics(
333323
completed=completed,
@@ -338,11 +328,7 @@ def calculate_metrics(
338328
input_throughput=total_input / dur_s,
339329
output_throughput=sum(actual_output_lens) / dur_s,
340330
total_token_throughput=(total_input + sum(actual_output_lens)) / dur_s,
341-
s_decode_filtered_mean=float(np.mean(reliable_s_decodes)) if reliable_s_decodes else 0.0,
342-
s_decode_filtered_median=float(np.median(reliable_s_decodes)) if reliable_s_decodes else 0.0,
343331
n_decode_total=n_decode_total,
344-
n_decode_filtered=n_decode_filtered,
345-
n_decode_reliable=n_decode_reliable,
346332
mean_s_decode=np.mean(s_decodes or 0) * 1, # ttfts is empty if streaming is not supported by backend
347333
std_s_decode=np.std(s_decodes or 0) * 1,
348334
median_s_decode=np.median(s_decodes or 0) * 1,
@@ -808,11 +794,7 @@ async def limited_request_func_per_ip(req_input, semaphore, pbar):
808794
"reasoning_contents": [output.reasoning_content for output in outputs],
809795
"errors": [output.error for output in outputs],
810796
"metrics": [output.metrics for output in outputs],
811-
"s_decode_filtered_mean": metrics.s_decode_filtered_mean,
812-
"s_decode_filtered_median": metrics.s_decode_filtered_median,
813797
"n_decode_total": metrics.n_decode_total,
814-
"n_decode_filtered": metrics.n_decode_filtered,
815-
"n_decode_reliable": metrics.n_decode_reliable,
816798
}
817799

818800
def process_one_metric(
@@ -961,22 +943,8 @@ def process_one_length(
961943
print("{:<40} {:<10.2f}".format(f"P{p_word} {metric_name}:", value))
962944
result[f"p{p_word}_{metric_attribute_name}"] = value
963945

964-
print("{s:{c}^{n}}".format(s="解码速度 (过滤TPOT<1ms)", n=50, c="-"))
965-
_n_total = max(metrics.n_decode_total, 1)
966-
print("{:<40} {:<10d}".format("Total requests:", metrics.n_decode_total))
967-
print(
968-
"{:<40} {:<10d} ({:.2f}%)".format(
969-
"Filtered (TPOT<1ms):", metrics.n_decode_filtered, 100 * metrics.n_decode_filtered / _n_total
970-
)
971-
)
972-
print(
973-
"{:<40} {:<10d} ({:.2f}%)".format(
974-
"Reliable (TPOT>=1ms):", metrics.n_decode_reliable, 100 * metrics.n_decode_reliable / _n_total
975-
)
976-
)
977-
print("{:<40} {:<10.2f}".format("Mean Decode (tok/s):", metrics.s_decode_filtered_mean))
978-
print("{:<40} {:<10.2f}".format("Median Decode (tok/s):", metrics.s_decode_filtered_median))
979-
process_one_length("s_decode", "Decode", "解码速度(tok/s)")
946+
print("{:<40} {:<10d}".format("Requests in decode stats:", metrics.n_decode_total))
947+
process_one_length("s_decode", "Decode", "解码速度(tok/s, 首token之后)")
980948
process_one_metric("ttft", "TTFT", "Time to First Token")
981949
process_one_metric("s_ttft", "S_TTFT", "Infer Time to First Token")
982950
process_one_metric("res_ttft", "Response TTFT", "包含思考首token耗时")
@@ -1164,22 +1132,8 @@ def process_one_length(
11641132
print("{:<40} {:<10.2f}".format(f"P{p_word} {metric_name}:", value))
11651133
result[f"p{p_word}_{metric_attribute_name}"] = value
11661134

1167-
print("{s:{c}^{n}}".format(s="解码速度 (过滤TPOT<1ms)", n=50, c="-"))
1168-
_n_total = max(metrics.n_decode_total, 1)
1169-
print("{:<40} {:<10d}".format("Total requests:", metrics.n_decode_total))
1170-
print(
1171-
"{:<40} {:<10d} ({:.2f}%)".format(
1172-
"Filtered (TPOT<1ms):", metrics.n_decode_filtered, 100 * metrics.n_decode_filtered / _n_total
1173-
)
1174-
)
1175-
print(
1176-
"{:<40} {:<10d} ({:.2f}%)".format(
1177-
"Reliable (TPOT>=1ms):", metrics.n_decode_reliable, 100 * metrics.n_decode_reliable / _n_total
1178-
)
1179-
)
1180-
print("{:<40} {:<10.2f}".format("Mean Decode (tok/s):", metrics.s_decode_filtered_mean))
1181-
print("{:<40} {:<10.2f}".format("Median Decode (tok/s):", metrics.s_decode_filtered_median))
1182-
process_one_length("s_decode", "Decode", "解码速度(tok/s)")
1135+
print("{:<40} {:<10d}".format("Requests in decode stats:", metrics.n_decode_total))
1136+
process_one_length("s_decode", "Decode", "解码速度(tok/s, 首token之后)")
11831137
process_one_metric("ttft", "TTFT", "Time to First Token")
11841138
process_one_metric("s_ttft", "S_TTFT", "Infer Time to First Token")
11851139
process_one_metric("tpot", "TPOT", "Time per Output Token (excl. 1st token)")

0 commit comments

Comments
 (0)