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/env.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Board probe → TargetKey. The thing the resolver matches artifacts against.
|
|
2
|
+
|
|
3
|
+
On Qualcomm silicon the question that decides everything is *which chip* — specifically the Hexagon
|
|
4
|
+
architecture, the installed QAIRT version, and the SoC. `chipwright doctor` reads those off the board (over
|
|
5
|
+
SSH/ADB) rather than guessing. This mirrors the `qualcomm-env-discovery` skill's `probe-env.sh`, kept
|
|
6
|
+
self-contained here so chipwright-core has no hard dependency on a skill checkout.
|
|
7
|
+
|
|
8
|
+
Offline (no board reachable) is a first-class state, not an error: resolution can still run against a
|
|
9
|
+
TargetKey the user names by hand (`--target htpv68,qnn2.37`).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
# soc_id -> (marketing name, Hexagon HTP arch). Extend as boards are added.
|
|
18
|
+
SOC_ID = {
|
|
19
|
+
498: ("QCS6490 / RB3 Gen2", "v68"),
|
|
20
|
+
35: ("QCS6490 (Advantech)", "v68"),
|
|
21
|
+
30: ("QCS8250 / RB5", "v66"),
|
|
22
|
+
57: ("QCS8550", "v73"),
|
|
23
|
+
69: ("QCS8650", "v75"),
|
|
24
|
+
# QDC fleet soc_ids (verified on-device via `chipwright doctor`):
|
|
25
|
+
603: ("QCS8550 / kalama", "v73"),
|
|
26
|
+
660: ("SM8850 / Snapdragon 8 Elite Gen 5", "v81"),
|
|
27
|
+
# Snapdragon 8 Elite (SM8750) — Hexagon v79, the arch the LLM path reaches over adb. The arch is the
|
|
28
|
+
# load-bearing fact (it is what fails at load and the resolver's hard axis); the on-device lib-name
|
|
29
|
+
# fallback in `probe`/`probe_adb` recovers it even if this marketing soc_id drifts across images.
|
|
30
|
+
618: ("SM8750 / Snapdragon 8 Elite", "v79"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class TargetKey:
|
|
36
|
+
"""What an artifact must be compatible with to load on this board."""
|
|
37
|
+
htp_arch: str # e.g. "v68" — the axis that most often fails at load
|
|
38
|
+
qairt: Optional[str] = None # on-device QAIRT version, e.g. "2.37.1"
|
|
39
|
+
soc_id: Optional[int] = None
|
|
40
|
+
soc_name: Optional[str] = None
|
|
41
|
+
reachable: bool = False
|
|
42
|
+
qnn_libs: tuple = field(default_factory=tuple) # e.g. ("libQnnHtpV68.so",)
|
|
43
|
+
thermal_c: Optional[float] = None
|
|
44
|
+
ram_kb: Optional[int] = None # MemTotal from the board — an LLM bundle's load headroom check
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
sdk = f", qnn{self.qairt}" if self.qairt else ""
|
|
48
|
+
soc = f" [{self.soc_name}]" if self.soc_name else ""
|
|
49
|
+
return f"htp{self.htp_arch}{sdk}{soc}"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def manual(cls, spec: str) -> "TargetKey":
|
|
53
|
+
"""Build from a hand-typed spec like 'htpv68,qnn2.37' — for offline resolution."""
|
|
54
|
+
arch, qairt = None, None
|
|
55
|
+
for tok in spec.replace(" ", "").split(","):
|
|
56
|
+
if tok.startswith("htp"):
|
|
57
|
+
arch = tok[3:]
|
|
58
|
+
elif tok.startswith("qnn"):
|
|
59
|
+
qairt = tok[3:]
|
|
60
|
+
if not arch:
|
|
61
|
+
raise ValueError(f"target spec must name an arch, e.g. 'htpv68': got {spec!r}")
|
|
62
|
+
return cls(htp_arch=arch, qairt=qairt)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _arch_from_libs(libs) -> Optional[str]:
|
|
66
|
+
"""Recover the Hexagon arch from the on-device HTP lib names, or None when it is ambiguous.
|
|
67
|
+
|
|
68
|
+
A board carrying one `libQnnHtpV<NN>.so` answers the arch (libQnnHtpV68.so -> v68). We strip any
|
|
69
|
+
suffix (…Stub, …CalculatorStub) so we never emit a garbage arch like "v68CalculatorStub", and we
|
|
70
|
+
return None unless *every* HTP arch lib agrees on one version — a board with multiple arch stubs is
|
|
71
|
+
ambiguous and we must NOT guess. Shared by the SSH probe and the adb probe so the hardened fallback
|
|
72
|
+
behaves identically on both transports.
|
|
73
|
+
"""
|
|
74
|
+
found = set()
|
|
75
|
+
for lib in libs:
|
|
76
|
+
if lib.startswith("libQnnHtpV"):
|
|
77
|
+
digits = ""
|
|
78
|
+
for ch in lib[len("libQnnHtpV"):]:
|
|
79
|
+
if ch.isdigit():
|
|
80
|
+
digits += ch
|
|
81
|
+
else:
|
|
82
|
+
break
|
|
83
|
+
if digits:
|
|
84
|
+
found.add("v" + digits)
|
|
85
|
+
return found.pop() if len(found) == 1 else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ssh_opts(pw):
|
|
89
|
+
return ["sshpass", "-p", pw, "ssh", "-o", "StrictHostKeyChecking=no",
|
|
90
|
+
"-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=15",
|
|
91
|
+
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def probe(host: str, user: str, pw: str, port: int = 22) -> TargetKey:
|
|
95
|
+
"""SSH to a board and read SoC id, Hexagon arch, on-device QNN libs, temperature.
|
|
96
|
+
|
|
97
|
+
Returns a TargetKey with reachable=False (and whatever it could read) if the board is down.
|
|
98
|
+
"""
|
|
99
|
+
dest = f"{user}@{host}"
|
|
100
|
+
script = (
|
|
101
|
+
"echo SOC=$(cat /sys/devices/soc0/soc_id 2>/dev/null); "
|
|
102
|
+
"echo NETRUN=$(command -v qnn-net-run); "
|
|
103
|
+
"echo LIBS=$(ls /usr/lib/libQnnHtpV*.so 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\\n' ' '); "
|
|
104
|
+
# QAIRT version from the device runtime: 'QNN SDK v2.38.0.<build>' -> 2.38.0 (printed to stderr)
|
|
105
|
+
"echo QAIRT=$(qnn-net-run --version 2>&1 | grep -oE 'v[0-9]+\\.[0-9]+\\.[0-9]+' | head -n 1 | tr -d v); "
|
|
106
|
+
"echo TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
r = subprocess.run(_ssh_opts(pw) + ["-p", str(port), dest, script],
|
|
110
|
+
capture_output=True, text=True, timeout=30)
|
|
111
|
+
except Exception:
|
|
112
|
+
return TargetKey(htp_arch="unknown", reachable=False)
|
|
113
|
+
if r.returncode != 0:
|
|
114
|
+
return TargetKey(htp_arch="unknown", reachable=False)
|
|
115
|
+
|
|
116
|
+
out = dict(line.split("=", 1) for line in r.stdout.strip().splitlines() if "=" in line)
|
|
117
|
+
soc_id = int(out["SOC"]) if out.get("SOC", "").strip().isdigit() else None
|
|
118
|
+
soc_name, arch = SOC_ID.get(soc_id, (None, "unknown"))
|
|
119
|
+
libs = tuple(out.get("LIBS", "").split()) if out.get("LIBS", "").strip() else ()
|
|
120
|
+
# arch fallback from the on-device lib name (libQnnHtpV68.so -> v68) — only when unambiguous.
|
|
121
|
+
if arch == "unknown":
|
|
122
|
+
arch = _arch_from_libs(libs) or arch
|
|
123
|
+
temp = None
|
|
124
|
+
if out.get("TEMP", "").strip().isdigit():
|
|
125
|
+
temp = round(int(out["TEMP"]) / 1000.0, 1)
|
|
126
|
+
qairt = out.get("QAIRT", "").strip() or None
|
|
127
|
+
return TargetKey(htp_arch=arch, qairt=qairt, soc_id=soc_id, soc_name=soc_name,
|
|
128
|
+
reachable=True, qnn_libs=libs, thermal_c=temp)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- adb (Android) probe ------------------------------------------------------------------------
|
|
132
|
+
# A Snapdragon *phone*-class target (the LLM path) is reached over `adb`, not SSH. The question is the
|
|
133
|
+
# same — which chip, which arch, which QAIRT — so probe_adb mirrors `probe` and returns the same
|
|
134
|
+
# TargetKey; only the transport (an `adb -s <serial> shell …`) and the extra RAM read differ.
|
|
135
|
+
|
|
136
|
+
def _adb_base(serial, adb_bin="adb"):
|
|
137
|
+
return [adb_bin] + (["-s", serial] if serial else [])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def probe_adb(serial: Optional[str] = None, adb_bin: str = "adb") -> TargetKey:
|
|
141
|
+
"""adb to a board and read SoC id, Hexagon arch, on-device QNN libs, RAM, temperature.
|
|
142
|
+
|
|
143
|
+
The adb parallel of `probe`. Returns a TargetKey with reachable=False (and whatever it could read)
|
|
144
|
+
if the board is down or `adb` is missing — offline is a first-class state, not an error, exactly as
|
|
145
|
+
on the SSH path. RAM (`MemTotal`) is read too: an LLM Genie bundle is GBs of context binaries, so the
|
|
146
|
+
load-headroom number is worth carrying in the key.
|
|
147
|
+
"""
|
|
148
|
+
script = (
|
|
149
|
+
"echo SOC=$(cat /sys/devices/soc0/soc_id 2>/dev/null); "
|
|
150
|
+
"echo NETRUN=$(command -v qnn-net-run); "
|
|
151
|
+
"echo LIBS=$(ls /usr/lib/libQnnHtpV*.so 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\\n' ' '); "
|
|
152
|
+
"echo QAIRT=$(qnn-net-run --version 2>&1 | grep -oE 'v[0-9]+\\.[0-9]+\\.[0-9]+' | head -n 1 | tr -d v); "
|
|
153
|
+
"echo RAM=$(grep -oE '[0-9]+' /proc/meminfo 2>/dev/null | head -n 1); "
|
|
154
|
+
"echo TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
r = subprocess.run(_adb_base(serial, adb_bin) + ["shell", script],
|
|
158
|
+
capture_output=True, text=True, timeout=30)
|
|
159
|
+
except Exception:
|
|
160
|
+
return TargetKey(htp_arch="unknown", reachable=False)
|
|
161
|
+
if r.returncode != 0:
|
|
162
|
+
return TargetKey(htp_arch="unknown", reachable=False)
|
|
163
|
+
|
|
164
|
+
out = dict(line.split("=", 1) for line in r.stdout.strip().splitlines() if "=" in line)
|
|
165
|
+
soc_id = int(out["SOC"]) if out.get("SOC", "").strip().isdigit() else None
|
|
166
|
+
soc_name, arch = SOC_ID.get(soc_id, (None, "unknown"))
|
|
167
|
+
libs = tuple(out.get("LIBS", "").split()) if out.get("LIBS", "").strip() else ()
|
|
168
|
+
if arch == "unknown":
|
|
169
|
+
arch = _arch_from_libs(libs) or arch
|
|
170
|
+
temp = None
|
|
171
|
+
if out.get("TEMP", "").strip().isdigit():
|
|
172
|
+
temp = round(int(out["TEMP"]) / 1000.0, 1)
|
|
173
|
+
ram_kb = int(out["RAM"]) if out.get("RAM", "").strip().isdigit() else None
|
|
174
|
+
qairt = out.get("QAIRT", "").strip() or None
|
|
175
|
+
return TargetKey(htp_arch=arch, qairt=qairt, soc_id=soc_id, soc_name=soc_name,
|
|
176
|
+
reachable=True, qnn_libs=libs, thermal_c=temp, ram_kb=ram_kb)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- skel-load capability -----------------------------------------------------------------------
|
|
180
|
+
# Some device images (observed: SM8750/v79 on QDC) block loading the unsigned DSP skel — a trivial
|
|
181
|
+
# backend init dies with "Failed to load skel, error 4000" and the LLM simply cannot run there. This is
|
|
182
|
+
# a *device-capability* fact, not a model fault: we detect it up front and report a clean FAIL (naming
|
|
183
|
+
# the axis) instead of letting a run hang on a backend that will never initialize.
|
|
184
|
+
|
|
185
|
+
SKEL_ERR_CODE = 4000
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class SkelCheck:
|
|
190
|
+
"""The result of a trivial on-device backend init. `ok=False` => the DSP skel would not load."""
|
|
191
|
+
ok: bool
|
|
192
|
+
code: Optional[int] = None # the error code when it failed (4000 = skel lockdown)
|
|
193
|
+
reason: str = "" # a plain, axis-naming sentence
|
|
194
|
+
raw: str = "" # the tail of the init output, for the record
|
|
195
|
+
|
|
196
|
+
def __bool__(self) -> bool:
|
|
197
|
+
return self.ok
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# The default trivial init: force the HTP backend (and thus the fastRPC DSP skel) to load. On a locked
|
|
201
|
+
# image this is exactly where "Failed to load skel, error 4000" surfaces, before any model is involved.
|
|
202
|
+
_SKEL_INIT_CMD = ("export ADSP_LIBRARY_PATH=/usr/lib/rfsa/adsp:/usr/lib LD_LIBRARY_PATH=/usr/lib && "
|
|
203
|
+
"qnn-net-run --backend /usr/lib/libQnnHtp.so --version 2>&1")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def detect_skel_load(shell, init_cmd: str = _SKEL_INIT_CMD, timeout: int = 60) -> SkelCheck:
|
|
207
|
+
"""Run a trivial backend init through `shell` and classify whether the DSP skel loads.
|
|
208
|
+
|
|
209
|
+
`shell` is any callable `shell(cmd, timeout=…) -> CompletedProcess` — it duck-types to both
|
|
210
|
+
`AdbDevice.shell` and the SSH transport, so the same detector serves either board. A
|
|
211
|
+
`Failed to load skel … 4000` in the output is a device-capability FAIL (named, not a hang); a
|
|
212
|
+
timeout or an unreachable shell is reported as its own FAIL rather than being swallowed.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
r = shell(init_cmd, timeout=timeout)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
return SkelCheck(ok=False, reason=f"could not run the backend-init probe on the board ({e})")
|
|
218
|
+
if r is None:
|
|
219
|
+
return SkelCheck(ok=False, reason="backend-init probe returned no result (board unreachable?)")
|
|
220
|
+
|
|
221
|
+
raw = ((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or ""))
|
|
222
|
+
low = raw.lower()
|
|
223
|
+
if "failed to load skel" in low or f"error {SKEL_ERR_CODE}" in low or f"0x{SKEL_ERR_CODE:04x}" in low:
|
|
224
|
+
return SkelCheck(
|
|
225
|
+
ok=False, code=SKEL_ERR_CODE,
|
|
226
|
+
reason=("device image blocks loading the DSP skel (Failed to load skel, error 4000) — the "
|
|
227
|
+
"LLM cannot run on this board; this is a device-capability FAIL, not a model fault"),
|
|
228
|
+
raw=raw[-500:])
|
|
229
|
+
return SkelCheck(ok=True, reason="DSP skel loads; backend initialises", raw=raw[-500:])
|
chipwright/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Typed build errors — the build verb fails by naming the axis, never by a bare traceback.
|
|
2
|
+
|
|
3
|
+
A build shells out to the QAIRT toolchain, which may be absent (no x86_64-linux host, no SDK) or may
|
|
4
|
+
reject the graph. Each failure mode is a distinct type so the route orchestrator can stop at the right
|
|
5
|
+
stage, set `BuildResult.failed_axis`, and let the CLI print an honest reason — the same name-the-axis
|
|
6
|
+
discipline the resolver uses for a FAIL.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BuildError(Exception):
|
|
12
|
+
"""Base for every typed build failure. `axis` names the stage that stopped the route.
|
|
13
|
+
|
|
14
|
+
The specific subclasses fix `axis` at the class level; a bare `BuildError` may name its stage
|
|
15
|
+
per-instance (e.g. the convert/quantize executors, which have no dedicated subclass)."""
|
|
16
|
+
axis: str = "build"
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, axis: str = None):
|
|
19
|
+
super().__init__(*args)
|
|
20
|
+
if axis is not None:
|
|
21
|
+
self.axis = axis
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PreflightError(BuildError):
|
|
25
|
+
"""A required host tool (qairt-converter / -quantizer / context-binary-generator) is missing."""
|
|
26
|
+
axis = "preflight"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpUnsupported(BuildError):
|
|
30
|
+
"""The op-support gate found an op that will not run on the target HTP (before any tool runs)."""
|
|
31
|
+
axis = "op_support"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContextGenError(BuildError):
|
|
35
|
+
"""qnn-context-binary-generator failed (e.g. SDK×arch mismatch, VTCM sizing, missing backend .so)."""
|
|
36
|
+
axis = "context"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FidelityBelowThreshold(BuildError):
|
|
40
|
+
"""The built artifact loaded but missed its cosine / task-metric gate — never published."""
|
|
41
|
+
axis = "fidelity"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The CPU reference decoder — what "correct" means for every LLM run, and the pinned reference.
|
|
2
|
+
|
|
3
|
+
The Layer-1 tripwire (`verify_llm.py`) needs something to compare the chip against. That something is
|
|
4
|
+
the **same quantized artifact** (the w4a16 ONNX) run greedily on CPU through onnxruntime — the qai-hub
|
|
5
|
+
`onnxruntime_genai` path. Because it is the same quantized model, a token mismatch isolates the
|
|
6
|
+
backend (the chip), not the quantization. This file is the reference the whole gate trusts, so it must
|
|
7
|
+
match the Genie decode semantics exactly: same tokenizer, same chat template, same KV layout, greedy.
|
|
8
|
+
|
|
9
|
+
**Why it is pinned (FR-L10).** The CPU reference runs on the x86-linux *build host*, not on the host
|
|
10
|
+
that drives the board over adb. So v1 computes the reference greedy tokens/logits **once** and records
|
|
11
|
+
them (with a `sha256`) in the artifact's provenance block; the on-device run compares against that pin
|
|
12
|
+
rather than re-running the CPU reference live over the board transport. `pin_reference` below emits
|
|
13
|
+
that pin and its hash.
|
|
14
|
+
|
|
15
|
+
The ORT-GenAI model load is validated on hardware later (checkpoint C2). Here the decode-loop
|
|
16
|
+
*structure* is written cleanly, and the pin/hash emit is made unit-testable by injecting the decode fn
|
|
17
|
+
(`decode` parameter) — so the provenance shape can be tested without onnxruntime or a model present.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- the greedy decode loop (structure; model load validated on hardware) -------------------------
|
|
28
|
+
|
|
29
|
+
def greedy_decode(session, tokenizer, prompt_ids, max_new_tokens=64, eos_id=None):
|
|
30
|
+
"""Greedy-decode one prompt through the w4a16 prefill/decode split with KV-cache + host embedding.
|
|
31
|
+
|
|
32
|
+
This is the structure of the qai-hub `onnxruntime_genai` path:
|
|
33
|
+
1. **Prefill** — run the prompt token ids through the prefill graph (seq-N, the VTCM-fitting
|
|
34
|
+
length the artifact was exported at), seeding the KV-cache. The embedding lookup is done on
|
|
35
|
+
the host (host embedding) and the hidden states fed in.
|
|
36
|
+
2. **Decode** — autoregressively run the decode graph one token at a time: pick argmax (greedy,
|
|
37
|
+
deterministic — sampling OFF so a mismatch is signal, not sampler noise), append to the
|
|
38
|
+
KV-cache, feed back, until EOS or `max_new_tokens`.
|
|
39
|
+
|
|
40
|
+
Returns (tokens, logits): the generated token ids [T] and the per-step next-token logits [T, vocab].
|
|
41
|
+
`session` is an onnxruntime-genai-style handle exposing `.prefill(ids)->(logits, kv)` and
|
|
42
|
+
`.decode(token, kv)->(logits, kv)`; it is injected so the loop is testable with a fake.
|
|
43
|
+
"""
|
|
44
|
+
logits0, kv = session.prefill(prompt_ids) # [vocab], seeded KV-cache
|
|
45
|
+
tokens, logits = [], []
|
|
46
|
+
nxt = int(np.argmax(logits0)) # greedy: argmax, no sampling
|
|
47
|
+
for _ in range(max_new_tokens):
|
|
48
|
+
tokens.append(nxt)
|
|
49
|
+
if eos_id is not None and nxt == eos_id: # terminate cleanly on EOS
|
|
50
|
+
break
|
|
51
|
+
step_logits, kv = session.decode(nxt, kv)
|
|
52
|
+
logits.append(np.asarray(step_logits, dtype=np.float64))
|
|
53
|
+
nxt = int(np.argmax(step_logits))
|
|
54
|
+
return tokens, (np.stack(logits) if logits else np.zeros((0, 0)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- the pinned reference + its hash (provenance; unit-testable with a fake decoder) --------------
|
|
58
|
+
|
|
59
|
+
def _canonical_bytes(tokens_per_prompt, logits_per_prompt) -> bytes:
|
|
60
|
+
"""Deterministic byte serialization of the reference, so the sha256 is stable across runs/hosts.
|
|
61
|
+
|
|
62
|
+
Logits are rounded to float32 and dumped in a fixed, sorted-key JSON envelope; token ids are plain
|
|
63
|
+
ints. Rounding to float32 keeps the hash reproducible across numpy/BLAS builds that differ only in
|
|
64
|
+
float64 noise below the precision the chip could ever match.
|
|
65
|
+
"""
|
|
66
|
+
env = {
|
|
67
|
+
"tokens": [[int(t) for t in toks] for toks in tokens_per_prompt],
|
|
68
|
+
"logits": [np.asarray(lg, dtype=np.float32).round(6).tolist() for lg in logits_per_prompt],
|
|
69
|
+
}
|
|
70
|
+
return json.dumps(env, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pin_reference(prompt_set, decode, max_new_tokens=64, eos_id=None) -> dict:
|
|
74
|
+
"""Compute the pinned reference for a fixed prompt set and emit it with its sha256 (FR-L10 / T-L08b).
|
|
75
|
+
|
|
76
|
+
`prompt_set` is the fixed list of (already tokenized) prompts. `decode` is the decode function —
|
|
77
|
+
`greedy_decode` bound to a real session on the build host, or a fake in tests; injecting it makes
|
|
78
|
+
the pin/hash emit testable without onnxruntime or a model. The returned dict is the provenance
|
|
79
|
+
payload the record carries: the greedy tokens + per-step logits per prompt and the hash the board
|
|
80
|
+
compares against.
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"prompts": <N>,
|
|
84
|
+
"decode": "greedy",
|
|
85
|
+
"tokens": [[...], ...], # per prompt
|
|
86
|
+
"logits": [<np.ndarray [T, vocab]>, ...],
|
|
87
|
+
"reference_logits_sha256": "<hex>", # goes into provenance.reference_logits_sha256
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
tokens_per_prompt, logits_per_prompt = [], []
|
|
91
|
+
for ids in prompt_set:
|
|
92
|
+
toks, lg = decode(ids, max_new_tokens=max_new_tokens, eos_id=eos_id)
|
|
93
|
+
tokens_per_prompt.append([int(t) for t in toks])
|
|
94
|
+
logits_per_prompt.append(np.asarray(lg))
|
|
95
|
+
|
|
96
|
+
digest = hashlib.sha256(_canonical_bytes(tokens_per_prompt, logits_per_prompt)).hexdigest()
|
|
97
|
+
return {
|
|
98
|
+
"prompts": len(prompt_set),
|
|
99
|
+
"decode": "greedy",
|
|
100
|
+
"tokens": tokens_per_prompt,
|
|
101
|
+
"logits": logits_per_prompt,
|
|
102
|
+
"reference_logits_sha256": digest,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def verify_pin_hash(pin: dict) -> bool:
|
|
107
|
+
"""Recompute the sha256 from a pin's tokens/logits and check it matches the recorded hash.
|
|
108
|
+
|
|
109
|
+
Lets a reader confirm the board compared against the reference the provenance block names — the
|
|
110
|
+
reproducibility guarantee of rule 5 (the record is reproducible from its provenance block).
|
|
111
|
+
"""
|
|
112
|
+
expect = pin.get("reference_logits_sha256")
|
|
113
|
+
actual = hashlib.sha256(_canonical_bytes(pin["tokens"], pin["logits"])).hexdigest()
|
|
114
|
+
return expect == actual
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""ASR streaming-encoder modality runner (a slice of what a `chipwright-asr` package would own).
|
|
2
|
+
|
|
3
|
+
Runs ONE encoder window two ways on the same input and hands back both outputs for the verifier:
|
|
4
|
+
- NPU: quantize inputs with the artifact's own I/O scales, execute the w8a16 context binary.
|
|
5
|
+
- CPU: the float ONNX through ONNX Runtime — the reference.
|
|
6
|
+
|
|
7
|
+
The streaming encoder takes an audio feature window plus cached state. For a single window the
|
|
8
|
+
state is zero, which sidesteps the quant-layout transposes the per-chunk loop needs — enough to
|
|
9
|
+
prove the Run verb and produce an honest cosine.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
_NPDT = {"QNN_DATATYPE_UFIXED_POINT_16": np.uint16, "QNN_DATATYPE_INT_32": np.int32,
|
|
17
|
+
"QNN_DATATYPE_FLOAT_32": np.float32}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _dims(d):
|
|
21
|
+
return [1 if (isinstance(x, str) or x in (0, None)) else x for x in d]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_meta(meta_path):
|
|
25
|
+
g = json.load(open(meta_path))["info"]["graphs"][0]["info"]
|
|
26
|
+
def tl(key):
|
|
27
|
+
out = []
|
|
28
|
+
for t in g[key]:
|
|
29
|
+
i = t["info"]; qp = i.get("quantizeParams", {}) or {}
|
|
30
|
+
so = qp.get("scaleOffset") if isinstance(qp, dict) else None
|
|
31
|
+
out.append(dict(name=i["name"], dtype=i["dataType"], dims=list(i["dimensions"]),
|
|
32
|
+
scale=(so or {}).get("scale"), offset=(so or {}).get("offset")))
|
|
33
|
+
return out
|
|
34
|
+
return g["graphName"], tl("graphInputs"), tl("graphOutputs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _q16(real, scale, offset):
|
|
38
|
+
return np.clip(np.rint(real / scale) - offset, 0, 65535).astype(np.uint16)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dq16(q, scale, offset):
|
|
42
|
+
return (q.astype(np.float32) + offset) * scale
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(device, ctx_board_path, meta_path, cpu_onnx, input_npy, window=39):
|
|
46
|
+
"""Return (npu_out, cpu_out) dicts keyed by 'encoder_out'."""
|
|
47
|
+
import onnxruntime as ort
|
|
48
|
+
|
|
49
|
+
gname, gin, gout = _load_meta(meta_path)
|
|
50
|
+
imap = {t["name"]: t for t in gin}
|
|
51
|
+
enc_out_spec = next(t for t in gout if t["name"] == "encoder_out")
|
|
52
|
+
|
|
53
|
+
feats = np.load(input_npy).astype(np.float32) # [frames, 80]
|
|
54
|
+
if feats.shape[0] < window:
|
|
55
|
+
feats = np.pad(feats, ((0, window - feats.shape[0]), (0, 0)), constant_values=feats.min())
|
|
56
|
+
x = feats[:window][None, :, :] # [1, 39, 80] (float, onnx layout)
|
|
57
|
+
|
|
58
|
+
# ---- CPU reference (float ONNX) --------------------------------------------------------------
|
|
59
|
+
sess = ort.InferenceSession(cpu_onnx, providers=["CPUExecutionProvider"])
|
|
60
|
+
cpu_feeds = {"x": x.astype(np.float32)}
|
|
61
|
+
for i in sess.get_inputs():
|
|
62
|
+
if i.name == "x":
|
|
63
|
+
continue
|
|
64
|
+
shp = _dims(i.shape)
|
|
65
|
+
cpu_feeds[i.name] = np.zeros(shp, np.float32 if i.type == "tensor(float)" else np.int64)
|
|
66
|
+
onames = [o.name for o in sess.get_outputs()]
|
|
67
|
+
cpu_vals = dict(zip(onames, sess.run(None, cpu_feeds)))
|
|
68
|
+
cpu_out = {"encoder_out": cpu_vals["encoder_out"]}
|
|
69
|
+
|
|
70
|
+
# ---- NPU (quantize with the artifact's scales, run the context binary) -----------------------
|
|
71
|
+
native = {}
|
|
72
|
+
xi = imap["x"]
|
|
73
|
+
native["x"] = _q16(np.transpose(x, (0, 2, 1)), xi["scale"], xi["offset"]) # -> [1,80,39]
|
|
74
|
+
for t in gin:
|
|
75
|
+
n = t["name"]
|
|
76
|
+
if n == "x":
|
|
77
|
+
continue
|
|
78
|
+
shp = _dims(t["dims"])
|
|
79
|
+
if t["dtype"] == "QNN_DATATYPE_INT_32":
|
|
80
|
+
native[n] = np.zeros(shp, np.int32)
|
|
81
|
+
else: # zero state -> quantize zeros
|
|
82
|
+
native[n] = _q16(np.zeros(shp, np.float32), t["scale"], t["offset"])
|
|
83
|
+
out_shape = tuple(_dims(enc_out_spec["dims"]))
|
|
84
|
+
raw = device.run(ctx_board_path, native,
|
|
85
|
+
[{"name": "encoder_out", "dtype": "uint16", "shape": out_shape}])
|
|
86
|
+
npu_eo = _dq16(raw["encoder_out"], enc_out_spec["scale"], enc_out_spec["offset"])
|
|
87
|
+
# align shapes (CPU may be [1,T,512]; NPU dims from meta) for a fair cosine
|
|
88
|
+
npu_out = {"encoder_out": npu_eo.reshape(cpu_out["encoder_out"].shape)
|
|
89
|
+
if npu_eo.size == cpu_out["encoder_out"].size else npu_eo}
|
|
90
|
+
return npu_out, cpu_out
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Single-image CV modality runner (first slice of a `chipwright-cv` package).
|
|
2
|
+
|
|
3
|
+
Works for any model whose input is one preprocessed image tensor and whose output is a single tensor
|
|
4
|
+
— classifier logits, a super-resolved image, a segmentation map, a depth field. Runs it two ways on
|
|
5
|
+
the same input and hands both outputs to the verifier:
|
|
6
|
+
- NPU: the quantized context binary, float I/O (qnn-net-run quantizes the input and dequantizes the
|
|
7
|
+
output), so the runner needs no per-tensor scales.
|
|
8
|
+
- CPU: the float ONNX through ONNX Runtime — the reference.
|
|
9
|
+
|
|
10
|
+
A single-graph image model is the cheapest clean end-to-end proof: fixed input shape, one output, a
|
|
11
|
+
cosine that directly measures how faithful the quantized graph is to float.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(device, ctx_board_path, meta, cpu_onnx, input_npy):
|
|
19
|
+
"""Return (npu_out, cpu_out) dicts keyed by the model's output name. `meta` is unused (float I/O
|
|
20
|
+
needs no scales); kept for a uniform modality signature."""
|
|
21
|
+
import onnxruntime as ort
|
|
22
|
+
|
|
23
|
+
x = np.load(input_npy).astype(np.float32) # [1, C, H, W]
|
|
24
|
+
|
|
25
|
+
sess = ort.InferenceSession(cpu_onnx, providers=["CPUExecutionProvider"])
|
|
26
|
+
in_name = sess.get_inputs()[0].name
|
|
27
|
+
out_names = [o.name for o in sess.get_outputs()]
|
|
28
|
+
cpu_vals = sess.run(None, {in_name: x})
|
|
29
|
+
cpu_out = {n: v for n, v in zip(out_names, cpu_vals)}
|
|
30
|
+
|
|
31
|
+
specs = [{"name": n, "dtype": "float32", "shape": tuple(cpu_out[n].shape)} for n in out_names]
|
|
32
|
+
npu = device.run(ctx_board_path, {in_name: x}, specs, native_io=False)
|
|
33
|
+
npu_out = {n: npu[n].reshape(cpu_out[n].shape) for n in out_names}
|
|
34
|
+
return npu_out, cpu_out
|