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/runner.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Command orchestration: what `abench data` and `abench run` actually do.
|
|
5
|
+
|
|
6
|
+
Resolves the dataset source and sampling knobs, plans the workload, drives
|
|
7
|
+
warmup and the measured closed-loop run, then hands results to the report
|
|
8
|
+
layer. Argument *parsing* lives in ``abench.cli``; talking to the server
|
|
9
|
+
lives in ``abench.serve``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
from abench import (
|
|
22
|
+
PROFILES,
|
|
23
|
+
build_system_prompt,
|
|
24
|
+
compute_metrics,
|
|
25
|
+
download_tier,
|
|
26
|
+
fetch_server_info,
|
|
27
|
+
flush_cache,
|
|
28
|
+
load_sessions,
|
|
29
|
+
iter_jsonl_sessions,
|
|
30
|
+
measure_text_tokens,
|
|
31
|
+
plan_workload,
|
|
32
|
+
print_report,
|
|
33
|
+
run_closed_loop,
|
|
34
|
+
save_results,
|
|
35
|
+
select_sessions,
|
|
36
|
+
summarize_server_metrics,
|
|
37
|
+
synthesize_sessions,
|
|
38
|
+
warmup,
|
|
39
|
+
wait_for_server_ready,
|
|
40
|
+
write_tier_manifest,
|
|
41
|
+
)
|
|
42
|
+
from abench.core.dataset import TIERS, NormSession
|
|
43
|
+
from abench.serve.endpoints import (
|
|
44
|
+
_detect_backend,
|
|
45
|
+
_fetch_max_model_len,
|
|
46
|
+
_parse_extra_request_body,
|
|
47
|
+
_parse_header_args,
|
|
48
|
+
_resolve_api_url,
|
|
49
|
+
_resolve_base_url,
|
|
50
|
+
_verify_declared_backend,
|
|
51
|
+
load_tokenizer,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------------
|
|
55
|
+
# data subcommand (ModelScope download)
|
|
56
|
+
# ----------------------------------------------------------------------
|
|
57
|
+
def cmd_data_entry(args: argparse.Namespace) -> None:
|
|
58
|
+
"""`python3 -m abench data`: download a dataset x tier from ModelScope.
|
|
59
|
+
|
|
60
|
+
Data lands under ``<data-path>/<dataset>/<tier>/`` as ``*.jsonl.zst``
|
|
61
|
+
plus a ``manifest.json`` carrying the tier's selection knobs.
|
|
62
|
+
"""
|
|
63
|
+
if args.list:
|
|
64
|
+
_list_datasets()
|
|
65
|
+
return
|
|
66
|
+
spec_str = (args.dataset or "").strip().strip("/")
|
|
67
|
+
if not spec_str:
|
|
68
|
+
print(
|
|
69
|
+
"usage: python3 -m abench data <DATASET>[/<TIER>]\n"
|
|
70
|
+
" e.g. python3 -m abench data deepswe/mixed\n"
|
|
71
|
+
" see --list for the available combinations",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
if "/" in spec_str:
|
|
76
|
+
dataset, tier = (p.strip() for p in spec_str.split("/", 1))
|
|
77
|
+
else:
|
|
78
|
+
dataset, tier = spec_str, ""
|
|
79
|
+
if dataset == "random":
|
|
80
|
+
print("random needs no download: python3 -m abench run --dataset-name random", file=sys.stderr)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
if not tier:
|
|
83
|
+
print(
|
|
84
|
+
"error: specify a tier (see --list): "
|
|
85
|
+
+ " / ".join(TIERS),
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
key = f"{dataset}/{tier}"
|
|
90
|
+
spec = TIERS.get(key)
|
|
91
|
+
if spec is None:
|
|
92
|
+
print(
|
|
93
|
+
f"unknown dataset/tier {key!r}; see `python3 -m abench data --list`",
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
out_dir = os.path.join(args.data_path, dataset, tier)
|
|
98
|
+
files = download_tier(spec, out_dir, verbose=True)
|
|
99
|
+
write_tier_manifest(spec, out_dir, files)
|
|
100
|
+
total_mb = sum(os.path.getsize(p) for p in files) / 1e6
|
|
101
|
+
print(
|
|
102
|
+
f"[data] {key}: {spec.sessions} sessions, {len(files)} file(s), "
|
|
103
|
+
f"{total_mb:.1f} MB -> {out_dir}"
|
|
104
|
+
)
|
|
105
|
+
if os.path.abspath(os.path.expanduser(args.data_path)) == DEFAULT_DATA_ROOT:
|
|
106
|
+
print(f"[data] run with: python3 -m abench run --dataset-name {key}")
|
|
107
|
+
else:
|
|
108
|
+
print(
|
|
109
|
+
f"[data] run with: python3 -m abench run --dataset-name {key} "
|
|
110
|
+
f"--dataset-path {out_dir}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _split_dataset_spec(spec: str) -> tuple:
|
|
115
|
+
"""Split a `<dataset>/<tier>` spec into (dataset, tier).
|
|
116
|
+
|
|
117
|
+
A bare dataset name defaults to the 'mixed' tier.
|
|
118
|
+
"""
|
|
119
|
+
name = (spec or "deepswe/mixed").strip()
|
|
120
|
+
if "/" in name:
|
|
121
|
+
ds, _, tier = name.partition("/")
|
|
122
|
+
return (ds or "deepswe"), (tier or "mixed")
|
|
123
|
+
return name, "mixed"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Fixed root for the standard layout written by `python3 -m abench data`.
|
|
127
|
+
DEFAULT_DATA_ROOT = os.path.expanduser("~/abench_data")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _raw_knobs(args: argparse.Namespace) -> dict:
|
|
131
|
+
"""Knobs for raw data (no manifest): no band filter, min_turns floor
|
|
132
|
+
of 1, no synthesized system prompt. Explicit CLI knobs still win."""
|
|
133
|
+
return dict(
|
|
134
|
+
min_turns=args.min_turns if args.min_turns is not None else 1,
|
|
135
|
+
max_turns=args.max_turns,
|
|
136
|
+
min_context=args.min_context,
|
|
137
|
+
max_context=args.max_context,
|
|
138
|
+
output_policy="trace",
|
|
139
|
+
system_prompt_len=args.system_prompt_len
|
|
140
|
+
if args.system_prompt_len is not None
|
|
141
|
+
else 0,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pool_size(args: argparse.Namespace) -> int:
|
|
146
|
+
"""Pool size for both branches: --num-sessions when given, else the
|
|
147
|
+
validated 2 x max-concurrency default.
|
|
148
|
+
Uses an explicit None check so a passed 0 is not silently replaced."""
|
|
149
|
+
if args.num_sessions is not None:
|
|
150
|
+
return args.num_sessions
|
|
151
|
+
size = 2 * max(1, args.max_concurrency)
|
|
152
|
+
print(
|
|
153
|
+
f"[plan] num-sessions unset -> pool = 2 x C = {size} sessions "
|
|
154
|
+
"(pass --num-sessions to override; see the pool-sizing note)"
|
|
155
|
+
)
|
|
156
|
+
return size
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_data_source(
|
|
160
|
+
dataset_path: str, ds_name: str, tier: str
|
|
161
|
+
) -> tuple:
|
|
162
|
+
"""Resolve --dataset-path to (single_file, data_dir, manifest_dir).
|
|
163
|
+
|
|
164
|
+
Search order: file (use its dir's manifest if present) -> this dir
|
|
165
|
+
holds manifest.json (tier dir) -> descend one level to
|
|
166
|
+
``<name>/<tier>/manifest.json`` (root dir) -> bare data dir (raw).
|
|
167
|
+
``manifest_dir`` is None for raw mode; ``data_dir`` is where the
|
|
168
|
+
session files live. Caller treats a nonexistent path as raw with an
|
|
169
|
+
empty load.
|
|
170
|
+
"""
|
|
171
|
+
p = os.path.abspath(os.path.expanduser(dataset_path))
|
|
172
|
+
if os.path.isfile(p):
|
|
173
|
+
parent = os.path.dirname(p)
|
|
174
|
+
man = parent if os.path.exists(os.path.join(parent, "manifest.json")) else None
|
|
175
|
+
return p, parent, man
|
|
176
|
+
cand = os.path.join(p, ds_name, tier)
|
|
177
|
+
if os.path.exists(os.path.join(p, "manifest.json")):
|
|
178
|
+
return None, p, p
|
|
179
|
+
if os.path.exists(os.path.join(cand, "manifest.json")):
|
|
180
|
+
return None, cand, cand
|
|
181
|
+
return None, p, None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _dataset_dir(spec: str, data_path: str) -> str:
|
|
185
|
+
"""Standard on-disk location for a `<dataset>/<tier>` spec:
|
|
186
|
+
``<data-path>/<dataset>/<tier>/``, as written by
|
|
187
|
+
``python3 -m abench data``."""
|
|
188
|
+
ds, tier = _split_dataset_spec(spec)
|
|
189
|
+
return os.path.join(data_path, ds, tier)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_profile_knobs(
|
|
193
|
+
args: argparse.Namespace, data_dir: Optional[str] = None
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Fill in any knob the user did not set explicitly.
|
|
196
|
+
|
|
197
|
+
Band defaults come from the dataset directory's ``manifest.json``
|
|
198
|
+
(written by ``python3 -m abench data``): a ``band`` entry selects by peak context,
|
|
199
|
+
an absent/null band means no filtering (e.g. full / lmcache tiers).
|
|
200
|
+
Only a directory *without* a manifest falls back to the
|
|
201
|
+
``PROFILES['mixed']`` preset. Explicit CLI knobs always win.
|
|
202
|
+
"""
|
|
203
|
+
defaults: dict = {}
|
|
204
|
+
have_manifest = False
|
|
205
|
+
if data_dir:
|
|
206
|
+
try:
|
|
207
|
+
with open(os.path.join(data_dir, "manifest.json")) as f:
|
|
208
|
+
m = json.load(f)
|
|
209
|
+
have_manifest = True
|
|
210
|
+
except (OSError, ValueError):
|
|
211
|
+
m = {}
|
|
212
|
+
if not isinstance(m, dict):
|
|
213
|
+
m = {}
|
|
214
|
+
band = m.get("band")
|
|
215
|
+
if isinstance(band, (list, tuple)) and len(band) == 2:
|
|
216
|
+
defaults["min_context"] = band[0]
|
|
217
|
+
defaults["max_context"] = band[1]
|
|
218
|
+
for k in ("min_context", "max_context", "min_turns", "system_prompt_len"):
|
|
219
|
+
v = m.get(k)
|
|
220
|
+
if v is not None:
|
|
221
|
+
defaults[k] = v
|
|
222
|
+
if not have_manifest:
|
|
223
|
+
p = PROFILES.get("mixed")
|
|
224
|
+
if p is not None:
|
|
225
|
+
for k in ("min_turns", "min_context", "max_context", "system_prompt_len"):
|
|
226
|
+
defaults.setdefault(k, getattr(p, k, None))
|
|
227
|
+
# A manifest without a band explicitly means "no context filtering";
|
|
228
|
+
# leaving it unset would re-apply the mixed band and drop everything.
|
|
229
|
+
if have_manifest and "min_context" not in defaults:
|
|
230
|
+
defaults["min_context"] = None
|
|
231
|
+
defaults["max_context"] = None
|
|
232
|
+
knobs = dict(
|
|
233
|
+
min_turns=args.min_turns,
|
|
234
|
+
max_turns=args.max_turns,
|
|
235
|
+
min_context=args.min_context,
|
|
236
|
+
max_context=args.max_context,
|
|
237
|
+
output_policy="trace",
|
|
238
|
+
system_prompt_len=args.system_prompt_len,
|
|
239
|
+
)
|
|
240
|
+
for k in ("min_turns", "min_context", "max_context", "system_prompt_len"):
|
|
241
|
+
if knobs[k] is None:
|
|
242
|
+
knobs[k] = defaults.get(k)
|
|
243
|
+
# final fallbacks
|
|
244
|
+
if knobs["min_turns"] is None:
|
|
245
|
+
knobs["min_turns"] = 1
|
|
246
|
+
if knobs["system_prompt_len"] is None:
|
|
247
|
+
knobs["system_prompt_len"] = 0
|
|
248
|
+
return knobs
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _prepare_plan(args: argparse.Namespace, tokenizer) -> tuple:
|
|
252
|
+
"""Build the workload plan; returns (plan, knobs, sampling, force_lengths)."""
|
|
253
|
+
routing_fn = None
|
|
254
|
+
if getattr(args, "routing_key", False):
|
|
255
|
+
routing_fn = lambda s: s # noqa: E731 - stable per-session key
|
|
256
|
+
|
|
257
|
+
if args.dataset_name == "random":
|
|
258
|
+
knobs = _resolve_profile_knobs(args)
|
|
259
|
+
try:
|
|
260
|
+
plan = synthesize_sessions(
|
|
261
|
+
num_sessions=_pool_size(args),
|
|
262
|
+
turns=args.turns,
|
|
263
|
+
input_increment=args.input_increment,
|
|
264
|
+
output_len=args.output_len,
|
|
265
|
+
system_prompt_len=(
|
|
266
|
+
args.system_prompt_len
|
|
267
|
+
if args.system_prompt_len is not None
|
|
268
|
+
else 8192
|
|
269
|
+
),
|
|
270
|
+
gap_policy=args.gap_policy,
|
|
271
|
+
gap_cap_ms=args.gap_cap_ms,
|
|
272
|
+
tokenizer=tokenizer,
|
|
273
|
+
routing_key_fn=routing_fn,
|
|
274
|
+
seed=args.seed,
|
|
275
|
+
)
|
|
276
|
+
except ValueError as e:
|
|
277
|
+
print(f"error: {e}", file=sys.stderr)
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
if not plan:
|
|
280
|
+
print("no sessions synthesized; nothing to run", file=sys.stderr)
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
knobs["system_prompt_len_actual"] = measure_text_tokens(
|
|
283
|
+
plan[0].system_prompt, tokenizer
|
|
284
|
+
)
|
|
285
|
+
knobs["output_policy"] = f"fixed:{args.output_len}"
|
|
286
|
+
print(
|
|
287
|
+
f"[plan] random: {len(plan)} sessions x {args.turns} turns, "
|
|
288
|
+
f"input_increment={args.input_increment}, output_len={args.output_len}, "
|
|
289
|
+
f"gap_policy={args.gap_policy}"
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
ds_name, tier = _split_dataset_spec(args.dataset_name)
|
|
293
|
+
if args.dataset_path:
|
|
294
|
+
single_file, data_dir, manifest_dir = _resolve_data_source(
|
|
295
|
+
args.dataset_path, ds_name, tier
|
|
296
|
+
)
|
|
297
|
+
if manifest_dir:
|
|
298
|
+
knobs = _resolve_profile_knobs(args, manifest_dir)
|
|
299
|
+
print(
|
|
300
|
+
f"[plan] dataset-path: applying manifest.json from "
|
|
301
|
+
f"{manifest_dir} (band/turn/system-prompt defaults active)"
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
knobs = _raw_knobs(args)
|
|
305
|
+
print("[plan] no manifest found; running raw (no band/prompt defaults)")
|
|
306
|
+
if single_file:
|
|
307
|
+
sessions = [
|
|
308
|
+
NormSession.from_dict(raw)
|
|
309
|
+
for raw in iter_jsonl_sessions([single_file])
|
|
310
|
+
]
|
|
311
|
+
else:
|
|
312
|
+
sessions = load_sessions(data_dir)
|
|
313
|
+
else:
|
|
314
|
+
data_dir = _dataset_dir(args.dataset_name, DEFAULT_DATA_ROOT)
|
|
315
|
+
if not os.path.isdir(data_dir):
|
|
316
|
+
print(
|
|
317
|
+
f"no dataset directory {data_dir}; run "
|
|
318
|
+
f"`python3 -m abench data {ds_name}/{tier}` first, "
|
|
319
|
+
"or point --dataset-path at the data",
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
knobs = _resolve_profile_knobs(args, data_dir)
|
|
324
|
+
sessions = load_sessions(data_dir)
|
|
325
|
+
if not sessions:
|
|
326
|
+
print(
|
|
327
|
+
f"no sessions in {args.dataset_path or data_dir}; check the "
|
|
328
|
+
f"path or run `python3 -m abench data {ds_name}/{tier}`",
|
|
329
|
+
file=sys.stderr,
|
|
330
|
+
)
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
# The shared system prompt is built once up front so its measured
|
|
333
|
+
# length can participate in the context band: the band must
|
|
334
|
+
# describe what the server actually sees (recorded peak + injected
|
|
335
|
+
# prompt), not the recorded peak alone.
|
|
336
|
+
system_prompt = (
|
|
337
|
+
build_system_prompt(knobs["system_prompt_len"], tokenizer)
|
|
338
|
+
if knobs["system_prompt_len"]
|
|
339
|
+
else ""
|
|
340
|
+
)
|
|
341
|
+
sys_len = measure_text_tokens(system_prompt, tokenizer)
|
|
342
|
+
knobs["system_prompt_len_actual"] = sys_len
|
|
343
|
+
# --max-context is the MODEL context window (user-facing contract):
|
|
344
|
+
# the injected system prompt consumes part of it, so the recorded-
|
|
345
|
+
# peak cap the band enforces is max_context - sys_len. Surface the
|
|
346
|
+
# derivation instead of making the user subtract.
|
|
347
|
+
if (
|
|
348
|
+
knobs["max_context"] is not None
|
|
349
|
+
and knobs["max_context"] <= sys_len
|
|
350
|
+
):
|
|
351
|
+
print(
|
|
352
|
+
f"error: --max-context {knobs['max_context']} leaves no room "
|
|
353
|
+
f"for the {sys_len}-token injected system prompt "
|
|
354
|
+
f"(recorded-peak cap would be <= 0)",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
sys.exit(1)
|
|
358
|
+
try:
|
|
359
|
+
selected = select_sessions(
|
|
360
|
+
sessions,
|
|
361
|
+
min_turns=knobs["min_turns"],
|
|
362
|
+
max_turns=knobs["max_turns"],
|
|
363
|
+
min_context=knobs["min_context"],
|
|
364
|
+
max_context=knobs["max_context"],
|
|
365
|
+
num_sessions=_pool_size(args),
|
|
366
|
+
seed=args.seed,
|
|
367
|
+
sys_prompt_tokens=sys_len,
|
|
368
|
+
)
|
|
369
|
+
except ValueError as e:
|
|
370
|
+
print(f"error: {e}", file=sys.stderr)
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
if not selected:
|
|
373
|
+
print("no sessions matched the selection knobs", file=sys.stderr)
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
plan = plan_workload(
|
|
376
|
+
sessions=selected,
|
|
377
|
+
system_prompt_len=knobs["system_prompt_len"],
|
|
378
|
+
output_policy=knobs["output_policy"],
|
|
379
|
+
tokenizer=tokenizer,
|
|
380
|
+
routing_key_fn=(lambda s: s.session_id) if args.routing_key else None,
|
|
381
|
+
system_prompt=system_prompt,
|
|
382
|
+
)
|
|
383
|
+
spl_actual = knobs.get("system_prompt_len_actual", 0)
|
|
384
|
+
spl_txt = f"system_prompt_len={knobs['system_prompt_len']}" + (
|
|
385
|
+
f" (actual {spl_actual})"
|
|
386
|
+
if spl_actual != knobs["system_prompt_len"]
|
|
387
|
+
else ""
|
|
388
|
+
)
|
|
389
|
+
ctx_txt = f"context[{knobs['min_context']},{knobs['max_context']}]"
|
|
390
|
+
if knobs["max_context"] is not None:
|
|
391
|
+
ctx_txt += (
|
|
392
|
+
f" (model window: recorded-peak cap "
|
|
393
|
+
f"{knobs['max_context'] - sys_len} after -{sys_len} sys)"
|
|
394
|
+
)
|
|
395
|
+
print(
|
|
396
|
+
f"[plan] {len(plan)} sessions from {args.dataset_name} ({data_dir}), "
|
|
397
|
+
f"{spl_txt}, "
|
|
398
|
+
f"output_policy={knobs['output_policy']}, "
|
|
399
|
+
f"turns[min={knobs['min_turns']},max={knobs['max_turns']}], "
|
|
400
|
+
f"{ctx_txt}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
total_turns = sum(len(p.turns) for p in plan)
|
|
404
|
+
total_out = sum(t.max_new_tokens for p in plan for t in p.turns)
|
|
405
|
+
basis = "pinned per-turn lengths"
|
|
406
|
+
total_gap_s = sum(t.pre_gap_ms for p in plan for t in p.turns) / 1000.0
|
|
407
|
+
waves = len(plan) / max(1, args.max_concurrency)
|
|
408
|
+
gap_per_slot_min = total_gap_s / max(1, args.max_concurrency) / 60.0
|
|
409
|
+
print(
|
|
410
|
+
f"[workload] {total_turns} turns, {total_out:,} output tokens "
|
|
411
|
+
f"[{basis}], {total_gap_s / 60:.0f} min of tool waits, "
|
|
412
|
+
f"{waves:.1f} waves over {args.max_concurrency} slots"
|
|
413
|
+
)
|
|
414
|
+
est = " ".join(
|
|
415
|
+
f"{tput}tok/s->{total_out / tput / 60 + gap_per_slot_min:.0f}min"
|
|
416
|
+
for tput in (250, 1000, 4000)
|
|
417
|
+
)
|
|
418
|
+
print(f"[estimate] wall clock at assumed aggregate throughput: {est}")
|
|
419
|
+
print("[estimate] the [progress] lines below carry a measured ETA.")
|
|
420
|
+
|
|
421
|
+
sampling, force_lengths = _build_sampling(args, knobs)
|
|
422
|
+
return plan, knobs, sampling, force_lengths
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _build_sampling(args: argparse.Namespace, knobs: dict) -> tuple:
|
|
426
|
+
"""Pinned sampling; returns (sampling dict, ignore_eos)."""
|
|
427
|
+
sampling: dict = {"temperature": args.temperature}
|
|
428
|
+
# Mirrors bench_serving: top_p is only sent when it actually
|
|
429
|
+
# constrains sampling, avoiding divergent edge-case handling of an
|
|
430
|
+
# explicit top_p=1.0 across backends.
|
|
431
|
+
if args.top_p < 1.0:
|
|
432
|
+
sampling["top_p"] = args.top_p
|
|
433
|
+
if args.top_k is not None:
|
|
434
|
+
sampling["top_k"] = args.top_k
|
|
435
|
+
if args.presence_penalty is not None:
|
|
436
|
+
sampling["presence_penalty"] = args.presence_penalty
|
|
437
|
+
try:
|
|
438
|
+
ctk = (
|
|
439
|
+
json.loads(args.chat_template_kwargs)
|
|
440
|
+
if args.chat_template_kwargs
|
|
441
|
+
else None
|
|
442
|
+
)
|
|
443
|
+
except json.JSONDecodeError as e:
|
|
444
|
+
raise SystemExit(f"bad --chat-template-kwargs: {e}")
|
|
445
|
+
if ctk:
|
|
446
|
+
sampling["chat_template_kwargs"] = ctk
|
|
447
|
+
# Lengths are always pinned (trace records / fixed:K), so ignore_eos is
|
|
448
|
+
# always on: without it a natural EOS would cut turns short.
|
|
449
|
+
force_lengths = True
|
|
450
|
+
extras = f" top_p={args.top_p}" if args.top_p < 1.0 else ""
|
|
451
|
+
print(
|
|
452
|
+
f"[sampling] temperature={args.temperature}{extras}"
|
|
453
|
+
f" ignore_eos={force_lengths} chat_template_kwargs={ctk or '{}'}"
|
|
454
|
+
)
|
|
455
|
+
return sampling, force_lengths
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _execute_point(
|
|
459
|
+
args: argparse.Namespace,
|
|
460
|
+
plan,
|
|
461
|
+
knobs: dict,
|
|
462
|
+
tokenizer,
|
|
463
|
+
backend: str = "sglang",
|
|
464
|
+
max_model_len: Optional[int] = None,
|
|
465
|
+
) -> Any:
|
|
466
|
+
"""Flush, warm up, replay, and compute metrics for one run."""
|
|
467
|
+
api_url = _resolve_api_url(args.base_url)
|
|
468
|
+
sampling, force_lengths = _build_sampling(args, knobs)
|
|
469
|
+
extra_headers = _parse_header_args(args.header)
|
|
470
|
+
extra_request_body = _parse_extra_request_body(args.extra_request_body)
|
|
471
|
+
|
|
472
|
+
async def _main() -> tuple:
|
|
473
|
+
if args.flush_cache:
|
|
474
|
+
await flush_cache(args.base_url, backend=backend)
|
|
475
|
+
if args.warmup_requests > 0:
|
|
476
|
+
await warmup(
|
|
477
|
+
pool=plan,
|
|
478
|
+
api_url=api_url,
|
|
479
|
+
model=args.model,
|
|
480
|
+
tokenizer=tokenizer,
|
|
481
|
+
n=args.warmup_requests,
|
|
482
|
+
disable_stream=args.disable_stream,
|
|
483
|
+
extra_headers=extra_headers,
|
|
484
|
+
extra_request_body=extra_request_body,
|
|
485
|
+
backend=backend,
|
|
486
|
+
max_duration_s=args.max_duration,
|
|
487
|
+
)
|
|
488
|
+
return await run_closed_loop(
|
|
489
|
+
pool=plan,
|
|
490
|
+
api_url=api_url,
|
|
491
|
+
model=args.model,
|
|
492
|
+
max_concurrency=args.max_concurrency,
|
|
493
|
+
tokenizer=tokenizer,
|
|
494
|
+
content_mode="trace",
|
|
495
|
+
disable_stream=args.disable_stream,
|
|
496
|
+
duration_s=args.duration,
|
|
497
|
+
respect_gaps=not args.no_gaps,
|
|
498
|
+
gap_scale=args.gap_scale,
|
|
499
|
+
worker_header=args.worker_header,
|
|
500
|
+
verbose=args.verbose,
|
|
501
|
+
progress_interval=args.progress_interval,
|
|
502
|
+
extra_body=sampling,
|
|
503
|
+
ignore_eos=force_lengths,
|
|
504
|
+
turn_timeout_s=args.turn_timeout,
|
|
505
|
+
metrics_interval=args.metrics_interval,
|
|
506
|
+
session_timeout_s=args.session_timeout,
|
|
507
|
+
extra_headers=extra_headers,
|
|
508
|
+
extra_request_body=extra_request_body,
|
|
509
|
+
backend=backend,
|
|
510
|
+
max_duration_s=args.max_duration,
|
|
511
|
+
disable_tqdm=args.disable_tqdm,
|
|
512
|
+
max_model_len=max_model_len,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
results, server_snapshots = asyncio.run(_main())
|
|
516
|
+
metrics = compute_metrics(
|
|
517
|
+
sessions=results,
|
|
518
|
+
target_concurrency=args.max_concurrency,
|
|
519
|
+
steady_frac=args.steady_frac,
|
|
520
|
+
steady_only=True,
|
|
521
|
+
)
|
|
522
|
+
summarize_server_metrics(metrics, server_snapshots, backend=backend)
|
|
523
|
+
return metrics, results, server_snapshots
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
527
|
+
if args.dataset_name == "random" and args.dataset_path:
|
|
528
|
+
print(
|
|
529
|
+
"error: --dataset-name random synthesizes locally and reads no "
|
|
530
|
+
"files; drop --dataset-path (or pick a real dataset name)",
|
|
531
|
+
file=sys.stderr,
|
|
532
|
+
)
|
|
533
|
+
sys.exit(1)
|
|
534
|
+
base_url = _resolve_base_url(args)
|
|
535
|
+
args.base_url = base_url
|
|
536
|
+
asyncio.run(wait_for_server_ready(base_url, args.ready_check_timeout_sec))
|
|
537
|
+
backend = _detect_backend(base_url, args.backend)
|
|
538
|
+
print(f"[backend] {backend}")
|
|
539
|
+
if args.backend != "auto":
|
|
540
|
+
_verify_declared_backend(base_url, backend)
|
|
541
|
+
if args.flush_cache and backend == "openai":
|
|
542
|
+
print(
|
|
543
|
+
"[flush_cache] skipped: the openai backend has no "
|
|
544
|
+
"cache-flush endpoint"
|
|
545
|
+
)
|
|
546
|
+
args.flush_cache = False
|
|
547
|
+
max_model_len = _fetch_max_model_len(base_url)
|
|
548
|
+
if max_model_len:
|
|
549
|
+
print(
|
|
550
|
+
f"[ctx-guard] model context {max_model_len} tokens; sessions "
|
|
551
|
+
"whose estimated prompt + pinned completion exceed it stop early"
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
print("[ctx-guard] disabled: /v1/models reports no max_model_len")
|
|
555
|
+
# /model_info is an SGLang endpoint; for other backends the tokenizer
|
|
556
|
+
# resolves from --model (local dir) or --tokenizer instead.
|
|
557
|
+
tokenizer = load_tokenizer(
|
|
558
|
+
args.tokenizer or args.model,
|
|
559
|
+
base_url if backend == "sglang" else None,
|
|
560
|
+
)
|
|
561
|
+
plan, knobs, _, _ = _prepare_plan(args, tokenizer)
|
|
562
|
+
metrics, results, server_snapshots = _execute_point(
|
|
563
|
+
args, plan, knobs, tokenizer, backend, max_model_len=max_model_len
|
|
564
|
+
)
|
|
565
|
+
if backend != "sglang":
|
|
566
|
+
# host/device cache layering is an sglang-only concept
|
|
567
|
+
# (not measurable != zero). Capacity and eviction are measured
|
|
568
|
+
# for vllm too; openai has no /metrics so both stay None via the
|
|
569
|
+
# empty-snapshot no-op in summarize_server_metrics.
|
|
570
|
+
metrics.host_cache_ratio = None
|
|
571
|
+
metrics.host_share_of_cache = None
|
|
572
|
+
|
|
573
|
+
server_info = (
|
|
574
|
+
asyncio.run(fetch_server_info(args.base_url))
|
|
575
|
+
if backend == "sglang"
|
|
576
|
+
else None
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
config = {
|
|
580
|
+
"model": args.model,
|
|
581
|
+
"backend": backend,
|
|
582
|
+
"dataset_name": args.dataset_name,
|
|
583
|
+
"dataset_path": args.dataset_path,
|
|
584
|
+
"tag": args.tag,
|
|
585
|
+
"system_prompt_len": knobs["system_prompt_len"],
|
|
586
|
+
"system_prompt_len_actual": knobs.get("system_prompt_len_actual", 0),
|
|
587
|
+
"max_model_len": max_model_len,
|
|
588
|
+
"temperature": args.temperature,
|
|
589
|
+
"top_p": args.top_p,
|
|
590
|
+
"chat_template_kwargs": args.chat_template_kwargs,
|
|
591
|
+
"ignore_eos": True,
|
|
592
|
+
"min_context": knobs["min_context"],
|
|
593
|
+
"max_context": knobs["max_context"],
|
|
594
|
+
"output_details": args.output_details,
|
|
595
|
+
"max_concurrency": args.max_concurrency,
|
|
596
|
+
"num_sessions": metrics.num_sessions,
|
|
597
|
+
"duration_s": args.duration,
|
|
598
|
+
"respect_gaps": not args.no_gaps,
|
|
599
|
+
"gap_scale": args.gap_scale,
|
|
600
|
+
"seed": args.seed,
|
|
601
|
+
}
|
|
602
|
+
print_report(metrics, config)
|
|
603
|
+
# bench_serving habit: results always land on disk. Without an explicit
|
|
604
|
+
# --output-file, pick a dated, collision-resistant default name.
|
|
605
|
+
output_file = args.output_file
|
|
606
|
+
if not output_file:
|
|
607
|
+
import datetime as _dt
|
|
608
|
+
|
|
609
|
+
today = _dt.datetime.now().strftime("%m%d_%H%M%S")
|
|
610
|
+
ds_label = args.dataset_name.replace("/", "-")
|
|
611
|
+
output_file = f"{backend}_{today}_{ds_label}.jsonl"
|
|
612
|
+
save_results(
|
|
613
|
+
results,
|
|
614
|
+
metrics,
|
|
615
|
+
output_file,
|
|
616
|
+
config,
|
|
617
|
+
server_snapshots,
|
|
618
|
+
server_info=server_info,
|
|
619
|
+
include_details=args.output_details,
|
|
620
|
+
)
|
|
621
|
+
print(f"[save] appended to {output_file}")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ----------------------------------------------------------------------
|
|
625
|
+
# data listing
|
|
626
|
+
# ----------------------------------------------------------------------
|
|
627
|
+
def _list_datasets() -> None:
|
|
628
|
+
"""Print the dataset x tier combinations `python3 -m abench data` fetches."""
|
|
629
|
+
print("Available dataset x tier combinations (from ModelScope):")
|
|
630
|
+
for spec in TIERS.values():
|
|
631
|
+
size_mb = sum(f[1] for f in spec.files) / 1e6
|
|
632
|
+
print(
|
|
633
|
+
f" {spec.name:<16s} {spec.sessions:>6d} sessions "
|
|
634
|
+
f"({size_mb:.0f} MB)"
|
|
635
|
+
)
|
|
636
|
+
print()
|
|
637
|
+
print("'random' needs no download: python3 -m abench run --dataset-name random")
|
abench/serve/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Engine-facing layer: everything that talks to the server under test.
|
|
5
|
+
|
|
6
|
+
``endpoints`` discovery, auth, readiness, tokenizer resolution, cache flush
|
|
7
|
+
``telemetry`` /metrics scraping and Prometheus parsing
|
|
8
|
+
``report`` terminal report and JSONL persistence
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from abench.serve.endpoints import (
|
|
12
|
+
fetch_server_info,
|
|
13
|
+
flush_cache,
|
|
14
|
+
get_auth_headers,
|
|
15
|
+
load_tokenizer,
|
|
16
|
+
server_root,
|
|
17
|
+
wait_for_server_ready,
|
|
18
|
+
)
|
|
19
|
+
from abench.serve.report import metrics_to_dict, print_report, save_results
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"fetch_server_info",
|
|
23
|
+
"flush_cache",
|
|
24
|
+
"get_auth_headers",
|
|
25
|
+
"load_tokenizer",
|
|
26
|
+
"server_root",
|
|
27
|
+
"wait_for_server_ready",
|
|
28
|
+
"metrics_to_dict",
|
|
29
|
+
"print_report",
|
|
30
|
+
"save_results",
|
|
31
|
+
]
|