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/replay/engine.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Closed-loop agentic replay engine.
|
|
5
|
+
|
|
6
|
+
Implements the concurrency model from the benchmark design:
|
|
7
|
+
|
|
8
|
+
* A fixed number of **active sessions** ``C`` is maintained. Each of the
|
|
9
|
+
``C`` async workers holds exactly one session at a time and drives it
|
|
10
|
+
turn-by-turn (a session is strictly sequential: turn N+1 waits for
|
|
11
|
+
turn N to complete). When a session finishes, the worker pulls the
|
|
12
|
+
next one from the pool — so a short trajectory is simply *recycled
|
|
13
|
+
faster*, keeping the active count pinned at ``C`` without any turn
|
|
14
|
+
truncation.
|
|
15
|
+
|
|
16
|
+
* ``--duration`` (optional) turns the pool into a looping source and
|
|
17
|
+
stops dispatching new sessions once the deadline passes; in-flight
|
|
18
|
+
sessions are allowed to finish. This removes the drain phase from the
|
|
19
|
+
measurement window.
|
|
20
|
+
|
|
21
|
+
* **Tool waits** (``pre_gap_ms``) are honored *between* turns but are not
|
|
22
|
+
counted in per-turn latency — the model is idle, not the client.
|
|
23
|
+
|
|
24
|
+
* Every turn's decode length is pinned by the output policy — the
|
|
25
|
+
recorded ``completion_tokens`` when replaying a trace, a constant when
|
|
26
|
+
the workload is synthetic — and sent as ``max_completion_tokens`` with
|
|
27
|
+
``ignore_eos``. The model's own EOS never decides how much work a turn
|
|
28
|
+
does, which is what makes two engines comparable on the same workload.
|
|
29
|
+
|
|
30
|
+
HTTP plumbing lives in ``abench.replay.transport``, server discovery and
|
|
31
|
+
telemetry in ``abench.serve``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
import traceback
|
|
40
|
+
from dataclasses import replace
|
|
41
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
42
|
+
|
|
43
|
+
import aiohttp
|
|
44
|
+
|
|
45
|
+
from abench.core.metrics import ServerMetricsSnapshot, SessionRecord, TurnRecord
|
|
46
|
+
from abench.core.workload import PlannedSession
|
|
47
|
+
from abench.replay.transport import (
|
|
48
|
+
_count_tokens_async,
|
|
49
|
+
_process_non_stream,
|
|
50
|
+
_process_stream,
|
|
51
|
+
)
|
|
52
|
+
from abench.serve.endpoints import get_auth_headers, server_root
|
|
53
|
+
from abench.serve.telemetry import _scrape_server_metrics
|
|
54
|
+
|
|
55
|
+
# ----------------------------------------------------------------------
|
|
56
|
+
# Shared run state (concurrency accounting + pool)
|
|
57
|
+
# ----------------------------------------------------------------------
|
|
58
|
+
class _RunState:
|
|
59
|
+
"""Thread/async-safe dispatch state shared by all workers."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
pool: List[PlannedSession],
|
|
64
|
+
loop_pool: bool,
|
|
65
|
+
deadline: Optional[float],
|
|
66
|
+
max_duration_s: Optional[float] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._pool = pool
|
|
69
|
+
self._loop = loop_pool
|
|
70
|
+
self._deadline = deadline
|
|
71
|
+
# Runaway fuse: hard cap on total run wall clock, independent of
|
|
72
|
+
# any window. Guards against a wedged server or endless retry
|
|
73
|
+
# dragging a run on forever; a normal run should never reach it.
|
|
74
|
+
self._max_deadline = (
|
|
75
|
+
time.perf_counter() + max_duration_s if max_duration_s else None
|
|
76
|
+
)
|
|
77
|
+
self._idx = 0
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
self.active = 0
|
|
80
|
+
self.exhausted = False
|
|
81
|
+
self.planned_turns = sum(len(s.turns) for s in pool)
|
|
82
|
+
self.turns_done = 0
|
|
83
|
+
self.output_tokens = 0
|
|
84
|
+
# Optional tqdm handle; set by run_closed_loop when a progress bar
|
|
85
|
+
# is active. Workers update it per completed turn.
|
|
86
|
+
self.pbar = None
|
|
87
|
+
self.sessions_done = 0
|
|
88
|
+
self.sessions_truncated = 0
|
|
89
|
+
|
|
90
|
+
def effective_deadline(self) -> Optional[float]:
|
|
91
|
+
"""Earliest of the --duration window and the --max-duration cap."""
|
|
92
|
+
if self._deadline is not None and self._max_deadline is not None:
|
|
93
|
+
return min(self._deadline, self._max_deadline)
|
|
94
|
+
return self._deadline if self._deadline is not None else self._max_deadline
|
|
95
|
+
|
|
96
|
+
def next_session(self) -> Optional[PlannedSession]:
|
|
97
|
+
d = self.effective_deadline()
|
|
98
|
+
if d is not None and time.perf_counter() >= d:
|
|
99
|
+
return None
|
|
100
|
+
with self._lock:
|
|
101
|
+
if self._idx >= len(self._pool):
|
|
102
|
+
if not self._loop:
|
|
103
|
+
self.exhausted = True
|
|
104
|
+
return None
|
|
105
|
+
self._idx = 0
|
|
106
|
+
sess = self._pool[self._idx]
|
|
107
|
+
self._idx += 1
|
|
108
|
+
return sess
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ----------------------------------------------------------------------
|
|
112
|
+
# Single-session driver
|
|
113
|
+
# ----------------------------------------------------------------------
|
|
114
|
+
def _turn_request_timeout(
|
|
115
|
+
turn_timeout_s: float, deadline: Optional[float], now: float
|
|
116
|
+
) -> Optional[aiohttp.ClientTimeout]:
|
|
117
|
+
"""Per-request cap: the tighter of the turn timeout and the remaining
|
|
118
|
+
``--max-duration`` budget. The fuse's between-turn check cannot stop a
|
|
119
|
+
hung in-flight request; without this, one wedged turn holds the run
|
|
120
|
+
hostage far past the deadline. ``None``
|
|
121
|
+
means "no deadline": fall back to the session's default timeout."""
|
|
122
|
+
if deadline is None:
|
|
123
|
+
return None
|
|
124
|
+
remaining = deadline - now
|
|
125
|
+
if remaining <= 0:
|
|
126
|
+
return None
|
|
127
|
+
return aiohttp.ClientTimeout(total=min(turn_timeout_s, remaining))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _run_session(
|
|
131
|
+
sess: PlannedSession,
|
|
132
|
+
state: _RunState,
|
|
133
|
+
api_url: str,
|
|
134
|
+
model: str,
|
|
135
|
+
session: aiohttp.ClientSession,
|
|
136
|
+
tokenizer: Optional[Any],
|
|
137
|
+
content_mode: str,
|
|
138
|
+
disable_stream: bool,
|
|
139
|
+
extra_body: Dict[str, Any],
|
|
140
|
+
respect_gaps: bool,
|
|
141
|
+
gap_scale: float = 1.0,
|
|
142
|
+
ignore_eos: bool = True,
|
|
143
|
+
worker_header: Optional[str] = None,
|
|
144
|
+
session_timeout_s: float = 0.0,
|
|
145
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
146
|
+
extra_request_body: Optional[Dict[str, Any]] = None,
|
|
147
|
+
backend: str = "sglang",
|
|
148
|
+
max_model_len: Optional[int] = None,
|
|
149
|
+
turn_timeout_s: float = 3600.0,
|
|
150
|
+
) -> SessionRecord:
|
|
151
|
+
rec = SessionRecord(
|
|
152
|
+
session_id=sess.session_id,
|
|
153
|
+
source_model=str(sess.metadata.get("model", "")),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
session_start = time.perf_counter()
|
|
157
|
+
|
|
158
|
+
messages: List[Dict[str, str]] = []
|
|
159
|
+
# Running client-side estimate of the prompt length. Each piece of
|
|
160
|
+
# text is encoded exactly once (incremental), and the server-reported
|
|
161
|
+
# ``usage.prompt_tokens`` overrides the estimate whenever available.
|
|
162
|
+
input_len_est = 0
|
|
163
|
+
if sess.system_prompt:
|
|
164
|
+
messages.append({"role": "system", "content": sess.system_prompt})
|
|
165
|
+
input_len_est += await _count_tokens_async(sess.system_prompt, tokenizer)
|
|
166
|
+
|
|
167
|
+
for turn in sess.turns:
|
|
168
|
+
# Timed runs: truncate in-flight sessions once the window closes
|
|
169
|
+
# instead of draining their whole trajectory. Drain turns are
|
|
170
|
+
# excluded from steady-window statistics anyway, so keeping them
|
|
171
|
+
# only stretches the wall clock (a timed run can last 5-10x the
|
|
172
|
+
# window on long trajectories with tool gaps).
|
|
173
|
+
d = state.effective_deadline()
|
|
174
|
+
if d is not None and time.perf_counter() > d:
|
|
175
|
+
rec.truncated = True
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
# Per-session timeout: stop dispatching new turns once the budget
|
|
179
|
+
# is spent. Already-completed turns stay; the session is marked
|
|
180
|
+
# truncated so it is excluded from trajectory stats.
|
|
181
|
+
if session_timeout_s > 0 and (time.perf_counter() - session_start) >= session_timeout_s:
|
|
182
|
+
rec.truncated = True
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# Client-side context guard: estimated prompt plus the pinned
|
|
186
|
+
# completion must fit the model's context window, else the server
|
|
187
|
+
# rejects the turn with an HTTP 400. Later turns only grow, so
|
|
188
|
+
# stop the session instead of burning guaranteed failures.
|
|
189
|
+
if (
|
|
190
|
+
max_model_len is not None
|
|
191
|
+
and input_len_est + turn.max_new_tokens > max_model_len
|
|
192
|
+
):
|
|
193
|
+
rec.truncated = True
|
|
194
|
+
rec.ctx_guard_stop = True
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
if respect_gaps and turn.pre_gap_ms > 0:
|
|
198
|
+
await asyncio.sleep(turn.pre_gap_ms * gap_scale / 1000.0)
|
|
199
|
+
|
|
200
|
+
for m in turn.input_delta:
|
|
201
|
+
# Tool observations are recorded with role="tool" but carry no
|
|
202
|
+
# tool_call_id, which strict OpenAI-schema validators (routers)
|
|
203
|
+
# reject. Role does not affect prefill/decode cost, so coerce any
|
|
204
|
+
# non-standard role to "user" for schema-safe replay.
|
|
205
|
+
role = m["role"] if m["role"] in ("system", "user", "assistant") else "user"
|
|
206
|
+
messages.append({"role": role, "content": m["content"]})
|
|
207
|
+
input_len_est += await _count_tokens_async(m["content"], tokenizer)
|
|
208
|
+
|
|
209
|
+
payload: Dict[str, Any] = {
|
|
210
|
+
"model": model,
|
|
211
|
+
"messages": messages,
|
|
212
|
+
"max_completion_tokens": turn.max_new_tokens,
|
|
213
|
+
"stream": not disable_stream,
|
|
214
|
+
}
|
|
215
|
+
if ignore_eos:
|
|
216
|
+
payload["ignore_eos"] = True
|
|
217
|
+
if not disable_stream:
|
|
218
|
+
payload["stream_options"] = {"include_usage": True}
|
|
219
|
+
if backend == "sglang":
|
|
220
|
+
# Off by default server-side; without it the device/host split
|
|
221
|
+
# of prefix-cache hits is not reported at all. SGLang-only:
|
|
222
|
+
# other backends may reject unknown request fields outright.
|
|
223
|
+
payload["return_cached_tokens_details"] = True
|
|
224
|
+
payload.update(extra_body or {})
|
|
225
|
+
# --extra-request-body goes last so it can override anything above.
|
|
226
|
+
payload.update(extra_request_body or {})
|
|
227
|
+
|
|
228
|
+
headers = {"Content-Type": "application/json"}
|
|
229
|
+
headers.update(get_auth_headers())
|
|
230
|
+
headers.update(extra_headers or {})
|
|
231
|
+
if sess.routing_key:
|
|
232
|
+
headers["X-SMG-Routing-Key"] = sess.routing_key
|
|
233
|
+
|
|
234
|
+
tr = TurnRecord(
|
|
235
|
+
session_id=sess.session_id,
|
|
236
|
+
turn_index=turn.index,
|
|
237
|
+
input_len=input_len_est,
|
|
238
|
+
active_at_dispatch=state.active,
|
|
239
|
+
recorded_output_len=turn.recorded_output_tokens,
|
|
240
|
+
)
|
|
241
|
+
start = time.perf_counter()
|
|
242
|
+
tr.dispatch_time = start
|
|
243
|
+
tr.dispatch_epoch = time.time() # exported as start_ts (epoch s)
|
|
244
|
+
gen_text = ""
|
|
245
|
+
try:
|
|
246
|
+
req_timeout = _turn_request_timeout(
|
|
247
|
+
turn_timeout_s, state.effective_deadline(), time.perf_counter()
|
|
248
|
+
)
|
|
249
|
+
async with session.post(
|
|
250
|
+
api_url, json=payload, headers=headers, timeout=req_timeout
|
|
251
|
+
) as resp:
|
|
252
|
+
if resp.status != 200:
|
|
253
|
+
body = await resp.text()
|
|
254
|
+
tr.success = False
|
|
255
|
+
tr.error = f"HTTP {resp.status}: {body[:200]}"
|
|
256
|
+
else:
|
|
257
|
+
if disable_stream:
|
|
258
|
+
gen_text, ttft, itl, usage = await _process_non_stream(
|
|
259
|
+
resp, start
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
gen_text, ttft, itl, usage = await _process_stream(
|
|
263
|
+
resp, start
|
|
264
|
+
)
|
|
265
|
+
tr.ttft = ttft
|
|
266
|
+
tr.itl = itl
|
|
267
|
+
tr.server_cached_tokens = usage["cached_tokens"]
|
|
268
|
+
tr.cached_device_tokens = usage["cached_device_tokens"]
|
|
269
|
+
tr.cached_host_tokens = usage["cached_host_tokens"]
|
|
270
|
+
# Server-reported token counts are authoritative (they
|
|
271
|
+
# include chat-template overhead the client can't see).
|
|
272
|
+
if usage["prompt_tokens"] > 0:
|
|
273
|
+
tr.input_len = usage["prompt_tokens"]
|
|
274
|
+
if usage["completion_tokens"] > 0:
|
|
275
|
+
tr.output_len = usage["completion_tokens"]
|
|
276
|
+
else:
|
|
277
|
+
tr.output_len = await _count_tokens_async(
|
|
278
|
+
gen_text, tokenizer
|
|
279
|
+
)
|
|
280
|
+
tr.success = bool(gen_text) or tr.output_len > 0
|
|
281
|
+
if worker_header:
|
|
282
|
+
worker = resp.headers.get(worker_header)
|
|
283
|
+
if worker:
|
|
284
|
+
rec.assigned_workers.append(worker)
|
|
285
|
+
except Exception as exc: # noqa: BLE001
|
|
286
|
+
tr.success = False
|
|
287
|
+
tr.error = f"{type(exc).__name__}: {exc} | " + traceback.format_exc(
|
|
288
|
+
limit=1
|
|
289
|
+
)
|
|
290
|
+
tr.complete_time = time.perf_counter()
|
|
291
|
+
tr.complete_epoch = time.time() # exported as end_ts (epoch s)
|
|
292
|
+
tr.latency = tr.complete_time - start
|
|
293
|
+
rec.turns.append(tr)
|
|
294
|
+
state.turns_done += 1
|
|
295
|
+
state.output_tokens += tr.output_len
|
|
296
|
+
if state.pbar is not None:
|
|
297
|
+
state.pbar.update(1)
|
|
298
|
+
|
|
299
|
+
# Advance the running context with the assistant slot.
|
|
300
|
+
if content_mode == "model" and tr.success:
|
|
301
|
+
messages.append({"role": "assistant", "content": gen_text})
|
|
302
|
+
input_len_est += tr.output_len
|
|
303
|
+
else:
|
|
304
|
+
messages.append({"role": "assistant", "content": turn.assistant_text})
|
|
305
|
+
input_len_est += await _count_tokens_async(
|
|
306
|
+
turn.assistant_text, tokenizer
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return rec
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ----------------------------------------------------------------------
|
|
313
|
+
# Closed-loop orchestration
|
|
314
|
+
# ----------------------------------------------------------------------
|
|
315
|
+
async def run_closed_loop(
|
|
316
|
+
pool: List[PlannedSession],
|
|
317
|
+
api_url: str,
|
|
318
|
+
model: str,
|
|
319
|
+
max_concurrency: int,
|
|
320
|
+
tokenizer: Optional[Any],
|
|
321
|
+
content_mode: str = "trace",
|
|
322
|
+
disable_stream: bool = False,
|
|
323
|
+
duration_s: Optional[float] = None,
|
|
324
|
+
respect_gaps: bool = True,
|
|
325
|
+
gap_scale: float = 1.0,
|
|
326
|
+
extra_body: Optional[Dict[str, Any]] = None,
|
|
327
|
+
worker_header: Optional[str] = None,
|
|
328
|
+
verbose: bool = False,
|
|
329
|
+
progress_interval: float = 30.0,
|
|
330
|
+
ignore_eos: bool = True,
|
|
331
|
+
turn_timeout_s: float = 3600.0,
|
|
332
|
+
metrics_interval: float = 5.0,
|
|
333
|
+
session_timeout_s: float = 0.0,
|
|
334
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
335
|
+
extra_request_body: Optional[Dict[str, Any]] = None,
|
|
336
|
+
backend: str = "sglang",
|
|
337
|
+
max_duration_s: Optional[float] = None,
|
|
338
|
+
disable_tqdm: bool = False,
|
|
339
|
+
max_model_len: Optional[int] = None,
|
|
340
|
+
) -> Tuple[List[SessionRecord], List[ServerMetricsSnapshot]]:
|
|
341
|
+
"""Drive ``pool`` with ``concurrency`` active sessions.
|
|
342
|
+
|
|
343
|
+
When ``duration_s`` is set the pool loops and dispatch stops at the
|
|
344
|
+
deadline; otherwise every session is played exactly once.
|
|
345
|
+
``max_duration_s`` is a hard cap on top of either mode (runaway fuse).
|
|
346
|
+
|
|
347
|
+
``backend`` gates the observation layer: only ``sglang`` sends
|
|
348
|
+
``return_cached_tokens_details`` and scrapes ``/metrics`` with the
|
|
349
|
+
``sglang:`` gauge names; ``vllm`` scrapes with ``vllm:`` names;
|
|
350
|
+
``openai`` skips scraping entirely.
|
|
351
|
+
|
|
352
|
+
Returns ``(session_records, server_metrics_snapshots)``. The snapshots
|
|
353
|
+
list is empty when the server does not expose ``/metrics``.
|
|
354
|
+
"""
|
|
355
|
+
deadline = (time.perf_counter() + duration_s) if duration_s else None
|
|
356
|
+
state = _RunState(
|
|
357
|
+
pool,
|
|
358
|
+
loop_pool=duration_s is not None,
|
|
359
|
+
deadline=deadline,
|
|
360
|
+
max_duration_s=max_duration_s,
|
|
361
|
+
)
|
|
362
|
+
results: List[SessionRecord] = []
|
|
363
|
+
results_lock = asyncio.Lock()
|
|
364
|
+
|
|
365
|
+
connector = aiohttp.TCPConnector(limit=max(max_concurrency * 2, 32), ssl=False)
|
|
366
|
+
# Applies per request: a hung turn frees its slot after this long instead
|
|
367
|
+
# of holding it for the rest of the run.
|
|
368
|
+
timeout = aiohttp.ClientTimeout(total=turn_timeout_s)
|
|
369
|
+
|
|
370
|
+
async def progress() -> None:
|
|
371
|
+
"""Heartbeat so a multi-hour run is distinguishable from a stall."""
|
|
372
|
+
t0 = time.perf_counter()
|
|
373
|
+
while True:
|
|
374
|
+
await asyncio.sleep(heartbeat_interval)
|
|
375
|
+
el = time.perf_counter() - t0
|
|
376
|
+
rate = state.output_tokens / el if el > 0 else 0.0
|
|
377
|
+
line = (
|
|
378
|
+
f"[progress] {el / 60:6.1f}min "
|
|
379
|
+
f"turns {state.turns_done}/{state.planned_turns} "
|
|
380
|
+
f"({100.0 * state.turns_done / max(1, state.planned_turns):4.1f}%) "
|
|
381
|
+
f"sessions done={state.sessions_done} trunc={state.sessions_truncated} "
|
|
382
|
+
f"active={state.active}/{max_concurrency} "
|
|
383
|
+
f"out={rate:7.1f} tok/s"
|
|
384
|
+
)
|
|
385
|
+
if state.planned_turns and state.turns_done:
|
|
386
|
+
remaining = state.planned_turns - state.turns_done
|
|
387
|
+
eta_min = remaining / (state.turns_done / el) / 60.0
|
|
388
|
+
line += f" eta~{eta_min:5.1f}min"
|
|
389
|
+
print(line, flush=True)
|
|
390
|
+
|
|
391
|
+
server_snapshots: List[ServerMetricsSnapshot] = []
|
|
392
|
+
metrics_url = server_root(api_url) + "/metrics"
|
|
393
|
+
|
|
394
|
+
# Optional turn-level progress bar. tqdm is not a hard dependency: with
|
|
395
|
+
# it absent (or disable_tqdm set) the periodic heartbeat above remains
|
|
396
|
+
# the only progress signal, exactly as before.
|
|
397
|
+
pbar = None
|
|
398
|
+
if not disable_tqdm:
|
|
399
|
+
try:
|
|
400
|
+
from tqdm.auto import tqdm
|
|
401
|
+
|
|
402
|
+
pbar = tqdm(
|
|
403
|
+
total=state.planned_turns,
|
|
404
|
+
unit="turn",
|
|
405
|
+
dynamic_ncols=True,
|
|
406
|
+
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]",
|
|
407
|
+
)
|
|
408
|
+
except ImportError:
|
|
409
|
+
pbar = None
|
|
410
|
+
state.pbar = pbar
|
|
411
|
+
# With the bar live the heartbeat is redundant visual noise unless the
|
|
412
|
+
# interval is explicitly raised; keep it but at a coarser cadence.
|
|
413
|
+
heartbeat_interval = progress_interval if pbar is None else max(
|
|
414
|
+
progress_interval, 300.0
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as http:
|
|
418
|
+
|
|
419
|
+
async def worker(wid: int) -> None:
|
|
420
|
+
while True:
|
|
421
|
+
sess = state.next_session()
|
|
422
|
+
if sess is None:
|
|
423
|
+
return
|
|
424
|
+
state.active += 1
|
|
425
|
+
try:
|
|
426
|
+
rec = await _run_session(
|
|
427
|
+
sess=sess,
|
|
428
|
+
state=state,
|
|
429
|
+
api_url=api_url,
|
|
430
|
+
model=model,
|
|
431
|
+
session=http,
|
|
432
|
+
tokenizer=tokenizer,
|
|
433
|
+
content_mode=content_mode,
|
|
434
|
+
disable_stream=disable_stream,
|
|
435
|
+
extra_body=extra_body or {},
|
|
436
|
+
respect_gaps=respect_gaps,
|
|
437
|
+
gap_scale=gap_scale,
|
|
438
|
+
ignore_eos=ignore_eos,
|
|
439
|
+
worker_header=worker_header,
|
|
440
|
+
session_timeout_s=session_timeout_s,
|
|
441
|
+
extra_headers=extra_headers,
|
|
442
|
+
extra_request_body=extra_request_body,
|
|
443
|
+
backend=backend,
|
|
444
|
+
max_model_len=max_model_len,
|
|
445
|
+
turn_timeout_s=turn_timeout_s,
|
|
446
|
+
)
|
|
447
|
+
finally:
|
|
448
|
+
state.active -= 1
|
|
449
|
+
if rec.truncated:
|
|
450
|
+
state.sessions_truncated += 1
|
|
451
|
+
else:
|
|
452
|
+
state.sessions_done += 1
|
|
453
|
+
async with results_lock:
|
|
454
|
+
results.append(rec)
|
|
455
|
+
if verbose:
|
|
456
|
+
print(
|
|
457
|
+
f"[w{wid}] {sess.session_id[:32]} "
|
|
458
|
+
f"turns={len(rec.turns)} active={state.active}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
workers = [asyncio.create_task(worker(i)) for i in range(max_concurrency)]
|
|
462
|
+
reporter = (
|
|
463
|
+
asyncio.create_task(progress()) if progress_interval > 0 else None
|
|
464
|
+
)
|
|
465
|
+
# The generic openai backend has no known /metrics gauges to read.
|
|
466
|
+
scraper = (
|
|
467
|
+
asyncio.create_task(
|
|
468
|
+
_scrape_server_metrics(
|
|
469
|
+
http,
|
|
470
|
+
metrics_url,
|
|
471
|
+
metrics_interval,
|
|
472
|
+
server_snapshots,
|
|
473
|
+
verbose,
|
|
474
|
+
backend=backend,
|
|
475
|
+
headers=get_auth_headers(),
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
if metrics_interval > 0 and backend in ("sglang", "vllm")
|
|
479
|
+
else None
|
|
480
|
+
)
|
|
481
|
+
try:
|
|
482
|
+
await asyncio.gather(*workers)
|
|
483
|
+
finally:
|
|
484
|
+
if reporter is not None:
|
|
485
|
+
reporter.cancel()
|
|
486
|
+
if scraper is not None:
|
|
487
|
+
scraper.cancel()
|
|
488
|
+
if pbar is not None:
|
|
489
|
+
pbar.close()
|
|
490
|
+
|
|
491
|
+
if max_model_len is not None:
|
|
492
|
+
stopped = sum(1 for r in results if r.ctx_guard_stop)
|
|
493
|
+
if stopped:
|
|
494
|
+
print(
|
|
495
|
+
f"[ctx-guard] stopped {stopped} session(s) early: estimated "
|
|
496
|
+
f"prompt + pinned completion exceeded the model context "
|
|
497
|
+
f"({max_model_len} tokens)",
|
|
498
|
+
flush=True,
|
|
499
|
+
)
|
|
500
|
+
return results, server_snapshots
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# Warmup exists to prime the shared prefix and surface config errors, not
|
|
504
|
+
# to pre-run whole trajectories: the prefix-cache benefit concentrates in
|
|
505
|
+
# the first blocks, and a full long session replayed at concurrency 1 can
|
|
506
|
+
# outlast the measured run and hand that session a fully warm cache into
|
|
507
|
+
# the measurement (found in large-scale real-server testing).
|
|
508
|
+
WARMUP_MAX_TURNS = 3
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def warmup(
|
|
512
|
+
pool: List[PlannedSession],
|
|
513
|
+
api_url: str,
|
|
514
|
+
model: str,
|
|
515
|
+
tokenizer: Optional[Any],
|
|
516
|
+
n: int,
|
|
517
|
+
disable_stream: bool = False,
|
|
518
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
519
|
+
extra_request_body: Optional[Dict[str, Any]] = None,
|
|
520
|
+
backend: str = "sglang",
|
|
521
|
+
max_duration_s: Optional[float] = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Play the first ``n`` sessions once to populate the prefix cache.
|
|
524
|
+
|
|
525
|
+
Each session is truncated to its first ``WARMUP_MAX_TURNS`` turns and
|
|
526
|
+
the whole phase is bounded by ``max_duration_s`` (the same runaway fuse
|
|
527
|
+
the measured run gets), so warmup can never dominate wall clock.
|
|
528
|
+
"""
|
|
529
|
+
if n <= 0:
|
|
530
|
+
return
|
|
531
|
+
trimmed: List[PlannedSession] = []
|
|
532
|
+
for sess in pool[:n]:
|
|
533
|
+
if len(sess.turns) > WARMUP_MAX_TURNS:
|
|
534
|
+
sess = replace(sess, turns=sess.turns[:WARMUP_MAX_TURNS])
|
|
535
|
+
trimmed.append(sess)
|
|
536
|
+
await run_closed_loop(
|
|
537
|
+
pool=trimmed,
|
|
538
|
+
api_url=api_url,
|
|
539
|
+
model=model,
|
|
540
|
+
max_concurrency=min(n, len(pool)) or 1,
|
|
541
|
+
tokenizer=tokenizer,
|
|
542
|
+
content_mode="trace",
|
|
543
|
+
disable_stream=disable_stream,
|
|
544
|
+
respect_gaps=False,
|
|
545
|
+
# Warmup runs at its own tiny concurrency over a handful of
|
|
546
|
+
# sessions; reporting its progress would look like the real run's.
|
|
547
|
+
progress_interval=0,
|
|
548
|
+
metrics_interval=0, # no scraping during warmup
|
|
549
|
+
extra_headers=extra_headers,
|
|
550
|
+
extra_request_body=extra_request_body,
|
|
551
|
+
backend=backend,
|
|
552
|
+
max_duration_s=max_duration_s,
|
|
553
|
+
)
|