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/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