ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
vela_gen.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Offline Ethos-U55 model build step for issue #227.
5
6Two responsibilities, deliberately separated so the golden pipeline runs in CI
7WITHOUT the heavy, optional Vela toolchain:
8
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.
13
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.
20
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.
31
32Usage:
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
38"""
39
40from __future__ import annotations
41
42import argparse
43import json
44import shutil
45import struct
46import subprocess
47import sys
48from pathlib import Path
49
50REPO_ROOT = Path(__file__).resolve().parents[3]
51
52# ---------------------------------------------------------------------------
53# Container constants -- MUST match libs/ra8_hal/inc/ra8_npu_blob.h exactly.
54# ---------------------------------------------------------------------------
55BLOB_MAGIC = 0x3155504E # "NPU1" little-endian
56BLOB_VERSION = 1
57HEADER_WORDS = 8
58WORD_BYTES = 4
59HEADER_BYTES = HEADER_WORDS * WORD_BYTES
60REGION_WORDS = 4
61REGION_DESC_BYTES = REGION_WORDS * WORD_BYTES
62RFLAG_BAKED = 0x1
63FNV_OFFSET = 0x811C9DC5
64FNV_PRIME = 0x01000193
65U32_MASK = 0xFFFFFFFF
66BYTE_MASK = 0xFF
67
68# Fake command-stream convention -- MUST match libs/ra8_hal/inc/ra8_npu_fake_cmd.h.
69FAKE_MAGIC = 0x5E550000
70FAKE_OP = {"copy": 0x0001, "addk": 0x0002}
71
72ROLE = {
73 "weights": 0,
74 "scratch": 1,
75 "input": 2,
76 "output": 3,
77 "other": 4,
78}
79
80# Ethos-U55 accelerator config Vela targets for the RA8P1 SKU.
81ACCEL_ETHOS_U55_256 = 256
82
83# BASEPn region base-pointer pairs the Ethos-U55 exposes (k_ra8_npu_region_count).
84MAX_REGIONS = 8
85
86# Pinned Vela accelerator argument (see tools/vela/README.md and the uv lock).
87VELA_ACCEL_CONFIG = "ethos-u55-256"
88
89
90def _fnv1a(data: bytes) -> int:
91 """FNV-1a 32-bit digest over `data` (matches ra8_npu_blob.h / the loader)."""
92 digest = FNV_OFFSET
93 for byte in data:
94 digest = ((digest ^ byte) * FNV_PRIME) & U32_MASK
95 return digest
96
97
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))
101
102
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")
107 if fill == "seed":
108 return _seed_bytes(size, int(region.get("mul", 1)), int(region.get("add", 0)))
109 if fill == "const":
110 return bytes([int(region.get("value", 0)) & BYTE_MASK]) * size
111 return bytes(size)
112
113
114def _build_command_stream(desc: dict) -> bytes:
115 """Pack the SE55 stand-in command stream (5 little-endian 32-bit words)."""
116 op_name = desc["op"]
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)
120 words = [
121 FAKE_MAGIC | FAKE_OP[op_name],
122 int(desc["src_region"]),
123 int(desc["dst_region"]),
124 int(desc["count"]),
125 int(desc.get("addk", 0)),
126 ]
127 return b"".join(struct.pack("<I", w & U32_MASK) for w in words)
128
129
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)
138
139 # Baked region data blocks follow the command stream, in descriptor order.
140 table = bytearray()
141 baked = bytearray()
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)
152 baked += payload
153 data_cursor += size
154 else:
155 table += struct.pack("<IIII", role, 0, size, 0)
156
157 total = data_cursor
158 payload = bytes(table) + cmd + bytes(baked)
159 checksum = _fnv1a(payload)
160 header = struct.pack(
161 "<IIIIIIII",
162 BLOB_MAGIC,
163 BLOB_VERSION,
164 total,
165 len(regions),
166 cmd_offset,
167 len(cmd),
168 int(desc.get("accel", ACCEL_ETHOS_U55_256)),
169 checksum,
170 )
171 blob = header + payload
172 if len(blob) != total:
173 msg = f"blob length {len(blob)} != declared total {total}"
174 raise ValueError(msg)
175 return blob
176
177
178def emit_header(desc: dict, blob: bytes) -> str:
179 """Render `blob` as a self-contained, linkable C byte-array header."""
180 symbol = desc["symbol"]
181 name = desc["name"]
182 # 16 bytes/row keeps each data line at 97 columns (2 indent + 16*"0xXX, "
183 # minus the trailing space), inside the .clang-format ColumnLimit of 100, so
184 # the emitted golden is already clang-format-22 clean and
185 # `just quality::local::vela_check`
186 # (generator output vs committed header) and the format gate never disagree.
187 per_row = 16
188 rows = []
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)
193 lines = [
194 "/*",
195 " * Copyright (c) 2026 Brighton Sikarskie",
196 " * SPDX-License-Identifier: MIT",
197 " *",
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).",
201 " *",
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.",
207 " */",
208 "",
209 "#pragma once",
210 "",
211 "#include <stdint.h>",
212 "",
213 f"/** @brief Raw bytes of the '{name}' .npub model container. */",
214 f"static const uint8_t s_{symbol}_data[] = {{",
215 body,
216 "};",
217 "",
218 f"/** @brief Base pointer of the '{name}' .npub blob. */",
219 f"static inline const uint8_t* {symbol}_blob(void)",
220 "{",
221 f" return s_{symbol}_data;",
222 "}",
223 "",
224 f"/** @brief Byte length of the '{name}' .npub blob. */",
225 f"static inline uint32_t {symbol}_bytes(void)",
226 "{",
227 f" return (uint32_t)sizeof(s_{symbol}_data);",
228 "}",
229 "",
230 ]
231 return "\n".join(lines)
232
233
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"):
238 if key not in desc:
239 msg = f"{path}: descriptor missing required key '{key}'"
240 raise ValueError(msg)
241 return desc
242
243
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)")
252 return 0
253
254
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():
261 print(
262 f"vela_gen: MISSING golden {golden} (run `just quality::local::vela_regen`)",
263 file=sys.stderr,
264 )
265 return 1
266 current = golden.read_text(encoding="ascii")
267 if current != fresh:
268 print(
269 f"vela_gen: DRIFT -- {golden} is stale vs {args.descriptor}.\n"
270 " Run `just quality::local::vela_regen` and commit the result.",
271 file=sys.stderr,
272 )
273 return 1
274 print(f"vela_gen: {golden} is up to date")
275 return 0
276
277
278def cmd_compile(args: argparse.Namespace) -> int:
279 """compile: run the pinned Vela on a real .tflite."""
280 vela = shutil.which("vela")
281 if vela is None:
282 print(
283 "vela_gen: locked ethos-u-vela is missing; run just setup and retry",
284 file=sys.stderr,
285 )
286 return 1
287 tflite = Path(args.tflite)
288 if not tflite.is_file():
289 print(f"vela_gen: no such .tflite: {tflite}", file=sys.stderr)
290 return 1
291 out_dir = Path(args.output)
292 out_dir.mkdir(parents=True, exist_ok=True)
293 argv = [
294 vela,
295 "--accelerator-config",
296 VELA_ACCEL_CONFIG,
297 "--output-dir",
298 str(out_dir),
299 str(tflite),
300 ]
301 print(f"vela_gen: running {' '.join(argv)}")
302 proc = subprocess.run(argv, check=False) # noqa: S603 -- resolved path, fixed argv
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)
308 return 1
309 print(f"vela_gen: Vela wrote {produced}")
310 # TODO(#227 follow-up): distilling this real _vela.tflite (the ethos-u custom
311 # op command stream + region layout) into a .npub requires a validated Vela
312 # output and an RA8P1 board to confirm the extracted stream runs on silicon.
313 # Until then the committed golden uses the SE55 stand-in descriptor via `emit`.
314 print(
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.",
317 )
318 return 0
319
320
321def main(argv: list[str]) -> int:
322 """Parse the subcommand line and dispatch to the matching `cmd_*` handler.
323
324 A subcommand is required, so a bare invocation is an argparse usage error
325 rather than a default action.
326
327 Every handler returns 0 only after its requested operation ran and its
328 expected output or golden comparison was verified.
329
330 Args:
331 argv: Argument list WITHOUT the program name (callers pass
332 `sys.argv[1:]`).
333
334 Returns:
335 The handler's status, for `sys.exit`.
336 """
337 parser = argparse.ArgumentParser(description="Offline Ethos-U55 model build step (#227).")
338 sub = parser.add_subparsers(dest="command", required=True)
339
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)
344
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)
349
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)
354
355 args = parser.parse_args(argv)
356 return int(args.func(args))
357
358
359if __name__ == "__main__":
360 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298