agentic-bench 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abench/__init__.py +107 -0
- abench/__main__.py +7 -0
- abench/_version.py +4 -0
- abench/cli.py +389 -0
- abench/core/__init__.py +72 -0
- abench/core/dataset.py +421 -0
- abench/core/metrics.py +505 -0
- abench/core/workload.py +614 -0
- abench/data/recording_rates.json +23 -0
- abench/replay/__init__.py +14 -0
- abench/replay/engine.py +553 -0
- abench/replay/transport.py +157 -0
- abench/runner.py +637 -0
- abench/serve/__init__.py +31 -0
- abench/serve/endpoints.py +388 -0
- abench/serve/report.py +365 -0
- abench/serve/telemetry.py +166 -0
- agentic_bench-0.2.0.dist-info/METADATA +169 -0
- agentic_bench-0.2.0.dist-info/RECORD +24 -0
- agentic_bench-0.2.0.dist-info/WHEEL +5 -0
- agentic_bench-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_bench-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentic_bench-0.2.0.dist-info/licenses/NOTICE +44 -0
- agentic_bench-0.2.0.dist-info/top_level.txt +1 -0
abench/core/metrics.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Metrics for closed-loop agentic replay.
|
|
5
|
+
|
|
6
|
+
The central design choices here address the two fidelity problems that
|
|
7
|
+
plague fixed-concurrency agent benchmarks:
|
|
8
|
+
|
|
9
|
+
* **Long-tail / ramp-drain bias** — at the start (ramp) and end (drain)
|
|
10
|
+
of a closed-loop run the system is under-loaded, so turns completing in
|
|
11
|
+
those windows see artificially low latency. We tag every turn with the
|
|
12
|
+
number of *active sessions* at dispatch time and, by default, compute
|
|
13
|
+
headline metrics only over the **steady-state window** where the active
|
|
14
|
+
count is at (or near) the target concurrency ``C``. The achieved
|
|
15
|
+
concurrency distribution is always reported so the trim is auditable.
|
|
16
|
+
|
|
17
|
+
* **Heterogeneous turn shape** — a turn-1 request (tiny context) and a
|
|
18
|
+
turn-30 request (huge context) are different beasts; averaging them
|
|
19
|
+
hides everything. All latency metrics are therefore **bucketed by turn
|
|
20
|
+
index** (T1 vs. T2+) and, optionally, by input-length band.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from collections import Counter, defaultdict
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TurnRecord:
|
|
33
|
+
"""Everything measured for a single issued turn."""
|
|
34
|
+
|
|
35
|
+
session_id: str
|
|
36
|
+
turn_index: int
|
|
37
|
+
input_len: int
|
|
38
|
+
output_len: int = 0
|
|
39
|
+
ttft: float = 0.0 # seconds
|
|
40
|
+
latency: float = 0.0 # seconds (server E2E for the turn)
|
|
41
|
+
itl: List[float] = field(default_factory=list)
|
|
42
|
+
server_cached_tokens: int = 0
|
|
43
|
+
# Breakdown of ``server_cached_tokens`` by cache layer. The total already
|
|
44
|
+
# includes host hits, so only the split shows whether the DRAM layer (L2)
|
|
45
|
+
# is doing any work. Requires ``return_cached_tokens_details``.
|
|
46
|
+
cached_device_tokens: int = 0
|
|
47
|
+
cached_host_tokens: int = 0
|
|
48
|
+
recorded_output_len: int = 0 # what the trace generated for this turn
|
|
49
|
+
dispatch_time: float = 0.0 # perf_counter at request send
|
|
50
|
+
complete_time: float = 0.0 # perf_counter at response end
|
|
51
|
+
dispatch_epoch: float = 0.0 # wall-clock epoch (s) at request send
|
|
52
|
+
complete_epoch: float = 0.0 # wall-clock epoch (s) at response end
|
|
53
|
+
active_at_dispatch: int = 0
|
|
54
|
+
success: bool = False
|
|
55
|
+
error: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SessionRecord:
|
|
60
|
+
"""Per-session roll-up (trajectory inference time, worker)."""
|
|
61
|
+
|
|
62
|
+
session_id: str
|
|
63
|
+
source_model: str = "" # model that produced the recorded trajectory
|
|
64
|
+
turns: List[TurnRecord] = field(default_factory=list)
|
|
65
|
+
assigned_workers: List[str] = field(default_factory=list)
|
|
66
|
+
# True when the benchmark stopped this session early because the pool
|
|
67
|
+
# was exhausted (stop-on-exhaustion). Not a failure: such sessions are
|
|
68
|
+
# excluded from session-level completion/trajectory statistics.
|
|
69
|
+
truncated: bool = False
|
|
70
|
+
# Subset of ``truncated``: the session was stopped by the client-side
|
|
71
|
+
# context guard because the estimated prompt + pinned completion no
|
|
72
|
+
# longer fit the model's context window.
|
|
73
|
+
ctx_guard_stop: bool = False
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def trajectory_inference_time(self) -> float:
|
|
77
|
+
return sum(t.latency for t in self.turns if t.success)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ----------------------------------------------------------------------
|
|
81
|
+
# Small statistics helpers
|
|
82
|
+
# ----------------------------------------------------------------------
|
|
83
|
+
def _pct(values: List[float], p: float) -> float:
|
|
84
|
+
if not values:
|
|
85
|
+
return 0.0
|
|
86
|
+
s = sorted(values)
|
|
87
|
+
k = (len(s) - 1) * p
|
|
88
|
+
lo = math.floor(k)
|
|
89
|
+
hi = math.ceil(k)
|
|
90
|
+
if lo == hi:
|
|
91
|
+
return s[int(k)]
|
|
92
|
+
return s[lo] * (hi - k) + s[hi] * (k - lo)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mean(values: List[float]) -> float:
|
|
96
|
+
return sum(values) / len(values) if values else 0.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class BucketStats:
|
|
101
|
+
label: str
|
|
102
|
+
count: int
|
|
103
|
+
mean_ms: float
|
|
104
|
+
p50_ms: float
|
|
105
|
+
p90_ms: float
|
|
106
|
+
p95_ms: float
|
|
107
|
+
p99_ms: float
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _summarize(label: str, secs: List[float]) -> BucketStats:
|
|
111
|
+
ms = [x * 1000.0 for x in secs]
|
|
112
|
+
return BucketStats(
|
|
113
|
+
label=label,
|
|
114
|
+
count=len(ms),
|
|
115
|
+
mean_ms=_mean(ms),
|
|
116
|
+
p50_ms=_pct(ms, 0.50),
|
|
117
|
+
p90_ms=_pct(ms, 0.90),
|
|
118
|
+
p95_ms=_pct(ms, 0.95),
|
|
119
|
+
p99_ms=_pct(ms, 0.99),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ----------------------------------------------------------------------
|
|
124
|
+
# Aggregate metrics
|
|
125
|
+
# ----------------------------------------------------------------------
|
|
126
|
+
@dataclass
|
|
127
|
+
class AgenticMetrics:
|
|
128
|
+
# window / load audit
|
|
129
|
+
target_concurrency: int = 0
|
|
130
|
+
steady_frac: float = 0.9
|
|
131
|
+
total_turns: int = 0
|
|
132
|
+
steady_turns: int = 0
|
|
133
|
+
concurrency_hist: Dict[int, int] = field(default_factory=dict)
|
|
134
|
+
steady_window_s: float = 0.0
|
|
135
|
+
|
|
136
|
+
# Whole-run roll-up (bench_serving-style header). Unlike the steady
|
|
137
|
+
# window above, these cover every successful turn of the run: wall
|
|
138
|
+
# clock is first dispatch -> last completion (wall-clock epochs), so
|
|
139
|
+
# wall_duration_s >= steady_window_s by construction.
|
|
140
|
+
successful_turns: int = 0
|
|
141
|
+
wall_duration_s: float = 0.0
|
|
142
|
+
total_input_tokens: int = 0
|
|
143
|
+
total_output_tokens: int = 0
|
|
144
|
+
turns_throughput: float = 0.0
|
|
145
|
+
input_throughput: float = 0.0
|
|
146
|
+
# Peak per-second output rate: each turn's output tokens are spread
|
|
147
|
+
# evenly across the 1s buckets it spans, then the busiest bucket wins.
|
|
148
|
+
peak_output_throughput: float = 0.0
|
|
149
|
+
e2e_overall: Optional[BucketStats] = None
|
|
150
|
+
concurrency_mean: float = 0.0
|
|
151
|
+
concurrency_peak: int = 0
|
|
152
|
+
|
|
153
|
+
# latency buckets (steady window)
|
|
154
|
+
t1_ttft: Optional[BucketStats] = None
|
|
155
|
+
t2plus_ttft: Optional[BucketStats] = None
|
|
156
|
+
tpot_ms: float = 0.0
|
|
157
|
+
# Inter-token latency distribution over the steady window. The mean
|
|
158
|
+
# (tpot_ms) hides mid-decode stalls — chunked-prefill preemption and
|
|
159
|
+
# KV-eviction recompute both show up as ITL tail, not as mean.
|
|
160
|
+
itl_p50_ms: float = 0.0
|
|
161
|
+
itl_p90_ms: float = 0.0
|
|
162
|
+
itl_p95_ms: float = 0.0
|
|
163
|
+
itl_p99_ms: float = 0.0
|
|
164
|
+
itl_max_ms: float = 0.0
|
|
165
|
+
e2e_by_turn: List[BucketStats] = field(default_factory=list)
|
|
166
|
+
e2e_by_inputlen: List[BucketStats] = field(default_factory=list)
|
|
167
|
+
ttft_degradation: float = 0.0
|
|
168
|
+
|
|
169
|
+
# cache / throughput
|
|
170
|
+
prefix_cache_ratio: float = 0.0
|
|
171
|
+
# Host-layer cache split. Only SGLang reports it (sglext); ``None``
|
|
172
|
+
# when the backend cannot measure it (not measured != zero).
|
|
173
|
+
host_cache_ratio: Optional[float] = 0.0
|
|
174
|
+
host_share_of_cache: Optional[float] = 0.0
|
|
175
|
+
output_throughput: float = 0.0
|
|
176
|
+
total_throughput: float = 0.0
|
|
177
|
+
# Pareto point, as used by MLPerf's agentic methodology: the system axis
|
|
178
|
+
# is aggregate output tokens/s, the per-user axis divides a trajectory's
|
|
179
|
+
# output tokens by its serving time only, with injected tool waits
|
|
180
|
+
# excluded rather than charged to the server.
|
|
181
|
+
per_user_output_tps: float = 0.0
|
|
182
|
+
# Achieved vs recorded mean output length. Lengths are pinned per turn,
|
|
183
|
+
# so this is a fidelity check on the server: a mean outside the band
|
|
184
|
+
# means the engine did not honor the pin.
|
|
185
|
+
osl_mean_achieved: float = 0.0
|
|
186
|
+
osl_mean_expected: float = 0.0
|
|
187
|
+
osl_tolerance: float = 0.10
|
|
188
|
+
osl_within_band: bool = False
|
|
189
|
+
|
|
190
|
+
# session level
|
|
191
|
+
num_sessions: int = 0
|
|
192
|
+
completed_sessions: int = 0
|
|
193
|
+
truncated_sessions: int = 0
|
|
194
|
+
ctx_guard_sessions: int = 0
|
|
195
|
+
mean_trajectory_time_s: float = 0.0
|
|
196
|
+
completion_rate: float = 0.0
|
|
197
|
+
|
|
198
|
+
# Fraction of sessions whose turns all hit one worker. ``None`` when the
|
|
199
|
+
# run had no worker header at all (single endpoint, no router), where a
|
|
200
|
+
# stickiness number would be meaningless rather than zero.
|
|
201
|
+
routing_stickiness: Optional[float] = None
|
|
202
|
+
|
|
203
|
+
# server-side KV pool (from Prometheus /metrics scraping).
|
|
204
|
+
# capacity/eviction come from SGLang-only gauges: ``None`` when the
|
|
205
|
+
# backend does not expose them (not measurable != zero).
|
|
206
|
+
kv_pool_capacity_tokens: Optional[float] = None # avail+used+evictable (median)
|
|
207
|
+
kv_pool_utilization_mean: Optional[float] = None # used / capacity, mean
|
|
208
|
+
kv_pool_utilization_p99: Optional[float] = None
|
|
209
|
+
kv_eviction_delta: Optional[float] = None # total evictions during the run
|
|
210
|
+
kv_queue_depth_p99: Optional[float] = None # num_waiting_reqs p99
|
|
211
|
+
kv_running_mean: Optional[float] = None # num_running_reqs mean
|
|
212
|
+
kv_snapshots_count: int = 0 # how many scrapes succeeded
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_INPUT_LEN_BUCKETS: List[Tuple[int, int, str]] = [
|
|
216
|
+
(0, 2_000, "0-2K"),
|
|
217
|
+
(2_000, 8_000, "2-8K"),
|
|
218
|
+
(8_000, 16_000, "8-16K"),
|
|
219
|
+
(16_000, 32_000, "16-32K"),
|
|
220
|
+
(32_000, 1_000_000_000, "32K+"),
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _inputlen_label(n: int) -> str:
|
|
225
|
+
for lo, hi, lbl in _INPUT_LEN_BUCKETS:
|
|
226
|
+
if lo <= n < hi:
|
|
227
|
+
return lbl
|
|
228
|
+
return "32K+"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compute_metrics(
|
|
232
|
+
sessions: List[SessionRecord],
|
|
233
|
+
target_concurrency: int,
|
|
234
|
+
steady_frac: float = 0.9,
|
|
235
|
+
steady_only: bool = True,
|
|
236
|
+
) -> AgenticMetrics:
|
|
237
|
+
"""Aggregate raw records into headline metrics.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
sessions: per-session records collected by the replay engine.
|
|
241
|
+
target_concurrency: the configured ``C``.
|
|
242
|
+
steady_frac: a turn is "steady" if ``active_at_dispatch >=
|
|
243
|
+
ceil(steady_frac * C)``. Filters ramp/drain.
|
|
244
|
+
steady_only: when False, all turns are used (no trim); useful for
|
|
245
|
+
debugging or open-loop-style runs.
|
|
246
|
+
"""
|
|
247
|
+
all_turns: List[TurnRecord] = [t for s in sessions for t in s.turns]
|
|
248
|
+
m = AgenticMetrics(
|
|
249
|
+
target_concurrency=target_concurrency,
|
|
250
|
+
steady_frac=steady_frac,
|
|
251
|
+
total_turns=len(all_turns),
|
|
252
|
+
num_sessions=len(sessions),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# concurrency audit (over successful turns)
|
|
256
|
+
m.concurrency_hist = dict(
|
|
257
|
+
Counter(t.active_at_dispatch for t in all_turns if t.success)
|
|
258
|
+
)
|
|
259
|
+
if m.concurrency_hist:
|
|
260
|
+
hist_total = sum(m.concurrency_hist.values())
|
|
261
|
+
m.concurrency_mean = (
|
|
262
|
+
sum(c * n for c, n in m.concurrency_hist.items()) / hist_total
|
|
263
|
+
)
|
|
264
|
+
m.concurrency_peak = max(m.concurrency_hist)
|
|
265
|
+
|
|
266
|
+
# Whole-run roll-up over successful turns (bench_serving-style header).
|
|
267
|
+
ok = [t for t in all_turns if t.success and t.complete_epoch > 0]
|
|
268
|
+
m.successful_turns = len(ok)
|
|
269
|
+
m.e2e_overall = _summarize("overall", [t.latency for t in ok])
|
|
270
|
+
if ok:
|
|
271
|
+
w0 = min(t.dispatch_epoch for t in ok)
|
|
272
|
+
w1 = max(t.complete_epoch for t in ok)
|
|
273
|
+
m.wall_duration_s = max(1e-6, w1 - w0)
|
|
274
|
+
m.total_input_tokens = sum(t.input_len for t in ok)
|
|
275
|
+
m.total_output_tokens = sum(t.output_len for t in ok)
|
|
276
|
+
m.turns_throughput = len(ok) / m.wall_duration_s
|
|
277
|
+
m.input_throughput = m.total_input_tokens / m.wall_duration_s
|
|
278
|
+
buckets: Dict[int, float] = defaultdict(float)
|
|
279
|
+
for t in ok:
|
|
280
|
+
b0 = int(t.dispatch_epoch)
|
|
281
|
+
b1 = int(t.complete_epoch)
|
|
282
|
+
per = t.output_len / max(1, b1 - b0 + 1)
|
|
283
|
+
for b in range(b0, b1 + 1):
|
|
284
|
+
buckets[b] += per
|
|
285
|
+
m.peak_output_throughput = max(buckets.values()) if buckets else 0.0
|
|
286
|
+
|
|
287
|
+
threshold = math.ceil(steady_frac * target_concurrency) if target_concurrency else 0
|
|
288
|
+
def _in_window(t: TurnRecord) -> bool:
|
|
289
|
+
if not t.success:
|
|
290
|
+
return False
|
|
291
|
+
if not steady_only:
|
|
292
|
+
return True
|
|
293
|
+
return t.active_at_dispatch >= threshold
|
|
294
|
+
|
|
295
|
+
win = [t for t in all_turns if _in_window(t)]
|
|
296
|
+
m.steady_turns = len(win)
|
|
297
|
+
|
|
298
|
+
# latency buckets
|
|
299
|
+
t1 = [t.ttft for t in win if t.turn_index == 0]
|
|
300
|
+
t2 = [t.ttft for t in win if t.turn_index >= 1]
|
|
301
|
+
m.t1_ttft = _summarize("T1", t1)
|
|
302
|
+
m.t2plus_ttft = _summarize("T2+", t2)
|
|
303
|
+
if m.t1_ttft.mean_ms > 0:
|
|
304
|
+
m.ttft_degradation = m.t2plus_ttft.mean_ms / m.t1_ttft.mean_ms
|
|
305
|
+
|
|
306
|
+
all_itl = [x for t in win for x in t.itl]
|
|
307
|
+
itl_ms = [x * 1000.0 for x in all_itl]
|
|
308
|
+
m.tpot_ms = _mean(itl_ms)
|
|
309
|
+
m.itl_p50_ms = _pct(itl_ms, 0.50)
|
|
310
|
+
m.itl_p90_ms = _pct(itl_ms, 0.90)
|
|
311
|
+
m.itl_p95_ms = _pct(itl_ms, 0.95)
|
|
312
|
+
m.itl_p99_ms = _pct(itl_ms, 0.99)
|
|
313
|
+
m.itl_max_ms = max(itl_ms) if itl_ms else 0.0
|
|
314
|
+
|
|
315
|
+
# E2E bucketed by turn index (cap the number of explicit buckets)
|
|
316
|
+
by_turn: Dict[int, List[float]] = defaultdict(list)
|
|
317
|
+
for t in win:
|
|
318
|
+
key = t.turn_index if t.turn_index < 10 else 10
|
|
319
|
+
by_turn[key].append(t.latency)
|
|
320
|
+
for k in sorted(by_turn):
|
|
321
|
+
lbl = f"turn>={k}" if k == 10 else f"turn{k}"
|
|
322
|
+
m.e2e_by_turn.append(_summarize(lbl, by_turn[k]))
|
|
323
|
+
|
|
324
|
+
# E2E bucketed by input length
|
|
325
|
+
by_len: Dict[str, List[float]] = defaultdict(list)
|
|
326
|
+
for t in win:
|
|
327
|
+
by_len[_inputlen_label(t.input_len)].append(t.latency)
|
|
328
|
+
for _, _, lbl in _INPUT_LEN_BUCKETS:
|
|
329
|
+
if lbl in by_len:
|
|
330
|
+
m.e2e_by_inputlen.append(_summarize(lbl, by_len[lbl]))
|
|
331
|
+
|
|
332
|
+
# Prefix cache ratio: token-weighted (sum cached / sum input), so long
|
|
333
|
+
# turns aren't given the same weight as short ones. Turns where the
|
|
334
|
+
# server reported no usage are excluded rather than counted as 0%.
|
|
335
|
+
cached_sum = sum(t.server_cached_tokens for t in win if t.input_len > 0)
|
|
336
|
+
input_sum = sum(t.input_len for t in win if t.input_len > 0)
|
|
337
|
+
m.prefix_cache_ratio = min(1.0, cached_sum / input_sum) if input_sum else 0.0
|
|
338
|
+
|
|
339
|
+
# Where the hits came from. ``prefix_cache_ratio`` already counts host
|
|
340
|
+
# hits, so only this split distinguishes an HBM hit from a DRAM one.
|
|
341
|
+
host_sum = sum(t.cached_host_tokens for t in win if t.input_len > 0)
|
|
342
|
+
m.host_cache_ratio = min(1.0, host_sum / input_sum) if input_sum else 0.0
|
|
343
|
+
m.host_share_of_cache = min(1.0, host_sum / cached_sum) if cached_sum else 0.0
|
|
344
|
+
|
|
345
|
+
# throughput over the steady wall-clock window
|
|
346
|
+
if win:
|
|
347
|
+
w0 = min(t.dispatch_time for t in win)
|
|
348
|
+
w1 = max(t.complete_time for t in win)
|
|
349
|
+
wall = max(1e-6, w1 - w0)
|
|
350
|
+
m.steady_window_s = wall
|
|
351
|
+
out_tok = sum(t.output_len for t in win)
|
|
352
|
+
in_tok = sum(t.input_len for t in win)
|
|
353
|
+
m.output_throughput = out_tok / wall
|
|
354
|
+
m.total_throughput = (out_tok + in_tok) / wall
|
|
355
|
+
|
|
356
|
+
# Session level. Sessions the benchmark itself truncated (pool
|
|
357
|
+
# exhaustion) are neither "completed" nor "failed" — they are reported
|
|
358
|
+
# separately and excluded from trajectory statistics.
|
|
359
|
+
intact = [s for s in sessions if not s.truncated]
|
|
360
|
+
m.truncated_sessions = len(sessions) - len(intact)
|
|
361
|
+
m.ctx_guard_sessions = sum(1 for s in sessions if s.ctx_guard_stop)
|
|
362
|
+
completed = [s for s in intact if s.turns and all(t.success for t in s.turns)]
|
|
363
|
+
m.completed_sessions = len(completed)
|
|
364
|
+
intact_turns = [t for s in intact for t in s.turns]
|
|
365
|
+
m.completion_rate = (
|
|
366
|
+
(sum(1 for t in intact_turns if t.success) / len(intact_turns))
|
|
367
|
+
if intact_turns
|
|
368
|
+
else 0.0
|
|
369
|
+
)
|
|
370
|
+
m.mean_trajectory_time_s = _mean(
|
|
371
|
+
[s.trajectory_inference_time for s in completed]
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Per-user progression speed. ``trajectory_inference_time`` sums per-turn
|
|
375
|
+
# server time, so the tool waits slept between turns are already out.
|
|
376
|
+
per_user = [
|
|
377
|
+
sum(t.output_len for t in s.turns) / s.trajectory_inference_time
|
|
378
|
+
for s in completed
|
|
379
|
+
if s.trajectory_inference_time > 0
|
|
380
|
+
]
|
|
381
|
+
m.per_user_output_tps = _mean(per_user)
|
|
382
|
+
|
|
383
|
+
# Output-length fidelity over the measured window.
|
|
384
|
+
if win:
|
|
385
|
+
m.osl_mean_achieved = _mean([float(t.output_len) for t in win])
|
|
386
|
+
expected = [float(t.recorded_output_len) for t in win if t.recorded_output_len]
|
|
387
|
+
m.osl_mean_expected = _mean(expected)
|
|
388
|
+
if m.osl_mean_expected > 0:
|
|
389
|
+
lo = m.osl_mean_expected * (1.0 - m.osl_tolerance)
|
|
390
|
+
hi = m.osl_mean_expected * (1.0 + m.osl_tolerance)
|
|
391
|
+
m.osl_within_band = lo <= m.osl_mean_achieved <= hi
|
|
392
|
+
|
|
393
|
+
# routing stickiness: fraction of sessions whose turns all hit one worker
|
|
394
|
+
sticky_count = 0
|
|
395
|
+
sessions_with_workers = 0
|
|
396
|
+
for s in sessions:
|
|
397
|
+
if s.assigned_workers:
|
|
398
|
+
sessions_with_workers += 1
|
|
399
|
+
if len(set(s.assigned_workers)) == 1:
|
|
400
|
+
sticky_count += 1
|
|
401
|
+
m.routing_stickiness = (
|
|
402
|
+
sticky_count / sessions_with_workers if sessions_with_workers else None
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return m
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def summarize_server_metrics(
|
|
409
|
+
m: AgenticMetrics, snapshots: list, backend: str = "sglang"
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Populate KV pool fields on ``m`` from raw Prometheus snapshots.
|
|
412
|
+
|
|
413
|
+
Called after ``compute_metrics``; a no-op when ``snapshots`` is empty
|
|
414
|
+
(server did not expose /metrics). Metrics the backend cannot provide
|
|
415
|
+
stay ``None`` rather than being reported as zero.
|
|
416
|
+
"""
|
|
417
|
+
if not snapshots:
|
|
418
|
+
return
|
|
419
|
+
m.kv_snapshots_count = len(snapshots)
|
|
420
|
+
|
|
421
|
+
waiting = [s.num_waiting_reqs for s in snapshots]
|
|
422
|
+
m.kv_queue_depth_p99 = _pct(waiting, 0.99)
|
|
423
|
+
|
|
424
|
+
running = [s.num_running_reqs for s in snapshots]
|
|
425
|
+
m.kv_running_mean = _mean(running)
|
|
426
|
+
|
|
427
|
+
if backend == "vllm":
|
|
428
|
+
# vLLM: the cache usage gauge is already used/capacity; capacity
|
|
429
|
+
# comes from the cache_config_info info-gauge labels and eviction
|
|
430
|
+
# from the num_preemptions counter (both parsed at scrape time).
|
|
431
|
+
utils = [s.token_usage for s in snapshots]
|
|
432
|
+
m.kv_pool_utilization_mean = _mean(utils)
|
|
433
|
+
m.kv_pool_utilization_p99 = _pct(utils, 0.99)
|
|
434
|
+
caps = [s.max_total_tokens for s in snapshots if s.max_total_tokens > 0]
|
|
435
|
+
if caps:
|
|
436
|
+
m.kv_pool_capacity_tokens = _pct(caps, 0.5)
|
|
437
|
+
m.kv_eviction_delta = max(
|
|
438
|
+
0.0, snapshots[-1].num_evicted_requests - snapshots[0].num_evicted_requests
|
|
439
|
+
)
|
|
440
|
+
# Client-side cached_tokens is null on current vLLM builds; the
|
|
441
|
+
# prefix-cache ratio comes from the server counters instead.
|
|
442
|
+
dq = max(0.0, snapshots[-1].prefix_cache_queries_total - snapshots[0].prefix_cache_queries_total)
|
|
443
|
+
dh = max(0.0, snapshots[-1].prefix_cache_hits_total - snapshots[0].prefix_cache_hits_total)
|
|
444
|
+
if dq > 0:
|
|
445
|
+
m.prefix_cache_ratio = min(1.0, dh / dq)
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
capacities = [
|
|
449
|
+
s.cache_available_tokens + s.cache_used_tokens + s.cache_evictable_tokens
|
|
450
|
+
for s in snapshots
|
|
451
|
+
]
|
|
452
|
+
# The three pool gauges are absent on some builds; max_total_num_tokens
|
|
453
|
+
# and token_usage carry the same information and are always present.
|
|
454
|
+
if max(capacities, default=0.0) <= 0:
|
|
455
|
+
capacities = [s.max_total_tokens for s in snapshots]
|
|
456
|
+
utils = [s.token_usage for s in snapshots]
|
|
457
|
+
else:
|
|
458
|
+
utils = [
|
|
459
|
+
s.cache_used_tokens / cap if cap > 0 else 0.0
|
|
460
|
+
for s, cap in zip(snapshots, capacities)
|
|
461
|
+
]
|
|
462
|
+
m.kv_pool_capacity_tokens = _pct(capacities, 0.5)
|
|
463
|
+
m.kv_pool_utilization_mean = _mean(utils)
|
|
464
|
+
m.kv_pool_utilization_p99 = _pct(utils, 0.99)
|
|
465
|
+
|
|
466
|
+
# Eviction delta: last counter value minus first.
|
|
467
|
+
m.kv_eviction_delta = max(
|
|
468
|
+
0.0, snapshots[-1].num_evicted_requests - snapshots[0].num_evicted_requests
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ----------------------------------------------------------------------
|
|
473
|
+
# Server-side Prometheus metrics scraping
|
|
474
|
+
# ----------------------------------------------------------------------
|
|
475
|
+
@dataclass
|
|
476
|
+
class ServerMetricsSnapshot:
|
|
477
|
+
"""One scrape of the server's /metrics, normalized across engines.
|
|
478
|
+
|
|
479
|
+
Filled by abench.serve.telemetry; kept here with the other result
|
|
480
|
+
records so the core layer stays free of HTTP concerns.
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
timestamp: float # perf_counter at scrape time
|
|
484
|
+
cache_available_tokens: float = 0.0
|
|
485
|
+
cache_used_tokens: float = 0.0
|
|
486
|
+
cache_evictable_tokens: float = 0.0
|
|
487
|
+
num_evicted_requests: float = 0.0 # COUNTER (cumulative)
|
|
488
|
+
num_running_reqs: float = 0.0
|
|
489
|
+
num_waiting_reqs: float = 0.0
|
|
490
|
+
token_usage: float = 0.0 # used / capacity, as the server computes it
|
|
491
|
+
max_total_tokens: float = 0.0 # KV pool capacity
|
|
492
|
+
# Unified cache / HiCache host-layer metrics
|
|
493
|
+
cache_hit_rate: float = 0.0
|
|
494
|
+
cached_tokens_total: float = 0.0 # COUNTER, device+host summed
|
|
495
|
+
evicted_tokens_total: float = 0.0 # COUNTER
|
|
496
|
+
kv_evictable_tokens: float = 0.0
|
|
497
|
+
mamba_evictable_tokens: float = 0.0
|
|
498
|
+
hicache_host_used_tokens: float = 0.0
|
|
499
|
+
hicache_host_total_tokens: float = 0.0
|
|
500
|
+
# vLLM prefix-cache counters (cumulative); the client-side
|
|
501
|
+
# usage.prompt_tokens_details.cached_tokens path is null on current
|
|
502
|
+
# vLLM builds even when caching is active, so the ratio is derived
|
|
503
|
+
# from these counter deltas instead.
|
|
504
|
+
prefix_cache_queries_total: float = 0.0
|
|
505
|
+
prefix_cache_hits_total: float = 0.0
|