feat(analyzer): surface TTFT/TPOT as features and CDF figures
The gateway records three epoch-ms stamps per request (admitted, forwarded to backend, backend first token). They reached *-raw.jsonl but stopped there: features.csv had only total latency, and none of the figures covered time-to-first-token. Derive ttft_ms, ttft_e2e_ms and tpot_ms in one helper shared by the streaming path (preparation.py) and the in-memory path (features.py) so the two cannot drift, and add figures 14 (TTFT, backend vs end-to-end) and 15 (TPOT). The two TTFT series are plotted together because their offset is the gateway admission delay, which is then readable straight off the chart. Rows missing a backend stamp and single-token responses are recorded as 0 and filtered at plot time; left in, they would stack at the origin and pull every percentile down. Figure 15 is a single panel: TPOT is near-deterministic (p50 21.9, p90 23.0), so a p90 zoom renders a flat line and a vertical edge. Verified by recomputing ttft_ms straight from the original SLS rows for all 2190 requests of a sample: 0 mismatches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
- `trace_analyzer` 不再直接处理 provider-native 原始日志。
|
- `trace_analyzer` 不再直接处理 provider-native 原始日志。
|
||||||
- `details/` 只放画图所需数据。
|
- `details/` 只放画图所需数据。
|
||||||
- `figures/` 只放最终图片。
|
- `figures/` 只放最终图片。
|
||||||
- 当前主分析入口只维护 13 张图,对应的数据也只保留为这 13 张图服务的最小集合。
|
- 当前主分析入口只维护 15 张图,对应的数据也只保留为这 15 张图服务的最小集合。
|
||||||
|
|
||||||
朴素 wall-clock 时间字段如 `time` 按 `UTC+8` 解释。
|
朴素 wall-clock 时间字段如 `time` 按 `UTC+8` 解释。
|
||||||
|
|
||||||
@@ -113,6 +113,7 @@ outputs/
|
|||||||
|
|
||||||
- `request_metrics.csv`
|
- `request_metrics.csv`
|
||||||
请求级主表。用于:
|
请求级主表。用于:
|
||||||
|
- TTFT / TPOT
|
||||||
- input/output length
|
- input/output length
|
||||||
- session turns / turn 维度请求长度
|
- session turns / turn 维度请求长度
|
||||||
- trigger role 占比
|
- trigger role 占比
|
||||||
@@ -131,7 +132,7 @@ outputs/
|
|||||||
- `progress.json`
|
- `progress.json`
|
||||||
大 trace 运行时的进度与资源占用信息。
|
大 trace 运行时的进度与资源占用信息。
|
||||||
|
|
||||||
### `figures/` 当前只保留的 13 张图
|
### `figures/` 当前只保留的 15 张图
|
||||||
|
|
||||||
`figures/` 是展示层,只放图片和轻量说明文件。
|
`figures/` 是展示层,只放图片和轻量说明文件。
|
||||||
|
|
||||||
@@ -148,6 +149,8 @@ outputs/
|
|||||||
11. `11_alive_kvcache_blocks_timeline.png`
|
11. `11_alive_kvcache_blocks_timeline.png`
|
||||||
12. `12_bucket_kvcache_reuse_ratio.png`
|
12. `12_bucket_kvcache_reuse_ratio.png`
|
||||||
13. `13_session_cross_bucket_kvcache_miss.png`
|
13. `13_session_cross_bucket_kvcache_miss.png`
|
||||||
|
14. `14_ttft_cdf.png`
|
||||||
|
15. `15_tpot_cdf.png`
|
||||||
|
|
||||||
另外还有:
|
另外还有:
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ def main(argv=None):
|
|||||||
progress,
|
progress,
|
||||||
4,
|
4,
|
||||||
total_steps,
|
total_steps,
|
||||||
"figures: 13 approved request/session/tool/kvcache plots",
|
"figures: 15 approved request/session/tool/latency/kvcache plots",
|
||||||
)
|
)
|
||||||
figure_result = render_figures(
|
figure_result = render_figures(
|
||||||
analysis_dir=output_dir,
|
analysis_dir=output_dir,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
from .helpers import percentile, safe_div
|
from .helpers import latency_breakdown, percentile, safe_div
|
||||||
from .models import TraceFeatures
|
from .models import TraceFeatures
|
||||||
|
|
||||||
LONG_CONTEXT_THRESHOLD = 32000
|
LONG_CONTEXT_THRESHOLD = 32000
|
||||||
@@ -75,6 +75,7 @@ def compute_features(records):
|
|||||||
high_cache=1 if cache_hit_ratio >= HIGH_CACHE_THRESHOLD else 0,
|
high_cache=1 if cache_hit_ratio >= HIGH_CACHE_THRESHOLD else 0,
|
||||||
tool_burst_alert=1 if (max(bursts) if bursts else 0) >= TOOL_BURST_THRESHOLD else 0,
|
tool_burst_alert=1 if (max(bursts) if bursts else 0) >= TOOL_BURST_THRESHOLD else 0,
|
||||||
tool_loop_alert=1 if tool_to_tool_count >= TOOL_LOOP_THRESHOLD else 0,
|
tool_loop_alert=1 if tool_to_tool_count >= TOOL_LOOP_THRESHOLD else 0,
|
||||||
|
**latency_breakdown(record.meta, output_tokens),
|
||||||
)
|
)
|
||||||
feature.pattern_labels = base_pattern_labels(feature)
|
feature.pattern_labels = base_pattern_labels(feature)
|
||||||
features.append(feature)
|
features.append(feature)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ FIGURE_STEMS = [
|
|||||||
"11_alive_kvcache_blocks_timeline",
|
"11_alive_kvcache_blocks_timeline",
|
||||||
"12_bucket_kvcache_reuse_ratio",
|
"12_bucket_kvcache_reuse_ratio",
|
||||||
"13_session_cross_bucket_kvcache_miss",
|
"13_session_cross_bucket_kvcache_miss",
|
||||||
|
"14_ttft_cdf",
|
||||||
|
"15_tpot_cdf",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +114,9 @@ def _load_request_metrics(path: Path) -> list[dict]:
|
|||||||
"output_tokens": safe_int(row.get("output_tokens")),
|
"output_tokens": safe_int(row.get("output_tokens")),
|
||||||
"request_ready_time_ms": safe_int(row.get("request_ready_time_ms")),
|
"request_ready_time_ms": safe_int(row.get("request_ready_time_ms")),
|
||||||
"request_end_time_ms": safe_int(row.get("request_end_time_ms")),
|
"request_end_time_ms": safe_int(row.get("request_end_time_ms")),
|
||||||
|
"ttft_ms": safe_int(row.get("ttft_ms")),
|
||||||
|
"ttft_e2e_ms": safe_int(row.get("ttft_e2e_ms")),
|
||||||
|
"tpot_ms": safe_float(row.get("tpot_ms")),
|
||||||
"input_length_bucket": row.get("input_length_bucket", ""),
|
"input_length_bucket": row.get("input_length_bucket", ""),
|
||||||
"theoretical_prompt_unit_length": safe_int(row.get("theoretical_prompt_unit_length")),
|
"theoretical_prompt_unit_length": safe_int(row.get("theoretical_prompt_unit_length")),
|
||||||
"theoretical_prefix_hit_blocks": safe_int(row.get("theoretical_prefix_hit_blocks")),
|
"theoretical_prefix_hit_blocks": safe_int(row.get("theoretical_prefix_hit_blocks")),
|
||||||
@@ -623,6 +628,42 @@ def _plot_session_cross_bucket_miss(fig_dir: Path, rows: list[dict]) -> None:
|
|||||||
_save(fig, fig_dir, "13_session_cross_bucket_kvcache_miss")
|
_save(fig, fig_dir, "13_session_cross_bucket_kvcache_miss")
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_ttft_cdf(fig_dir: Path, request_rows: list[dict]) -> None:
|
||||||
|
# Requests missing either backend timestamp carry 0. Plotting those would pile
|
||||||
|
# a spike at the origin and drag every percentile down, so drop them.
|
||||||
|
backend_values = [row["ttft_ms"] for row in request_rows if row["ttft_ms"] > 0]
|
||||||
|
e2e_values = [row["ttft_e2e_ms"] for row in request_rows if row["ttft_e2e_ms"] > 0]
|
||||||
|
_plot_two_series_cdf_with_zoom(
|
||||||
|
fig_dir,
|
||||||
|
stem="14_ttft_cdf",
|
||||||
|
title="Time To First Token CDF",
|
||||||
|
xlabel="Milliseconds",
|
||||||
|
first_label="TTFT backend",
|
||||||
|
first_values=backend_values,
|
||||||
|
first_color=PALETTE["teal"],
|
||||||
|
second_label="TTFT end-to-end",
|
||||||
|
second_values=e2e_values,
|
||||||
|
second_color=PALETTE["red"],
|
||||||
|
zoom_quantile=0.90,
|
||||||
|
stats_labels=("mean", "p50", "p90", "p95", "p99"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_tpot_cdf(fig_dir: Path, request_rows: list[dict]) -> None:
|
||||||
|
# A single-token response has no decode interval to average over and is
|
||||||
|
# reported as 0; it is not a zero-cost token.
|
||||||
|
values = [row["tpot_ms"] for row in request_rows if row["tpot_ms"] > 0]
|
||||||
|
_plot_single_cdf(
|
||||||
|
fig_dir,
|
||||||
|
stem="15_tpot_cdf",
|
||||||
|
title="Time Per Output Token CDF",
|
||||||
|
xlabel="Milliseconds per output token",
|
||||||
|
label="TPOT",
|
||||||
|
values=values,
|
||||||
|
color=PALETTE["green"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_manifest(fig_dir: Path, manifest: dict) -> None:
|
def _write_manifest(fig_dir: Path, manifest: dict) -> None:
|
||||||
(fig_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
(fig_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
@@ -781,6 +822,14 @@ def render_figures(
|
|||||||
progress.update(1)
|
progress.update(1)
|
||||||
progress.set_postfix(current="13_session_cross_bucket_kvcache_miss")
|
progress.set_postfix(current="13_session_cross_bucket_kvcache_miss")
|
||||||
_plot_session_cross_bucket_miss(fig_root, session_bucket_rows)
|
_plot_session_cross_bucket_miss(fig_root, session_bucket_rows)
|
||||||
|
if show_progress:
|
||||||
|
progress.update(1)
|
||||||
|
progress.set_postfix(current="14_ttft_cdf")
|
||||||
|
_plot_ttft_cdf(fig_root, request_rows)
|
||||||
|
if show_progress:
|
||||||
|
progress.update(1)
|
||||||
|
progress.set_postfix(current="15_tpot_cdf")
|
||||||
|
_plot_tpot_cdf(fig_root, request_rows)
|
||||||
_plot_session_gap_cdf(fig_root, session_rows_by_id)
|
_plot_session_gap_cdf(fig_root, session_rows_by_id)
|
||||||
|
|
||||||
if show_progress:
|
if show_progress:
|
||||||
|
|||||||
@@ -154,6 +154,38 @@ def series_stats(values):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def latency_breakdown(meta, output_tokens):
|
||||||
|
"""Split one request's latency into time-to-first-token and time-per-output-token.
|
||||||
|
|
||||||
|
The gateway records three epoch-ms stamps per request: when it admitted the
|
||||||
|
request, when it forwarded it to the backend, and when the backend's first
|
||||||
|
token came back. So:
|
||||||
|
|
||||||
|
ttft_ms backend serving time to first token (prefill + backend queue)
|
||||||
|
ttft_e2e_ms the same, plus the gateway admission delay ahead of it
|
||||||
|
tpot_ms decode phase averaged over the tokens after the first
|
||||||
|
|
||||||
|
Rows missing either backend stamp -- about 0.5% of a production dump -- and
|
||||||
|
single-token responses (no decode interval exists) yield 0. Callers must drop
|
||||||
|
those rather than plot them as genuine zero-latency requests.
|
||||||
|
"""
|
||||||
|
first_request_ms = safe_int(getattr(meta, "backend_first_request_time_ms", 0))
|
||||||
|
first_response_ms = safe_int(getattr(meta, "backend_first_response_time_ms", 0))
|
||||||
|
ready_ms = safe_int(getattr(meta, "request_ready_time_ms", 0))
|
||||||
|
end_ms = safe_int(getattr(meta, "request_end_time_ms", 0))
|
||||||
|
output_tokens = safe_int(output_tokens)
|
||||||
|
|
||||||
|
ttft_ms = first_response_ms - first_request_ms if first_request_ms and first_response_ms else 0
|
||||||
|
ttft_e2e_ms = first_response_ms - ready_ms if ready_ms and first_response_ms else 0
|
||||||
|
decode_ms = end_ms - first_response_ms if end_ms and first_response_ms else 0
|
||||||
|
tpot_ms = decode_ms / (output_tokens - 1) if decode_ms > 0 and output_tokens > 1 else 0.0
|
||||||
|
return {
|
||||||
|
"ttft_ms": ttft_ms if ttft_ms > 0 else 0,
|
||||||
|
"ttft_e2e_ms": ttft_e2e_ms if ttft_e2e_ms > 0 else 0,
|
||||||
|
"tpot_ms": round(tpot_ms, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def safe_div(numerator, denominator):
|
def safe_div(numerator, denominator):
|
||||||
if not denominator:
|
if not denominator:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -91,4 +91,7 @@ class TraceFeatures:
|
|||||||
tool_burst_alert: int
|
tool_burst_alert: int
|
||||||
tool_loop_alert: int
|
tool_loop_alert: int
|
||||||
slow_request: int = 0
|
slow_request: int = 0
|
||||||
|
ttft_ms: int = 0
|
||||||
|
ttft_e2e_ms: int = 0
|
||||||
|
tpot_ms: float = 0.0
|
||||||
pattern_labels: list[str] = field(default_factory=list)
|
pattern_labels: list[str] = field(default_factory=list)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from trace_analyzer.helpers import percentile
|
from trace_analyzer.helpers import latency_breakdown, percentile
|
||||||
from trace_analyzer.parser import get_adapter
|
from trace_analyzer.parser import get_adapter
|
||||||
from tqdm.auto import tqdm
|
from tqdm.auto import tqdm
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ def stream_prepare(input_path: str | Path, output_dir: str | Path, *, show_progr
|
|||||||
"latency_ms": record.meta.total_cost_time_ms,
|
"latency_ms": record.meta.total_cost_time_ms,
|
||||||
"ms_per_input_token": _safe_div(record.meta.total_cost_time_ms, record.usage.input_tokens),
|
"ms_per_input_token": _safe_div(record.meta.total_cost_time_ms, record.usage.input_tokens),
|
||||||
"ms_per_output_token": _safe_div(record.meta.total_cost_time_ms, record.usage.output_tokens),
|
"ms_per_output_token": _safe_div(record.meta.total_cost_time_ms, record.usage.output_tokens),
|
||||||
|
**latency_breakdown(record.meta, record.usage.output_tokens),
|
||||||
"long_context": 1 if record.usage.input_tokens >= 32000 else 0,
|
"long_context": 1 if record.usage.input_tokens >= 32000 else 0,
|
||||||
"high_cache": 1 if cache_hit_ratio >= 0.8 else 0,
|
"high_cache": 1 if cache_hit_ratio >= 0.8 else 0,
|
||||||
"tool_burst_alert": 1 if max_tool_burst >= 4 else 0,
|
"tool_burst_alert": 1 if max_tool_burst >= 4 else 0,
|
||||||
|
|||||||
@@ -751,7 +751,7 @@ def run_advanced_from_existing(
|
|||||||
details_summary = {
|
details_summary = {
|
||||||
"schema_version": 3,
|
"schema_version": 3,
|
||||||
"request_count": total_requests,
|
"request_count": total_requests,
|
||||||
"figure_count": 13,
|
"figure_count": 15,
|
||||||
"cache_analysis_mode": "release_hash_ids",
|
"cache_analysis_mode": "release_hash_ids",
|
||||||
"release_path": str(release_path),
|
"release_path": str(release_path),
|
||||||
"bucket_definition": {"buckets": _bucket_definition_rows(bucket_defs)},
|
"bucket_definition": {"buckets": _bucket_definition_rows(bucket_defs)},
|
||||||
|
|||||||
Reference in New Issue
Block a user