chipwright 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.
- chipwright/__init__.py +11 -0
- chipwright/adapt.py +57 -0
- chipwright/build.py +329 -0
- chipwright/cache.py +65 -0
- chipwright/cli.py +242 -0
- chipwright/config.py +106 -0
- chipwright/data/behavior_prompts.yaml +108 -0
- chipwright/data/index.yaml +327 -0
- chipwright/device.py +143 -0
- chipwright/device_adb.py +121 -0
- chipwright/env.py +229 -0
- chipwright/errors.py +41 -0
- chipwright/llm_reference.py +114 -0
- chipwright/modalities/__init__.py +3 -0
- chipwright/modalities/asr_encoder.py +90 -0
- chipwright/modalities/cv.py +34 -0
- chipwright/modalities/llm.py +300 -0
- chipwright/recipe.py +205 -0
- chipwright/registry.py +65 -0
- chipwright/resolver.py +197 -0
- chipwright/run.py +173 -0
- chipwright/sdk.py +140 -0
- chipwright/tags.py +56 -0
- chipwright/task_eval.py +190 -0
- chipwright/tests/conftest.py +12 -0
- chipwright/tests/test_adapt.py +61 -0
- chipwright/tests/test_build.py +279 -0
- chipwright/tests/test_cache.py +147 -0
- chipwright/tests/test_device_adb.py +140 -0
- chipwright/tests/test_env.py +73 -0
- chipwright/tests/test_env_adb.py +120 -0
- chipwright/tests/test_llm_modality.py +259 -0
- chipwright/tests/test_packaging.py +147 -0
- chipwright/tests/test_precision_support.py +128 -0
- chipwright/tests/test_registry.py +107 -0
- chipwright/tests/test_resolver.py +93 -0
- chipwright/tests/test_run_llm.py +47 -0
- chipwright/tests/test_tags.py +95 -0
- chipwright/tests/test_task_eval.py +164 -0
- chipwright/tests/test_verify.py +92 -0
- chipwright/tests/test_verify_llm.py +244 -0
- chipwright/verify.py +24 -0
- chipwright/verify_llm.py +203 -0
- chipwright-0.2.0.dist-info/METADATA +268 -0
- chipwright-0.2.0.dist-info/RECORD +49 -0
- chipwright-0.2.0.dist-info/WHEEL +5 -0
- chipwright-0.2.0.dist-info/entry_points.txt +2 -0
- chipwright-0.2.0.dist-info/licenses/LICENSE +21 -0
- chipwright-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""LLM modality runner — the Genie driver (a slice of what a `chipwright-llm` package would own).
|
|
2
|
+
|
|
3
|
+
Unlike the CV/ASR runners, which diff one NPU tensor against a CPU reference in the same call, the LLM
|
|
4
|
+
runner *generates*: it assembles the Genie bundle on the device, applies the model's chat template,
|
|
5
|
+
runs `genie-t2t-run`, and parses back the generated text, then RETOKENIZES that text into token ids
|
|
6
|
+
(QAIRT 2.51 `genie-t2t-run` emits TEXT, not ids — there is no `--print-token-ids`), plus the optional
|
|
7
|
+
`--profile` latency JSON. The verification against the pinned reference happens in `verify_llm.py` (the
|
|
8
|
+
CPU reference runs on the build host, not here — FR-L10), so this runner's job is to produce an honest
|
|
9
|
+
on-chip generation.
|
|
10
|
+
|
|
11
|
+
Driver facts confirmed on real SM8850 / HTP v81 (QAIRT 2.51), which this runner is written to match:
|
|
12
|
+
- `genie-t2t-run` takes the prompt via `--prompt_file <path>`, NOT an inline `--prompt` of the wrapped
|
|
13
|
+
ChatML (which carries newlines and |<> markers that fight shell quoting).
|
|
14
|
+
- It has NO `--print-token-ids` and NO `--max-length`; both were removed — passing a non-existent flag
|
|
15
|
+
makes genie error out and the gate false-FAIL a good artifact.
|
|
16
|
+
- Deterministic greedy is `sampler.top-k: 1` in the config; `sampler.greedy: true` is silently ignored.
|
|
17
|
+
|
|
18
|
+
Driven entirely over the device interface (`push_file` / `shell` / `pull` / `exists`) so it is
|
|
19
|
+
transport-agnostic (adb for Android targets); it does not import a concrete transport. The chat-template
|
|
20
|
+
wrap and the profile-JSON parse are pulled out as pure functions so they are unit-testable without a
|
|
21
|
+
device — the two places a silent bug (a mis-wrapped prompt makes an instruct model echo/loop) hides.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- chat template (pure; the modality wraps, the caller passes raw text) -------------------------
|
|
30
|
+
|
|
31
|
+
def apply_chat_template(prompt: str, template: str = "chatml", system: str | None = None) -> str:
|
|
32
|
+
"""Wrap raw user text in the model's chat template. The caller passes raw text; the modality wraps.
|
|
33
|
+
|
|
34
|
+
Instruct models need their chat template or they echo/loop (ChatML for Qwen). Supported templates:
|
|
35
|
+
- "chatml" (Qwen/Qwen2.5): <|im_start|>role\\n...<|im_end|> turns, ending with an open assistant
|
|
36
|
+
turn so generation continues the assistant message.
|
|
37
|
+
- "raw": passthrough (base/non-instruct models).
|
|
38
|
+
An unknown template name is refused by name rather than silently passed through — a mis-wrapped
|
|
39
|
+
prompt is exactly the silent failure this function exists to prevent.
|
|
40
|
+
"""
|
|
41
|
+
t = (template or "raw").lower()
|
|
42
|
+
if t == "raw":
|
|
43
|
+
return prompt
|
|
44
|
+
if t == "chatml":
|
|
45
|
+
parts = []
|
|
46
|
+
if system:
|
|
47
|
+
parts.append(f"<|im_start|>system\n{system}<|im_end|>")
|
|
48
|
+
parts.append(f"<|im_start|>user\n{prompt}<|im_end|>")
|
|
49
|
+
parts.append("<|im_start|>assistant\n") # open assistant turn — generation continues it
|
|
50
|
+
return "\n".join(parts)
|
|
51
|
+
raise ValueError(f"unknown chat template {template!r} (known: chatml, raw)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- Genie --profile JSON parse (pure) ------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def parse_profile(profile_json: str) -> dict:
|
|
57
|
+
"""Parse the `genie-t2t-run --profile` JSON into the record's latency fields.
|
|
58
|
+
|
|
59
|
+
Genie's profiler reports init/prompt-processing/token-generation rates and times. Field names vary
|
|
60
|
+
across Genie builds, so each metric is looked up over a set of aliases and missing ones come back
|
|
61
|
+
None (shown, not hidden) rather than raising — an absent number is a true state of the run.
|
|
62
|
+
|
|
63
|
+
Returns {decode_toks_per_sec, prefill_toks_per_sec, time_to_first_token_ms, model_load_s}.
|
|
64
|
+
"""
|
|
65
|
+
data = json.loads(profile_json) if isinstance(profile_json, str) else profile_json
|
|
66
|
+
flat = _flatten(data)
|
|
67
|
+
|
|
68
|
+
def pick(*aliases):
|
|
69
|
+
for a in aliases:
|
|
70
|
+
for k, v in flat.items():
|
|
71
|
+
kl = k.lower()
|
|
72
|
+
if all(tok in kl for tok in a) and isinstance(v, (int, float)):
|
|
73
|
+
return float(v)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"decode_toks_per_sec": pick(("token", "generation", "rate"), ("decode", "rate"),
|
|
78
|
+
("token", "per", "sec")),
|
|
79
|
+
"prefill_toks_per_sec": pick(("prompt", "processing", "rate"), ("prefill", "rate"),
|
|
80
|
+
("prompt", "rate")),
|
|
81
|
+
"time_to_first_token_ms": pick(("time", "first", "token"), ("ttft",), ("first", "token", "ms")),
|
|
82
|
+
"model_load_s": pick(("init", "time"), ("load", "time"), ("model", "load")),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _flatten(obj, prefix=""):
|
|
87
|
+
"""Flatten a nested dict/list into {dotted.key: scalar} so alias lookup is order-independent."""
|
|
88
|
+
out = {}
|
|
89
|
+
if isinstance(obj, dict):
|
|
90
|
+
for k, v in obj.items():
|
|
91
|
+
out.update(_flatten(v, f"{prefix}.{k}" if prefix else str(k)))
|
|
92
|
+
elif isinstance(obj, list):
|
|
93
|
+
for i, v in enumerate(obj):
|
|
94
|
+
out.update(_flatten(v, f"{prefix}.{i}"))
|
|
95
|
+
else:
|
|
96
|
+
out[prefix] = obj
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- generated-text / token-ids parse (pure) ------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def parse_generation(stdout: str) -> dict:
|
|
103
|
+
"""Pull the generated text out of `genie-t2t-run` stdout (QAIRT 2.51 emits TEXT, not token ids).
|
|
104
|
+
|
|
105
|
+
Genie prints the generated text after a `[BEGIN]` marker (with an optional `:`). On a clean run it
|
|
106
|
+
closes with `[END]`; but w4a16 degenerates and never emits EOS, so on the real board the run is
|
|
107
|
+
bounded by `timeout` and gets killed mid-generation — the `[END]` is then absent. So the text is
|
|
108
|
+
matched up to `[END]` *or* the end of stdout (`\\Z`), never requiring `[END]`, or a correct-but-long
|
|
109
|
+
generation would be dropped as if empty.
|
|
110
|
+
|
|
111
|
+
`tokens` is still parsed best-effort for any genie build that prints a bracketed id trace, but
|
|
112
|
+
QAIRT 2.51 `genie-t2t-run` has NO `--print-token-ids` flag, so it is normally None here and the
|
|
113
|
+
real token ids come from retokenizing `text` (see `retokenize`).
|
|
114
|
+
"""
|
|
115
|
+
text = None
|
|
116
|
+
m = re.search(r"\[BEGIN\]:?(.*?)(\[END\]|\Z)", stdout, re.DOTALL)
|
|
117
|
+
if m:
|
|
118
|
+
text = m.group(1).strip()
|
|
119
|
+
tokens = None
|
|
120
|
+
tm = re.search(r"token[_ ]ids?\s*[:=]\s*\[([\d,\s]+)\]", stdout, re.IGNORECASE)
|
|
121
|
+
if tm:
|
|
122
|
+
tokens = [int(x) for x in tm.group(1).replace(" ", "").split(",") if x != ""]
|
|
123
|
+
return {"text": text, "tokens": tokens}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- retokenize generated text -> token ids (genie gives no ids on QAIRT 2.51) --------------------
|
|
127
|
+
|
|
128
|
+
def load_bundle_tokenizer(path: str):
|
|
129
|
+
"""Load the bundle's `tokenizer.json` with the lightweight HuggingFace `tokenizers` library.
|
|
130
|
+
|
|
131
|
+
QAIRT 2.51 `genie-t2t-run` emits generated TEXT, not token ids (no `--print-token-ids`), but the
|
|
132
|
+
Layer-1 tripwire (`verify_llm.token_match`) compares TOKEN IDS. So the modality must turn genie's
|
|
133
|
+
text back into ids with the SAME tokenizer the bundle shipped — the `tokenizer.json` genie itself
|
|
134
|
+
decoded with — or the ids won't line up with the pinned reference.
|
|
135
|
+
|
|
136
|
+
If `tokenizers` is not importable, the modality FAILS BY NAME here (like the sshpass/adb preflights)
|
|
137
|
+
rather than silently returning no tokens — a missing retokenizer would otherwise look like an
|
|
138
|
+
all-mismatch FAIL, the exact false-FAIL this module exists to avoid.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
from tokenizers import Tokenizer
|
|
142
|
+
except ImportError as e: # fail by name, not silently — mirrors the adb/sshpass preflights
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"llm modality: the 'tokenizers' library is required to retokenize genie's text output into "
|
|
145
|
+
"token ids (QAIRT 2.51 genie-t2t-run emits text, not ids) but could not be imported. "
|
|
146
|
+
"Install the 'llm' extra: pip install 'chipwright[llm]'") from e
|
|
147
|
+
import os
|
|
148
|
+
if not os.path.exists(path):
|
|
149
|
+
raise FileNotFoundError(
|
|
150
|
+
f"llm modality: bundle tokenizer not found at {path!r} — the gate cannot retokenize "
|
|
151
|
+
f"genie's text without the bundle's tokenizer.json")
|
|
152
|
+
return Tokenizer.from_file(path)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def retokenize(text: str, tokenizer_path: str | None = None, *, tokenizer=None) -> list:
|
|
156
|
+
"""Encode generated `text` into token ids with the bundle tokenizer, special tokens OFF.
|
|
157
|
+
|
|
158
|
+
Pure/injectable: pass a loaded `tokenizer` (any object exposing
|
|
159
|
+
`.encode(text, add_special_tokens=False).ids`) to make it unit-testable without a file or the
|
|
160
|
+
library; otherwise it loads `tokenizer_path` via `load_bundle_tokenizer`. `add_special_tokens=False`
|
|
161
|
+
because we match the ANSWER tokens against the reference's answer tokens — a stray BOS/EOS prepended
|
|
162
|
+
by the tokenizer's post-processor would shift every position and tank token_match for a correct run.
|
|
163
|
+
"""
|
|
164
|
+
tok = tokenizer if tokenizer is not None else load_bundle_tokenizer(tokenizer_path)
|
|
165
|
+
enc = tok.encode(text, add_special_tokens=False)
|
|
166
|
+
return list(enc.ids)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- deterministic greedy config (genie silently ignores sampler.greedy — use top-k:1) ------------
|
|
170
|
+
|
|
171
|
+
def greedy_sampler(base: dict | None = None) -> dict:
|
|
172
|
+
"""Return a genie `sampler` block that decodes deterministically (greedy) via `top-k: 1`.
|
|
173
|
+
|
|
174
|
+
Bug proven on SM8850/v81: genie (QAIRT 2.51) SILENTLY IGNORES `sampler.greedy: true` and falls back
|
|
175
|
+
to its default temp-0.8 sampling — the tripwire went non-deterministic and good-vs-self token_match
|
|
176
|
+
was only 0.44. The greedy control genie actually honors is `top-k: 1` (temp 0, top-p 1). We set that
|
|
177
|
+
explicitly and do NOT rely on `greedy: true`.
|
|
178
|
+
"""
|
|
179
|
+
s = dict(base or {})
|
|
180
|
+
s.setdefault("version", 1)
|
|
181
|
+
s["top-k"] = 1 # <-- the greedy control genie HONORS; `greedy: true` is silently ignored
|
|
182
|
+
s["temp"] = 0.0
|
|
183
|
+
s["top-p"] = 1.0
|
|
184
|
+
return s
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def apply_greedy_to_config(config: dict) -> dict:
|
|
188
|
+
"""Return a copy of a genie_config with its dialog sampler forced to deterministic greedy (top-k:1).
|
|
189
|
+
|
|
190
|
+
Pure (deep-copies, does not mutate the input) so it is unit-testable and the on-disk bundle config is
|
|
191
|
+
never clobbered. The modality emits this derived config for the tripwire's greedy run.
|
|
192
|
+
"""
|
|
193
|
+
import copy
|
|
194
|
+
cfg = copy.deepcopy(config)
|
|
195
|
+
dialog = cfg.setdefault("dialog", {})
|
|
196
|
+
dialog["sampler"] = greedy_sampler(dialog.get("sampler"))
|
|
197
|
+
return cfg
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- the modality run (drives Genie over the device interface) ------------------------------------
|
|
201
|
+
|
|
202
|
+
def run(device, bundle_dir_local, meta, prompt, *, template="chatml", system=None,
|
|
203
|
+
remote_dir="/data/local/tmp/chipwright_llm", greedy=True, profile=False, tokenizer=None):
|
|
204
|
+
"""Assemble the Genie bundle on the device, generate for one prompt, return text + tokens + latency.
|
|
205
|
+
|
|
206
|
+
`meta` names the bundle pieces (context bins + genie_config + tokenizer + htp ext config) and the
|
|
207
|
+
model fields (template, eos). Signature stays modality-shaped: (device, bundle, meta, prompt).
|
|
208
|
+
Optional `meta` keys honour real-board layout: `genie_bin` (default "genie-t2t-run"), `env`
|
|
209
|
+
(extra shell exports, e.g. LD_LIBRARY_PATH/ADSP_LIBRARY_PATH on the proven v81 board), `timeout_s`
|
|
210
|
+
(wrap the run in `timeout` — w4a16 degenerates and never emits EOS, so an unbounded run hangs).
|
|
211
|
+
|
|
212
|
+
Steps:
|
|
213
|
+
1. Push the bundle **file-by-file** (a single multi-GB push of a directory can wedge the adb
|
|
214
|
+
daemon — FR-L02), skipping files already present.
|
|
215
|
+
2. Wrap the raw prompt in the chat template (ChatML for Qwen) and push it as a PROMPT FILE —
|
|
216
|
+
QAIRT 2.51 genie-t2t-run reads the prompt via `--prompt_file <path>`, not inline.
|
|
217
|
+
3. For the greedy tripwire, emit a derived config with `sampler.top-k: 1` (genie ignores
|
|
218
|
+
`sampler.greedy`) and point genie at it.
|
|
219
|
+
4. Run `genie-t2t-run -c <cfg> --prompt_file <file>`, parse the `[BEGIN]..[END]` text, and
|
|
220
|
+
RETOKENIZE it into token ids (genie emits no ids on QAIRT 2.51).
|
|
221
|
+
|
|
222
|
+
`profile` defaults **off**: on a real SM8850/v81 board, passing `--profile` to QAIRT 2.51
|
|
223
|
+
genie-t2t-run broke the generation output and made even a known-good artifact score token_match
|
|
224
|
+
0.0 (false FAIL). Pass `profile=True` only on a genie build confirmed to accept the flag.
|
|
225
|
+
|
|
226
|
+
Returns {text, tokens, latency, generation: <raw stdout>}. The pass/fail gate is `verify_llm`, not
|
|
227
|
+
here — this returns the honest on-chip generation. `tokenizer` may be injected (a loaded Tokenizer)
|
|
228
|
+
for testing; otherwise the bundle's tokenizer.json is loaded.
|
|
229
|
+
"""
|
|
230
|
+
import os
|
|
231
|
+
|
|
232
|
+
# 1. bundle transport — one file at a time (FR-L02).
|
|
233
|
+
files = meta.get("bundle_files") or sorted(os.listdir(bundle_dir_local))
|
|
234
|
+
device.shell(f"mkdir -p {remote_dir}")
|
|
235
|
+
for name in files:
|
|
236
|
+
remote = f"{remote_dir}/{name}"
|
|
237
|
+
if device.exists(remote):
|
|
238
|
+
continue
|
|
239
|
+
device.push_file(os.path.join(bundle_dir_local, name), remote)
|
|
240
|
+
|
|
241
|
+
# 2. chat template -> PROMPT FILE. genie-t2t-run (QAIRT 2.51) takes the prompt via --prompt_file,
|
|
242
|
+
# not an inline --prompt: the wrapped ChatML carries newlines and |<> markers that fight shell
|
|
243
|
+
# quoting, and the proven SM8850/v81 path fed it as a file.
|
|
244
|
+
wrapped = apply_chat_template(prompt, template=meta.get("template", template),
|
|
245
|
+
system=meta.get("system", system))
|
|
246
|
+
prompt_local = os.path.join(bundle_dir_local, "prompt.txt")
|
|
247
|
+
with open(prompt_local, "w") as f:
|
|
248
|
+
f.write(wrapped)
|
|
249
|
+
prompt_remote = f"{remote_dir}/prompt.txt"
|
|
250
|
+
device.push_file(prompt_local, prompt_remote)
|
|
251
|
+
|
|
252
|
+
# 3. config — force deterministic greedy via sampler top-k:1 (genie SILENTLY IGNORES sampler.greedy:
|
|
253
|
+
# true, falling back to temp-0.8 sampling — the tripwire went non-deterministic, good-vs-self
|
|
254
|
+
# token_match only 0.44 on SM8850/v81). Emit a derived config; never clobber the bundle's.
|
|
255
|
+
cfg_name = meta.get("genie_config", "genie_config.json")
|
|
256
|
+
if greedy:
|
|
257
|
+
cfg_local = os.path.join(bundle_dir_local, cfg_name)
|
|
258
|
+
with open(cfg_local) as f:
|
|
259
|
+
greedy_obj = apply_greedy_to_config(json.load(f))
|
|
260
|
+
cfg_name = cfg_name.rsplit(".", 1)[0] + ".greedy.json"
|
|
261
|
+
greedy_local = os.path.join(bundle_dir_local, cfg_name)
|
|
262
|
+
with open(greedy_local, "w") as f:
|
|
263
|
+
json.dump(greedy_obj, f)
|
|
264
|
+
device.push_file(greedy_local, f"{remote_dir}/{cfg_name}")
|
|
265
|
+
|
|
266
|
+
# 4. run genie-t2t-run — ONLY flags confirmed on QAIRT 2.51 (-c, --prompt_file, and --profile for
|
|
267
|
+
# latency). --print-token-ids and --max-length were REMOVED: neither exists on the board, and a
|
|
268
|
+
# non-existent flag makes genie error out -> the gate false-FAILs a good artifact.
|
|
269
|
+
genie_bin = meta.get("genie_bin", "genie-t2t-run")
|
|
270
|
+
profile_remote = f"{remote_dir}/profile.json"
|
|
271
|
+
profile_flag = f" --profile {profile_remote}" if profile else ""
|
|
272
|
+
timeout_s = meta.get("timeout_s")
|
|
273
|
+
timeout_prefix = f"timeout {timeout_s} " if timeout_s else ""
|
|
274
|
+
segments = [f"cd {remote_dir}"]
|
|
275
|
+
segments += [f"export {k}={_shquote(str(v))}" for k, v in (meta.get("env") or {}).items()]
|
|
276
|
+
segments.append(f"{timeout_prefix}{genie_bin} -c {cfg_name} --prompt_file {prompt_remote}{profile_flag}")
|
|
277
|
+
proc = device.shell(" && ".join(segments))
|
|
278
|
+
stdout = getattr(proc, "stdout", "") or ""
|
|
279
|
+
|
|
280
|
+
# 5. parse generated text, then RETOKENIZE text -> ids (genie emits no ids on QAIRT 2.51).
|
|
281
|
+
gen = parse_generation(stdout)
|
|
282
|
+
tokens = None
|
|
283
|
+
if gen["text"] is not None:
|
|
284
|
+
tok_path = os.path.join(bundle_dir_local, meta.get("tokenizer", "tokenizer.json"))
|
|
285
|
+
tokens = retokenize(gen["text"], tok_path, tokenizer=tokenizer)
|
|
286
|
+
|
|
287
|
+
# 6. latency (only if profiled).
|
|
288
|
+
latency = {}
|
|
289
|
+
if profile and device.exists(profile_remote):
|
|
290
|
+
local_profile = os.path.join(bundle_dir_local, "profile.json")
|
|
291
|
+
device.pull(profile_remote, local_profile)
|
|
292
|
+
with open(local_profile) as f:
|
|
293
|
+
latency = parse_profile(f.read())
|
|
294
|
+
|
|
295
|
+
return {"text": gen["text"], "tokens": tokens, "latency": latency, "generation": stdout}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _shquote(s: str) -> str:
|
|
299
|
+
"""Single-quote a string for the remote shell (the wrapped prompt carries newlines and |<> marks)."""
|
|
300
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
chipwright/recipe.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Recipe — the reproducible build input, loaded from a YAML document.
|
|
2
|
+
|
|
3
|
+
A recipe is the *only* input to a build: the same recipe plus the same SDK produces the same
|
|
4
|
+
artifact, which is what makes the resulting verification record trustworthy (Constitution III). It is
|
|
5
|
+
a pure dataclass — `load(path)` reads and validates the YAML and nothing else; both build routes
|
|
6
|
+
(`build.classic`, `build.dlc_route`) read the same schema, only the orchestration differs.
|
|
7
|
+
|
|
8
|
+
See `specs/001-chipwright-core/data-model.md` (Recipe ✅) for the authored schema.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
ROUTES = ("classic", "dlc_route")
|
|
18
|
+
RUNS_ON = ("npu", "host")
|
|
19
|
+
|
|
20
|
+
# Precision-support statuses an LLM recipe may declare (spec 002, FR-L06). A status is either the bare
|
|
21
|
+
# word "supported" or a "<kind>:<reason>" where kind names *why* it is not available — so a blocked or
|
|
22
|
+
# unsupported precision is a tracked, named limitation, never a silent gap. The reason is carried
|
|
23
|
+
# through to the resolver's refusal verbatim.
|
|
24
|
+
_PRECISION_KINDS = ("supported", "blocked", "unsupported")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Source:
|
|
29
|
+
"""The float model this recipe converts — content-addressed so a build is pinned to one graph."""
|
|
30
|
+
format: str = "onnx"
|
|
31
|
+
sha256: Optional[str] = None
|
|
32
|
+
url: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Target:
|
|
37
|
+
"""The single target key this recipe builds for (the axes an artifact tag carries)."""
|
|
38
|
+
arch: str = ""
|
|
39
|
+
quant: str = ""
|
|
40
|
+
shape: str = ""
|
|
41
|
+
sdk_range: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Adaptation:
|
|
46
|
+
"""One declared, auditable graph edit. `kind` is the YAML key; `params` is its value."""
|
|
47
|
+
kind: str
|
|
48
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Quant:
|
|
53
|
+
"""How the DLC is quantized — maps to qairt-quantizer flags (act/bias bitwidth, per-channel)."""
|
|
54
|
+
weights: int = 8
|
|
55
|
+
activations: int = 16
|
|
56
|
+
bias: int = 32
|
|
57
|
+
per_channel: bool = True
|
|
58
|
+
calibration_ref: Optional[str] = None
|
|
59
|
+
algorithm: str = "min_max"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Host:
|
|
64
|
+
"""The modality adapter — the CPU stages around the NPU graph(s). `pre`/`post` are 'module:callable'."""
|
|
65
|
+
pre: Optional[str] = None
|
|
66
|
+
post: Optional[str] = None
|
|
67
|
+
deps: List[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Graph:
|
|
72
|
+
"""One ordered, typed stage. `runs_on` ∈ {npu, host}; list order is execution order."""
|
|
73
|
+
name: str
|
|
74
|
+
runs_on: str = "npu"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Recipe:
|
|
79
|
+
name: str
|
|
80
|
+
version: str
|
|
81
|
+
route: str
|
|
82
|
+
source: Source
|
|
83
|
+
target: Target
|
|
84
|
+
adaptations: List[Adaptation] = field(default_factory=list)
|
|
85
|
+
quant: Quant = field(default_factory=Quant)
|
|
86
|
+
host: Host = field(default_factory=Host)
|
|
87
|
+
graphs: List[Graph] = field(default_factory=list)
|
|
88
|
+
precision_support: Dict[str, str] = field(default_factory=dict) # {precision: status} — LLM, FR-L06
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def quant_tag(self) -> str:
|
|
92
|
+
"""The 'w8a16'-style tag this recipe's quant block realises."""
|
|
93
|
+
return f"w{self.quant.weights}a{self.quant.activations}"
|
|
94
|
+
|
|
95
|
+
def precision_status(self, precision: str) -> Optional[str]:
|
|
96
|
+
"""The declared status for `precision` ('supported' / 'blocked:<r>' / 'unsupported:<r>'), or None."""
|
|
97
|
+
return self.precision_support.get(precision)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def supported_precisions(self) -> List[str]:
|
|
101
|
+
"""The precisions this recipe declares runnable through the pipeline."""
|
|
102
|
+
return [p for p, s in self.precision_support.items() if s == "supported"]
|
|
103
|
+
|
|
104
|
+
def adaptation(self, kind: str) -> Optional[Adaptation]:
|
|
105
|
+
"""The declared adaptation of a given kind, or None (e.g. recipe.adaptation('erf_to_tanh'))."""
|
|
106
|
+
return next((a for a in self.adaptations if a.kind == kind), None)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def npu_graphs(self) -> List[Graph]:
|
|
110
|
+
return [g for g in self.graphs if g.runs_on == "npu"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_adaptations(items: Any) -> List[Adaptation]:
|
|
114
|
+
"""`adaptations:` is a list of single-key maps ({erf_to_tanh: {...}}) — keep key as kind, value as params."""
|
|
115
|
+
out: List[Adaptation] = []
|
|
116
|
+
for item in items or []:
|
|
117
|
+
if isinstance(item, dict):
|
|
118
|
+
for kind, params in item.items():
|
|
119
|
+
out.append(Adaptation(kind=str(kind), params=params if isinstance(params, dict) else {"value": params}))
|
|
120
|
+
else:
|
|
121
|
+
out.append(Adaptation(kind=str(item), params={}))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_precision_support(items: Any) -> Dict[str, str]:
|
|
126
|
+
"""Parse + validate `precision_support:` — a map {precision: status}. Raises ValueError if malformed.
|
|
127
|
+
|
|
128
|
+
A status must be "supported" or "<blocked|unsupported>:<reason>" with a non-empty reason, so a
|
|
129
|
+
recipe cannot smuggle in a vague "no" — a limitation is named or it is not a valid recipe (the same
|
|
130
|
+
strictness as the tag grammar: a status that doesn't parse is a bug, not a near-miss to paper over).
|
|
131
|
+
"""
|
|
132
|
+
if not items:
|
|
133
|
+
return {}
|
|
134
|
+
if not isinstance(items, dict):
|
|
135
|
+
raise ValueError(f"precision_support must be a mapping {{precision: status}}, got {type(items).__name__}")
|
|
136
|
+
out: Dict[str, str] = {}
|
|
137
|
+
for precision, status in items.items():
|
|
138
|
+
status = str(status)
|
|
139
|
+
head = status.split(":", 1)[0]
|
|
140
|
+
if head not in _PRECISION_KINDS:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"precision_support[{precision!r}] = {status!r}: status must start with one of "
|
|
143
|
+
f"{_PRECISION_KINDS} (e.g. 'supported' or 'blocked:kv-cache-encoding')")
|
|
144
|
+
if head != "supported" and len(status.split(":", 1)) == 2 and not status.split(":", 1)[1].strip():
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"precision_support[{precision!r}] = {status!r}: a {head!r} status must name a reason "
|
|
147
|
+
f"after the colon (e.g. '{head}:kv-cache-encoding')")
|
|
148
|
+
if head != "supported" and ":" not in status:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"precision_support[{precision!r}] = {status!r}: a {head!r} status must name a reason "
|
|
151
|
+
f"('{head}:<reason>'); only 'supported' stands alone")
|
|
152
|
+
out[str(precision)] = status
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def from_dict(doc: Dict[str, Any]) -> Recipe:
|
|
157
|
+
"""Build a Recipe from an already-parsed YAML mapping. Pure — no I/O. Raises ValueError if malformed."""
|
|
158
|
+
if not isinstance(doc, dict):
|
|
159
|
+
raise ValueError(f"recipe must be a YAML mapping, got {type(doc).__name__}")
|
|
160
|
+
for req in ("name", "version", "source", "target"):
|
|
161
|
+
if req not in doc:
|
|
162
|
+
raise ValueError(f"recipe missing required field {req!r}")
|
|
163
|
+
|
|
164
|
+
route = str(doc.get("route", "classic"))
|
|
165
|
+
if route not in ROUTES:
|
|
166
|
+
raise ValueError(f"unknown route {route!r}; expected one of {ROUTES}")
|
|
167
|
+
|
|
168
|
+
src = doc["source"] or {}
|
|
169
|
+
source = Source(format=str(src.get("format", "onnx")), sha256=src.get("sha256"), url=src.get("url"))
|
|
170
|
+
if source.format != "onnx":
|
|
171
|
+
raise ValueError(f"source.format must be 'onnx' (the only supported source); got {source.format!r}")
|
|
172
|
+
|
|
173
|
+
tgt = doc["target"] or {}
|
|
174
|
+
target = Target(arch=str(tgt.get("arch", "")), quant=str(tgt.get("quant", "")),
|
|
175
|
+
shape=str(tgt.get("shape", "")), sdk_range=str(tgt.get("sdk_range", "")))
|
|
176
|
+
if not target.arch:
|
|
177
|
+
raise ValueError("target.arch is required (the hard axis, e.g. 'v68')")
|
|
178
|
+
|
|
179
|
+
q = doc.get("quant") or {}
|
|
180
|
+
quant = Quant(weights=int(q.get("weights", 8)), activations=int(q.get("activations", 16)),
|
|
181
|
+
bias=int(q.get("bias", 32)), per_channel=bool(q.get("per_channel", True)),
|
|
182
|
+
calibration_ref=q.get("calibration_ref"), algorithm=str(q.get("algorithm", "min_max")))
|
|
183
|
+
|
|
184
|
+
h = doc.get("host") or {}
|
|
185
|
+
host = Host(pre=h.get("pre"), post=h.get("post"), deps=list(h.get("deps", []) or []))
|
|
186
|
+
|
|
187
|
+
graphs: List[Graph] = []
|
|
188
|
+
for g in doc.get("graphs", []) or []:
|
|
189
|
+
name = str(g.get("name", "")) if isinstance(g, dict) else str(g)
|
|
190
|
+
runs_on = str(g.get("runs_on", "npu")) if isinstance(g, dict) else "npu"
|
|
191
|
+
if runs_on not in RUNS_ON:
|
|
192
|
+
raise ValueError(f"graph {name!r} has runs_on={runs_on!r}; expected one of {RUNS_ON}")
|
|
193
|
+
graphs.append(Graph(name=name, runs_on=runs_on))
|
|
194
|
+
|
|
195
|
+
return Recipe(name=str(doc["name"]), version=str(doc["version"]), route=route,
|
|
196
|
+
source=source, target=target, adaptations=_parse_adaptations(doc.get("adaptations")),
|
|
197
|
+
quant=quant, host=host, graphs=graphs,
|
|
198
|
+
precision_support=_parse_precision_support(doc.get("precision_support")))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load(path: str) -> Recipe:
|
|
202
|
+
"""Load and validate a recipe YAML file into a `Recipe`. Raises ValueError on a malformed recipe."""
|
|
203
|
+
with open(path) as f:
|
|
204
|
+
doc = yaml.safe_load(f)
|
|
205
|
+
return from_dict(doc)
|
chipwright/registry.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Registry client — read the index (a git-repo file, cached) and list a model's variants.
|
|
2
|
+
|
|
3
|
+
Start the registry as a git repository, not a service. The index is a single YAML
|
|
4
|
+
file the client reads; artifacts live in object storage addressed by hash. No server to run, and the
|
|
5
|
+
whole index is reviewable in pull requests — which is what makes a verification record trustworthy.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .resolver import Variant
|
|
16
|
+
|
|
17
|
+
# Default index: the portable public index bundled inside the package, so it ships in the wheel and
|
|
18
|
+
# resolves after `pip install` (not only in a source checkout). The author's machine-specific overlay
|
|
19
|
+
# is loaded instead via CW_REGISTRY (see registry/index.dev.yaml, git-ignored).
|
|
20
|
+
DEFAULT_INDEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "index.yaml")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_yaml(src: str) -> dict:
|
|
24
|
+
if src.startswith(("http://", "https://")):
|
|
25
|
+
with urllib.request.urlopen(src, timeout=30) as r:
|
|
26
|
+
return yaml.safe_load(r.read())
|
|
27
|
+
with open(src) as f:
|
|
28
|
+
return yaml.safe_load(f)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Registry:
|
|
32
|
+
def __init__(self, index: str = None):
|
|
33
|
+
self.index_src = index or os.environ.get("CW_REGISTRY", DEFAULT_INDEX)
|
|
34
|
+
self._models: Dict[str, List[Variant]] = {}
|
|
35
|
+
self._schema = 0
|
|
36
|
+
self._load()
|
|
37
|
+
|
|
38
|
+
def _load(self):
|
|
39
|
+
doc = _load_yaml(self.index_src) or {}
|
|
40
|
+
self._schema = int(doc.get("registry_schema", 1))
|
|
41
|
+
for entry in doc.get("models", []):
|
|
42
|
+
model = entry["model"]
|
|
43
|
+
vs = []
|
|
44
|
+
for v in entry.get("variants", []):
|
|
45
|
+
vs.append(Variant(
|
|
46
|
+
model=model,
|
|
47
|
+
version=str(v.get("version", entry.get("version", "0.0.0"))),
|
|
48
|
+
arch=v["arch"], quant=v["quant"], shape=v["shape"],
|
|
49
|
+
sdk_tested=str(v["sdk_tested"]), sdk_compatible=str(v["sdk_compatible"]),
|
|
50
|
+
ext=v.get("ext", "qnnctx"),
|
|
51
|
+
url=v.get("url"), sha256=v.get("sha256"),
|
|
52
|
+
local=v.get("local"), run=v.get("run"),
|
|
53
|
+
recipe=v.get("recipe"), verified=v.get("verified"),
|
|
54
|
+
))
|
|
55
|
+
self._models[model] = vs
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def schema(self) -> int:
|
|
59
|
+
return self._schema
|
|
60
|
+
|
|
61
|
+
def models(self) -> List[str]:
|
|
62
|
+
return sorted(self._models)
|
|
63
|
+
|
|
64
|
+
def variants(self, model: str) -> List[Variant]:
|
|
65
|
+
return self._models.get(model, [])
|