benchkit-cli 0.1.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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/cli.py
ADDED
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
"""BenchKit entry point.
|
|
2
|
+
|
|
3
|
+
Running `benchkit` opens the full-screen terminal app. `--headless` keeps a
|
|
4
|
+
scriptable path that prints to stdout instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from rich import box
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from benchkit import catalog
|
|
19
|
+
from benchkit.benchmarks import DESCRIPTIONS, REGISTRY
|
|
20
|
+
from benchkit.client import InferenceClient
|
|
21
|
+
from benchkit.engine import (
|
|
22
|
+
MAX_REPAIR_ATTEMPTS,
|
|
23
|
+
JobSpec,
|
|
24
|
+
SliceError,
|
|
25
|
+
expand_jobs,
|
|
26
|
+
parse_slice,
|
|
27
|
+
route_decision_jobs,
|
|
28
|
+
slice_task_count,
|
|
29
|
+
task_count,
|
|
30
|
+
)
|
|
31
|
+
from benchkit.history import serve_history
|
|
32
|
+
from benchkit.jobs import job_matrix
|
|
33
|
+
from benchkit.metrics import (
|
|
34
|
+
aggregate_tok_s,
|
|
35
|
+
decision_speed,
|
|
36
|
+
effective_concurrency,
|
|
37
|
+
is_decision,
|
|
38
|
+
latency_text,
|
|
39
|
+
stream_tok_s,
|
|
40
|
+
)
|
|
41
|
+
from benchkit.perf import DEFAULT_DEPTHS, PerfConfig, parse_depths, run_profile
|
|
42
|
+
from benchkit.perf_report import save_profile
|
|
43
|
+
from benchkit.perturbations import (
|
|
44
|
+
PERTURBATIONS,
|
|
45
|
+
perturbations_for,
|
|
46
|
+
unsupported_benchmarks,
|
|
47
|
+
)
|
|
48
|
+
from benchkit.report import save
|
|
49
|
+
from benchkit.runner import run
|
|
50
|
+
from benchkit.tui import run_tui
|
|
51
|
+
|
|
52
|
+
console = Console(highlight=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="benchkit",
|
|
58
|
+
description="Benchmark local LLMs with real evaluation suites.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"command",
|
|
62
|
+
nargs="?",
|
|
63
|
+
choices=("perf", "history", "render-check", "datasets"),
|
|
64
|
+
help=(
|
|
65
|
+
"Run a performance profile, browse historical results, check "
|
|
66
|
+
"that headless rendering works on this machine, or list and "
|
|
67
|
+
"install benchmark datasets"
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"perf_model",
|
|
72
|
+
nargs="?",
|
|
73
|
+
metavar="MODEL",
|
|
74
|
+
help=(
|
|
75
|
+
"Model name for `benchkit perf MODEL`; `install` for "
|
|
76
|
+
"`benchkit datasets install [BENCHMARK ...]`"
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"targets",
|
|
81
|
+
nargs="*",
|
|
82
|
+
metavar="BENCHMARK",
|
|
83
|
+
help="Datasets: benchmarks to install (default: every missing one)",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument("--host", help="Inference host, overrides BENCHKIT_HOST")
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--demo",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Run against a built-in offline model server (no inference server needed)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--headless",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Skip the TUI and run from flags, printing progress to stdout",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--models",
|
|
98
|
+
default="",
|
|
99
|
+
help="Headless: comma separated model names, or 'all'",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--benchmarks",
|
|
103
|
+
default="",
|
|
104
|
+
help=(
|
|
105
|
+
"Headless: comma separated benchmarks, e.g. humaneval:20,gsm8k:-50 "
|
|
106
|
+
"or aider-polyglot:cpp:5"
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--perturbation",
|
|
111
|
+
choices=PERTURBATIONS,
|
|
112
|
+
help=("Headless: add a paired perturbed run alongside each clean baseline"),
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--perturbation-seed",
|
|
116
|
+
type=int,
|
|
117
|
+
default=42,
|
|
118
|
+
metavar="SEED",
|
|
119
|
+
help="Headless: deterministic perturbation seed (default: 42)",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--harness",
|
|
123
|
+
choices=("direct", "pi", "both"),
|
|
124
|
+
default="direct",
|
|
125
|
+
help=(
|
|
126
|
+
"Execution harness: raw API, latest stock Pi in Docker, or paired "
|
|
127
|
+
"direct + Pi runs (default: direct)"
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--decision-models",
|
|
132
|
+
default="",
|
|
133
|
+
metavar="MODELS",
|
|
134
|
+
help=(
|
|
135
|
+
"Comma-separated models to run as decision models over "
|
|
136
|
+
"/v1/systemone, for servers or proxies that do not advertise them "
|
|
137
|
+
"(llama.cpp's own listing is detected automatically)"
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--repair-attempts",
|
|
142
|
+
type=int,
|
|
143
|
+
choices=range(MAX_REPAIR_ATTEMPTS + 1),
|
|
144
|
+
default=0,
|
|
145
|
+
metavar=f"0-{MAX_REPAIR_ATTEMPTS}",
|
|
146
|
+
help=(
|
|
147
|
+
"Verifier-feedback repair turns after an incorrect first answer; "
|
|
148
|
+
"works with direct and Pi harnesses (default: 0; max: "
|
|
149
|
+
f"{MAX_REPAIR_ATTEMPTS})"
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--force-unload",
|
|
154
|
+
action="store_true",
|
|
155
|
+
help=(
|
|
156
|
+
"Headless: evict each model from VRAM between models "
|
|
157
|
+
"(llama.cpp POST /models/unload; Ollama keep_alive=0)"
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--list",
|
|
162
|
+
action="store_true",
|
|
163
|
+
help="Print the available benchmarks and exit",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"-v",
|
|
167
|
+
"--verbose",
|
|
168
|
+
action="store_true",
|
|
169
|
+
help="Headless: print every prompt and response",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--depths",
|
|
173
|
+
default=",".join(
|
|
174
|
+
"minimal" if value == 0 else f"{value // 1024}k" for value in DEFAULT_DEPTHS
|
|
175
|
+
),
|
|
176
|
+
help="Perf: comma-separated context depths (default: minimal,4k,16k,32k,64k)",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--gen",
|
|
180
|
+
type=int,
|
|
181
|
+
default=128,
|
|
182
|
+
metavar="TOKENS",
|
|
183
|
+
help="Perf: generated tokens per measured run (default: 128)",
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--reps",
|
|
187
|
+
type=int,
|
|
188
|
+
default=5,
|
|
189
|
+
help="Perf: measured repetitions per depth (default: 5)",
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--warmups",
|
|
193
|
+
type=int,
|
|
194
|
+
default=1,
|
|
195
|
+
help="Perf: discarded warmups per depth (default: 1)",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--config-note",
|
|
199
|
+
default="",
|
|
200
|
+
help="Perf: optional hardware or server configuration note",
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--results-dir",
|
|
204
|
+
action="append",
|
|
205
|
+
default=None,
|
|
206
|
+
metavar="PATH",
|
|
207
|
+
help=(
|
|
208
|
+
"History: results directory to scan; repeat to combine archives "
|
|
209
|
+
"(default: ./results)"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--no-open",
|
|
214
|
+
action="store_true",
|
|
215
|
+
help="History: print the local URL without opening a browser",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--port",
|
|
219
|
+
type=int,
|
|
220
|
+
default=0,
|
|
221
|
+
metavar="PORT",
|
|
222
|
+
help="History: local dashboard port; 0 chooses a free port (default: 0)",
|
|
223
|
+
)
|
|
224
|
+
return parser.parse_args(argv)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fmt_time(seconds: float) -> str:
|
|
228
|
+
seconds = round(seconds)
|
|
229
|
+
if seconds >= 60:
|
|
230
|
+
return f"{seconds // 60}m {seconds % 60}s"
|
|
231
|
+
return f"{seconds}s"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _score_text(score: float) -> Text:
|
|
235
|
+
if score >= 80:
|
|
236
|
+
style = "bold green"
|
|
237
|
+
elif score < 50:
|
|
238
|
+
style = "bold red"
|
|
239
|
+
else:
|
|
240
|
+
style = "bold"
|
|
241
|
+
return Text(f"{score:.1f}%", style=style)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _outcome_counts(result: dict) -> tuple[int, int, int]:
|
|
245
|
+
"""Return pass, ordinary failure and error-task counts for a result."""
|
|
246
|
+
tasks = result.get("tasks", [])
|
|
247
|
+
failed = sum(not task.get("passed") and not task.get("error") for task in tasks)
|
|
248
|
+
errors = sum(not task.get("passed") and bool(task.get("error")) for task in tasks)
|
|
249
|
+
return int(result["passed"]), failed, errors
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _list_benchmarks() -> None:
|
|
253
|
+
table = Table(box=box.MINIMAL, border_style="dim", header_style="bold")
|
|
254
|
+
table.add_column("Benchmark")
|
|
255
|
+
table.add_column("Tasks", justify="right")
|
|
256
|
+
table.add_column("Description")
|
|
257
|
+
table.add_column("Perturbations")
|
|
258
|
+
table.add_column("Notes")
|
|
259
|
+
for key in REGISTRY:
|
|
260
|
+
try:
|
|
261
|
+
declared_label = getattr(REGISTRY[key], "list_task_count", "")
|
|
262
|
+
count = declared_label or f"{task_count(key):,}"
|
|
263
|
+
except Exception:
|
|
264
|
+
count = "?"
|
|
265
|
+
note = getattr(REGISTRY[key], "list_note", "")
|
|
266
|
+
table.add_row(
|
|
267
|
+
key,
|
|
268
|
+
count,
|
|
269
|
+
DESCRIPTIONS.get(key, ""),
|
|
270
|
+
f"[cyan]{' '.join(perturbations_for(key))}[/cyan]",
|
|
271
|
+
f"[yellow]{note}[/yellow]" if note else "",
|
|
272
|
+
)
|
|
273
|
+
console.print(table)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _datasets_table() -> None:
|
|
277
|
+
table = Table(box=box.MINIMAL, border_style="dim", header_style="bold")
|
|
278
|
+
table.add_column("Benchmark")
|
|
279
|
+
table.add_column("Status")
|
|
280
|
+
table.add_column("Size", justify="right")
|
|
281
|
+
table.add_column("Source")
|
|
282
|
+
labels = {
|
|
283
|
+
"bundled": "[dim]bundled[/dim]",
|
|
284
|
+
"installed": "[green]✓ installed[/green]",
|
|
285
|
+
"download": "[yellow]✗ missing[/yellow]",
|
|
286
|
+
"manual": "[red]✗ manual setup[/red]",
|
|
287
|
+
}
|
|
288
|
+
entries = catalog.statuses()
|
|
289
|
+
for entry in entries:
|
|
290
|
+
state = (
|
|
291
|
+
entry.kind
|
|
292
|
+
if entry.kind == "bundled" or not entry.installed
|
|
293
|
+
else "installed"
|
|
294
|
+
)
|
|
295
|
+
source = entry.summary
|
|
296
|
+
if entry.kind == "manual" and not entry.installed and entry.hint:
|
|
297
|
+
source += f"\n[dim]{entry.hint}[/dim]"
|
|
298
|
+
table.add_row(
|
|
299
|
+
entry.benchmark,
|
|
300
|
+
labels[state],
|
|
301
|
+
catalog.size_label(entry),
|
|
302
|
+
source,
|
|
303
|
+
)
|
|
304
|
+
console.print(table)
|
|
305
|
+
missing = catalog.missing()
|
|
306
|
+
if missing:
|
|
307
|
+
console.print(
|
|
308
|
+
f"[yellow]{len(missing)} missing:[/yellow] {', '.join(missing)} "
|
|
309
|
+
"[dim]· run 'benchkit datasets install' to fetch them all[/dim]"
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
console.print("[green]Every downloadable dataset is installed.[/green]")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _install_datasets(keys: list[str]) -> bool:
|
|
316
|
+
"""Install each benchmark's data with a progress line. True if all worked."""
|
|
317
|
+
from rich.progress import (
|
|
318
|
+
BarColumn,
|
|
319
|
+
DownloadColumn,
|
|
320
|
+
Progress,
|
|
321
|
+
SpinnerColumn,
|
|
322
|
+
TextColumn,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
ok = True
|
|
326
|
+
for key in keys:
|
|
327
|
+
entry = catalog.status(key)
|
|
328
|
+
if entry.installed:
|
|
329
|
+
note = "bundled" if entry.kind == "bundled" else "already installed"
|
|
330
|
+
console.print(f"[dim]{key}: {note}[/dim]")
|
|
331
|
+
continue
|
|
332
|
+
with Progress(
|
|
333
|
+
SpinnerColumn(),
|
|
334
|
+
TextColumn("{task.description}"),
|
|
335
|
+
BarColumn(),
|
|
336
|
+
DownloadColumn(),
|
|
337
|
+
console=console,
|
|
338
|
+
transient=True,
|
|
339
|
+
) as progress:
|
|
340
|
+
task = progress.add_task(f"Installing {key}", total=entry.size)
|
|
341
|
+
try:
|
|
342
|
+
catalog.install(
|
|
343
|
+
key,
|
|
344
|
+
lambda done, total, task=task: progress.update(
|
|
345
|
+
task, completed=done, total=total
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
ok = False
|
|
350
|
+
progress.stop()
|
|
351
|
+
console.print(f"[red]✗ {key}:[/red] {exc}")
|
|
352
|
+
continue
|
|
353
|
+
console.print(f"[green]✓ {key}[/green] [dim]{entry.location}[/dim]")
|
|
354
|
+
return ok
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _datasets(args: argparse.Namespace) -> None:
|
|
358
|
+
if args.perf_model is None:
|
|
359
|
+
_datasets_table()
|
|
360
|
+
return
|
|
361
|
+
if args.perf_model != "install":
|
|
362
|
+
console.print(
|
|
363
|
+
f"[red]Unknown datasets action:[/red] {args.perf_model} "
|
|
364
|
+
"[dim]· use 'benchkit datasets' or 'benchkit datasets install'[/dim]"
|
|
365
|
+
)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
unknown = [key for key in args.targets if key not in REGISTRY]
|
|
368
|
+
if unknown:
|
|
369
|
+
console.print(f"[red]Unknown benchmark(s):[/red] {', '.join(unknown)}")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
keys = args.targets or catalog.missing()
|
|
372
|
+
if not keys:
|
|
373
|
+
console.print("[green]Every downloadable dataset is already installed.[/green]")
|
|
374
|
+
return
|
|
375
|
+
if not _install_datasets(keys):
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _render_check() -> None:
|
|
380
|
+
"""Report whether this machine can run the suites that draw pictures.
|
|
381
|
+
|
|
382
|
+
Every treejs-arena task depends on two things BenchKit does not control:
|
|
383
|
+
a headless browser that can produce a WebGL canvas, and a route to the
|
|
384
|
+
module CDN the generated page imports from. This checks both, so a broken
|
|
385
|
+
server is diagnosed in one command instead of ten scored-zero tasks.
|
|
386
|
+
mc-arena needs the same browser, but only for its screenshots: its scores
|
|
387
|
+
come from the block list, and it needs Docker rather than the CDN.
|
|
388
|
+
"""
|
|
389
|
+
from benchkit.browser import allowed_hosts, probe_environment
|
|
390
|
+
|
|
391
|
+
ok, detail = probe_environment(force=True)
|
|
392
|
+
if ok:
|
|
393
|
+
console.print(f"[green]Headless rendering works.[/green] [dim]{detail}[/dim]")
|
|
394
|
+
else:
|
|
395
|
+
console.print(f"[red]Headless rendering is unavailable.[/red] {detail}")
|
|
396
|
+
|
|
397
|
+
hosts = allowed_hosts()
|
|
398
|
+
if hosts:
|
|
399
|
+
console.print(
|
|
400
|
+
"[dim]Generated pages may fetch modules from:[/dim] " + ", ".join(hosts)
|
|
401
|
+
)
|
|
402
|
+
console.print(
|
|
403
|
+
"[dim]The machine needs outbound HTTPS to at least one of them; "
|
|
404
|
+
"otherwise every scene fails on a blocked import.[/dim]"
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
console.print(
|
|
408
|
+
"[yellow]Network access is disabled "
|
|
409
|
+
"(BENCHKIT_RENDER_OFFLINE).[/yellow] [dim]Pages that import "
|
|
410
|
+
"three.js from a CDN cannot render.[/dim]"
|
|
411
|
+
)
|
|
412
|
+
console.print(
|
|
413
|
+
"[dim]mc-arena additionally needs Docker, and uses the browser only "
|
|
414
|
+
"for screenshots; its builds are scored either way.[/dim]"
|
|
415
|
+
)
|
|
416
|
+
if not ok:
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _client(args: argparse.Namespace):
|
|
421
|
+
if args.demo:
|
|
422
|
+
from benchkit.demo import DemoClient
|
|
423
|
+
|
|
424
|
+
return DemoClient()
|
|
425
|
+
|
|
426
|
+
client = InferenceClient.from_env()
|
|
427
|
+
if args.host:
|
|
428
|
+
client.host = args.host.rstrip("/")
|
|
429
|
+
return client
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _headless_jobs(args: argparse.Namespace, available: list[str]) -> list[JobSpec]:
|
|
433
|
+
if args.models.strip().lower() in {"all", "*"}:
|
|
434
|
+
models = available
|
|
435
|
+
else:
|
|
436
|
+
models = [name.strip() for name in args.models.split(",") if name.strip()]
|
|
437
|
+
|
|
438
|
+
unknown = [name for name in models if name not in available]
|
|
439
|
+
if unknown:
|
|
440
|
+
console.print(f"[red]Unknown model(s):[/red] {', '.join(unknown)}")
|
|
441
|
+
console.print(f"[dim]Available: {', '.join(available)}[/dim]")
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
if not models:
|
|
444
|
+
console.print("[red]--models is required with --headless[/red]")
|
|
445
|
+
sys.exit(1)
|
|
446
|
+
|
|
447
|
+
requested = [part for part in args.benchmarks.split(",") if part.strip()]
|
|
448
|
+
|
|
449
|
+
specs: list[tuple[str, str | None, str | None]] = []
|
|
450
|
+
for part in requested:
|
|
451
|
+
part = part.strip()
|
|
452
|
+
if not part:
|
|
453
|
+
continue
|
|
454
|
+
pieces = [piece.strip() for piece in part.split(":")]
|
|
455
|
+
key = pieces[0]
|
|
456
|
+
if key not in REGISTRY:
|
|
457
|
+
console.print(f"[red]Unknown benchmark:[/red] {key}")
|
|
458
|
+
console.print(f"[dim]Available: {', '.join(REGISTRY)}[/dim]")
|
|
459
|
+
sys.exit(1)
|
|
460
|
+
if len(pieces) > 3 or any(not piece for piece in pieces[1:]):
|
|
461
|
+
console.print(f"[red]Invalid benchmark selector:[/red] {part}")
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
|
|
464
|
+
bench = REGISTRY[key]()
|
|
465
|
+
resolve_variant = getattr(bench, "resolve_variant", None)
|
|
466
|
+
variant = None
|
|
467
|
+
slice_spec = None
|
|
468
|
+
if len(pieces) == 2:
|
|
469
|
+
candidate = (
|
|
470
|
+
resolve_variant(pieces[1]) if callable(resolve_variant) else None
|
|
471
|
+
)
|
|
472
|
+
if candidate is not None:
|
|
473
|
+
variant = str(candidate)
|
|
474
|
+
else:
|
|
475
|
+
slice_spec = pieces[1]
|
|
476
|
+
elif len(pieces) == 3:
|
|
477
|
+
candidate = (
|
|
478
|
+
resolve_variant(pieces[1]) if callable(resolve_variant) else None
|
|
479
|
+
)
|
|
480
|
+
if candidate is None:
|
|
481
|
+
console.print(f"[red]Unknown {key} variant:[/red] {pieces[1]}")
|
|
482
|
+
sys.exit(1)
|
|
483
|
+
variant = str(candidate)
|
|
484
|
+
slice_spec = pieces[2]
|
|
485
|
+
try:
|
|
486
|
+
variant_count = getattr(bench, "variant_task_count", None)
|
|
487
|
+
total = (
|
|
488
|
+
int(variant_count(variant))
|
|
489
|
+
if variant is not None and callable(variant_count)
|
|
490
|
+
else slice_task_count(key)
|
|
491
|
+
)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
console.print(f"[red]Could not load {key}:[/red] {exc}")
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
try:
|
|
496
|
+
parse_slice(slice_spec, total)
|
|
497
|
+
except SliceError as exc:
|
|
498
|
+
console.print(f"[red]{exc}[/red]")
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
specs.append((key, slice_spec, variant))
|
|
501
|
+
|
|
502
|
+
if not specs:
|
|
503
|
+
console.print("[red]--benchmarks is required with --headless[/red]")
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
|
|
506
|
+
if args.perturbation:
|
|
507
|
+
unsupported = unsupported_benchmarks(
|
|
508
|
+
args.perturbation, [key for key, _, _ in specs]
|
|
509
|
+
)
|
|
510
|
+
if unsupported:
|
|
511
|
+
console.print(
|
|
512
|
+
f"[red]{args.perturbation} is unsupported for:[/red] "
|
|
513
|
+
+ ", ".join(unsupported)
|
|
514
|
+
)
|
|
515
|
+
supported = [
|
|
516
|
+
key for key in REGISTRY if args.perturbation in perturbations_for(key)
|
|
517
|
+
]
|
|
518
|
+
console.print(f"[dim]Supported: {', '.join(supported)}[/dim]")
|
|
519
|
+
sys.exit(1)
|
|
520
|
+
|
|
521
|
+
harnesses = ("direct", "pi") if args.harness == "both" else (args.harness,)
|
|
522
|
+
return job_matrix(
|
|
523
|
+
models,
|
|
524
|
+
specs,
|
|
525
|
+
harnesses,
|
|
526
|
+
repair_attempts=args.repair_attempts,
|
|
527
|
+
perturbation=args.perturbation,
|
|
528
|
+
perturbation_seed=args.perturbation_seed,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _headless(args: argparse.Namespace) -> None:
|
|
533
|
+
if args.demo and args.decision_models.strip():
|
|
534
|
+
console.print("[red]Decision models are unavailable in demo mode.[/red]")
|
|
535
|
+
sys.exit(1)
|
|
536
|
+
if args.demo and args.harness != "direct":
|
|
537
|
+
console.print("[red]Pi harness is unavailable in demo mode.[/red]")
|
|
538
|
+
sys.exit(1)
|
|
539
|
+
try:
|
|
540
|
+
client = _client(args)
|
|
541
|
+
except ValueError as exc:
|
|
542
|
+
console.print(f"[red]Configuration error:[/red] {exc}")
|
|
543
|
+
sys.exit(1)
|
|
544
|
+
|
|
545
|
+
console.print(f"[bold]BenchKit[/bold] [dim]{client.host}[/dim]")
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
models = client.list_models()
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
console.print(f"[red]Connection failed:[/red] {exc}")
|
|
551
|
+
sys.exit(1)
|
|
552
|
+
|
|
553
|
+
selected = _headless_jobs(args, [model["name"] for model in models])
|
|
554
|
+
forced = {name.strip() for name in args.decision_models.split(",") if name.strip()}
|
|
555
|
+
stray = sorted(forced - {job.model for job in selected})
|
|
556
|
+
if stray:
|
|
557
|
+
console.print(
|
|
558
|
+
"[red]--decision-models names models that are not in --models:[/red] "
|
|
559
|
+
+ ", ".join(stray)
|
|
560
|
+
)
|
|
561
|
+
sys.exit(1)
|
|
562
|
+
try:
|
|
563
|
+
jobs = route_decision_jobs(selected, client, forced)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
console.print(f"[red]{exc}[/red]")
|
|
566
|
+
sys.exit(1)
|
|
567
|
+
jobs = expand_jobs(jobs, client)
|
|
568
|
+
if not jobs:
|
|
569
|
+
console.print(
|
|
570
|
+
"[red]No supported RULER context bucket fits the selected model(s).[/red]"
|
|
571
|
+
)
|
|
572
|
+
sys.exit(1)
|
|
573
|
+
# Fetch what the run needs up front, with a progress bar, rather than
|
|
574
|
+
# stalling silently inside the first job's task loader.
|
|
575
|
+
missing = catalog.missing(dict.fromkeys(job.benchmark for job in jobs))
|
|
576
|
+
if missing:
|
|
577
|
+
console.print(f"[dim]Fetching datasets:[/dim] {', '.join(missing)}")
|
|
578
|
+
# A partial install (RULER without its essay corpus) can still serve
|
|
579
|
+
# some slices, so leave the verdict to the task loader.
|
|
580
|
+
if not _install_datasets(missing):
|
|
581
|
+
console.print(
|
|
582
|
+
"[yellow]Continuing; tasks that need the missing data will "
|
|
583
|
+
"fail when they load.[/yellow]"
|
|
584
|
+
)
|
|
585
|
+
if args.demo:
|
|
586
|
+
perturbations = sorted(
|
|
587
|
+
{
|
|
588
|
+
(job.perturbation, job.perturbation_seed)
|
|
589
|
+
for job in jobs
|
|
590
|
+
if job.perturbation
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
client.prime(
|
|
594
|
+
sorted({job.benchmark for job in jobs}),
|
|
595
|
+
perturbations=perturbations,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
results, failure = run(
|
|
599
|
+
client,
|
|
600
|
+
jobs,
|
|
601
|
+
console,
|
|
602
|
+
args.verbose,
|
|
603
|
+
force_unload=args.force_unload,
|
|
604
|
+
)
|
|
605
|
+
if not results:
|
|
606
|
+
console.print("[yellow]No results.[/yellow]")
|
|
607
|
+
sys.exit(1 if failure else 0)
|
|
608
|
+
|
|
609
|
+
table = Table(
|
|
610
|
+
box=box.MINIMAL,
|
|
611
|
+
border_style="dim",
|
|
612
|
+
header_style="bold",
|
|
613
|
+
pad_edge=False,
|
|
614
|
+
padding=(0, 1),
|
|
615
|
+
)
|
|
616
|
+
# Fold long model and benchmark names instead of ellipsizing them: the
|
|
617
|
+
# summary is the part of a headless run people copy into a report.
|
|
618
|
+
table.add_column("Model", overflow="fold")
|
|
619
|
+
table.add_column("Harness")
|
|
620
|
+
table.add_column("Benchmark", overflow="fold")
|
|
621
|
+
table.add_column("Score", justify="right")
|
|
622
|
+
has_harness_pairs = any(
|
|
623
|
+
result.get("harness_score_delta_pp") is not None for result in results
|
|
624
|
+
)
|
|
625
|
+
if has_harness_pairs:
|
|
626
|
+
table.add_column("Harness score Δ", justify="right")
|
|
627
|
+
table.add_column("Loop-kill Δ", justify="right")
|
|
628
|
+
has_repairs = any(result.get("repair_attempts", 0) for result in results)
|
|
629
|
+
if has_repairs:
|
|
630
|
+
table.add_column("Repair Δ", justify="right")
|
|
631
|
+
table.add_column("Fixed", justify="right")
|
|
632
|
+
has_perturbations = any(result.get("perturbation") for result in results)
|
|
633
|
+
if has_perturbations:
|
|
634
|
+
table.add_column("Delta", justify="right")
|
|
635
|
+
table.add_column("Regress", justify="right")
|
|
636
|
+
table.add_column("Pass/Scored", justify="right")
|
|
637
|
+
table.add_column("Fail/Loop/TO/Len/HE", justify="right")
|
|
638
|
+
has_parallel = any(result.get("concurrency", 1) > 1 for result in results)
|
|
639
|
+
# Decision rows are measured in dec/s, so a table that mixes them with
|
|
640
|
+
# generating rows names the unit in every cell instead of the header.
|
|
641
|
+
has_decisions = any(is_decision(result) for result in results)
|
|
642
|
+
unit = " tok/s" if has_decisions else ""
|
|
643
|
+
if has_parallel:
|
|
644
|
+
table.add_column("Parallel", justify="right", style="dim")
|
|
645
|
+
table.add_column("Agg speed" if has_decisions else "Agg tok/s", justify="right")
|
|
646
|
+
table.add_column("Stream tok/s", justify="right", style="dim")
|
|
647
|
+
table.add_column("Eff", justify="right", style="dim")
|
|
648
|
+
else:
|
|
649
|
+
table.add_column("Speed" if has_decisions else "Tok/s", justify="right")
|
|
650
|
+
table.add_column("Avg Time", justify="right", style="dim")
|
|
651
|
+
table.add_column("Wall", justify="right", style="dim")
|
|
652
|
+
|
|
653
|
+
for result in results:
|
|
654
|
+
passed, failed, errors = _outcome_counts(result)
|
|
655
|
+
parallel = result.get("concurrency", 1) > 1
|
|
656
|
+
row = [
|
|
657
|
+
result["model"],
|
|
658
|
+
result.get("harness_label", "Direct"),
|
|
659
|
+
result.get("benchmark_label", result["benchmark"]),
|
|
660
|
+
_score_text(result["score"]),
|
|
661
|
+
]
|
|
662
|
+
if has_harness_pairs:
|
|
663
|
+
harness_delta = result.get("harness_score_delta_pp")
|
|
664
|
+
row.append(
|
|
665
|
+
f"{float(harness_delta):+.1f}pp" if harness_delta is not None else "—"
|
|
666
|
+
)
|
|
667
|
+
loop_delta = result.get("loop_kill_delta_pp")
|
|
668
|
+
row.append(f"{float(loop_delta):+.1f}pp" if loop_delta is not None else "—")
|
|
669
|
+
if has_repairs:
|
|
670
|
+
if result.get("repair_attempts", 0):
|
|
671
|
+
row.extend(
|
|
672
|
+
[
|
|
673
|
+
f"{float(result.get('repair_delta_pp', 0)):+.1f}pp",
|
|
674
|
+
(
|
|
675
|
+
f"{result.get('repair_successes', 0)}/"
|
|
676
|
+
f"{result.get('repair_attempted', 0)}"
|
|
677
|
+
),
|
|
678
|
+
]
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
row.extend(["—", "—"])
|
|
682
|
+
if has_perturbations:
|
|
683
|
+
delta = result.get("score_delta_pp")
|
|
684
|
+
row.extend(
|
|
685
|
+
[
|
|
686
|
+
f"{float(delta):+.1f}pp" if delta is not None else "—",
|
|
687
|
+
(
|
|
688
|
+
f"{result.get('regressions', 0)}/"
|
|
689
|
+
f"{result.get('paired_total', 0)}"
|
|
690
|
+
if result.get("perturbation")
|
|
691
|
+
else "—"
|
|
692
|
+
),
|
|
693
|
+
]
|
|
694
|
+
)
|
|
695
|
+
row.extend(
|
|
696
|
+
[
|
|
697
|
+
f"{passed}/{result.get('scored_total', result['total'])}",
|
|
698
|
+
f"{result.get('failures', failed)}/{result.get('loop_kills', 0)}/"
|
|
699
|
+
f"{result.get('timeouts', 0)}/"
|
|
700
|
+
f"{result.get('length_exceeded', 0)}/"
|
|
701
|
+
f"{result.get('harness_errors', errors)}",
|
|
702
|
+
]
|
|
703
|
+
)
|
|
704
|
+
decisions = is_decision(result)
|
|
705
|
+
if has_parallel:
|
|
706
|
+
row.extend(
|
|
707
|
+
[
|
|
708
|
+
str(result.get("concurrency", 1)),
|
|
709
|
+
decision_speed(result)[0]
|
|
710
|
+
if decisions
|
|
711
|
+
else f"{aggregate_tok_s(result):.1f}{unit}"
|
|
712
|
+
if parallel
|
|
713
|
+
else "—",
|
|
714
|
+
"—" if decisions else f"{stream_tok_s(result):.1f}",
|
|
715
|
+
f"{effective_concurrency(result):.2f}x" if parallel else "—",
|
|
716
|
+
]
|
|
717
|
+
)
|
|
718
|
+
else:
|
|
719
|
+
row.append(
|
|
720
|
+
decision_speed(result)[0]
|
|
721
|
+
if decisions
|
|
722
|
+
else f"{stream_tok_s(result):.1f}{unit}"
|
|
723
|
+
)
|
|
724
|
+
row.extend([latency_text(result), _fmt_time(result["total_time"])])
|
|
725
|
+
table.add_row(
|
|
726
|
+
*row,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
console.print()
|
|
730
|
+
console.print(table)
|
|
731
|
+
|
|
732
|
+
out = save(results, provider=getattr(client, "label", ""), host=client.host)
|
|
733
|
+
console.print(f"[dim]Saved:[/dim] [white]{out}[/white]")
|
|
734
|
+
if (out / "arena.html").exists():
|
|
735
|
+
console.print(
|
|
736
|
+
f"[dim]Gallery:[/dim] [white]{out / 'arena.html'}[/white] "
|
|
737
|
+
"[dim]· click a preview to open the generated page[/dim]"
|
|
738
|
+
)
|
|
739
|
+
# A run whose render checks were all skipped still scores 100%, so say
|
|
740
|
+
# so plainly rather than letting the score imply the pages were judged.
|
|
741
|
+
workspaces = [
|
|
742
|
+
task.get("workspace") or {}
|
|
743
|
+
for result in results
|
|
744
|
+
for task in result.get("tasks") or []
|
|
745
|
+
]
|
|
746
|
+
skipped = sum(
|
|
747
|
+
workspace.get("render_status") == "skipped"
|
|
748
|
+
and not workspace.get("build_status")
|
|
749
|
+
for workspace in workspaces
|
|
750
|
+
)
|
|
751
|
+
if skipped:
|
|
752
|
+
console.print(
|
|
753
|
+
f"[yellow]Skipped {skipped} render check(s):[/yellow] "
|
|
754
|
+
"[dim]this machine could not render them, so they were not "
|
|
755
|
+
"scored against the model · run 'benchkit render-check'[/dim]"
|
|
756
|
+
)
|
|
757
|
+
# mc-arena scores the block list, so a build without pictures is a
|
|
758
|
+
# missing illustration rather than an unjudged task.
|
|
759
|
+
unillustrated = sum(
|
|
760
|
+
workspace.get("render_status") == "skipped"
|
|
761
|
+
and bool(workspace.get("build_status"))
|
|
762
|
+
for workspace in workspaces
|
|
763
|
+
)
|
|
764
|
+
if unillustrated:
|
|
765
|
+
console.print(
|
|
766
|
+
f"[yellow]Rendered no image for {unillustrated} build(s):[/yellow] "
|
|
767
|
+
"[dim]the builds were still scored from their block lists · "
|
|
768
|
+
"run 'benchkit render-check'[/dim]"
|
|
769
|
+
)
|
|
770
|
+
if failure:
|
|
771
|
+
sys.exit(1)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _perf(args: argparse.Namespace) -> None:
|
|
775
|
+
if args.demo:
|
|
776
|
+
console.print("[red]Performance profiling is unavailable in demo mode.[/red]")
|
|
777
|
+
sys.exit(1)
|
|
778
|
+
if not args.perf_model:
|
|
779
|
+
console.print("[red]A model name is required:[/red] benchkit perf MODEL")
|
|
780
|
+
sys.exit(1)
|
|
781
|
+
if args.gen < 1 or args.reps < 1 or args.warmups < 0:
|
|
782
|
+
console.print(
|
|
783
|
+
"[red]Perf settings require --gen and --reps above zero, "
|
|
784
|
+
"and --warmups at least zero.[/red]"
|
|
785
|
+
)
|
|
786
|
+
sys.exit(1)
|
|
787
|
+
try:
|
|
788
|
+
depths = parse_depths(args.depths)
|
|
789
|
+
client = _client(args)
|
|
790
|
+
except ValueError as exc:
|
|
791
|
+
console.print(f"[red]Configuration error:[/red] {exc}")
|
|
792
|
+
sys.exit(1)
|
|
793
|
+
|
|
794
|
+
console.print(f"[bold]BenchKit perf[/bold] [dim]{client.host}[/dim]")
|
|
795
|
+
try:
|
|
796
|
+
models = client.list_models()
|
|
797
|
+
except Exception as exc:
|
|
798
|
+
console.print(f"[red]Connection failed:[/red] {exc}")
|
|
799
|
+
sys.exit(1)
|
|
800
|
+
|
|
801
|
+
model_meta = next(
|
|
802
|
+
(model for model in models if model.get("name") == args.perf_model),
|
|
803
|
+
None,
|
|
804
|
+
)
|
|
805
|
+
if model_meta is None:
|
|
806
|
+
console.print(f"[red]Unknown model:[/red] {args.perf_model}")
|
|
807
|
+
console.print(
|
|
808
|
+
"[dim]Available: "
|
|
809
|
+
+ ", ".join(str(model.get("name")) for model in models)
|
|
810
|
+
+ "[/dim]"
|
|
811
|
+
)
|
|
812
|
+
sys.exit(1)
|
|
813
|
+
if client.provider != "openai":
|
|
814
|
+
console.print(
|
|
815
|
+
"[red]Perf v1 requires an OpenAI-compatible llama.cpp/llama-swap "
|
|
816
|
+
"endpoint.[/red]"
|
|
817
|
+
)
|
|
818
|
+
sys.exit(1)
|
|
819
|
+
|
|
820
|
+
model_status = str(model_meta.get("status") or "").strip().lower()
|
|
821
|
+
if model_status == "unloaded":
|
|
822
|
+
console.print(
|
|
823
|
+
"[yellow]Model status: unloaded[/yellow] "
|
|
824
|
+
"[dim]· the first request may measure a cold start[/dim]"
|
|
825
|
+
)
|
|
826
|
+
elif model_status == "loaded":
|
|
827
|
+
console.print(
|
|
828
|
+
"[green]Model status: loaded[/green] "
|
|
829
|
+
"[dim]· the model is already resident, so this is a warm test[/dim]"
|
|
830
|
+
)
|
|
831
|
+
elif model_status:
|
|
832
|
+
console.print(
|
|
833
|
+
f"[dim]Model status: {model_status} · cold/warm state is not known[/dim]"
|
|
834
|
+
)
|
|
835
|
+
else:
|
|
836
|
+
console.print(
|
|
837
|
+
"[dim]Model status: unavailable · cold/warm state is not known[/dim]"
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
config = PerfConfig(
|
|
841
|
+
model=args.perf_model,
|
|
842
|
+
depths=depths,
|
|
843
|
+
gen_tokens=args.gen,
|
|
844
|
+
reps=args.reps,
|
|
845
|
+
warmups=args.warmups,
|
|
846
|
+
config_note=args.config_note,
|
|
847
|
+
)
|
|
848
|
+
try:
|
|
849
|
+
profile = run_profile(
|
|
850
|
+
client,
|
|
851
|
+
config,
|
|
852
|
+
model_meta=model_meta,
|
|
853
|
+
progress=lambda message: console.print(f"[dim]· {message}[/dim]"),
|
|
854
|
+
)
|
|
855
|
+
except Exception as exc:
|
|
856
|
+
console.print(f"[red]Performance profile failed:[/red] {exc}")
|
|
857
|
+
sys.exit(1)
|
|
858
|
+
|
|
859
|
+
table = Table(
|
|
860
|
+
box=box.MINIMAL,
|
|
861
|
+
border_style="dim",
|
|
862
|
+
header_style="bold",
|
|
863
|
+
pad_edge=False,
|
|
864
|
+
)
|
|
865
|
+
table.add_column("Depth")
|
|
866
|
+
table.add_column("Input", justify="right")
|
|
867
|
+
table.add_column("PP tok/s", justify="right")
|
|
868
|
+
table.add_column("TG tok/s", justify="right")
|
|
869
|
+
table.add_column("TTFT", justify="right")
|
|
870
|
+
table.add_column("Wall", justify="right")
|
|
871
|
+
table.add_column("Overhead", justify="right")
|
|
872
|
+
table.add_column("Runs", justify="right")
|
|
873
|
+
for case in profile["cases"]:
|
|
874
|
+
inaccurate = not case.get("depth_within_tolerance", True)
|
|
875
|
+
depth = case["depth_label"]
|
|
876
|
+
if inaccurate:
|
|
877
|
+
depth = f"[yellow]{depth}![/yellow]"
|
|
878
|
+
table.add_row(
|
|
879
|
+
depth,
|
|
880
|
+
f"{case['actual_input_tokens']:,}" if case["actual_input_tokens"] else "—",
|
|
881
|
+
(f"{case['pp_tps']['median']:.1f}" if case.get("pp_reliable") else "n/a"),
|
|
882
|
+
f"{case['tg_tps']['median']:.1f}",
|
|
883
|
+
f"{case['ttft_s']['median']:.3f}s",
|
|
884
|
+
f"{case['wall_time_s']['median']:.2f}s",
|
|
885
|
+
(
|
|
886
|
+
f"{case['overhead_s']['median']:.3f}s"
|
|
887
|
+
if case.get("overhead_available")
|
|
888
|
+
else "—"
|
|
889
|
+
),
|
|
890
|
+
f"{case['successful_reps']}/{args.reps}",
|
|
891
|
+
)
|
|
892
|
+
console.print()
|
|
893
|
+
console.print(table)
|
|
894
|
+
|
|
895
|
+
sweep = profile.get("sweep_summary") or {}
|
|
896
|
+
console.print(
|
|
897
|
+
"[dim]Sweep median · "
|
|
898
|
+
f"TG {float(sweep.get('tg_tps_median') or 0):.1f} tok/s · "
|
|
899
|
+
f"PP {float(sweep.get('pp_tps_median') or 0):.1f} tok/s"
|
|
900
|
+
"[/dim]"
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
calibrated = [
|
|
904
|
+
case for case in profile["cases"] if case.get("depth_method") == "calibrated"
|
|
905
|
+
]
|
|
906
|
+
inaccurate = [
|
|
907
|
+
case
|
|
908
|
+
for case in profile["cases"]
|
|
909
|
+
if not case.get("depth_within_tolerance", True)
|
|
910
|
+
]
|
|
911
|
+
estimated = [
|
|
912
|
+
case for case in profile["cases"] if case.get("depth_method") == "estimated"
|
|
913
|
+
]
|
|
914
|
+
if calibrated:
|
|
915
|
+
console.print(
|
|
916
|
+
"[dim]Depth method: calibrated · native tokenizer is not exposed "
|
|
917
|
+
"by the server; server-reported input tokens were used.[/dim]"
|
|
918
|
+
)
|
|
919
|
+
if inaccurate:
|
|
920
|
+
labels = ", ".join(case["depth_label"] for case in inaccurate)
|
|
921
|
+
console.print(
|
|
922
|
+
f"[bold yellow]! Depth tolerance not reached for: {labels}. "
|
|
923
|
+
"Use the Actual Input column when comparing results.[/bold yellow]"
|
|
924
|
+
)
|
|
925
|
+
if estimated:
|
|
926
|
+
labels = ", ".join(case["depth_label"] for case in estimated)
|
|
927
|
+
console.print(
|
|
928
|
+
f"[bold yellow]Depth calibration unavailable for: {labels}. "
|
|
929
|
+
"Use the Actual Input column.[/bold yellow]"
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
drift = profile.get("drift_check") or {}
|
|
933
|
+
if drift.get("error"):
|
|
934
|
+
console.print(f"[yellow]Drift check failed:[/yellow] {drift['error']}")
|
|
935
|
+
elif drift:
|
|
936
|
+
message = (
|
|
937
|
+
f"Drift check · {drift['depth_label']} TG "
|
|
938
|
+
f"{drift['initial_tg_tps']:.1f} → {drift['final_tg_tps']:.1f} tok/s "
|
|
939
|
+
f"({drift['change']:+.1%})"
|
|
940
|
+
+ ("" if drift.get("stable") else " · possible warmup/drift anomaly")
|
|
941
|
+
)
|
|
942
|
+
console.print(
|
|
943
|
+
f"[dim]{message}[/dim]"
|
|
944
|
+
if drift.get("stable")
|
|
945
|
+
else f"[bold yellow]{message}[/bold yellow]"
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
output = save_profile(profile)
|
|
949
|
+
console.print(
|
|
950
|
+
f"[dim]Saved:[/dim] [white]{output}[/white] "
|
|
951
|
+
"[dim]· perf.json · csv · md · html[/dim]"
|
|
952
|
+
)
|
|
953
|
+
console.print(
|
|
954
|
+
"[dim]Tip: Run the ruler benchmark to verify how much of the configured "
|
|
955
|
+
"context window is actually usable.[/dim]"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def main(argv: list[str] | None = None) -> None:
|
|
960
|
+
# Only the working directory's .env: walking up to parent directories would
|
|
961
|
+
# let a file in a shared parent such as /tmp redirect the host and API key.
|
|
962
|
+
load_dotenv(".env")
|
|
963
|
+
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
964
|
+
|
|
965
|
+
if args.list:
|
|
966
|
+
_list_benchmarks()
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
if args.command == "render-check":
|
|
970
|
+
_render_check()
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
if args.command == "datasets":
|
|
974
|
+
_datasets(args)
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
if args.command == "perf":
|
|
978
|
+
_perf(args)
|
|
979
|
+
return
|
|
980
|
+
|
|
981
|
+
if args.command == "history":
|
|
982
|
+
if not 0 <= args.port <= 65535:
|
|
983
|
+
console.print("[red]--port must be between 0 and 65535.[/red]")
|
|
984
|
+
sys.exit(1)
|
|
985
|
+
serve_history(
|
|
986
|
+
args.results_dir or ["results"],
|
|
987
|
+
port=args.port,
|
|
988
|
+
open_browser=not args.no_open,
|
|
989
|
+
)
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
if args.headless:
|
|
993
|
+
_headless(args)
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
run_tui(demo=args.demo, host=args.host)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
if __name__ == "__main__":
|
|
1000
|
+
main()
|