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/__init__.py +7 -0
- edgellm/base.py +44 -0
- edgellm/benchmark.py +226 -0
- edgellm/card.py +234 -0
- edgellm/cli.py +370 -0
- edgellm/cli_bench.py +195 -0
- edgellm/config.py +120 -0
- edgellm/data/SOURCE.md +17 -0
- edgellm/data/eval_wikitext2.txt +205 -0
- edgellm/eval_lite.py +128 -0
- edgellm/export.py +65 -0
- edgellm/hub.py +125 -0
- edgellm/leaderboard.py +210 -0
- edgellm/models.py +91 -0
- edgellm/ort_lite.py +236 -0
- edgellm/quantize.py +134 -0
- edgellm/render.py +164 -0
- edgellm/report.py +53 -0
- edgellm/runners.py +170 -0
- edgellm/submit.py +225 -0
- edgellm/sweep.py +309 -0
- edgellm/validate.py +274 -0
- quantcost-0.2.0.dist-info/METADATA +265 -0
- quantcost-0.2.0.dist-info/RECORD +27 -0
- quantcost-0.2.0.dist-info/WHEEL +4 -0
- quantcost-0.2.0.dist-info/entry_points.txt +3 -0
- quantcost-0.2.0.dist-info/licenses/LICENSE +21 -0
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())
|