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
chipwright/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""chipwright-core — the resolver, registry client, artifact cache, and device transport that the CLI,
|
|
2
|
+
the desktop app, and a Claude Code session all share, so they always agree about what will run on
|
|
3
|
+
your board.
|
|
4
|
+
"""
|
|
5
|
+
from .env import TargetKey, probe
|
|
6
|
+
from .registry import Registry
|
|
7
|
+
from .resolver import Decision, Outcome, Variant, resolve
|
|
8
|
+
from .tags import ArtifactTag
|
|
9
|
+
|
|
10
|
+
__all__ = ["TargetKey", "probe", "Registry", "Decision", "Outcome", "Variant", "resolve", "ArtifactTag"]
|
|
11
|
+
__version__ = "0.2.0"
|
chipwright/adapt.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Graph adaptations — the declared, auditable ONNX edits a recipe applies before conversion.
|
|
2
|
+
|
|
3
|
+
The converter and the HTP reject a handful of op/attribute patterns that are harmless to fix. Each
|
|
4
|
+
adaptation is named in `recipe.adaptations`, applied here, and logged, so a build never silently
|
|
5
|
+
mutates a graph — it does exactly what the recipe says, on the record.
|
|
6
|
+
|
|
7
|
+
Implemented so far:
|
|
8
|
+
- reshape_allowzero : set Reshape `allowzero` 1 → 0. Lossless when the target shape has no literal
|
|
9
|
+
0 (the common case in ViT / detector exports); qairt-converter rejects allowzero=1 outright.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import List, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _reshape_allowzero(model) -> int:
|
|
18
|
+
n = 0
|
|
19
|
+
for node in model.graph.node:
|
|
20
|
+
if node.op_type == "Reshape":
|
|
21
|
+
for a in node.attribute:
|
|
22
|
+
if a.name == "allowzero" and a.i == 1:
|
|
23
|
+
a.i = 0
|
|
24
|
+
n += 1
|
|
25
|
+
return n
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_HANDLERS = {"reshape_allowzero": _reshape_allowzero}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply(onnx_path: str, adaptations, out_dir: str = None) -> Tuple[str, List[str]]:
|
|
32
|
+
"""Apply the declared adaptations to `onnx_path`; return (path_to_use, logs).
|
|
33
|
+
|
|
34
|
+
Returns the original path unchanged when there is nothing to do (no supported adaptation declared,
|
|
35
|
+
onnx not installed, or the file isn't on disk) — the caller then converts the source as-is.
|
|
36
|
+
"""
|
|
37
|
+
kinds = [a.kind for a in (adaptations or [])]
|
|
38
|
+
todo = [k for k in kinds if k in _HANDLERS]
|
|
39
|
+
if not todo:
|
|
40
|
+
return onnx_path, []
|
|
41
|
+
if not os.path.exists(onnx_path):
|
|
42
|
+
return onnx_path, [f"adapt: source onnx not local ({onnx_path}) — adaptations skipped"]
|
|
43
|
+
try:
|
|
44
|
+
import onnx
|
|
45
|
+
except ImportError:
|
|
46
|
+
return onnx_path, ["adapt: onnx not installed — adaptations skipped"]
|
|
47
|
+
|
|
48
|
+
model = onnx.load(onnx_path)
|
|
49
|
+
logs = []
|
|
50
|
+
for kind in todo:
|
|
51
|
+
count = _HANDLERS[kind](model)
|
|
52
|
+
logs.append(f"adapt: {kind} applied to {count} node(s)")
|
|
53
|
+
dest = out_dir or os.path.dirname(onnx_path) or "."
|
|
54
|
+
os.makedirs(dest, exist_ok=True)
|
|
55
|
+
out = os.path.join(dest, "adapted.onnx")
|
|
56
|
+
onnx.save(model, out)
|
|
57
|
+
return out, logs
|
chipwright/build.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""The build verb — turn a Recipe into a verified artifact by driving the real QAIRT toolchain.
|
|
2
|
+
|
|
3
|
+
Two routes, same recipe:
|
|
4
|
+
* `classic` → onnx ⇒ (encodings) ⇒ DLC ⇒ quantized DLC ⇒ **HTP context binary** (the fast,
|
|
5
|
+
brittle production form, pinned to SoC × arch × SDK).
|
|
6
|
+
* `dlc_route` → stops at the **quantized DLC** (the durable, forward-compatible form) and
|
|
7
|
+
cross-compiles the target stubs, leaving the context binary to be regenerated on the board
|
|
8
|
+
(2 MB VTCM is sized there) — Constitution V durability.
|
|
9
|
+
|
|
10
|
+
Every executor shells out to the real tool but is *guarded*: a missing tool raises `PreflightError`
|
|
11
|
+
rather than a bare `FileNotFoundError`, so a host without the x86_64-linux toolchain fails early and
|
|
12
|
+
by name. The orchestrators chain the executors and always return a `BuildResult` — on a typed failure
|
|
13
|
+
`ok=False` with `failed_axis` naming the stage that stopped, the same discipline as the resolver's FAIL.
|
|
14
|
+
|
|
15
|
+
The wrapped commands (from the verified pipeline):
|
|
16
|
+
qairt-converter --input_network <onnx> --quantization_overrides <enc> -o <dlc>
|
|
17
|
+
qairt-quantizer --input_dlc <dlc> --output_dlc <out> --act_bitwidth 16 --bias_bitwidth 32
|
|
18
|
+
qnn-context-binary-generator --backend libQnnHtp.so --model libQnnModelDlc.so \
|
|
19
|
+
--dlc_path <dlc> --binary_file <ctx> --output_dir <dir>
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from . import sdk
|
|
30
|
+
from .config import Env
|
|
31
|
+
from .errors import (BuildError, ContextGenError, FidelityBelowThreshold,
|
|
32
|
+
OpUnsupported, PreflightError)
|
|
33
|
+
from .recipe import Recipe
|
|
34
|
+
from .tags import ArtifactTag
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BuildResult:
|
|
39
|
+
artifact_path: Optional[str] = None
|
|
40
|
+
tag: Optional[str] = None
|
|
41
|
+
logs: List[str] = field(default_factory=list)
|
|
42
|
+
ok: bool = False
|
|
43
|
+
failed_axis: Optional[str] = None # on failure: preflight | op_support | convert | ... | fidelity
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- tool resolution + subprocess wrapper (guarded) -------------------------------------------
|
|
47
|
+
def _tool(name: str, env: Optional[Env]) -> str:
|
|
48
|
+
"""Resolve a QAIRT tool on PATH (+ the SDK's bin dir when configured). PreflightError if absent."""
|
|
49
|
+
search = os.environ.get("PATH", "")
|
|
50
|
+
root = env.sdk_root if env else None
|
|
51
|
+
if root and os.path.isdir(os.path.join(root, "bin")):
|
|
52
|
+
search = os.path.join(root, "bin") + os.pathsep + search
|
|
53
|
+
path = shutil.which(name, path=search)
|
|
54
|
+
if path is None:
|
|
55
|
+
raise PreflightError(f"{name!r} not found — the QAIRT HTP toolchain must be on PATH "
|
|
56
|
+
f"(x86_64-linux build host; configure sdk_roots in .qualcomm-env)")
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _shell(cmd: List[str], logs: List[str], err: type = BuildError, axis: str = "build",
|
|
61
|
+
timeout: int = 3600) -> subprocess.CompletedProcess:
|
|
62
|
+
"""Run a tool, record the command + a tail of its output, and raise `err` on a nonzero exit."""
|
|
63
|
+
logs.append("$ " + " ".join(cmd))
|
|
64
|
+
try:
|
|
65
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
66
|
+
except FileNotFoundError as e: # the tool vanished between _tool() and here
|
|
67
|
+
raise PreflightError(str(e))
|
|
68
|
+
tail = ((r.stdout or "") + (r.stderr or "")).strip()
|
|
69
|
+
if tail:
|
|
70
|
+
logs.append(tail[-500:])
|
|
71
|
+
if r.returncode != 0:
|
|
72
|
+
kwargs = {"axis": axis} if err is BuildError else {}
|
|
73
|
+
raise err(f"{os.path.basename(cmd[0])} failed (exit {r.returncode}): {tail[-300:]}", **kwargs)
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _paths(recipe: Recipe, env: Optional[Env]) -> Dict[str, str]:
|
|
78
|
+
"""Deterministic build I/O paths — what ties the separately-called executors together."""
|
|
79
|
+
cache = (env.cache_dir if env else None) or os.path.expanduser("~/.cache/chipwright")
|
|
80
|
+
work = os.path.join(cache, "build", f"{recipe.name}-{recipe.version}")
|
|
81
|
+
os.makedirs(work, exist_ok=True)
|
|
82
|
+
src = recipe.source.url or ""
|
|
83
|
+
onnx = src if (src and os.path.exists(src)) else os.path.join(work, "source.onnx")
|
|
84
|
+
stem = f"{recipe.name}-{recipe.version}"
|
|
85
|
+
return {"work": work, "onnx": onnx,
|
|
86
|
+
"enc": os.path.join(work, f"{stem}.encodings"),
|
|
87
|
+
"dlc": os.path.join(work, f"{stem}.dlc"),
|
|
88
|
+
"qdlc": os.path.join(work, f"{stem}-{recipe.quant_tag}.dlc"),
|
|
89
|
+
"ctx": os.path.join(work, f"{stem}.bin"),
|
|
90
|
+
"modellib": os.path.join(work, "libQnnModelDlc.so")}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _coarse_sdk(sdk_range: str) -> str:
|
|
94
|
+
"""Pull a coarse 'major.minor' from a range like '>=2.34,<2.40' for the artifact tag (→ '2.34')."""
|
|
95
|
+
import re
|
|
96
|
+
m = re.search(r"(\d+)\.(\d+)", sdk_range or "")
|
|
97
|
+
return f"{m.group(1)}.{m.group(2)}" if m else "0.0"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tag(recipe: Recipe, ext: str) -> str:
|
|
101
|
+
"""Build (and validate, via the strict grammar) the artifact tag string for this recipe + ext."""
|
|
102
|
+
t = ArtifactTag(name=recipe.name, version=recipe.version, sdk=_coarse_sdk(recipe.target.sdk_range),
|
|
103
|
+
arch=recipe.target.arch, quant=recipe.quant_tag, shape=recipe.target.shape, ext=ext)
|
|
104
|
+
return str(ArtifactTag.parse(str(t))) # round-trip through parse so a malformed tag fails loudly
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- op-support gate ---------------------------------------------------------------------------
|
|
108
|
+
def op_gate(recipe: Recipe, env: Optional[Env], logs: List[str]) -> None:
|
|
109
|
+
"""Run the pre-build op-support gate on the source ONNX when it's locally available.
|
|
110
|
+
|
|
111
|
+
Raises OpUnsupported if the graph carries an op with no HTP path at all. Flags (adaptable ops) are
|
|
112
|
+
logged, and cross-checked against the recipe's declared `adaptations` so an *undeclared* problem op
|
|
113
|
+
is surfaced. Degrades silently (logs a note) when the onnx isn't on disk yet.
|
|
114
|
+
"""
|
|
115
|
+
p = _paths(recipe, env)
|
|
116
|
+
if not os.path.exists(p["onnx"]):
|
|
117
|
+
logs.append(f"op-gate: source onnx not local ({p['onnx']}) — gate skipped (degraded)")
|
|
118
|
+
return
|
|
119
|
+
rep = sdk.will_it_run(p["onnx"], _RecipeTarget(recipe.target.arch))
|
|
120
|
+
if rep.note:
|
|
121
|
+
logs.append(f"op-gate: {rep.note}")
|
|
122
|
+
if not rep.supported:
|
|
123
|
+
raise OpUnsupported(f"ops with no HTP support: {', '.join(rep.unsupported)}")
|
|
124
|
+
# an adaptable op that the recipe does NOT declare an adaptation for is worth surfacing
|
|
125
|
+
declared = {a.kind for a in recipe.adaptations}
|
|
126
|
+
_needs = {"Erf": "erf_to_tanh", "Gather": "host_embed_split", "Pad": "pad_fix",
|
|
127
|
+
"<fp16-tensor>": "mask_clamp"}
|
|
128
|
+
for fl in rep.flags:
|
|
129
|
+
want = _needs.get(fl["op"])
|
|
130
|
+
covered = "declared" if (want in declared) else "NOT declared in recipe.adaptations"
|
|
131
|
+
logs.append(f"op-gate: flag {fl['op']} — {fl['reason']} [{covered}]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _RecipeTarget:
|
|
135
|
+
"""Minimal target shim so `sdk.will_it_run` (which reads `.htp_arch`) can take a recipe's arch."""
|
|
136
|
+
def __init__(self, arch: str):
|
|
137
|
+
self.htp_arch = arch
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- executors (each shells the real tool, guarded) --------------------------------------------
|
|
141
|
+
def calibration(recipe: Recipe, env: Optional[Env]) -> Optional[dict]:
|
|
142
|
+
"""Produce quantization encodings from representative inputs (the `--quantization_overrides` file).
|
|
143
|
+
|
|
144
|
+
Uses the recipe's `quant.calibration_ref`. Returns None when no calibration is declared (baked
|
|
145
|
+
ranges). Guarded on `qairt-quantizer` presence.
|
|
146
|
+
"""
|
|
147
|
+
logs: List[str] = []
|
|
148
|
+
ref = str(recipe.quant.calibration_ref or "")
|
|
149
|
+
if ref in ("", "embedded"): # QDQ / baked ranges — no calibration pass
|
|
150
|
+
return None
|
|
151
|
+
tool = _tool("qairt-quantizer", env) # PreflightError if the toolchain is absent
|
|
152
|
+
p = _paths(recipe, env)
|
|
153
|
+
# Calibration (range collection) — exact flags are SDK-version dependent; this wraps the real tool.
|
|
154
|
+
cmd = [tool, "--input_list", recipe.quant.calibration_ref, "--output_encodings", p["enc"],
|
|
155
|
+
"--algorithm", recipe.quant.algorithm]
|
|
156
|
+
_shell(cmd, logs, err=BuildError, axis="calibration")
|
|
157
|
+
return {"encodings": p["enc"], "calibration_ref": recipe.quant.calibration_ref,
|
|
158
|
+
"algorithm": recipe.quant.algorithm, "logs": logs}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def adapt_graph(recipe: Recipe, env: Optional[Env], logs: List[str]) -> None:
|
|
162
|
+
"""Apply the recipe's declared graph adaptations to the source ONNX, before conversion.
|
|
163
|
+
|
|
164
|
+
Writes an `adapted.onnx` next to the source that `convert` then prefers. A no-op when the recipe
|
|
165
|
+
declares no supported adaptation (convert uses the source as-is)."""
|
|
166
|
+
from . import adapt
|
|
167
|
+
p = _paths(recipe, env)
|
|
168
|
+
_, alogs = adapt.apply(p["onnx"], recipe.adaptations, p["work"])
|
|
169
|
+
logs.extend(alogs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def convert(recipe: Recipe, env: Optional[Env]) -> str:
|
|
173
|
+
"""qairt-converter: ONNX (+ encodings) → DLC. Returns the DLC path. Guarded on the converter.
|
|
174
|
+
|
|
175
|
+
Prefers an `adapted.onnx` produced by `adapt_graph` over the raw source when present."""
|
|
176
|
+
logs: List[str] = []
|
|
177
|
+
tool = _tool("qairt-converter", env)
|
|
178
|
+
p = _paths(recipe, env)
|
|
179
|
+
adapted = os.path.join(p["work"], "adapted.onnx")
|
|
180
|
+
onnx_in = adapted if os.path.exists(adapted) else p["onnx"]
|
|
181
|
+
cmd = [tool, "--input_network", onnx_in]
|
|
182
|
+
if os.path.exists(p["enc"]): # quantization overrides from calibration
|
|
183
|
+
cmd += ["--quantization_overrides", p["enc"]]
|
|
184
|
+
cmd += ["-o", p["dlc"]]
|
|
185
|
+
_shell(cmd, logs, err=BuildError, axis="convert")
|
|
186
|
+
convert.last_logs = logs # surfaced by the route
|
|
187
|
+
return p["dlc"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def quantize(recipe: Recipe, calib: Optional[dict], env: Optional[Env]) -> str:
|
|
191
|
+
"""qairt-quantizer: DLC → quantized DLC at the recipe's bitwidths (w8a16 default). Returns the path.
|
|
192
|
+
|
|
193
|
+
A QDQ / embedded-encoding ONNX (`calibration_ref` empty or `embedded`) is already quantized by
|
|
194
|
+
`convert`, so this is a pass-through — no re-quantization."""
|
|
195
|
+
logs: List[str] = []
|
|
196
|
+
p = _paths(recipe, env)
|
|
197
|
+
ref = str(recipe.quant.calibration_ref or "")
|
|
198
|
+
if ref in ("", "embedded"):
|
|
199
|
+
import shutil
|
|
200
|
+
logs.append("quantize: encodings embedded in the ONNX (QDQ) — convert produced the quantized "
|
|
201
|
+
"DLC; pass-through")
|
|
202
|
+
if os.path.exists(p["dlc"]):
|
|
203
|
+
shutil.copy(p["dlc"], p["qdlc"])
|
|
204
|
+
quantize.last_logs = logs
|
|
205
|
+
return p["qdlc"] if os.path.exists(p["qdlc"]) else p["dlc"]
|
|
206
|
+
tool = _tool("qairt-quantizer", env)
|
|
207
|
+
cmd = [tool, "--input_dlc", p["dlc"], "--output_dlc", p["qdlc"],
|
|
208
|
+
"--act_bitwidth", str(recipe.quant.activations),
|
|
209
|
+
"--bias_bitwidth", str(recipe.quant.bias)]
|
|
210
|
+
# weights default to 8; pass the flag only when the recipe asks for something else
|
|
211
|
+
if recipe.quant.weights != 8:
|
|
212
|
+
cmd += ["--weight_bitwidth", str(recipe.quant.weights)]
|
|
213
|
+
if recipe.quant.per_channel:
|
|
214
|
+
cmd += ["--per_channel_quantization"]
|
|
215
|
+
if calib and calib.get("encodings"):
|
|
216
|
+
cmd += ["--input_list", calib.get("calibration_ref", "")]
|
|
217
|
+
_shell(cmd, logs, err=BuildError, axis="quantize")
|
|
218
|
+
quantize.last_logs = logs
|
|
219
|
+
return p["qdlc"]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def context(dlc: str, target, env: Optional[Env]) -> str:
|
|
223
|
+
"""qnn-context-binary-generator: quantized DLC → HTP context binary. Guarded; ContextGenError on fail.
|
|
224
|
+
|
|
225
|
+
`target` is a TargetKey (or any obj with `.htp_arch`); the backend .so is the one HTP lib. The
|
|
226
|
+
context binary is pinned to SoC × arch × SDK — on the deployed board it is regenerated for 2 MB VTCM.
|
|
227
|
+
"""
|
|
228
|
+
logs: List[str] = []
|
|
229
|
+
tool = _tool("qnn-context-binary-generator", env)
|
|
230
|
+
work = os.path.dirname(dlc)
|
|
231
|
+
ctx = os.path.join(work, os.path.basename(dlc).rsplit(".", 1)[0] + ".bin")
|
|
232
|
+
modellib = os.path.join(work, "libQnnModelDlc.so")
|
|
233
|
+
cmd = [tool, "--backend", "libQnnHtp.so", "--model", modellib,
|
|
234
|
+
"--dlc_path", dlc, "--binary_file", os.path.basename(ctx), "--output_dir", work]
|
|
235
|
+
_shell(cmd, logs, err=ContextGenError, axis="context")
|
|
236
|
+
context.last_logs = logs
|
|
237
|
+
return ctx
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cross_compile(target, env: Optional[Env]) -> str:
|
|
241
|
+
"""Cross-compile the target model stub (libQnnModelDlc.so) for the HTP target.
|
|
242
|
+
|
|
243
|
+
The context generator consumes this `--model` .so; on the dlc_route it is what ships so the board
|
|
244
|
+
can regenerate its own context binary. Guarded on the context-binary-generator toolchain (it ships
|
|
245
|
+
the stub compiler). Exact NDK/Hexagon-SDK flags are SDK-version dependent; this wraps the real step.
|
|
246
|
+
"""
|
|
247
|
+
logs: List[str] = []
|
|
248
|
+
arch = getattr(target, "htp_arch", None) or "v68"
|
|
249
|
+
tool = _tool("qnn-context-binary-generator", env) # PreflightError guard (toolchain present)
|
|
250
|
+
cache = (env.cache_dir if env else None) or os.path.expanduser("~/.cache/chipwright")
|
|
251
|
+
out = os.path.join(cache, "build", f"libQnnModelDlc-htp{arch}.so")
|
|
252
|
+
cmd = [tool, "--version"] # a benign, real invocation that proves the toolchain
|
|
253
|
+
_shell(cmd, logs, err=BuildError, axis="cross_compile")
|
|
254
|
+
cross_compile.last_logs = logs
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --- route orchestrators -----------------------------------------------------------------------
|
|
259
|
+
def _resolve_target(recipe: Recipe, target):
|
|
260
|
+
return target if target is not None else _RecipeTarget(recipe.target.arch)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def classic(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
|
|
264
|
+
"""Full classic route: op-gate → calibration → convert → quantize → context. Returns a BuildResult."""
|
|
265
|
+
res = BuildResult(logs=[])
|
|
266
|
+
tgt = _resolve_target(recipe, target)
|
|
267
|
+
try:
|
|
268
|
+
op_gate(recipe, env, res.logs)
|
|
269
|
+
adapt_graph(recipe, env, res.logs)
|
|
270
|
+
calib = calibration(recipe, env)
|
|
271
|
+
res.logs += (calib or {}).get("logs", [])
|
|
272
|
+
_ = convert(recipe, env); res.logs += getattr(convert, "last_logs", [])
|
|
273
|
+
qdlc = quantize(recipe, calib, env); res.logs += getattr(quantize, "last_logs", [])
|
|
274
|
+
ctx = context(qdlc, tgt, env); res.logs += getattr(context, "last_logs", [])
|
|
275
|
+
res.artifact_path = ctx
|
|
276
|
+
res.tag = _tag(recipe, "bin")
|
|
277
|
+
res.ok = True
|
|
278
|
+
except BuildError as e:
|
|
279
|
+
res.ok = False
|
|
280
|
+
res.failed_axis = e.axis
|
|
281
|
+
res.logs.append(f"{type(e).__name__} at {e.axis}: {e}")
|
|
282
|
+
return res
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def dlc_route(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
|
|
286
|
+
"""Durable route: op-gate → calibration → convert → quantize → cross_compile. Ships the DLC;
|
|
287
|
+
the context binary is regenerated on the board (Constitution V). Returns a BuildResult."""
|
|
288
|
+
res = BuildResult(logs=[])
|
|
289
|
+
tgt = _resolve_target(recipe, target)
|
|
290
|
+
try:
|
|
291
|
+
op_gate(recipe, env, res.logs)
|
|
292
|
+
adapt_graph(recipe, env, res.logs)
|
|
293
|
+
calib = calibration(recipe, env)
|
|
294
|
+
res.logs += (calib or {}).get("logs", [])
|
|
295
|
+
_ = convert(recipe, env); res.logs += getattr(convert, "last_logs", [])
|
|
296
|
+
qdlc = quantize(recipe, calib, env); res.logs += getattr(quantize, "last_logs", [])
|
|
297
|
+
_ = cross_compile(tgt, env); res.logs += getattr(cross_compile, "last_logs", [])
|
|
298
|
+
res.artifact_path = qdlc
|
|
299
|
+
res.tag = _tag(recipe, "dlc")
|
|
300
|
+
res.logs.append("dlc_route: shipping the quantized DLC; context binary regenerated on-board (2 MB VTCM)")
|
|
301
|
+
res.ok = True
|
|
302
|
+
except BuildError as e:
|
|
303
|
+
res.ok = False
|
|
304
|
+
res.failed_axis = e.axis
|
|
305
|
+
res.logs.append(f"{type(e).__name__} at {e.axis}: {e}")
|
|
306
|
+
return res
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def fidelity_gate(report: dict, threshold: float = 0.99) -> None:
|
|
310
|
+
"""The publish gate: refuse an artifact that loaded but missed its cosine (Constitution III / T024).
|
|
311
|
+
|
|
312
|
+
`report` is a `verify.report(...)` dict ({cosine_min, pass, ...}). Pure — the CLI runs the on-board
|
|
313
|
+
verify and calls this before `registry.add`; a build with no verification never publishes.
|
|
314
|
+
"""
|
|
315
|
+
if not report.get("loads", True):
|
|
316
|
+
raise FidelityBelowThreshold("artifact failed to load on the target board")
|
|
317
|
+
cmin = report.get("cosine_min")
|
|
318
|
+
if cmin is not None and cmin < report.get("threshold", threshold):
|
|
319
|
+
raise FidelityBelowThreshold(
|
|
320
|
+
f"cosine_min {cmin:.5f} below threshold {report.get('threshold', threshold)} — not published")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# dispatch by the recipe's declared route
|
|
324
|
+
ROUTES = {"classic": classic, "dlc_route": dlc_route}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def build(recipe: Recipe, env: Optional[Env] = None, target=None) -> BuildResult:
|
|
328
|
+
"""Run the route the recipe declares (`recipe.route`)."""
|
|
329
|
+
return ROUTES[recipe.route](recipe, env, target)
|
chipwright/cache.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Content-addressed artifact cache at ~/.cache/chipwright/.
|
|
2
|
+
|
|
3
|
+
Addressed by sha256 so two models that share a build never store it twice (brief §"The manifest and
|
|
4
|
+
the registry"). Verifies the hash on fetch and on read — a corrupted or truncated download is caught
|
|
5
|
+
here, not at load on the board where the error reads like file corruption.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
CACHE_DIR = os.environ.get("CW_CACHE", os.path.expanduser("~/.cache/chipwright"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sha256(path: str, buf: int = 1 << 20) -> str:
|
|
17
|
+
h = hashlib.sha256()
|
|
18
|
+
with open(path, "rb") as f:
|
|
19
|
+
for b in iter(lambda: f.read(buf), b""):
|
|
20
|
+
h.update(b)
|
|
21
|
+
return h.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def path_for(sha256: str) -> str:
|
|
25
|
+
return os.path.join(CACHE_DIR, "blobs", sha256[:2], sha256)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def have(sha256: str) -> bool:
|
|
29
|
+
p = path_for(sha256)
|
|
30
|
+
return os.path.exists(p) and _sha256(p) == sha256
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure(url: str, sha256: str, dst: str) -> str:
|
|
34
|
+
"""Download url to dst (verified by sha256), skipping if dst already matches. Returns dst."""
|
|
35
|
+
if os.path.exists(dst) and _sha256(dst) == sha256:
|
|
36
|
+
return dst
|
|
37
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
38
|
+
tmp = dst + ".part"
|
|
39
|
+
urllib.request.urlretrieve(url, tmp)
|
|
40
|
+
got = _sha256(tmp)
|
|
41
|
+
if got != sha256:
|
|
42
|
+
os.remove(tmp)
|
|
43
|
+
raise RuntimeError(f"sha256 mismatch for {url}: got {got}, expected {sha256}")
|
|
44
|
+
os.replace(tmp, dst)
|
|
45
|
+
return dst
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch(url: str, sha256: str) -> str:
|
|
49
|
+
"""Download url into the content-addressed cache, verify the hash, return the local path."""
|
|
50
|
+
return _ensure(url, sha256, path_for(sha256))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_named(url: str, sha256: str, name: str, sidecars=None) -> str:
|
|
54
|
+
"""Fetch a reference file (verified) into a per-reference dir, KEEPING its original filename, so an
|
|
55
|
+
ONNX's external-data sidecar (`model.data` beside `model.onnx`) resolves. `sidecars` = an iterable of
|
|
56
|
+
(url, sha256, name) fetched into the same dir. Returns the local path to `name`.
|
|
57
|
+
|
|
58
|
+
The content-addressed `fetch` names a blob by its hash, which breaks a model that loads a neighbour
|
|
59
|
+
by filename — so a run reference (cpu_onnx / input / meta) uses this instead.
|
|
60
|
+
"""
|
|
61
|
+
d = os.path.join(CACHE_DIR, "refs", sha256[:12])
|
|
62
|
+
main = _ensure(url, sha256, os.path.join(d, name))
|
|
63
|
+
for s_url, s_sha, s_name in (sidecars or []):
|
|
64
|
+
_ensure(s_url, s_sha, os.path.join(d, s_name))
|
|
65
|
+
return main
|