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.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ /*!
2
+ * The buffer module from node.js, for the browser.
3
+ *
4
+ * @author Feross Aboukhadijeh <feross@feross.org> <http://feross.org>
5
+ * @license MIT
6
+ */
7
+
8
+ /*!
9
+ * The buffer module from node.js, for the browser.
10
+ *
11
+ * @author Feross Aboukhadijeh <https://feross.org>
12
+ * @license MIT
13
+ */
14
+
15
+ /*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
16
+
17
+ /*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
18
+
19
+ /** @license URI.js v4.4.1 (c) 2011 Gary Court. License: http://github.com/garycourt/uri-js */
benchkit/metrics.py ADDED
@@ -0,0 +1,100 @@
1
+ """Throughput metrics shared by execution, front-ends and reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+
8
+ def throughput_metrics(
9
+ *,
10
+ output_tokens: int,
11
+ generation_time_s: float,
12
+ request_time_s: float,
13
+ wall_time_s: float,
14
+ ) -> dict[str, float]:
15
+ """Calculate stream speed, total throughput and occupied request slots.
16
+
17
+ ``generation_time_s`` is the sum of server-reported decode times, while
18
+ ``request_time_s`` is the sum of complete per-task request durations.
19
+ Their denominators intentionally differ: stream speed describes decoder
20
+ performance and effective concurrency describes client/server saturation.
21
+ """
22
+ return {
23
+ "tok_s_per_stream": (
24
+ output_tokens / generation_time_s if generation_time_s > 0 else 0.0
25
+ ),
26
+ "tok_s_aggregate": (output_tokens / wall_time_s if wall_time_s > 0 else 0.0),
27
+ "concurrency_eff": (request_time_s / wall_time_s if wall_time_s > 0 else 0.0),
28
+ }
29
+
30
+
31
+ def _number(value: object) -> float:
32
+ try:
33
+ return float(value)
34
+ except (TypeError, ValueError):
35
+ return 0.0
36
+
37
+
38
+ def stream_tok_s(result: Mapping[str, object]) -> float:
39
+ """Read stream throughput from new or legacy result data."""
40
+ if "tok_s_per_stream" in result:
41
+ return _number(result["tok_s_per_stream"])
42
+ return _number(result.get("tok_s"))
43
+
44
+
45
+ def effective_concurrency(result: Mapping[str, object]) -> float:
46
+ """Read or derive occupied request slots from a result."""
47
+ if "concurrency_eff" in result:
48
+ return _number(result["concurrency_eff"])
49
+ wall_time = _number(result.get("total_time"))
50
+ if wall_time <= 0:
51
+ return 0.0
52
+ derived = (
53
+ _number(result.get("avg_response_time"))
54
+ * _number(result.get("total"))
55
+ / wall_time
56
+ )
57
+ configured = _number(result.get("concurrency"))
58
+ return min(configured, derived) if configured > 0 else derived
59
+
60
+
61
+ def aggregate_tok_s(result: Mapping[str, object]) -> float:
62
+ """Read or derive wall-clock output throughput from a result."""
63
+ if "tok_s_aggregate" in result:
64
+ return _number(result["tok_s_aggregate"])
65
+
66
+ wall_time = _number(result.get("total_time"))
67
+ tasks = result.get("tasks")
68
+ if wall_time > 0 and isinstance(tasks, list):
69
+ token_values = [
70
+ _number(task.get("output_tokens"))
71
+ for task in tasks
72
+ if isinstance(task, Mapping) and "output_tokens" in task
73
+ ]
74
+ if token_values:
75
+ return sum(token_values) / wall_time
76
+
77
+ # Legacy CSV summaries do not contain tasks. Stream throughput multiplied
78
+ # by measured slot occupancy is the closest derivation available.
79
+ return stream_tok_s(result) * effective_concurrency(result)
80
+
81
+
82
+ def is_decision(result: Mapping[str, object]) -> bool:
83
+ """Whether a result came from a native decision model."""
84
+ return result.get("harness") == "decision"
85
+
86
+
87
+ def decision_speed(result: Mapping[str, object]) -> tuple[str, str]:
88
+ """Decision models emit no tokens, so speed is decisions/s plus latency."""
89
+ return (
90
+ f"{_number(result.get('decisions_per_s')):.1f} dec/s",
91
+ f"p50 {_number(result.get('decision_latency_p50_ms')):.0f} ms · "
92
+ f"p95 {_number(result.get('decision_latency_p95_ms')):.0f} ms",
93
+ )
94
+
95
+
96
+ def latency_text(result: Mapping[str, object]) -> str:
97
+ """Typical request time: median ms for decisions, mean seconds otherwise."""
98
+ if is_decision(result):
99
+ return f"{_number(result.get('decision_latency_p50_ms')):.0f}ms"
100
+ return f"{result.get('avg_response_time', 0)}s"
benchkit/ollama.py ADDED
@@ -0,0 +1,60 @@
1
+ """Ollama API client."""
2
+
3
+ import os
4
+
5
+ import httpx
6
+
7
+ DEFAULT_HOST = "http://localhost:11434"
8
+
9
+
10
+ def get_host() -> str:
11
+ return os.environ.get("OLLAMA_HOST", DEFAULT_HOST).rstrip("/")
12
+
13
+
14
+ def list_models(host: str) -> list[dict]:
15
+ r = httpx.get(f"{host}/api/tags", timeout=10)
16
+ r.raise_for_status()
17
+ models = r.json().get("models", [])
18
+ return sorted(models, key=lambda m: m.get("size", 0))
19
+
20
+
21
+ def unload_model(host: str, model: str) -> None:
22
+ """Evict model from Ollama VRAM by setting keep_alive to 0."""
23
+ httpx.post(
24
+ f"{host}/api/generate",
25
+ json={"model": model, "keep_alive": 0},
26
+ timeout=30,
27
+ )
28
+
29
+
30
+ def generate(host: str, model: str, prompt: str) -> dict:
31
+ r = httpx.post(
32
+ f"{host}/api/generate",
33
+ json={
34
+ "model": model,
35
+ "prompt": prompt,
36
+ "stream": False,
37
+ "options": {"temperature": 0.0},
38
+ },
39
+ timeout=300,
40
+ )
41
+ r.raise_for_status()
42
+ data = r.json()
43
+
44
+ response = data.get("response", "")
45
+ done_reason = data.get("done_reason", "")
46
+
47
+ eval_count = data.get("eval_count", 0)
48
+ eval_duration_ns = data.get("eval_duration", 0)
49
+ total_duration_ns = data.get("total_duration", 0)
50
+ tok_s = eval_count / (eval_duration_ns / 1e9) if eval_duration_ns > 0 else 0.0
51
+ response_time_s = total_duration_ns / 1e9
52
+
53
+ return {
54
+ "response": response,
55
+ "tok_s": tok_s,
56
+ "eval_count": eval_count,
57
+ "eval_duration_ns": eval_duration_ns,
58
+ "response_time_s": response_time_s,
59
+ "done_reason": done_reason,
60
+ }
benchkit/perf.py ADDED
@@ -0,0 +1,353 @@
1
+ """Controlled model-speed profiling for llama.cpp-compatible servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import platform
8
+ import statistics
9
+ from collections.abc import Callable
10
+ from dataclasses import asdict, dataclass
11
+ from datetime import datetime
12
+
13
+ from benchkit.client import InferenceClient
14
+
15
+ DEFAULT_DEPTHS = (0, 4096, 16384, 32768, 65536)
16
+ DEPTH_TOLERANCE = 0.01
17
+ DRIFT_TOLERANCE = 0.10
18
+ MIN_RELIABLE_PP_TOKENS = 256
19
+ FILLER = (
20
+ "A local inference server processes this deterministic BenchKit workload. "
21
+ "The passage is intentionally ordinary so tokenization remains stable "
22
+ "across repetitions while context depth changes. "
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PerfConfig:
28
+ """Inputs that make a performance profile reproducible."""
29
+
30
+ model: str
31
+ depths: tuple[int, ...] = DEFAULT_DEPTHS
32
+ gen_tokens: int = 128
33
+ reps: int = 5
34
+ warmups: int = 1
35
+ config_note: str = ""
36
+
37
+
38
+ def parse_depths(value: str) -> tuple[int, ...]:
39
+ """Parse comma-separated token depths such as ``minimal,4k,16k``."""
40
+ depths: list[int] = []
41
+ for raw in value.split(","):
42
+ item = raw.strip().lower()
43
+ if item in {"minimal", "min", "0"}:
44
+ depth = 0
45
+ else:
46
+ multiplier = 1
47
+ if item.endswith("k"):
48
+ multiplier, item = 1024, item[:-1]
49
+ elif item.endswith("m"):
50
+ multiplier, item = 1024 * 1024, item[:-1]
51
+ try:
52
+ depth = int(float(item) * multiplier)
53
+ except ValueError as exc:
54
+ raise ValueError(f"Invalid depth: {raw.strip()!r}") from exc
55
+ if depth < 1:
56
+ raise ValueError(f"Invalid depth: {raw.strip()!r}")
57
+ if depth not in depths:
58
+ depths.append(depth)
59
+ if not depths:
60
+ raise ValueError("At least one context depth is required")
61
+ return tuple(depths)
62
+
63
+
64
+ def depth_label(depth: int) -> str:
65
+ if depth == 0:
66
+ return "minimal"
67
+ if depth % 1024 == 0:
68
+ return f"{depth // 1024}k"
69
+ return f"{depth:,}"
70
+
71
+
72
+ def _filler_text(length: int) -> str:
73
+ repetitions = max(1, length // len(FILLER) + 1)
74
+ return (FILLER * repetitions)[:length]
75
+
76
+
77
+ def _prompt_for(
78
+ client: InferenceClient,
79
+ model: str,
80
+ target_depth: int,
81
+ announce: Callable[[str], None],
82
+ ) -> tuple[str | list[int], str, list[dict]]:
83
+ if target_depth == 0:
84
+ return (
85
+ "BenchKit speed profile. Continue with concise factual prose:",
86
+ "minimal",
87
+ [],
88
+ )
89
+
90
+ content = _filler_text(target_depth * 8)
91
+ tokens = client.tokenize(model, content)
92
+ if tokens is not None:
93
+ while len(tokens) < target_depth:
94
+ content += _filler_text(target_depth * 2)
95
+ tokens = client.tokenize(model, content)
96
+ if tokens is None:
97
+ break
98
+ if tokens is not None and len(tokens) >= target_depth:
99
+ return tokens[:target_depth], "tokenizer", []
100
+
101
+ # llama-swap commonly proxies the OpenAI routes but not llama.cpp's native
102
+ # tokenizer. Calibrate text length against the completion response instead
103
+ # of silently trusting a characters-per-token estimate.
104
+ prompt = _filler_text(target_depth * 5)
105
+ calibration: list[dict] = []
106
+ for attempt in range(3):
107
+ announce(f"{depth_label(target_depth)} · calibrating input {attempt + 1}/3")
108
+ try:
109
+ measured = client.profile_completion(model, prompt, 1)
110
+ except Exception as exc:
111
+ calibration.append(
112
+ {
113
+ "attempt": attempt + 1,
114
+ "characters": len(prompt),
115
+ "error": f"{type(exc).__name__}: {exc}",
116
+ }
117
+ )
118
+ break
119
+
120
+ actual = int(measured.get("input_tokens") or 0)
121
+ calibration.append(
122
+ {
123
+ "attempt": attempt + 1,
124
+ "characters": len(prompt),
125
+ "actual_input_tokens": actual,
126
+ }
127
+ )
128
+ if not actual:
129
+ break
130
+ error_ratio = abs(actual - target_depth) / target_depth
131
+ if error_ratio <= DEPTH_TOLERANCE:
132
+ return prompt, "calibrated", calibration
133
+ prompt = _filler_text(max(1, round(len(prompt) * target_depth / actual)))
134
+
135
+ calibrated = any(item.get("actual_input_tokens") for item in calibration)
136
+ return prompt, "calibrated" if calibrated else "estimated", calibration
137
+
138
+
139
+ def _summary(values: list[float]) -> dict[str, float]:
140
+ if not values:
141
+ return {"median": 0.0, "min": 0.0, "max": 0.0}
142
+ return {
143
+ "median": round(statistics.median(values), 3),
144
+ "min": round(min(values), 3),
145
+ "max": round(max(values), 3),
146
+ }
147
+
148
+
149
+ def _summarize_case(
150
+ requested_depth: int,
151
+ depth_method: str,
152
+ calibration: list[dict],
153
+ runs: list[dict],
154
+ errors: list[str],
155
+ ) -> dict:
156
+ successful = [run for run in runs if not run.get("error")]
157
+ actual_depths = [run["input_tokens"] for run in successful if run["input_tokens"]]
158
+ actual_depth = int(statistics.median(actual_depths)) if actual_depths else 0
159
+ depth_deviation = (
160
+ (actual_depth - requested_depth) / requested_depth
161
+ if requested_depth and actual_depth
162
+ else 0.0
163
+ )
164
+ return {
165
+ "depth": requested_depth,
166
+ "depth_label": depth_label(requested_depth),
167
+ "actual_input_tokens": actual_depth,
168
+ "depth_method": depth_method,
169
+ "exact_depth": depth_method == "tokenizer",
170
+ "depth_deviation": round(depth_deviation, 5),
171
+ "depth_within_tolerance": (
172
+ requested_depth == 0
173
+ or (actual_depth > 0 and abs(depth_deviation) <= DEPTH_TOLERANCE)
174
+ ),
175
+ "calibration": calibration,
176
+ "pp_reliable": actual_depth >= MIN_RELIABLE_PP_TOKENS,
177
+ "successful_reps": len(successful),
178
+ "errors": errors,
179
+ "pp_tps": _summary([run["pp_tps"] for run in successful]),
180
+ "tg_tps": _summary([run["tg_tps"] for run in successful]),
181
+ "ttft_s": _summary([run["ttft_s"] for run in successful]),
182
+ "wall_time_s": _summary([run["wall_time_s"] for run in successful]),
183
+ "wall_tg_tps": _summary([run["wall_tg_tps"] for run in successful]),
184
+ "overhead_available": any(
185
+ run.get("server_time_s", 0) > 0 for run in successful
186
+ ),
187
+ "overhead_s": _summary([run["overhead_s"] for run in successful]),
188
+ "output_tokens": _summary([float(run["output_tokens"]) for run in successful]),
189
+ "runs": runs,
190
+ }
191
+
192
+
193
+ def run_profile(
194
+ client: InferenceClient,
195
+ config: PerfConfig,
196
+ *,
197
+ model_meta: dict | None = None,
198
+ progress: Callable[[str], None] | None = None,
199
+ ) -> dict:
200
+ """Run a serial context-depth sweep and return a complete raw profile."""
201
+
202
+ def announce(message: str) -> None:
203
+ if progress is not None:
204
+ progress(message)
205
+
206
+ cases: list[dict] = []
207
+ startup: dict | None = None
208
+ first_prompt: str | list[int] | None = None
209
+
210
+ # Run this before tokenization because llama-swap may need to load the model
211
+ # even for its native tokenizer endpoint.
212
+ if config.warmups:
213
+ announce("First request · startup probe")
214
+ try:
215
+ measured = client.profile_completion(
216
+ config.model,
217
+ "BenchKit startup probe. Continue with concise factual prose:",
218
+ min(config.gen_tokens, 32),
219
+ )
220
+ startup = {
221
+ "ttft_s": round(measured["ttft_s"], 3),
222
+ "wall_time_s": round(measured["wall_time_s"], 3),
223
+ "label": "may include llama-swap model loading",
224
+ }
225
+ except Exception as exc:
226
+ startup = {
227
+ "error": f"{type(exc).__name__}: {exc}",
228
+ "label": "first request",
229
+ }
230
+
231
+ for depth in config.depths:
232
+ label = depth_label(depth)
233
+ announce(f"Preparing {label} context")
234
+ prompt, depth_method, calibration = _prompt_for(
235
+ client, config.model, depth, announce
236
+ )
237
+ if first_prompt is None:
238
+ first_prompt = prompt
239
+
240
+ for warmup in range(config.warmups):
241
+ announce(f"{label} · warmup {warmup + 1}/{config.warmups}")
242
+ with contextlib.suppress(Exception):
243
+ client.profile_completion(
244
+ config.model, prompt, min(config.gen_tokens, 32)
245
+ )
246
+
247
+ runs: list[dict] = []
248
+ errors: list[str] = []
249
+ for repetition in range(config.reps):
250
+ announce(f"{label} · run {repetition + 1}/{config.reps}")
251
+ try:
252
+ measured = client.profile_completion(
253
+ config.model, prompt, config.gen_tokens
254
+ )
255
+ runs.append(
256
+ {
257
+ "repetition": repetition + 1,
258
+ **{
259
+ key: round(value, 4) if isinstance(value, float) else value
260
+ for key, value in measured.items()
261
+ if key != "response"
262
+ },
263
+ }
264
+ )
265
+ except Exception as exc:
266
+ error = f"{type(exc).__name__}: {exc}"
267
+ errors.append(error)
268
+ runs.append({"repetition": repetition + 1, "error": error})
269
+
270
+ cases.append(
271
+ _summarize_case(
272
+ depth,
273
+ depth_method,
274
+ calibration,
275
+ runs,
276
+ errors,
277
+ )
278
+ )
279
+
280
+ drift_check: dict | None = None
281
+ if first_prompt is not None and cases:
282
+ announce(f"{cases[0]['depth_label']} · end-of-sweep drift check")
283
+ try:
284
+ measured = client.profile_completion(
285
+ config.model, first_prompt, config.gen_tokens
286
+ )
287
+ initial_tg = float(cases[0]["tg_tps"]["median"])
288
+ final_tg = float(measured["tg_tps"])
289
+ change = (final_tg - initial_tg) / initial_tg if initial_tg else 0.0
290
+ drift_check = {
291
+ "depth": cases[0]["depth"],
292
+ "depth_label": cases[0]["depth_label"],
293
+ "initial_tg_tps": round(initial_tg, 3),
294
+ "final_tg_tps": round(final_tg, 3),
295
+ "change": round(change, 5),
296
+ "stable": bool(initial_tg) and abs(change) <= DRIFT_TOLERANCE,
297
+ "tolerance": DRIFT_TOLERANCE,
298
+ }
299
+ except Exception as exc:
300
+ drift_check = {
301
+ "depth": cases[0]["depth"],
302
+ "depth_label": cases[0]["depth_label"],
303
+ "error": f"{type(exc).__name__}: {exc}",
304
+ "stable": False,
305
+ "tolerance": DRIFT_TOLERANCE,
306
+ }
307
+
308
+ tg_depth_medians = [
309
+ float(case["tg_tps"]["median"])
310
+ for case in cases
311
+ if case["successful_reps"] and case["tg_tps"]["median"] > 0
312
+ ]
313
+ pp_depth_medians = [
314
+ float(case["pp_tps"]["median"])
315
+ for case in cases
316
+ if (
317
+ case["successful_reps"]
318
+ and case["pp_reliable"]
319
+ and case["pp_tps"]["median"] > 0
320
+ )
321
+ ]
322
+ sweep_summary = {
323
+ "tg_tps_median": round(statistics.median(tg_depth_medians), 3)
324
+ if tg_depth_medians
325
+ else 0.0,
326
+ "pp_tps_median": round(statistics.median(pp_depth_medians), 3)
327
+ if pp_depth_medians
328
+ else 0.0,
329
+ "tg_depths": len(tg_depth_medians),
330
+ "pp_depths": len(pp_depth_medians),
331
+ }
332
+
333
+ return {
334
+ "kind": "benchkit-performance-profile",
335
+ "version": 1,
336
+ "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
337
+ "model": config.model,
338
+ "provider": client.label,
339
+ "host": client.host,
340
+ "environment": {
341
+ "hardware": os.environ.get("BENCHKIT_HARDWARE", ""),
342
+ "system": platform.system(),
343
+ "machine": platform.machine(),
344
+ "config_note": config.config_note
345
+ or os.environ.get("BENCHKIT_PERF_CONFIG", ""),
346
+ },
347
+ "model_metadata": model_meta or {},
348
+ "settings": asdict(config),
349
+ "startup": startup,
350
+ "drift_check": drift_check,
351
+ "sweep_summary": sweep_summary,
352
+ "cases": cases,
353
+ }