4"""Offline Ethos-U55 model build step for issue #227.
6Two responsibilities, deliberately separated so the golden pipeline runs in CI
7WITHOUT the heavy, optional Vela toolchain:
91. `compile` runs the pinned ethos-u-vela on a real quantized .tflite to
10 produce a _vela.tflite, in which the "ethos-u" custom op wraps a Vela
11 command stream. An explicit compile request fails if the locked Vela tool is
12 absent or does not produce the expected output; `just setup` installs it.
142. `emit` / `check` turn a committed model DESCRIPTOR
15 (tools/vela/models/*.json) into the lean, linkable ".npub" container defined
16 by libs/ra8_hal/inc/ra8_npu_blob.h, baked as a C header the firmware links.
17 `emit` writes the header; `check` regenerates it in memory and diffs it
18 against the committed golden, failing on drift. Neither needs Vela, so this
19 pair IS the regenerate-and-diff gate the issue asks for.
21What the committed descriptor actually is matters for reading a green run: it
22is a SIM model -- the tiny, documented "SE55" command-stream convention in
23libs/ra8_hal/inc/ra8_npu_fake_cmd.h -- and NOT a real Vela program. It is the
24only Ethos-U55 command stream this repo can produce deterministically without
25a Vela install and without inventing NPU opcodes. The ra8_emulator NPU model and
26the ra8_npu driver both decode that same convention, so the whole
27submit -> run -> read-output path is exercised end to end, but a passing gate
28says nothing about real Vela output. Distilling a REAL _vela.tflite into a
29.npub is wired below as a documented follow-up (see `compile`), pending a
30validated Vela output and an RA8P1 board.
33 python3 tools/vela/src/vela_gen.py emit tools/vela/models/npu_addk_fake.json \
34 -o tools/vela/generated/ra8_npu_model_addk_fake.h
35 python3 tools/vela/src/vela_gen.py check tools/vela/models/npu_addk_fake.json \
36 tools/vela/generated/ra8_npu_model_addk_fake.h
37 python3 tools/vela/src/vela_gen.py compile model_int8.tflite -o build/vela
40from __future__
import annotations
48from pathlib
import Path
50REPO_ROOT = Path(__file__).resolve().parents[3]
55BLOB_MAGIC = 0x3155504E
59HEADER_BYTES = HEADER_WORDS * WORD_BYTES
61REGION_DESC_BYTES = REGION_WORDS * WORD_BYTES
63FNV_OFFSET = 0x811C9DC5
69FAKE_MAGIC = 0x5E550000
70FAKE_OP = {
"copy": 0x0001,
"addk": 0x0002}
81ACCEL_ETHOS_U55_256 = 256
87VELA_ACCEL_CONFIG =
"ethos-u55-256"
90def _fnv1a(data: bytes) -> int:
91 """FNV-1a 32-bit digest over `data` (matches ra8_npu_blob.h / the loader)."""
94 digest = ((digest ^ byte) * FNV_PRIME) & U32_MASK
98def _seed_bytes(size: int, mul: int, add: int) -> bytes:
99 """Deterministic byte pattern: out[i] = (i*mul + add) & 0xFF."""
100 return bytes(((i * mul + add) & BYTE_MASK)
for i
in range(size))
103def _region_payload(region: dict) -> bytes:
104 """Build the baked bytes for one region per its `fill` rule."""
105 size = int(region[
"size"])
106 fill = region.get(
"fill",
"zero")
108 return _seed_bytes(size, int(region.get(
"mul", 1)), int(region.get(
"add", 0)))
110 return bytes([int(region.get(
"value", 0)) & BYTE_MASK]) * size
114def _build_command_stream(desc: dict) -> bytes:
115 """Pack the SE55 stand-in command stream (5 little-endian 32-bit words)."""
117 if op_name
not in FAKE_OP:
118 msg = f
"unknown op '{op_name}' (expected one of {sorted(FAKE_OP)})"
119 raise ValueError(msg)
121 FAKE_MAGIC | FAKE_OP[op_name],
122 int(desc[
"src_region"]),
123 int(desc[
"dst_region"]),
125 int(desc.get(
"addk", 0)),
127 return b
"".join(struct.pack(
"<I", w & U32_MASK)
for w
in words)
130def build_blob(desc: dict) -> bytes:
131 """Assemble a full .npub blob (header + region table + cmd + baked data)."""
132 regions = desc[
"regions"]
133 if len(regions) > MAX_REGIONS:
134 msg = f
"too many regions: {len(regions)} > {MAX_REGIONS} (k_ra8_npu_region_count)"
135 raise ValueError(msg)
136 cmd = _build_command_stream(desc)
137 cmd_offset = HEADER_BYTES + (len(regions) * REGION_DESC_BYTES)
142 data_cursor = cmd_offset + len(cmd)
143 for region
in regions:
144 role = ROLE[region[
"role"]]
145 size = int(region[
"size"])
146 if region.get(
"mode",
"runtime") ==
"baked":
147 payload = _region_payload(region)
148 if len(payload) != size:
149 msg =
"baked payload length must equal region size"
150 raise ValueError(msg)
151 table += struct.pack(
"<IIII", role, RFLAG_BAKED, size, data_cursor)
155 table += struct.pack(
"<IIII", role, 0, size, 0)
158 payload = bytes(table) + cmd + bytes(baked)
159 checksum = _fnv1a(payload)
160 header = struct.pack(
168 int(desc.get(
"accel", ACCEL_ETHOS_U55_256)),
171 blob = header + payload
172 if len(blob) != total:
173 msg = f
"blob length {len(blob)} != declared total {total}"
174 raise ValueError(msg)
178def emit_header(desc: dict, blob: bytes) -> str:
179 """Render `blob` as a self-contained, linkable C byte-array header."""
180 symbol = desc[
"symbol"]
189 for start
in range(0, len(blob), per_row):
190 chunk = blob[start : start + per_row]
191 rows.append(
" " +
"".join(f
"0x{b:02X}, " for b
in chunk).rstrip())
192 body =
"\n".join(rows)
195 " * Copyright (c) 2026 Brighton Sikarskie",
196 " * SPDX-License-Identifier: MIT",
198 f
" * GENERATED by tools/vela/src/vela_gen.py from tools/vela/models/{name}.json.",
199 " * DO NOT EDIT BY HAND. Regenerate with `just quality::local::vela_regen`; the",
200 " * committed copy is diffed by `just quality::local::vela_check` (needs no Vela).",
202 " * A '.npub' Ethos-U55 model container (see libs/ra8_hal/inc/ra8_npu_blob.h):",
203 " * an Ethos-U55 command stream plus its tensor region layout, baked as a byte",
204 " * array the firmware links and the on-target loader (ra8_npu_loader.h) maps",
205 " * into an ra8_npu_job_t. The command stream here is the documented SE55 stand-in",
206 " * convention (ra8_npu_fake_cmd.h), NOT a real Vela program -- see vela_gen.py.",
211 "#include <stdint.h>",
213 f
"/** @brief Raw bytes of the '{name}' .npub model container. */",
214 f
"static const uint8_t s_{symbol}_data[] = {{",
218 f
"/** @brief Base pointer of the '{name}' .npub blob. */",
219 f
"static inline const uint8_t* {symbol}_blob(void)",
221 f
" return s_{symbol}_data;",
224 f
"/** @brief Byte length of the '{name}' .npub blob. */",
225 f
"static inline uint32_t {symbol}_bytes(void)",
227 f
" return (uint32_t)sizeof(s_{symbol}_data);",
231 return "\n".join(lines)
234def _load_desc(path: Path) -> dict:
235 """Read and minimally validate a model descriptor JSON."""
236 desc = json.loads(path.read_text(encoding=
"ascii"))
237 for key
in (
"name",
"symbol",
"op",
"regions",
"src_region",
"dst_region",
"count"):
239 msg = f
"{path}: descriptor missing required key '{key}'"
240 raise ValueError(msg)
244def cmd_emit(args: argparse.Namespace) -> int:
245 """emit: descriptor -> committed C header."""
246 desc = _load_desc(Path(args.descriptor))
247 header = emit_header(desc, build_blob(desc))
248 out = Path(args.output)
249 out.parent.mkdir(parents=
True, exist_ok=
True)
250 out.write_text(header, encoding=
"ascii")
251 print(f
"vela_gen: wrote {out} ({len(header)} bytes)")
255def cmd_check(args: argparse.Namespace) -> int:
256 """check: regenerate the header and diff it against the committed copy."""
257 desc = _load_desc(Path(args.descriptor))
258 fresh = emit_header(desc, build_blob(desc))
259 golden = Path(args.header)
260 if not golden.is_file():
262 f
"vela_gen: MISSING golden {golden} (run `just quality::local::vela_regen`)",
266 current = golden.read_text(encoding=
"ascii")
269 f
"vela_gen: DRIFT -- {golden} is stale vs {args.descriptor}.\n"
270 " Run `just quality::local::vela_regen` and commit the result.",
274 print(f
"vela_gen: {golden} is up to date")
278def cmd_compile(args: argparse.Namespace) -> int:
279 """compile: run the pinned Vela on a real .tflite."""
280 vela = shutil.which(
"vela")
283 "vela_gen: locked ethos-u-vela is missing; run just setup and retry",
287 tflite = Path(args.tflite)
288 if not tflite.is_file():
289 print(f
"vela_gen: no such .tflite: {tflite}", file=sys.stderr)
291 out_dir = Path(args.output)
292 out_dir.mkdir(parents=
True, exist_ok=
True)
295 "--accelerator-config",
301 print(f
"vela_gen: running {' '.join(argv)}")
302 proc = subprocess.run(argv, check=
False)
303 if proc.returncode != 0:
304 return proc.returncode
305 produced = out_dir / f
"{tflite.stem}_vela.tflite"
306 if not produced.is_file():
307 print(f
"vela_gen: Vela did not create expected output: {produced}", file=sys.stderr)
309 print(f
"vela_gen: Vela wrote {produced}")
315 "vela_gen: NOTE -- extracting the command stream from _vela.tflite into a "
316 ".npub is a documented follow-up; use `emit` on a model descriptor for now.",
321def main(argv: list[str]) -> int:
322 """Parse the subcommand line and dispatch to the matching `cmd_*` handler.
324 A subcommand is required, so a bare invocation is an argparse usage error
325 rather than a default action.
327 Every handler returns 0 only after its requested operation ran and its
328 expected output or golden comparison was verified.
331 argv: Argument list WITHOUT the program name (callers pass
335 The handler's status, for `sys.exit`.
337 parser = argparse.ArgumentParser(description=
"Offline Ethos-U55 model build step (#227).")
338 sub = parser.add_subparsers(dest=
"command", required=
True)
340 p_emit = sub.add_parser(
"emit", help=
"descriptor -> committed C header")
341 p_emit.add_argument(
"descriptor", help=
"model descriptor JSON")
342 p_emit.add_argument(
"-o",
"--output", required=
True, help=
"output C header path")
343 p_emit.set_defaults(func=cmd_emit)
345 p_check = sub.add_parser(
"check", help=
"regenerate and diff against the golden header")
346 p_check.add_argument(
"descriptor", help=
"model descriptor JSON")
347 p_check.add_argument(
"header", help=
"committed golden C header to diff")
348 p_check.set_defaults(func=cmd_check)
350 p_compile = sub.add_parser(
"compile", help=
"run the pinned Vela on a .tflite")
351 p_compile.add_argument(
"tflite", help=
"quantized INT8/INT16 .tflite model")
352 p_compile.add_argument(
"-o",
"--output", default=
"build/vela", help=
"Vela output dir")
353 p_compile.set_defaults(func=cmd_compile)
355 args = parser.parse_args(argv)
356 return int(args.func(args))
359if __name__ ==
"__main__":
360 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.