quantcost 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.
edgellm/submit.py ADDED
@@ -0,0 +1,225 @@
1
+ """Open a pull request that adds a result card to the public leaderboard.
2
+
3
+ Submission friction is the whole ballgame. A contributor who has just run a
4
+ benchmark will not fork a repo, clone it, copy a JSON file into the right folder,
5
+ commit, push and open a PR by hand — they will close the terminal. So this does
6
+ all of it with ``gh``, and when ``gh`` is unavailable it degrades to printing the
7
+ exact file and a prefilled URL rather than failing.
8
+
9
+ Nothing is pushed without validating the card first: a submission that CI would
10
+ reject should fail on the contributor's machine, where the error is actionable,
11
+ not twenty minutes later in a workflow log.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+
23
+ UPSTREAM = "vijay-kapse/EdgeLLM"
24
+ COMMUNITY_PATH = "results/community"
25
+ DEFAULT_BRANCH = "main"
26
+
27
+
28
+ class SubmitError(RuntimeError):
29
+ """Submission could not proceed."""
30
+
31
+
32
+ def _run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
33
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
34
+
35
+
36
+ def _gh_available() -> bool:
37
+ if shutil.which("gh") is None:
38
+ return False
39
+ return _run(["gh", "auth", "status"]).returncode == 0
40
+
41
+
42
+ def _manual_instructions(card_path: Path, card: dict) -> str:
43
+ return "\n".join(
44
+ [
45
+ "",
46
+ "GitHub CLI is not available or not signed in, so the PR was not opened for you.",
47
+ "Two ways to finish — both take under a minute:",
48
+ "",
49
+ " 1. Install and sign in, then re-run:",
50
+ " brew install gh && gh auth login && quantcost submit",
51
+ "",
52
+ f" 2. Or add the file by hand at https://github.com/{UPSTREAM}/new/{DEFAULT_BRANCH}"
53
+ f"?filename={COMMUNITY_PATH}/{card_path.name}",
54
+ " and paste the contents of:",
55
+ f" {card_path}",
56
+ "",
57
+ f"Either way the card describes: {card['machine']['cpu']} · "
58
+ f"{card['model_id']} · {len(card['rows'])} precisions.",
59
+ "",
60
+ ]
61
+ )
62
+
63
+
64
+ def _pr_body(card: dict) -> str:
65
+ machine = card["machine"]
66
+ rows = card["rows"]
67
+ by_precision = {r["precision"]: r for r in rows}
68
+ base = by_precision.get("fp32")
69
+
70
+ lines = [
71
+ "Adding a benchmark result card from my machine.",
72
+ "",
73
+ f"- **CPU**: {machine['cpu']} ({machine['arch']}), "
74
+ f"{machine['physical_cores']} physical cores, {machine['ram_gb']:.0f} GB RAM",
75
+ f"- **OS**: {machine['os']} {machine['os_release']}",
76
+ f"- **Runtime**: onnxruntime {machine['onnxruntime']}, {machine['provider']}, "
77
+ f"{machine['intra_op_threads']} threads",
78
+ f"- **Model**: `{card['model_id']}` @ `{card['revision']}`",
79
+ f"- **Tool**: quantcost {card['tool_version']}",
80
+ "",
81
+ "| Precision | Size (MB) | tok/s | Speedup vs fp32 | Peak RAM (MB) | Perplexity |",
82
+ "| --- | --- | --- | --- | --- | --- |",
83
+ ]
84
+ for row in rows:
85
+ if row["precision"] == "fp32":
86
+ speed = "baseline"
87
+ elif base and base["tokens_per_second"]:
88
+ speed = f"{row['tokens_per_second'] / base['tokens_per_second']:.2f}x"
89
+ else:
90
+ speed = "—"
91
+ ppl = f"{row['perplexity']:.2f}" if row.get("perplexity") else "—"
92
+ lines.append(
93
+ f"| {row['precision']} | {row['size_mb']:.0f} | {row['tokens_per_second']:.2f} | "
94
+ f"{speed} | {row['peak_ram_mb']:.0f} | {ppl} |"
95
+ )
96
+
97
+ if card.get("notes"):
98
+ lines += ["", f"**Notes**: {card['notes']}"]
99
+
100
+ lines += [
101
+ "",
102
+ "---",
103
+ "",
104
+ "Generated by `quantcost submit`. The card was validated locally "
105
+ "before this PR was opened.",
106
+ ]
107
+ return "\n".join(lines)
108
+
109
+
110
+ def submit_card(
111
+ card_path: Path,
112
+ *,
113
+ name: str = "",
114
+ notes: str = "",
115
+ dry_run: bool = False,
116
+ echo: Callable[[str], None] = print,
117
+ ) -> str | None:
118
+ """Validate ``card_path``, then fork-and-PR it upstream. Returns the PR URL."""
119
+ from edgellm.validate import validate_card
120
+
121
+ card = json.loads(card_path.read_text())
122
+ if name:
123
+ card["submitted_by"] = name
124
+ if notes:
125
+ card["notes"] = notes
126
+ if name or notes:
127
+ card_path.write_text(json.dumps(card, indent=2, sort_keys=True) + "\n")
128
+
129
+ report = validate_card(card_path)
130
+ echo(report.render())
131
+ if not report.ok:
132
+ raise SubmitError(
133
+ "This card would be rejected by CI, so it was not submitted. "
134
+ "Fix the errors above (usually by re-running the benchmark) and try again."
135
+ )
136
+
137
+ if dry_run:
138
+ echo("\n--- card (dry run, nothing pushed) ---")
139
+ echo(card_path.read_text())
140
+ echo(f"Would open a PR against {UPSTREAM} adding {COMMUNITY_PATH}/{card_path.name}")
141
+ return None
142
+
143
+ if not _gh_available():
144
+ echo(_manual_instructions(card_path, card))
145
+ return None
146
+
147
+ slug = card_path.stem
148
+ branch = f"result/{slug}"
149
+ title = f"Results: {card['machine']['cpu']} ({card['machine']['os']}) — {card['model_id']}"
150
+
151
+ with tempfile.TemporaryDirectory() as tmp:
152
+ workdir = Path(tmp) / "EdgeLLM"
153
+ echo(f"\nForking and cloning {UPSTREAM} ...")
154
+
155
+ # `gh repo fork --clone` is idempotent: it reuses an existing fork.
156
+ forked = _run(["gh", "repo", "fork", UPSTREAM, "--clone", "--remote=false", str(workdir)])
157
+ if forked.returncode != 0:
158
+ raise SubmitError(f"could not fork/clone {UPSTREAM}:\n{forked.stderr.strip()}")
159
+
160
+ target = workdir / COMMUNITY_PATH
161
+ target.mkdir(parents=True, exist_ok=True)
162
+ shutil.copy2(card_path, target / card_path.name)
163
+
164
+ for cmd in (
165
+ ["git", "checkout", "-B", branch],
166
+ ["git", "add", f"{COMMUNITY_PATH}/{card_path.name}"],
167
+ ):
168
+ result = _run(cmd, cwd=workdir)
169
+ if result.returncode != 0:
170
+ raise SubmitError(f"`{' '.join(cmd)}` failed:\n{result.stderr.strip()}")
171
+
172
+ staged = _run(["git", "diff", "--cached", "--name-only"], cwd=workdir)
173
+ if not staged.stdout.strip():
174
+ echo(
175
+ "This exact card is already on the leaderboard — nothing to submit. "
176
+ "Re-run the benchmark if you changed something."
177
+ )
178
+ return None
179
+
180
+ commit = _run(
181
+ ["git", "commit", "-m", f"Add benchmark results for {card['machine']['cpu']}"],
182
+ cwd=workdir,
183
+ )
184
+ if commit.returncode != 0:
185
+ raise SubmitError(f"commit failed:\n{commit.stderr.strip()}")
186
+
187
+ pushed = _run(["git", "push", "-u", "origin", branch, "--force"], cwd=workdir)
188
+ if pushed.returncode != 0:
189
+ raise SubmitError(f"push failed:\n{pushed.stderr.strip()}")
190
+
191
+ pr = _run(
192
+ [
193
+ "gh",
194
+ "pr",
195
+ "create",
196
+ "--repo",
197
+ UPSTREAM,
198
+ "--base",
199
+ DEFAULT_BRANCH,
200
+ "--title",
201
+ title,
202
+ "--body",
203
+ _pr_body(card),
204
+ ],
205
+ cwd=workdir,
206
+ )
207
+ if pr.returncode != 0:
208
+ # An open PR from this branch already exists — surface it instead of erroring.
209
+ existing = _run(
210
+ ["gh", "pr", "list", "--repo", UPSTREAM, "--head", branch, "--json", "url"],
211
+ cwd=workdir,
212
+ )
213
+ try:
214
+ urls = json.loads(existing.stdout or "[]")
215
+ if urls:
216
+ echo(f"\nYour existing PR was updated: {urls[0]['url']}")
217
+ return urls[0]["url"]
218
+ except json.JSONDecodeError:
219
+ pass
220
+ raise SubmitError(f"could not open the PR:\n{pr.stderr.strip()}")
221
+
222
+ url = pr.stdout.strip().splitlines()[-1] if pr.stdout.strip() else ""
223
+ echo(f"\nSubmitted: {url}")
224
+ echo("Thanks — once it merges, your machine appears on the leaderboard.")
225
+ return url
edgellm/sweep.py ADDED
@@ -0,0 +1,309 @@
1
+ """Measure every requested precision and assemble a result card.
2
+
3
+ **Each precision is measured in a fresh subprocess.** That is not incidental.
4
+ Peak RSS is a per-process high-water mark, and an ONNX Runtime session does not
5
+ hand all of its arenas back when it is dropped, so measuring several precisions
6
+ in one process reports the *union* of their footprints and attributes it to
7
+ whichever ran last. Isolating each one is the only way the peak-RAM column means
8
+ what it says. It also contains failures: a precision that OOMs or has no
9
+ published artifact is recorded as an error row instead of ending the sweep.
10
+
11
+ Run ``python -m edgellm.sweep --model ... --precision int8`` to execute the
12
+ single-precision worker directly; it prints one JSON object on stdout.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import statistics
21
+ import subprocess
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from edgellm import __version__
27
+ from edgellm.card import (
28
+ CARD_SCHEMA_VERSION,
29
+ Fingerprint,
30
+ PrecisionRow,
31
+ ResultCard,
32
+ fingerprint,
33
+ now_utc_iso,
34
+ )
35
+ from edgellm.config import GenerationConfig
36
+ from edgellm.eval_lite import DEFAULT_EVAL_WINDOWS, EVAL_CORPUS_SHA256, evaluate_perplexity
37
+ from edgellm.hub import DEFAULT_PRECISIONS, ModelResolutionError, resolve
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ #: The fixed prompt every run decodes from. Changing it changes the latency
42
+ #: numbers (prefill cost scales with its length), so its hash goes in the card.
43
+ BENCH_PROMPT = "Explain what neural network quantization is, in one short paragraph."
44
+
45
+ DEFAULT_WARMUP_RUNS = 2
46
+ DEFAULT_MEASURED_RUNS = 5
47
+ DEFAULT_GEN_TOKENS = 32
48
+
49
+ #: Above this ratio of latency std-dev to mean, the machine was too busy for the
50
+ #: number to mean much, and the report says so instead of quietly reporting it.
51
+ UNSTABLE_LATENCY_RATIO = 0.15
52
+
53
+
54
+ def warm_file_cache(path: Path, *, chunk_bytes: int = 8 << 20) -> None:
55
+ """Read ``path`` once so the OS page cache is warm before anything is timed.
56
+
57
+ ONNX Runtime memory-maps weights and faults them in lazily, so the first
58
+ pass over a freshly downloaded multi-hundred-megabyte model pays disk I/O
59
+ *and* competes with the writeback still flushing the download. Measured
60
+ cold, an fp32 baseline came in at 17 tok/s; warm, the same machine and
61
+ build measured 63-72 tok/s. That is a 4x error on the number every other
62
+ row is compared against, so the cache is warmed deliberately rather than
63
+ left to whatever state the run happens to start in.
64
+ """
65
+ with open(path, "rb") as handle:
66
+ while handle.read(chunk_bytes):
67
+ pass
68
+
69
+
70
+ def prompt_sha256(prompt: str = BENCH_PROMPT) -> str:
71
+ return hashlib.sha256(prompt.encode("utf-8")).hexdigest()
72
+
73
+
74
+ @dataclass
75
+ class SweepSettings:
76
+ """Everything that affects the numbers, in one place, so it can be recorded."""
77
+
78
+ model_id: str
79
+ precisions: tuple[str, ...] = DEFAULT_PRECISIONS
80
+ revision: str = "main"
81
+ provider: str = "CPUExecutionProvider"
82
+ intra_op_threads: int | None = None
83
+ warmup_runs: int = DEFAULT_WARMUP_RUNS
84
+ measured_runs: int = DEFAULT_MEASURED_RUNS
85
+ gen_tokens: int = DEFAULT_GEN_TOKENS
86
+ eval_windows: int = DEFAULT_EVAL_WINDOWS
87
+ skip_perplexity: bool = False
88
+
89
+
90
+ # --------------------------------------------------------------------- worker
91
+
92
+
93
+ def measure_one(precision: str, settings: SweepSettings) -> dict:
94
+ """Measure a single precision in this process. Returns a serializable dict."""
95
+ from edgellm.benchmark import _PeakRSSSampler
96
+ from edgellm.ort_lite import OnnxLiteRunner
97
+
98
+ resolved = resolve(
99
+ settings.model_id,
100
+ precision,
101
+ revision=settings.revision,
102
+ )
103
+ warm_file_cache(resolved.onnx_path)
104
+ runner = OnnxLiteRunner(
105
+ str(resolved.onnx_path),
106
+ str(resolved.tokenizer_path),
107
+ provider=settings.provider,
108
+ name=f"ort-{precision}",
109
+ intra_op_threads=settings.intra_op_threads,
110
+ )
111
+
112
+ generation = GenerationConfig(max_new_tokens=settings.gen_tokens, do_sample=False)
113
+
114
+ # Perplexity runs inside the RSS window on purpose: a full 512-token forward
115
+ # pass is usually the memory high-water mark of the whole run, and hiding it
116
+ # would understate what the model actually needs.
117
+ perplexity = None
118
+ with _PeakRSSSampler() as sampler:
119
+ for _ in range(settings.warmup_runs):
120
+ runner.generate(BENCH_PROMPT, generation)
121
+
122
+ latencies, generated = [], 0
123
+ for _ in range(settings.measured_runs):
124
+ result = runner.generate(BENCH_PROMPT, generation)
125
+ latencies.append(result.latency_s)
126
+ generated = result.generated_tokens
127
+
128
+ if not settings.skip_perplexity:
129
+ perplexity = evaluate_perplexity(
130
+ runner, runner.tokenizer, windows=settings.eval_windows
131
+ ).perplexity
132
+
133
+ latency_mean = statistics.fmean(latencies)
134
+
135
+ # Throughput is total tokens over total time, i.e. derived from latency_s_mean,
136
+ # not the mean of each run's own tok/s. Averaging per-run rates computes a
137
+ # different statistic (mean of ratios, not ratio of means) and the two disagree
138
+ # by a few percent, which would make the two columns of every card inconsistent.
139
+ row = PrecisionRow(
140
+ precision=precision,
141
+ size_mb=resolved.size_mb,
142
+ latency_s_mean=round(latency_mean, 4),
143
+ latency_s_std=round(statistics.pstdev(latencies), 4) if len(latencies) > 1 else 0.0,
144
+ tokens_per_second=round(generated / latency_mean, 2),
145
+ peak_ram_mb=round(sampler.peak_mb, 1),
146
+ perplexity=perplexity,
147
+ generated_tokens=generated,
148
+ measured_runs=settings.measured_runs,
149
+ )
150
+ runner.close()
151
+ return {
152
+ "row": row.as_dict(),
153
+ "threads": runner.intra_op_threads,
154
+ "thread_policy": runner.thread_policy,
155
+ }
156
+
157
+
158
+ # ---------------------------------------------------------------- orchestrator
159
+
160
+
161
+ def _worker_command(precision: str, settings: SweepSettings) -> list[str]:
162
+ cmd = [
163
+ sys.executable,
164
+ "-m",
165
+ "edgellm.sweep",
166
+ "--model",
167
+ settings.model_id,
168
+ "--precision",
169
+ precision,
170
+ "--revision",
171
+ settings.revision,
172
+ "--provider",
173
+ settings.provider,
174
+ "--warmup-runs",
175
+ str(settings.warmup_runs),
176
+ "--measured-runs",
177
+ str(settings.measured_runs),
178
+ "--gen-tokens",
179
+ str(settings.gen_tokens),
180
+ "--eval-windows",
181
+ str(settings.eval_windows),
182
+ ]
183
+ if settings.intra_op_threads is not None:
184
+ cmd += ["--intra-op-threads", str(settings.intra_op_threads)]
185
+ if settings.skip_perplexity:
186
+ cmd.append("--skip-perplexity")
187
+ return cmd
188
+
189
+
190
+ def run_sweep(
191
+ settings: SweepSettings,
192
+ *,
193
+ on_progress=None,
194
+ isolate: bool = True,
195
+ ) -> tuple[ResultCard, dict[str, str]]:
196
+ """Measure every precision and build a card.
197
+
198
+ Returns ``(card, errors)`` where ``errors`` maps a precision to why it was
199
+ skipped. A partial card is a valid result: "this model publishes no q4" is
200
+ ordinary, and the rows that did run are still worth reporting.
201
+ """
202
+ rows: list[PrecisionRow] = []
203
+ errors: dict[str, str] = {}
204
+ threads_used: int | None = settings.intra_op_threads
205
+ thread_policy = "explicit" if settings.intra_op_threads is not None else "unknown"
206
+
207
+ for precision in settings.precisions:
208
+ if on_progress:
209
+ on_progress(precision, "start")
210
+ try:
211
+ if isolate:
212
+ completed = subprocess.run(
213
+ _worker_command(precision, settings),
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=3600,
217
+ )
218
+ if completed.returncode != 0:
219
+ tail = (completed.stderr or "").strip().splitlines()
220
+ errors[precision] = tail[-1] if tail else f"exit {completed.returncode}"
221
+ if on_progress:
222
+ on_progress(precision, "fail")
223
+ continue
224
+ payload = json.loads(completed.stdout.strip().splitlines()[-1])
225
+ else:
226
+ payload = measure_one(precision, settings)
227
+
228
+ rows.append(PrecisionRow(**payload["row"]))
229
+ threads_used = payload.get("threads", threads_used)
230
+ thread_policy = payload.get("thread_policy", thread_policy)
231
+ if on_progress:
232
+ on_progress(precision, "ok")
233
+
234
+ except ModelResolutionError as exc:
235
+ errors[precision] = str(exc)
236
+ if on_progress:
237
+ on_progress(precision, "fail")
238
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
239
+ errors[precision] = f"{type(exc).__name__}: {exc}"
240
+ if on_progress:
241
+ on_progress(precision, "fail")
242
+
243
+ machine: Fingerprint = fingerprint(
244
+ provider=settings.provider,
245
+ intra_op_threads=threads_used or 0,
246
+ thread_policy=thread_policy,
247
+ )
248
+ card = ResultCard(
249
+ schema_version=CARD_SCHEMA_VERSION,
250
+ model_id=settings.model_id,
251
+ revision=settings.revision,
252
+ created_utc=now_utc_iso(),
253
+ tool_version=__version__,
254
+ machine=machine,
255
+ rows=rows,
256
+ eval_windows=0 if settings.skip_perplexity else settings.eval_windows,
257
+ eval_corpus_sha256="" if settings.skip_perplexity else EVAL_CORPUS_SHA256,
258
+ warmup_runs=settings.warmup_runs,
259
+ gen_tokens=settings.gen_tokens,
260
+ prompt_sha256=prompt_sha256(),
261
+ )
262
+ return card, errors
263
+
264
+
265
+ def save_card(card: ResultCard, directory: Path) -> Path:
266
+ """Write the card as pretty JSON under ``directory``, named for the machine."""
267
+ directory.mkdir(parents=True, exist_ok=True)
268
+ path = directory / card.filename
269
+ path.write_text(json.dumps(card.as_dict(), indent=2, sort_keys=True) + "\n")
270
+ return path
271
+
272
+
273
+ def _main(argv: list[str] | None = None) -> int:
274
+ """Single-precision worker: measure one precision, print one JSON object."""
275
+ import argparse
276
+
277
+ parser = argparse.ArgumentParser(prog="python -m edgellm.sweep")
278
+ parser.add_argument("--model", required=True)
279
+ parser.add_argument("--precision", required=True)
280
+ parser.add_argument("--revision", default="main")
281
+ parser.add_argument("--provider", default="CPUExecutionProvider")
282
+ parser.add_argument("--intra-op-threads", type=int, default=None)
283
+ parser.add_argument("--warmup-runs", type=int, default=DEFAULT_WARMUP_RUNS)
284
+ parser.add_argument("--measured-runs", type=int, default=DEFAULT_MEASURED_RUNS)
285
+ parser.add_argument("--gen-tokens", type=int, default=DEFAULT_GEN_TOKENS)
286
+ parser.add_argument("--eval-windows", type=int, default=DEFAULT_EVAL_WINDOWS)
287
+ parser.add_argument("--skip-perplexity", action="store_true")
288
+ args = parser.parse_args(argv)
289
+
290
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
291
+
292
+ settings = SweepSettings(
293
+ model_id=args.model,
294
+ precisions=(args.precision,),
295
+ revision=args.revision,
296
+ provider=args.provider,
297
+ intra_op_threads=args.intra_op_threads,
298
+ warmup_runs=args.warmup_runs,
299
+ measured_runs=args.measured_runs,
300
+ gen_tokens=args.gen_tokens,
301
+ eval_windows=args.eval_windows,
302
+ skip_perplexity=args.skip_perplexity,
303
+ )
304
+ print(json.dumps(measure_one(args.precision, settings)))
305
+ return 0
306
+
307
+
308
+ if __name__ == "__main__":
309
+ raise SystemExit(_main())