Merge branch 'feat/npu' into dev

This commit is contained in:
maksyuki committed 2026-09-22 12:23:21 +08:00
commit ca4b06d304
158 files changed
+48983 -232

No files matched your search

+51
View File
@@ -26,6 +26,57 @@ Scripts are part of the build contract. Prefer existing helpers over ad-hoc
shell behavior, preserve structured JSON results, and keep setup/download
behavior controlled by `dependencies/dependencies.lock.json`.
`setup_npu_reference.py` prepares the locked KWS/VWW inputs and the existing
TensorFlow/gemmlowp reference sources. `npu_framework_reference.py` builds the
host-only adapter in `tests/cpp/npu_framework_reference.cc`; its integer kernels
consume original tensor constants and quantization, never NPU packed artifacts.
Only raw model parsing is shared with the NPU compiler. Run
`make CONFIG=configs/ci/ihp130.mk npu-p0-qualify` after
`make CONFIG=configs/ci/ihp130.mk setup-npu-reference` to compare both complete
1000-input corpora against the Python graph reference and compiled executor.
The selected variant's `npu/p0/` directory retains oracle build provenance,
per-input golden tensors, three-way hashes, logs and `qualification-p0.json`.
The runner uses at most 16 workers (`JOBS`); missing sources/tools, numerical
differences and incomplete runs cannot pass. A direct `--limit` invocation is
debug-only and returns a nonzero status even when its selected cases match.
`npu_compiler.py` is the P5 production entry point. It accepts one static
batch-one INT8 TFLite v3 subgraph, rejects unsupported placement with an
operator-specific diagnostic, and emits ABI-1 descriptors, packed constants,
`npu.json`, and a model-prefixed freestanding C plan. The generated plan uses
caller-owned aligned descriptor/arena storage, checked physical relocation,
bounded HAL waits, 64-byte Zicbom maintenance when compiled for HP, and the
model-derived terminal integer Softmax. Generated files remain below the
selected build variant. `make CONFIG=configs/ci/ihp130.mk npu-p5-deployments`
builds the locked KWS and VWW packages reproducibly.
`qualify_npu_p5.py` builds one production NPU fixture per simulator and runs
the frozen ten-input sets from both manifests. Every accepted output byte is
checked in actual tile-write order, so later arena reuse cannot hide an
intermediate mismatch. Invoke it through
`make CONFIG=configs/ci/ihp130.mk npu-p5-rtl`; missing tools, inputs, terminal
markers, or any layer write fail the required run.
`npu_p5_report.py` fails closed while assembling P5 evidence. It verifies P0,
compiler/parity and embedded-C/HAL results; exact dual-simulator case counts;
source, trace, log and deployment hashes; LP/HP build manifests and firmware;
cross-profile KWS package identity; and zero skipped/rejected cases. Invoke it
through `npu-p5-report` with the retained LP/HP variant roots and config
digests. The generated report records its fully expanded command.
NPU-P6 qualification is split into auditable drivers. `npu_p6_corpus.py`
builds deterministic CRC-protected 100-case KWS/VWW shards from retained P0
goldens. `run_npu_p6_verilator.py` builds the existing `hp_boot` composition,
runs all shards on one PRODUCT Verilator model, and aggregates architectural
cycles and NPU counters. `run_npu_p6_netlist.py` runs macro-aware isolated
synthesis/STA and four directed synthesized-block transactions covering dense,
depthwise, rejection and accumulation-overflow behavior;
`run_npu_p6_physical.py` invokes the unchanged full PRODUCT Yosys/OpenSTA and
warning/metric gates. `run_npu_p6_regression.py` retains PR/nightly verdicts,
and `npu_p6_report.py` requires current-revision PASS evidence for all of
NPU-V015 through NPU-V018. None of these scripts treats FPGA execution or host
elapsed time as mandatory performance evidence.
`development_environment.py` is the shared Docker, Nix, and manual bootstrap
entry point. It installs only the checksum-verified open-source tool bundles and
hash-pinned Python dependencies; project-local PDK and source setup remains under
+16 -5
View File
@@ -15,21 +15,29 @@ def build(args: argparse.Namespace) -> None:
elf = output / "hp_smoke.elf"
compiler = f"{args.cross}gcc"
objcopy = f"{args.cross}objcopy"
subprocess.run(
[
command = [
compiler,
"-march=rv32imafdc_zicbom_zicsr_zifencei",
"-mabi=ilp32d",
"-nostdlib",
"-nostartfiles",
"-ffreestanding",
"-O3",
"-Wall",
"-Wextra",
"-Werror=implicit-function-declaration",
"-Werror=return-type",
"-Wl,--build-id=none",
f"-Wl,-T,{args.linker.resolve()}",
"-o",
str(elf),
str(args.source.resolve()),
],
check=True,
)
]
for include in args.include:
command.append(f"-I{include.resolve()}")
command.extend(str(source.resolve()) for source in args.extra_source)
command.extend(args.define)
subprocess.run(command, check=True)
subprocess.run(
[objcopy, "-O", "binary", str(elf), str(images / "fw_jump.bin")],
check=True,
@@ -45,6 +53,9 @@ def main() -> None:
parser.add_argument("--linker", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--cross", default="riscv32-unknown-elf-")
parser.add_argument("--extra-source", type=Path, action="append", default=[])
parser.add_argument("--include", type=Path, action="append", default=[])
parser.add_argument("--define", action="append", default=[])
build(parser.parse_args())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Emit the pinned first KWS input/output as build-local C acceptance data."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from npu_compiler_p0 import KWS_CORPUS_FIRST, compile_kws
from npu_executor import execute_job
def values(data: bytes) -> str:
return ",".join(f"INT8_C({value - 256 if value >= 128 else value})" for value in data)
def emit(output: Path) -> None:
input_data = KWS_CORPUS_FIRST.read_bytes()
result = execute_job(compile_kws(), input_data)
output_data = bytes(value & 0xFF for value in result.output.data)
output.mkdir(parents=True, exist_ok=True)
header = output / "npu_acceptance_data.h"
source = output / "npu_acceptance_data.c"
header.write_text("""#ifndef RETROSOC_NPU_ACCEPTANCE_DATA_H
#define RETROSOC_NPU_ACCEPTANCE_DATA_H
#include <stdint.h>
#define RS_NPU_ACCEPTANCE_INPUT_BYTES UINT32_C(490)
#define RS_NPU_ACCEPTANCE_OUTPUT_BYTES UINT32_C(12)
extern const int8_t rs_npu_acceptance_input[490];
extern const int8_t rs_npu_acceptance_output[12];
#endif
""", encoding="ascii")
source.write_text(f"""#include <stdint.h>
#include "npu_acceptance_data.h"
_Alignas(64) const int8_t rs_npu_acceptance_input[490] = {{{values(input_data)}}};
_Alignas(64) const int8_t rs_npu_acceptance_output[12] = {{{values(output_data)}}};
""", encoding="ascii")
manifest = {
"schema": 1,
"workload": "kws",
"input": KWS_CORPUS_FIRST.name,
"input_sha256": hashlib.sha256(input_data).hexdigest(),
"output_sha256": hashlib.sha256(output_data).hexdigest(),
"output": list(result.output.data),
}
(output / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True)
emit(parser.parse_args().output_dir.resolve())
if __name__ == "__main__":
main()
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Deterministic Mini NPU ABI-1 compiler and static bare-metal plan emitter."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from npu_compiler_p0 import compile_model, write_artifacts # noqa: E402
from npu_model import parse_tflite # noqa: E402
COMPILER_REVISION = "npu-p5/1.0.1"
SYMBOL = re.compile(r"^[a-z][a-z0-9_]*$")
def _c_bytes(name: str, data: bytes, *, align: bool = True) -> str:
payload = data or b"\x00"
values = ",".join(f"0x{value:02x}U" for value in payload)
alignment = "_Alignas(64) " if align else ""
return f"{alignment}const uint8_t {name}[{len(payload)}] = {{{values}}};\n"
def _c_words(name: str, data: bytes) -> str:
words = struct.unpack(f"<{len(data) // 4}I", data)
values = ",".join(f"UINT32_C(0x{value:08x})" for value in words)
return f"static const uint32_t {name}[{len(words)}] = {{{values}}};\n"
def _guard(prefix: str) -> str:
return f"RETROSOC_GENERATED_{prefix.upper()}_NPU_H"
def emit_header(job, prefix: str) -> str:
upper = prefix.upper()
guard = _guard(prefix)
output_bytes = job.report["output"]["bytes"]
return f"""#ifndef {guard}
#define {guard}
#include <stdint.h>
#include <retrosoc/hal/npu.h>
#define RS_{upper}_NPU_ARENA_BYTES UINT32_C({job.arena_bytes})
#define RS_{upper}_NPU_INPUT_BYTES UINT32_C({job.report['input']['bytes']})
#define RS_{upper}_NPU_OUTPUT_BYTES UINT32_C({output_bytes})
#define RS_{upper}_NPU_DESCRIPTOR_COUNT UINT32_C({len(job.descriptors)})
#define RS_{upper}_NPU_REQUIRED_CAPABILITY UINT32_C(0x{job.report['required_capability_mask']:08X})
#define RS_{upper}_NPU_REQUIRED_OPCODES UINT32_C(0x{job.report['required_opcode_mask']:08X})
typedef struct {{
uint32_t descriptor_base;
uint32_t arena_base;
uint32_t weights_base;
uint32_t params_base;
}} rs_{prefix}_npu_regions_t;
typedef struct {{
_Alignas(64) uint32_t descriptors[{len(job.descriptors)}][RS_NPU_DESCRIPTOR_WORDS];
_Alignas(64) uint8_t arena[{job.arena_bytes}];
rs_{prefix}_npu_regions_t regions;
uint64_t input_copy_cycles;
uint32_t prepared;
}} rs_{prefix}_npu_workspace_t;
typedef struct {{
uint64_t input_copy_cycles;
uint64_t npu_wait_cycles;
uint64_t softmax_cycles;
rs_npu_counters_t counters;
}} rs_{prefix}_npu_profile_t;
extern const uint8_t rs_{prefix}_npu_weights[{max(1, len(job.weights))}];
extern const uint8_t rs_{prefix}_npu_params[{max(1, len(job.params))}];
rs_status_t rs_{prefix}_npu_default_regions(rs_{prefix}_npu_workspace_t *workspace,
rs_{prefix}_npu_regions_t *regions);
rs_status_t rs_{prefix}_npu_prepare(rs_{prefix}_npu_workspace_t *workspace,
const rs_{prefix}_npu_regions_t *regions,
const int8_t *input, uint32_t input_bytes);
rs_status_t rs_{prefix}_npu_execute(rs_{prefix}_npu_workspace_t *workspace,
uint32_t job_id, rs_timeout_t timeout,
int8_t *output, uint32_t output_bytes,
rs_{prefix}_npu_profile_t *profile);
#if defined(RS_NPU_PLAN_TEST)
rs_status_t rs_{prefix}_npu_test_softmax(const int8_t *logits, int8_t *output);
#endif
#endif
"""
def _finalizer_c(prefix: str, params: dict | None, source_offset: int, output_bytes: int) -> str:
if params is None:
return f"""
static rs_status_t rs_{prefix}_finalize(const int8_t *logits, int8_t *output) {{
uint32_t index;
for (index = 0U; index < UINT32_C({output_bytes}); ++index) {{ output[index] = logits[index]; }}
return RS_OK;
}}
#if defined(RS_NPU_PLAN_TEST)
rs_status_t rs_{prefix}_npu_test_softmax(const int8_t *logits, int8_t *output) {{
if ((logits == NULL) || (output == NULL)) {{ return RS_EINVAL; }}
return rs_{prefix}_finalize(logits, output);
}}
#endif
#define RS_{prefix.upper()}_NPU_LOGITS_OFFSET UINT32_C({source_offset})
"""
barrel = ((-2, 1672461947), (-1, 1302514674), (0, 790015084),
(1, 290630308), (2, 39332535), (3, 720401), (4, 242))
conditions = "\n".join(
f" if ((remainder & (UINT32_C(1) << {26 + exponent}U)) != 0U) {{ result = rs_{prefix}_mul_high(result, INT32_C({value})); }}"
for exponent, value in barrel
)
return f"""
static int32_t rs_{prefix}_trunc_div(int64_t numerator, int64_t denominator) {{
int64_t value = numerator < 0 ? -numerator : numerator;
value /= denominator;
return (int32_t)(numerator < 0 ? -value : value);
}}
static int32_t rs_{prefix}_mul_high(int32_t a, int32_t b) {{
int64_t product;
int64_t nudge;
if ((a == INT32_MIN) && (b == INT32_MIN)) {{ return INT32_MAX; }}
product = (int64_t)a * (int64_t)b;
nudge = product >= 0 ? INT64_C(1073741824) : -INT64_C(1073741823);
return rs_{prefix}_trunc_div(product + nudge, INT64_C(2147483648));
}}
static int32_t rs_{prefix}_round_pot(int32_t value, uint32_t exponent) {{
uint32_t mask;
uint32_t remainder;
uint32_t threshold;
if (exponent == 0U) {{ return value; }}
mask = (UINT32_C(1) << exponent) - UINT32_C(1);
remainder = ((uint32_t)value) & mask;
threshold = (mask >> 1U) + (value < 0 ? UINT32_C(1) : UINT32_C(0));
return (value >> exponent) + (remainder > threshold ? 1 : 0);
}}
static int32_t rs_{prefix}_sat_shift(int32_t value, uint32_t exponent) {{
int64_t shifted = (int64_t)value * ((int64_t)UINT32_C(1) << exponent);
if (shifted > INT32_MAX) {{ return INT32_MAX; }}
if (shifted < INT32_MIN) {{ return INT32_MIN; }}
return (int32_t)shifted;
}}
static int32_t rs_{prefix}_exp_interval(int32_t value) {{
int32_t x = value + INT32_C(268435456);
int32_t x2 = rs_{prefix}_mul_high(x, x);
int32_t x3 = rs_{prefix}_mul_high(x2, x);
int32_t x4 = rs_{prefix}_mul_high(x2, x2);
int32_t series = rs_{prefix}_round_pot(
rs_{prefix}_mul_high(rs_{prefix}_round_pot(x4, 2U) + x3, INT32_C(715827883)) + x2, 1U);
return INT32_C(1895147668) + rs_{prefix}_mul_high(INT32_C(1895147668), x + series);
}}
static int32_t rs_{prefix}_exp_negative(int32_t value) {{
int32_t mod = (int32_t)(((uint32_t)value & UINT32_C(16777215)) - UINT32_C(16777216));
uint32_t remainder = (uint32_t)(mod - value);
int32_t result = rs_{prefix}_exp_interval(mod * 32);
{conditions}
return value == 0 ? INT32_MAX : result;
}}
static int32_t rs_{prefix}_reciprocal(int32_t value) {{
int64_t total = (int64_t)value + INT32_MAX;
int32_t half = rs_{prefix}_trunc_div(total + (total >= 0 ? 1 : -1), 2);
int32_t estimate = INT32_C(1515870810) + rs_{prefix}_mul_high(half, -INT32_C(1010580540));
uint32_t iteration;
for (iteration = 0U; iteration < 3U; ++iteration) {{
int32_t product = rs_{prefix}_mul_high(half, estimate);
int32_t increment = rs_{prefix}_mul_high(estimate, INT32_C(536870912) - product);
estimate += rs_{prefix}_sat_shift(increment, 2U);
}}
return rs_{prefix}_sat_shift(estimate, 1U);
}}
static uint32_t rs_{prefix}_clz(uint32_t value) {{
uint32_t count = 0U;
while ((value & UINT32_C(0x80000000)) == 0U) {{ ++count; value <<= 1U; }}
return count;
}}
static rs_status_t rs_{prefix}_finalize(const int8_t *logits, int8_t *output) {{
int8_t maximum = INT8_MIN;
int32_t exponentials[{output_bytes}];
int64_t accumulation = 0;
uint32_t index;
uint32_t headroom;
int32_t scale;
int32_t shifted_sum;
int32_t num_bits_over_unit;
for (index = 0U; index < UINT32_C({output_bytes}); ++index) {{
if (logits[index] > maximum) {{ maximum = logits[index]; }}
}}
for (index = 0U; index < UINT32_C({output_bytes}); ++index) {{
int32_t difference = (int32_t)logits[index] - (int32_t)maximum;
exponentials[index] = 0;
if (difference >= INT32_C({params['diff_min']})) {{
int64_t shifted = (int64_t)difference * ((int64_t)UINT32_C(1) << {params['input_left_shift']}U);
int32_t scaled = rs_{prefix}_mul_high((int32_t)shifted, INT32_C({params['input_multiplier']}));
exponentials[index] = rs_{prefix}_exp_negative(scaled);
accumulation += rs_{prefix}_round_pot(exponentials[index], 12U);
}}
}}
if ((accumulation <= 0) || (accumulation >= INT64_C(2147483648))) {{ return RS_EIO; }}
headroom = rs_{prefix}_clz((uint32_t)accumulation);
num_bits_over_unit = 12 - (int32_t)headroom;
shifted_sum = (int32_t)(((uint32_t)accumulation << headroom) - UINT32_C(0x80000000));
scale = rs_{prefix}_reciprocal(shifted_sum);
for (index = 0U; index < UINT32_C({output_bytes}); ++index) {{
int32_t quantized = -128;
if (exponentials[index] != 0) {{
int32_t value = rs_{prefix}_mul_high(scale, exponentials[index]);
value = rs_{prefix}_round_pot(value, (uint32_t)(num_bits_over_unit + 23));
quantized = value - 128;
if (quantized > 127) {{ quantized = 127; }}
if (quantized < -128) {{ quantized = -128; }}
}}
output[index] = (int8_t)quantized;
}}
return RS_OK;
}}
#if defined(RS_NPU_PLAN_TEST)
rs_status_t rs_{prefix}_npu_test_softmax(const int8_t *logits, int8_t *output) {{
if ((logits == NULL) || (output == NULL)) {{ return RS_EINVAL; }}
return rs_{prefix}_finalize(logits, output);
}}
#endif
#define RS_{prefix.upper()}_NPU_LOGITS_OFFSET UINT32_C({source_offset})
"""
def emit_source(job, prefix: str, header_name: str) -> str:
descriptors = b"".join(item.to_bytes() for item in job.descriptors)
relocations = ",".join(
"{" + f"{item['descriptor_index']}U,{item['word_index']}U,"
f"{ {'arena': 0, 'weights': 1, 'params': 2}[item['region']] }U,"
f"UINT32_C({item['offset']})" + "}"
for item in job.relocations
)
output = job.report["output"]
source_descriptor = output["source_descriptor"]
final = next(entry for entry in job.report["operators"]
if entry.get("descriptor_index") == source_descriptor)
source_offset = final["arena"]["output_offset"]
output_bytes = output["bytes"]
softmax = job.steps[-1] if job.steps[-1].get("op") == "softmax" else None
if softmax is not None and softmax["source_descriptor"] != source_descriptor:
raise ValueError("internal: Softmax source descriptor disagrees with graph output")
return f"""#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include "{header_name}"
typedef struct {{ uint16_t descriptor; uint8_t word; uint8_t region; uint32_t offset; }} rs_{prefix}_reloc_t;
{_c_words(f'rs_{prefix}_npu_templates', descriptors)}
static const rs_{prefix}_reloc_t rs_{prefix}_npu_relocations[{len(job.relocations)}] = {{{relocations}}};
{_c_bytes(f'rs_{prefix}_npu_weights', job.weights)}
{_c_bytes(f'rs_{prefix}_npu_params', job.params)}
static uint64_t rs_{prefix}_cycles(void) {{
#if defined(__riscv)
uint32_t high0; uint32_t low; uint32_t high1;
do {{ __asm__ volatile("rdcycleh %0" : "=r"(high0));
__asm__ volatile("rdcycle %0" : "=r"(low));
__asm__ volatile("rdcycleh %0" : "=r"(high1)); }} while (high0 != high1);
return ((uint64_t)high0 << 32U) | low;
#else
return UINT64_C(0);
#endif
}}
static void rs_{prefix}_fence(void) {{
#if defined(__riscv)
__asm__ volatile("fence rw, rw" ::: "memory");
#else
__asm__ volatile("" ::: "memory");
#endif
}}
static void rs_{prefix}_cache_clean(const void *pointer, uint32_t bytes) {{
#if defined(__riscv_zicbom)
uintptr_t cursor = (uintptr_t)pointer & ~(uintptr_t)UINT32_C(63);
uintptr_t end = ((uintptr_t)pointer + bytes + UINT32_C(63)) & ~(uintptr_t)UINT32_C(63);
while (cursor < end) {{ __asm__ volatile("cbo.clean 0(%0)" :: "r"(cursor) : "memory"); cursor += 64U; }}
#else
(void)pointer; (void)bytes;
#endif
rs_{prefix}_fence();
}}
static void rs_{prefix}_cache_invalidate(const void *pointer, uint32_t bytes) {{
#if defined(__riscv_zicbom)
uintptr_t cursor = (uintptr_t)pointer & ~(uintptr_t)UINT32_C(63);
uintptr_t end = ((uintptr_t)pointer + bytes + UINT32_C(63)) & ~(uintptr_t)UINT32_C(63);
while (cursor < end) {{ __asm__ volatile("cbo.inval 0(%0)" :: "r"(cursor) : "memory"); cursor += 64U; }}
#else
(void)pointer; (void)bytes;
#endif
rs_{prefix}_fence();
}}
{_finalizer_c(prefix, softmax, source_offset, output_bytes)}
static rs_status_t rs_{prefix}_address(const void *pointer, uint32_t *address) {{
uintptr_t value;
if ((pointer == NULL) || (address == NULL)) {{ return RS_EINVAL; }}
value = (uintptr_t)pointer;
if (value > UINT32_MAX) {{ return RS_EINVAL; }}
*address = (uint32_t)value;
return RS_OK;
}}
rs_status_t rs_{prefix}_npu_default_regions(rs_{prefix}_npu_workspace_t *workspace,
rs_{prefix}_npu_regions_t *regions) {{
if ((workspace == NULL) || (regions == NULL)) {{ return RS_EINVAL; }}
if ((rs_{prefix}_address(workspace->descriptors, &regions->descriptor_base) != RS_OK) ||
(rs_{prefix}_address(workspace->arena, &regions->arena_base) != RS_OK) ||
(rs_{prefix}_address(rs_{prefix}_npu_weights, &regions->weights_base) != RS_OK) ||
(rs_{prefix}_address(rs_{prefix}_npu_params, &regions->params_base) != RS_OK)) {{ return RS_EINVAL; }}
return RS_OK;
}}
rs_status_t rs_{prefix}_npu_prepare(rs_{prefix}_npu_workspace_t *workspace,
const rs_{prefix}_npu_regions_t *regions,
const int8_t *input, uint32_t input_bytes) {{
uint32_t expected[4]; uint32_t index; uint64_t start;
if (workspace == NULL) {{ return RS_EINVAL; }}
workspace->prepared = 0U;
if ((regions == NULL) || (input == NULL) ||
(input_bytes != RS_{prefix.upper()}_NPU_INPUT_BYTES)) {{ return RS_EINVAL; }}
if ((rs_{prefix}_address(workspace->descriptors, &expected[0]) != RS_OK) ||
(rs_{prefix}_address(workspace->arena, &expected[1]) != RS_OK) ||
(rs_{prefix}_address(rs_{prefix}_npu_weights, &expected[2]) != RS_OK) ||
(rs_{prefix}_address(rs_{prefix}_npu_params, &expected[3]) != RS_OK) ||
(regions->descriptor_base != expected[0]) || (regions->arena_base != expected[1]) ||
(regions->weights_base != expected[2]) || (regions->params_base != expected[3]) ||
((regions->descriptor_base | regions->arena_base | regions->weights_base |
regions->params_base) & UINT32_C(63)) != 0U) {{ return RS_EINVAL; }}
start = rs_{prefix}_cycles();
for (index = 0U; index < UINT32_C({len(descriptors) // 4}); ++index) {{
((uint32_t *)workspace->descriptors)[index] = rs_{prefix}_npu_templates[index];
}}
for (index = 0U; index < RS_{prefix.upper()}_NPU_ARENA_BYTES; ++index) {{ workspace->arena[index] = 0U; }}
for (index = 0U; index < input_bytes; ++index) {{ workspace->arena[UINT32_C({job.input_offset}) + index] = (uint8_t)input[index]; }}
for (index = 0U; index < UINT32_C({len(job.relocations)}); ++index) {{
const rs_{prefix}_reloc_t *reloc = &rs_{prefix}_npu_relocations[index];
uint32_t base = reloc->region == 0U ? regions->arena_base :
(reloc->region == 1U ? regions->weights_base : regions->params_base);
uint64_t value = (uint64_t)base + reloc->offset;
uint32_t *word = &workspace->descriptors[reloc->descriptor][reloc->word];
if ((value > UINT32_MAX) || (*word != reloc->offset)) {{ return RS_EFORMAT; }}
*word = (uint32_t)value;
}}
workspace->regions = *regions;
workspace->input_copy_cycles = rs_{prefix}_cycles() - start;
workspace->prepared = UINT32_C(0x4E505531);
return RS_OK;
}}
rs_status_t rs_{prefix}_npu_execute(rs_{prefix}_npu_workspace_t *workspace,
uint32_t job_id, rs_timeout_t timeout,
int8_t *output, uint32_t output_bytes,
rs_{prefix}_npu_profile_t *profile) {{
rs_npu_capability_t capability; rs_npu_status_t state; rs_npu_job_t job;
rs_status_t result; uint64_t started;
if ((workspace == NULL) || (output == NULL) || (profile == NULL) ||
(workspace->prepared != UINT32_C(0x4E505531)) ||
(output_bytes != RS_{prefix.upper()}_NPU_OUTPUT_BYTES)) {{ return RS_EINVAL; }}
result = rs_npu_get_capability(&capability);
if (result != RS_OK) {{ return result; }}
if (((capability.flags & RS_{prefix.upper()}_NPU_REQUIRED_CAPABILITY) != RS_{prefix.upper()}_NPU_REQUIRED_CAPABILITY) ||
((capability.op_mask & RS_{prefix.upper()}_NPU_REQUIRED_OPCODES) != RS_{prefix.upper()}_NPU_REQUIRED_OPCODES) ||
(capability.numeric_profile != RS_NPU_NUMERIC_PROFILE_VALUE)) {{ return RS_ENOTSUP; }}
rs_{prefix}_cache_clean(workspace->descriptors, sizeof(workspace->descriptors));
rs_{prefix}_cache_clean(workspace->arena, sizeof(workspace->arena));
rs_{prefix}_cache_clean(rs_{prefix}_npu_weights, UINT32_C({len(job.weights)}));
rs_{prefix}_cache_clean(rs_{prefix}_npu_params, UINT32_C({len(job.params)}));
rs_{prefix}_cache_invalidate(&workspace->arena[RS_{prefix.upper()}_NPU_LOGITS_OFFSET], output_bytes);
job.descriptor_address = workspace->regions.descriptor_base;
job.descriptor_count = RS_{prefix.upper()}_NPU_DESCRIPTOR_COUNT;
job.job_id = job_id; job.timeout_cycles = UINT32_C(72000000);
started = rs_{prefix}_cycles(); result = rs_npu_submit(&job);
if (result == RS_OK) {{ result = rs_npu_wait(job_id, timeout, &state); }}
profile->npu_wait_cycles = rs_{prefix}_cycles() - started;
if (result != RS_OK) {{ return result; }}
rs_{prefix}_cache_invalidate(&workspace->arena[RS_{prefix.upper()}_NPU_LOGITS_OFFSET], output_bytes);
started = rs_{prefix}_cycles();
result = rs_{prefix}_finalize((const int8_t *)&workspace->arena[RS_{prefix.upper()}_NPU_LOGITS_OFFSET], output);
profile->softmax_cycles = rs_{prefix}_cycles() - started;
profile->input_copy_cycles = workspace->input_copy_cycles;
if (result == RS_OK) {{ result = rs_npu_snapshot_counters(timeout, &profile->counters); }}
return result;
}}
"""
def compile_package(model: Path, workload: str, prefix: str, output: Path,
expected_sha256: str | None, preprocessing: str) -> dict:
payload = model.read_bytes()
digest = hashlib.sha256(payload).hexdigest()
if expected_sha256 is not None and digest != expected_sha256:
raise ValueError(f"source model SHA-256 {digest} does not match {expected_sha256}")
parsed = parse_tflite(payload)
if parsed.version != 3 or len(parsed.subgraphs) != 1:
raise ValueError("compiler accepts one TFLite v3 subgraph")
job = compile_model(parsed.main_graph(), workload=workload, source_model_sha256=digest)
job.report["compiler"]["contract"] = COMPILER_REVISION
manifest = write_artifacts(job, output)
manifest["compiler"]["contract"] = COMPILER_REVISION
manifest["preprocessing"] = {"kind": preprocessing, "placement": "application"}
manifest["required_capability_mask"] = manifest["required_capability_mask"]
job.report["required_capability_mask"] = manifest["required_capability_mask"]
job.report["required_opcode_mask"] = manifest["required_opcode_mask"]
header = output / f"{prefix}_npu.h"
source = output / f"{prefix}_npu.c"
header.write_text(emit_header(job, prefix), encoding="ascii")
source.write_text(emit_source(job, prefix, header.name), encoding="ascii")
for path in (header, source):
data = path.read_bytes()
manifest["files"][path.name] = {
"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()
}
manifest["report"]["compiler"]["contract"] = COMPILER_REVISION
(output / "npu.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
return manifest
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=Path, required=True)
parser.add_argument("--workload", required=True)
parser.add_argument("--symbol-prefix", required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--expected-sha256")
parser.add_argument("--preprocessing", default="application-defined")
args = parser.parse_args()
if not SYMBOL.fullmatch(args.symbol_prefix):
parser.error("--symbol-prefix must be lowercase C snake_case")
try:
compile_package(args.model, args.workload, args.symbol_prefix,
args.output_dir.resolve(), args.expected_sha256, args.preprocessing)
except (OSError, ValueError) as error:
parser.exit(2, f"NPU compiler error: {error}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large. Load diff
+880
View File
@@ -0,0 +1,880 @@
"""Mini NPU descriptor ABI 1.0 encode/decode and validation."""
from __future__ import annotations
import struct
from dataclasses import dataclass
from typing import Final
# Host-side offline validator for docs/ip/npu.md "Descriptor ABI 1.0".
#
# Fault classification follows the spec FAULT_CODE table:
# FAULT_DESCRIPTOR (1): malformed record or parameter encoding decidable from the
# record alone -- bad version, nonzero reserved bits/words, nonzero upper bits
# of signed-byte fields, out-of-range zero points or bounds, ACT_MIN > ACT_MAX,
# nonzero unused operation fields, K_SLICE outside 1..1024, TILE_H/TILE_W
# outside 1..8 or product above 8, inexact WEIGHT_BYTES/PARAM_BYTES, missing
# required bases, and misaligned PARAM_BASE/WEIGHT_BASE.
# FAULT_UNSUPPORTED (2): undefined opcode, or an operator-profile capability
# violation on a well-formed record -- kernel/stride limits, the fixed
# kernel/stride and padding words of FC/ADD/CLAMP, non-1x1 FC geometry,
# ADD/CLAMP shape changes (no broadcasting), Cout != Cin where required,
# unequal pool/GAP/CLAMP zero points, and K_SLICE above the full reduction.
# FAULT_RANGE (3): address, span, geometry, and overlap failures -- dimensions
# outside 1..4096, zero kernel/stride lanes, pad >= kernel, non-positive or
# mismatching computed output extent, tile above OH/OW, row stride below W*C,
# used span above the declared allocation, 32-bit address wrap, and any
# illegal allocation overlap.
#
# validate_descriptor checks in a deterministic order: structural record, opcode
# support, unused operation fields, geometry/dimensions, per-operator constraints,
# strides/spans, alignment, overlap. The first violation raises DescriptorError.
ABI_VERSION: Final = 0x0100
DESCRIPTOR_WORDS: Final = 32
DESCRIPTOR_BYTES: Final = 4 * DESCRIPTOR_WORDS
MAX_K_SLICE: Final = 1024
MAX_DIMENSION: Final = 4096
MAX_TILE: Final = 8
BASE_ALIGNMENT: Final = 64
ADD_PARAM_BYTES: Final = 32
PARAM_RECORD_BYTES: Final = 16
UINT32_MODULUS: Final = 1 << 32
FAULT_DESCRIPTOR: Final = 1
FAULT_UNSUPPORTED: Final = 2
FAULT_RANGE: Final = 3
OP_CONV2D: Final = 1
OP_DEPTHWISE3X3: Final = 2
OP_FULLY_CONNECTED: Final = 3
OP_ADD: Final = 4
OP_MAX_POOL: Final = 5
OP_AVERAGE_POOL: Final = 6
OP_GLOBAL_AVERAGE_POOL: Final = 7
OP_CLAMP: Final = 8
OPCODE_NAMES: Final = {
OP_CONV2D: "CONV2D",
OP_DEPTHWISE3X3: "DEPTHWISE3X3",
OP_FULLY_CONNECTED: "FULLY_CONNECTED",
OP_ADD: "ADD",
OP_MAX_POOL: "MAX_POOL",
OP_AVERAGE_POOL: "AVERAGE_POOL",
OP_GLOBAL_AVERAGE_POOL: "GLOBAL_AVERAGE_POOL",
OP_CLAMP: "CLAMP",
}
WORD_VERSION_OPCODE: Final = 0
WORD_RESERVED1: Final = 1
WORD_INPUT0_BASE: Final = 2
WORD_INPUT1_BASE: Final = 3
WORD_OUTPUT_BASE: Final = 4
WORD_PARAM_BASE: Final = 5
WORD_INPUT_HW: Final = 6
WORD_CHANNELS: Final = 7
WORD_OUTPUT_HW: Final = 8
WORD_INPUT0_ROW_BYTES: Final = 9
WORD_OUTPUT_ROW_BYTES: Final = 10
WORD_INPUT1_ROW_BYTES: Final = 11
WORD_KERNEL_STRIDE: Final = 12
WORD_PADDING: Final = 13
WORD_TILE_HW: Final = 14
WORD_K_SLICE: Final = 15
WORD_INPUT0_BYTES: Final = 16
WORD_INPUT1_BYTES: Final = 17
WORD_OUTPUT_BYTES: Final = 18
WORD_PARAM_BYTES: Final = 19
WORD_INPUT0_ZERO: Final = 20
WORD_INPUT1_ZERO: Final = 21
WORD_OUTPUT_ZERO: Final = 22
WORD_ACTIVATION_BOUNDS: Final = 23
WORD_WEIGHT_BASE: Final = 24
WORD_WEIGHT_BYTES: Final = 25
_KERNEL_OPS: Final = (OP_CONV2D, OP_DEPTHWISE3X3, OP_MAX_POOL, OP_AVERAGE_POOL)
_WEIGHTED_OPS: Final = (OP_CONV2D, OP_DEPTHWISE3X3, OP_FULLY_CONNECTED)
_PARAMETERIZED_OPS: Final = _WEIGHTED_OPS + (OP_ADD,)
_UNIT_KERNEL_STRIDE: Final = 0x01010101
class DescriptorError(ValueError):
"""Descriptor validation failure.
Attributes:
code: FAULT_DESCRIPTOR (1), FAULT_UNSUPPORTED (2), or FAULT_RANGE (3),
matching the FAULT_CODE table in docs/ip/npu.md.
word: Zero-based index of the first offending descriptor word, or None
when the failure is a relationship between words (overlap).
"""
def __init__(self, code: int, word: int | None, message: str) -> None:
super().__init__(message)
self.code = code
self.word = word
def _decode_zero_point(word: int) -> int:
if word <= 0xFF:
return word - 0x100 if word >= 0x80 else word
return word # nonzero upper bits: keep the raw word for validation to reject
def _encode_signed_byte(value: int) -> int:
return value & 0xFF if value < 0 else value
def _decode_bounds(word: int) -> tuple[int, int]:
act_min = _decode_zero_point(word & 0xFF)
act_max = _decode_zero_point((word >> 8) & 0xFF)
if word > 0xFFFF:
act_min = word # nonzero upper half: keep the raw word for validation
return act_min, act_max
def _encode_bounds(act_min: int, act_max: int) -> int:
return _encode_signed_byte(act_min) | (_encode_signed_byte(act_max) << 8)
@dataclass
class Descriptor:
"""One 128-byte ABI 1.0 record, one field per word (word 23 is split).
Fields are the packed little-endian words of the wire format; zero points
and activation bounds are stored as signed integers. from_bytes is
lossless: a signed-byte word with nonzero upper bits is kept as its raw
unsigned value so validate_descriptor can reject the encoding, and
to_bytes reproduces the original record byte-exactly.
"""
version_opcode: int = 0
reserved1: int = 0
input0_base: int = 0
input1_base: int = 0
output_base: int = 0
param_base: int = 0
input_hw: int = 0
channels: int = 0
output_hw: int = 0
input0_row_bytes: int = 0
output_row_bytes: int = 0
input1_row_bytes: int = 0
kernel_stride: int = 0
padding: int = 0
tile_hw: int = 0
k_slice: int = 0
input0_bytes: int = 0
input1_bytes: int = 0
output_bytes: int = 0
param_bytes: int = 0
input0_zero: int = 0
input1_zero: int = 0
output_zero: int = 0
act_min: int = 0
act_max: int = 0
weight_base: int = 0
weight_bytes: int = 0
reserved26: int = 0
reserved27: int = 0
reserved28: int = 0
reserved29: int = 0
reserved30: int = 0
reserved31: int = 0
@property
def version(self) -> int:
return (self.version_opcode >> 16) & 0xFFFF
@property
def opcode(self) -> int:
return self.version_opcode & 0xFF
@property
def h(self) -> int:
return self.input_hw & 0xFFFF
@property
def w(self) -> int:
return (self.input_hw >> 16) & 0xFFFF
@property
def cin(self) -> int:
return self.channels & 0xFFFF
@property
def cout(self) -> int:
return (self.channels >> 16) & 0xFFFF
@property
def oh(self) -> int:
return self.output_hw & 0xFFFF
@property
def ow(self) -> int:
return (self.output_hw >> 16) & 0xFFFF
@property
def kh(self) -> int:
return self.kernel_stride & 0xFF
@property
def kw(self) -> int:
return (self.kernel_stride >> 8) & 0xFF
@property
def sh(self) -> int:
return (self.kernel_stride >> 16) & 0xFF
@property
def sw(self) -> int:
return (self.kernel_stride >> 24) & 0xFF
@property
def pad_top(self) -> int:
return self.padding & 0xFF
@property
def pad_bottom(self) -> int:
return (self.padding >> 8) & 0xFF
@property
def pad_left(self) -> int:
return (self.padding >> 16) & 0xFF
@property
def pad_right(self) -> int:
return (self.padding >> 24) & 0xFF
@property
def tile_h(self) -> int:
return self.tile_hw & 0xFF
@property
def tile_w(self) -> int:
return (self.tile_hw >> 8) & 0xFF
def _words(self) -> list[int]:
return [
self.version_opcode,
self.reserved1,
self.input0_base,
self.input1_base,
self.output_base,
self.param_base,
self.input_hw,
self.channels,
self.output_hw,
self.input0_row_bytes,
self.output_row_bytes,
self.input1_row_bytes,
self.kernel_stride,
self.padding,
self.tile_hw,
self.k_slice,
self.input0_bytes,
self.input1_bytes,
self.output_bytes,
self.param_bytes,
_encode_signed_byte(self.input0_zero),
_encode_signed_byte(self.input1_zero),
_encode_signed_byte(self.output_zero),
_encode_bounds(self.act_min, self.act_max),
self.weight_base,
self.weight_bytes,
self.reserved26,
self.reserved27,
self.reserved28,
self.reserved29,
self.reserved30,
self.reserved31,
]
def to_bytes(self) -> bytes:
"""Encode as the 32 little-endian words of the wire record."""
return struct.pack(f"<{DESCRIPTOR_WORDS}I", *self._words())
@classmethod
def from_bytes(cls, data: bytes) -> Descriptor:
"""Decode a 128-byte record; fails only on a wrong byte length."""
if len(data) != DESCRIPTOR_BYTES:
raise ValueError(
f"descriptor record is {len(data)} bytes, expected {DESCRIPTOR_BYTES}"
)
words = struct.unpack(f"<{DESCRIPTOR_WORDS}I", data)
act_min, act_max = _decode_bounds(words[WORD_ACTIVATION_BOUNDS])
return cls(
version_opcode=words[WORD_VERSION_OPCODE],
reserved1=words[WORD_RESERVED1],
input0_base=words[WORD_INPUT0_BASE],
input1_base=words[WORD_INPUT1_BASE],
output_base=words[WORD_OUTPUT_BASE],
param_base=words[WORD_PARAM_BASE],
input_hw=words[WORD_INPUT_HW],
channels=words[WORD_CHANNELS],
output_hw=words[WORD_OUTPUT_HW],
input0_row_bytes=words[WORD_INPUT0_ROW_BYTES],
output_row_bytes=words[WORD_OUTPUT_ROW_BYTES],
input1_row_bytes=words[WORD_INPUT1_ROW_BYTES],
kernel_stride=words[WORD_KERNEL_STRIDE],
padding=words[WORD_PADDING],
tile_hw=words[WORD_TILE_HW],
k_slice=words[WORD_K_SLICE],
input0_bytes=words[WORD_INPUT0_BYTES],
input1_bytes=words[WORD_INPUT1_BYTES],
output_bytes=words[WORD_OUTPUT_BYTES],
param_bytes=words[WORD_PARAM_BYTES],
input0_zero=_decode_zero_point(words[WORD_INPUT0_ZERO]),
input1_zero=_decode_zero_point(words[WORD_INPUT1_ZERO]),
output_zero=_decode_zero_point(words[WORD_OUTPUT_ZERO]),
act_min=act_min,
act_max=act_max,
weight_base=words[WORD_WEIGHT_BASE],
weight_bytes=words[WORD_WEIGHT_BYTES],
reserved26=words[26],
reserved27=words[27],
reserved28=words[28],
reserved29=words[29],
reserved30=words[30],
reserved31=words[31],
)
def used_span(h: int, w: int, c: int, row_bytes: int) -> int:
"""Accessible bytes of one operand: (h - 1) * row_bytes + w * c."""
return (h - 1) * row_bytes + w * c
def full_reduction(desc: Descriptor) -> int:
"""Full K of the operator: the length of one complete output reduction."""
opcode = desc.opcode
if opcode == OP_CONV2D:
return desc.kh * desc.kw * desc.cin
if opcode == OP_DEPTHWISE3X3:
return 9
if opcode == OP_FULLY_CONNECTED:
return desc.cin
if opcode in (OP_MAX_POOL, OP_AVERAGE_POOL):
return desc.kh * desc.kw
if opcode == OP_GLOBAL_AVERAGE_POOL:
return desc.h * desc.w
return 1 # ADD and CLAMP reduce over a single element
def check_no_overlap(
ranges: list[tuple[int, int, str]],
*,
allow_alias: set[frozenset[str]] | None = None,
) -> None:
"""Raise FAULT_RANGE on the first overlapping pair of [base, base+bytes) ranges."""
allowed = allow_alias if allow_alias is not None else set()
for index, (base_a, size_a, name_a) in enumerate(ranges):
if size_a <= 0:
continue
for base_b, size_b, name_b in ranges[index + 1 :]:
if size_b <= 0:
continue
if frozenset((name_a, name_b)) in allowed:
continue
if base_a < base_b + size_b and base_b < base_a + size_a:
raise DescriptorError(
FAULT_RANGE,
None,
f"illegal overlap: {name_a} [0x{base_a:x}, 0x{base_a + size_a:x})"
f" vs {name_b} [0x{base_b:x}, 0x{base_b + size_b:x})",
)
def _check_structure(desc: Descriptor) -> None:
if desc.version != ABI_VERSION:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_VERSION_OPCODE,
f"version 0x{desc.version:04x} is not ABI {ABI_VERSION:#06x}",
)
if (desc.version_opcode >> 8) & 0xFF:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_VERSION_OPCODE, "VERSION_OPCODE bits15:8 must be zero"
)
if desc.reserved1:
raise DescriptorError(FAULT_DESCRIPTOR, WORD_RESERVED1, "word 1 must be zero")
reserved_tail = (
desc.reserved26,
desc.reserved27,
desc.reserved28,
desc.reserved29,
desc.reserved30,
desc.reserved31,
)
for offset, value in enumerate(reserved_tail):
if value:
raise DescriptorError(
FAULT_DESCRIPTOR, 26 + offset, f"reserved word {26 + offset} must be zero"
)
if desc.tile_hw >> 16:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_TILE_HW, "TILE_HW upper half must be zero"
)
for value, word, name in (
(desc.input0_zero, WORD_INPUT0_ZERO, "INPUT0_ZERO"),
(desc.output_zero, WORD_OUTPUT_ZERO, "OUTPUT_ZERO"),
):
if not -128 <= value <= 127:
raise DescriptorError(
FAULT_DESCRIPTOR, word, f"{name} is not a canonical signed byte"
)
if not -128 <= desc.act_min <= 127 or not -128 <= desc.act_max <= 127:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_ACTIVATION_BOUNDS,
"activation bounds are not canonical signed bytes",
)
if desc.act_min > desc.act_max:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_ACTIVATION_BOUNDS,
f"ACT_MIN {desc.act_min} exceeds ACT_MAX {desc.act_max}",
)
if not 1 <= desc.tile_h <= MAX_TILE or not 1 <= desc.tile_w <= MAX_TILE:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_TILE_HW,
f"TILE_H/TILE_W {desc.tile_h}x{desc.tile_w} outside 1..{MAX_TILE}",
)
if desc.tile_h * desc.tile_w > MAX_TILE:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_TILE_HW,
f"tile {desc.tile_h}x{desc.tile_w} exceeds {MAX_TILE} output positions",
)
if not 1 <= desc.k_slice <= MAX_K_SLICE:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_K_SLICE,
f"K_SLICE {desc.k_slice} outside 1..{MAX_K_SLICE}",
)
def _check_unused_fields(desc: Descriptor, opcode: int) -> None:
if opcode != OP_ADD:
for value, word, name in (
(desc.input1_base, WORD_INPUT1_BASE, "INPUT1_BASE"),
(desc.input1_row_bytes, WORD_INPUT1_ROW_BYTES, "INPUT1_ROW_BYTES"),
(desc.input1_bytes, WORD_INPUT1_BYTES, "INPUT1_BYTES"),
(desc.input1_zero, WORD_INPUT1_ZERO, "INPUT1_ZERO"),
):
if value != 0:
raise DescriptorError(
FAULT_DESCRIPTOR, word, f"{name} is only used by ADD and must be zero"
)
if opcode not in _WEIGHTED_OPS:
for value, word, name in (
(desc.weight_base, WORD_WEIGHT_BASE, "WEIGHT_BASE"),
(desc.weight_bytes, WORD_WEIGHT_BYTES, "WEIGHT_BYTES"),
):
if value != 0:
raise DescriptorError(
FAULT_DESCRIPTOR, word, f"{name} is unused by {OPCODE_NAMES[opcode]}"
)
if opcode not in _PARAMETERIZED_OPS:
for value, word, name in (
(desc.param_base, WORD_PARAM_BASE, "PARAM_BASE"),
(desc.param_bytes, WORD_PARAM_BYTES, "PARAM_BYTES"),
):
if value != 0:
raise DescriptorError(
FAULT_DESCRIPTOR, word, f"{name} is unused by {OPCODE_NAMES[opcode]}"
)
if opcode == OP_GLOBAL_AVERAGE_POOL:
if desc.kernel_stride != 0:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_KERNEL_STRIDE,
"GLOBAL_AVERAGE_POOL kernel/stride word must be zero",
)
if desc.padding != 0:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_PADDING, "GLOBAL_AVERAGE_POOL padding must be zero"
)
def _check_geometry(desc: Descriptor, opcode: int) -> None:
for value, word, name in (
(desc.h, WORD_INPUT_HW, "H"),
(desc.w, WORD_INPUT_HW, "W"),
(desc.cin, WORD_CHANNELS, "Cin"),
(desc.cout, WORD_CHANNELS, "Cout"),
(desc.oh, WORD_OUTPUT_HW, "OH"),
(desc.ow, WORD_OUTPUT_HW, "OW"),
):
if not 1 <= value <= MAX_DIMENSION:
raise DescriptorError(
FAULT_RANGE, word, f"{name} {value} outside 1..{MAX_DIMENSION}"
)
if opcode in _KERNEL_OPS:
if desc.kh < 1 or desc.kw < 1 or desc.sh < 1 or desc.sw < 1:
raise DescriptorError(
FAULT_RANGE,
WORD_KERNEL_STRIDE,
"kernel/stride lanes must be nonzero for a kernel operator",
)
if (
desc.pad_top >= desc.kh
or desc.pad_bottom >= desc.kh
or desc.pad_left >= desc.kw
or desc.pad_right >= desc.kw
):
raise DescriptorError(
FAULT_RANGE,
WORD_PADDING,
"padding must be strictly less than the kernel dimension",
)
expect_oh = (desc.h + desc.pad_top + desc.pad_bottom - desc.kh) // desc.sh + 1
expect_ow = (desc.w + desc.pad_left + desc.pad_right - desc.kw) // desc.sw + 1
if expect_oh <= 0 or expect_ow <= 0:
raise DescriptorError(
FAULT_RANGE,
WORD_OUTPUT_HW,
f"computed output extent {expect_oh}x{expect_ow} is not positive",
)
if desc.oh != expect_oh or desc.ow != expect_ow:
raise DescriptorError(
FAULT_RANGE,
WORD_OUTPUT_HW,
f"descriptor output {desc.oh}x{desc.ow} does not match computed"
f" {expect_oh}x{expect_ow}",
)
def _check_kernel_stride(desc: Descriptor, kernel_max: int) -> None:
if not 1 <= desc.kh <= kernel_max or not 1 <= desc.kw <= kernel_max:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
f"kernel {desc.kh}x{desc.kw} outside 1..{kernel_max}",
)
if desc.sh not in (1, 2) or desc.sw not in (1, 2):
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
f"stride {desc.sh}x{desc.sw} is not 1 or 2",
)
def _check_operator(desc: Descriptor, opcode: int) -> None:
if opcode == OP_CONV2D:
_check_kernel_stride(desc, 16)
elif opcode == OP_DEPTHWISE3X3:
if desc.kh != 3 or desc.kw != 3:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
f"DEPTHWISE3X3 kernel is {desc.kh}x{desc.kw}, not 3x3",
)
_check_kernel_stride(desc, 16)
if desc.cout != desc.cin:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_CHANNELS,
"depthwise multiplier 1 requires Cout == Cin",
)
elif opcode == OP_FULLY_CONNECTED:
if desc.h != 1 or desc.w != 1:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_INPUT_HW, "FULLY_CONNECTED requires H == W == 1"
)
if desc.oh != 1 or desc.ow != 1:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_OUTPUT_HW, "FULLY_CONNECTED requires OH == OW == 1"
)
if desc.kernel_stride != _UNIT_KERNEL_STRIDE:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
"FULLY_CONNECTED kernel/stride word must be 0x01010101",
)
if desc.padding != 0:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_PADDING, "FULLY_CONNECTED padding must be zero"
)
elif opcode == OP_ADD:
if not -128 <= desc.input1_zero <= 127:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_INPUT1_ZERO, "INPUT1_ZERO is not a signed byte"
)
if desc.input1_base == 0:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_INPUT1_BASE, "ADD requires INPUT1_BASE"
)
if desc.oh != desc.h or desc.ow != desc.w:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_HW,
"ADD does not broadcast: output geometry must equal input geometry",
)
if desc.cout != desc.cin:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_CHANNELS, "ADD requires Cout == Cin"
)
if desc.kernel_stride != _UNIT_KERNEL_STRIDE:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
"ADD kernel/stride word must be 0x01010101",
)
if desc.padding != 0:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_PADDING, "ADD padding must be zero"
)
elif opcode in (OP_MAX_POOL, OP_AVERAGE_POOL):
_check_kernel_stride(desc, 16)
if desc.cout != desc.cin:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_CHANNELS, "pooling requires Cout == Cin"
)
if desc.input0_zero != desc.output_zero:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_ZERO,
"pooling requires equal input/output zero points",
)
elif opcode == OP_GLOBAL_AVERAGE_POOL:
if desc.oh != 1 or desc.ow != 1:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_HW,
"GLOBAL_AVERAGE_POOL requires OH == OW == 1",
)
if desc.cout != desc.cin:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_CHANNELS, "GLOBAL_AVERAGE_POOL requires Cout == Cin"
)
if desc.input0_zero != desc.output_zero:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_ZERO,
"GLOBAL_AVERAGE_POOL requires equal input/output zero points",
)
elif opcode == OP_CLAMP:
if desc.oh != desc.h or desc.ow != desc.w:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_HW,
"CLAMP requires identical input/output geometry",
)
if desc.cout != desc.cin:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_CHANNELS, "CLAMP requires Cout == Cin"
)
if desc.input0_zero != desc.output_zero:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_OUTPUT_ZERO,
"CLAMP requires identical input/output quantization",
)
if desc.kernel_stride != _UNIT_KERNEL_STRIDE:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_KERNEL_STRIDE,
"CLAMP kernel/stride word must be 0x01010101",
)
if desc.padding != 0:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_PADDING, "CLAMP padding must be zero"
)
full_k = full_reduction(desc)
if desc.k_slice > full_k:
raise DescriptorError(
FAULT_UNSUPPORTED,
WORD_K_SLICE,
f"K_SLICE {desc.k_slice} exceeds the full reduction {full_k}",
)
if desc.tile_h > desc.oh or desc.tile_w > desc.ow:
raise DescriptorError(
FAULT_RANGE,
WORD_TILE_HW,
f"tile {desc.tile_h}x{desc.tile_w} exceeds output {desc.oh}x{desc.ow}",
)
if opcode in _WEIGHTED_OPS:
if desc.weight_base == 0:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_WEIGHT_BASE, "packed weights are required"
)
if opcode == OP_DEPTHWISE3X3:
expect_weight_bytes = ((desc.cin + 7) // 8) * 72
else:
expect_weight_bytes = ((desc.cout + 7) // 8) * full_k * 8
if desc.weight_bytes != expect_weight_bytes:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_WEIGHT_BYTES,
f"WEIGHT_BYTES {desc.weight_bytes} is not the exact packed length"
f" {expect_weight_bytes}",
)
if desc.param_base == 0:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_PARAM_BASE, "parameter array is required"
)
if desc.param_bytes != desc.cout * PARAM_RECORD_BYTES:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_PARAM_BYTES,
f"PARAM_BYTES {desc.param_bytes} is not Cout*16"
f" ({desc.cout * PARAM_RECORD_BYTES})",
)
elif opcode == OP_ADD:
if desc.param_base == 0:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_PARAM_BASE, "ADD parameter array is required"
)
if desc.param_bytes != ADD_PARAM_BYTES:
raise DescriptorError(
FAULT_DESCRIPTOR,
WORD_PARAM_BYTES,
f"ADD PARAM_BYTES {desc.param_bytes} is not {ADD_PARAM_BYTES}",
)
def _check_strides_and_spans(desc: Descriptor, opcode: int) -> None:
min_input_row = desc.w * desc.cin
if desc.input0_row_bytes < min_input_row:
raise DescriptorError(
FAULT_RANGE,
WORD_INPUT0_ROW_BYTES,
f"INPUT0_ROW_BYTES {desc.input0_row_bytes} is below W*Cin {min_input_row}",
)
min_output_row = desc.ow * desc.cout
if desc.output_row_bytes < min_output_row:
raise DescriptorError(
FAULT_RANGE,
WORD_OUTPUT_ROW_BYTES,
f"OUTPUT_ROW_BYTES {desc.output_row_bytes} is below OW*Cout {min_output_row}",
)
if opcode == OP_ADD and desc.input1_row_bytes < min_input_row:
raise DescriptorError(
FAULT_RANGE,
WORD_INPUT1_ROW_BYTES,
f"INPUT1_ROW_BYTES {desc.input1_row_bytes} is below W*Cin {min_input_row}",
)
operands = [
(
"INPUT0",
desc.input0_base,
used_span(desc.h, desc.w, desc.cin, desc.input0_row_bytes),
desc.input0_bytes,
WORD_INPUT0_BASE,
WORD_INPUT0_BYTES,
),
]
if opcode == OP_ADD:
operands.append(
(
"INPUT1",
desc.input1_base,
used_span(desc.h, desc.w, desc.cin, desc.input1_row_bytes),
desc.input1_bytes,
WORD_INPUT1_BASE,
WORD_INPUT1_BYTES,
)
)
operands.append(
(
"OUTPUT",
desc.output_base,
used_span(desc.oh, desc.ow, desc.cout, desc.output_row_bytes),
desc.output_bytes,
WORD_OUTPUT_BASE,
WORD_OUTPUT_BYTES,
)
)
for name, base, span, allocated, base_word, bytes_word in operands:
if span > allocated:
raise DescriptorError(
FAULT_RANGE,
bytes_word,
f"{name} used span {span} exceeds declared allocation {allocated}",
)
if base + span > UINT32_MODULUS:
raise DescriptorError(
FAULT_RANGE,
base_word,
f"{name} used span wraps the 32-bit address space",
)
if base + allocated > UINT32_MODULUS:
raise DescriptorError(
FAULT_RANGE,
base_word,
f"{name} allocation wraps the 32-bit address space",
)
for name, base, size, base_word in (
("WEIGHT", desc.weight_base, desc.weight_bytes, WORD_WEIGHT_BASE),
("PARAM", desc.param_base, desc.param_bytes, WORD_PARAM_BASE),
):
if size and base + size > UINT32_MODULUS:
raise DescriptorError(
FAULT_RANGE,
base_word,
f"{name} allocation wraps the 32-bit address space",
)
def _check_alignment(desc: Descriptor) -> None:
if desc.param_base and desc.param_base % BASE_ALIGNMENT:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_PARAM_BASE, "PARAM_BASE must be 64-byte aligned"
)
if desc.weight_base and desc.weight_base % BASE_ALIGNMENT:
raise DescriptorError(
FAULT_DESCRIPTOR, WORD_WEIGHT_BASE, "WEIGHT_BASE must be 64-byte aligned"
)
def _check_overlap(
desc: Descriptor, opcode: int, descriptor_region: tuple[int, int] | None
) -> None:
ranges: list[tuple[int, int, str]] = [
(desc.input0_base, desc.input0_bytes, "input0"),
(desc.output_base, desc.output_bytes, "output"),
]
if opcode == OP_ADD:
ranges.append((desc.input1_base, desc.input1_bytes, "input1"))
if desc.weight_bytes:
ranges.append((desc.weight_base, desc.weight_bytes, "weights"))
if desc.param_bytes:
ranges.append((desc.param_base, desc.param_bytes, "params"))
if descriptor_region is not None:
region_base, region_bytes = descriptor_region
if region_bytes and region_base + region_bytes > UINT32_MODULUS:
raise DescriptorError(
FAULT_RANGE, None, "descriptor region wraps the 32-bit address space"
)
ranges.append((region_base, region_bytes, "descriptor"))
allow_alias = {frozenset(("input0", "input1"))} if opcode == OP_ADD else None
check_no_overlap(ranges, allow_alias=allow_alias)
def validate_descriptor(
desc: Descriptor, *, descriptor_region: tuple[int, int] | None = None
) -> None:
"""Validate one record, raising DescriptorError on the first violation.
Checks run in a deterministic order: structural record (version, reserved,
signed-byte encodings, tile/K_SLICE domains), opcode support, unused
operation fields, geometry/dimensions, per-operator constraints,
strides/spans, alignment, overlap. descriptor_region, when given, is the
(base, bytes) of the job's descriptor array and must be disjoint from
every operand allocation.
"""
_check_structure(desc)
opcode = desc.opcode
if opcode not in OPCODE_NAMES:
raise DescriptorError(
FAULT_UNSUPPORTED, WORD_VERSION_OPCODE, f"opcode {opcode} is not supported"
)
_check_unused_fields(desc, opcode)
_check_geometry(desc, opcode)
_check_operator(desc, opcode)
_check_strides_and_spans(desc, opcode)
_check_alignment(desc)
_check_overlap(desc, opcode, descriptor_region)
+535
View File
@@ -0,0 +1,535 @@
"""Compiled-job functional executor for Mini NPU descriptor ABI 1.0 (NPU-P0).
Runs a CompiledJob (scripts/npu_compiler_p0.py) over byte regions with
arithmetic identical to scripts/npu_reference.py: tile iteration (TILE_H x
TILE_W row-major output positions, shortened at edges), output-channel groups
of eight with tail lanes, K-slice chunking (bias added once before the first
slice, partial sums carried across slices, requantization only after the full
reduction), dense A[k][8]/W[k][8] lane semantics (masked tail lanes carry
zero weights / input-zero activations and never affect results), depthwise
eight channel lanes, GLOBAL_AVERAGE_POOL count=H*W ties-away, and the
explicit terminal CPU softmax step. Opcode 4 ADD is executed end to end even
though the two locked models do not use it; pool/CLAMP opcodes are defined
by the ABI but are not placed by the P0 compiler and fault UNSUPPORTED here.
Address map: physical addresses are decoded through region bases and sizes
("arena", "weights", "params"); the descriptor array base ("descriptors") is
used for validation only. DEFAULT_BASES documents the aligned test map:
arena 0x1000_0000, weights 0x2000_0000, params 0x3000_0000, descriptors
0x4000_0000. Unmapped or region-crossing accesses raise
ExecutorFault(FAULT_RANGE); DescriptorError failures are re-raised as
ExecutorFault with the same code; checked INT32 overflow surfaces as
ExecutorFault(FAULT_ARITHMETIC).
"""
from __future__ import annotations
import hashlib
import struct
from dataclasses import dataclass
from typing import Final
from npu_descriptors import (
DESCRIPTOR_BYTES,
FAULT_DESCRIPTOR,
FAULT_RANGE,
FAULT_UNSUPPORTED,
OP_ADD,
OP_AVERAGE_POOL,
OP_CLAMP,
OP_CONV2D,
OP_DEPTHWISE3X3,
OP_FULLY_CONNECTED,
OP_GLOBAL_AVERAGE_POOL,
OP_MAX_POOL,
Descriptor,
DescriptorError,
used_span,
validate_descriptor,
)
from npu_reference import (
INT32_MAX,
INT32_MIN,
ArithmeticFault,
Tensor,
average_pool,
clamp,
max_pool,
multiply_by_quantized_multiplier,
softmax_int8,
)
FAULT_ARITHMETIC: Final = 7
DEFAULT_BASES: Final = {
"arena": 0x1000_0000,
"weights": 0x2000_0000,
"params": 0x3000_0000,
"descriptors": 0x4000_0000,
}
_RELOCATION_WORDS: Final = {
2: "input0_base",
3: "input1_base",
4: "output_base",
5: "param_base",
24: "weight_base",
}
ADD_LEFT_SHIFT: Final = 20
_UINT32_MODULUS: Final = 1 << 32
class ExecutorFault(Exception):
"""Execution failure carrying a spec FAULT_CODE (1/2/3/7)."""
def __init__(self, code: int, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass
class ExecutionResult:
"""Per-layer tensors (descriptors in order, then the CPU softmax), their
raw-byte SHA-256 digests, and the final output tensor."""
layers: list[Tensor]
digests: list[str]
output: Tensor
def _signed(block: bytes) -> list[int]:
return [value - 256 if value >= 128 else value for value in block]
class _Memory:
"""Region-based address decoder; unmapped/crossing accesses fault RANGE."""
def __init__(self, regions: dict[str, tuple[int, bytearray]]) -> None:
self._regions = regions
def _locate(self, address: int, size: int) -> tuple[bytearray, int]:
for base, buffer in self._regions.values():
if base <= address and address + size <= base + len(buffer):
return buffer, address - base
raise ExecutorFault(
FAULT_RANGE,
f"access at 0x{address:08x} ({size} bytes) is unmapped or crosses a region",
)
def read(self, address: int, size: int) -> bytes:
if size == 0:
return b""
buffer, offset = self._locate(address, size)
return bytes(buffer[offset : offset + size])
def write(self, address: int, payload: bytes) -> None:
if not payload:
return
buffer, offset = self._locate(address, len(payload))
buffer[offset : offset + len(payload)] = payload
def relocate_descriptors(job, bases: dict[str, int]) -> list[Descriptor]:
"""Finalize templates: add region bases to address words, checked arithmetic."""
relocated = [Descriptor(**vars(desc)) for desc in job.descriptors]
for relocation in job.relocations:
index = relocation["descriptor_index"]
word = relocation["word_index"]
region = relocation["region"]
offset = relocation["offset"]
field = _RELOCATION_WORDS.get(word)
if field is None:
raise ExecutorFault(FAULT_DESCRIPTOR, f"relocation targets word {word}")
if not 0 <= index < len(relocated):
raise ExecutorFault(FAULT_DESCRIPTOR, f"relocation descriptor {index} out of range")
if region not in bases:
raise ExecutorFault(FAULT_RANGE, f"relocation region {region!r} has no base")
if getattr(relocated[index], field) != offset:
raise ExecutorFault(
FAULT_DESCRIPTOR, "relocation offset does not match the template word"
)
value = bases[region] + offset
if value >= _UINT32_MODULUS:
raise ExecutorFault(FAULT_RANGE, "relocated address wraps the 32-bit space")
setattr(relocated[index], field, value)
return relocated
def _read_channel_params(mem: _Memory, desc: Descriptor) -> tuple[list[int], ...]:
raw = mem.read(desc.param_base, desc.param_bytes)
bias: list[int] = []
multipliers: list[int] = []
shifts: list[int] = []
for channel in range(desc.cout):
b_value, m_value, s_value, reserved = struct.unpack_from("<iiiI", raw, channel * 16)
if reserved:
raise ExecutorFault(FAULT_DESCRIPTOR, "parameter record reserved word nonzero")
if m_value < 0:
raise ExecutorFault(FAULT_DESCRIPTOR, "parameter multiplier must be nonnegative")
if not -31 <= s_value <= 30:
raise ExecutorFault(FAULT_DESCRIPTOR, "parameter shift outside -31..30")
bias.append(b_value)
multipliers.append(m_value)
shifts.append(s_value)
return bias, multipliers, shifts
def _accumulate(accumulator: int, term: int, checked: bool, context: str) -> int:
total = accumulator + term
if checked and (total > INT32_MAX or total < INT32_MIN):
raise ArithmeticFault(f"checked INT32 overflow in {context}")
return total
def _requantize_out(value: int, desc: Descriptor) -> int:
return max(desc.act_min, min(desc.act_max, value)) & 0xFF
def _execute_dense(mem: _Memory, desc: Descriptor) -> bytes:
"""CONV2D and FULLY_CONNECTED: dense A[k][8]/W[k][8] lane semantics."""
h, w, cin, cout = desc.h, desc.w, desc.cin, desc.cout
oh, ow = desc.oh, desc.ow
kh, kw, sh, sw = desc.kh, desc.kw, desc.sh, desc.sw
pad_top, pad_left = desc.pad_top, desc.pad_left
irow, orow = desc.input0_row_bytes, desc.output_row_bytes
zin, zout = desc.input0_zero, desc.output_zero
full_k = kh * kw * cin if desc.opcode == OP_CONV2D else cin
k_slice = desc.k_slice
source = _signed(mem.read(desc.input0_base, used_span(h, w, cin, irow)))
weights = _signed(mem.read(desc.weight_base, desc.weight_bytes))
bias, multipliers, shifts = _read_channel_params(mem, desc)
# Reduction decomposition in the locked kh,kw,cin order (FC: kh=kw=1).
kwcin = kw * cin
decomp = [(k // kwcin, (k % kwcin) // cin, k % cin) for k in range(full_k)]
# Bias starts each reduction; products are bounded by 255*127, so when the
# bound stays in INT32 no ordered update can overflow and checks are moot.
checked = max(abs(value) for value in bias) + 255 * 127 * full_k > INT32_MAX
out = bytearray(used_span(oh, ow, cout, orow))
tile_h, tile_w = desc.tile_h, desc.tile_w
for tile_y in range(0, oh, tile_h):
rows = min(tile_h, oh - tile_y)
for tile_x in range(0, ow, tile_w):
cols = min(tile_w, ow - tile_x)
for position in range(rows * cols):
oy = tile_y + position // cols
ox = tile_x + position % cols
iy0 = oy * sh - pad_top
ix0 = ox * sw - pad_left
# Gather A for this position once; padding reads the input zero
# point, which centers to zero and cannot affect any sum.
avec = [0] * full_k
for k, (dkh, dkw, ci) in enumerate(decomp):
iy = iy0 + dkh
ix = ix0 + dkw
if 0 <= iy < h and 0 <= ix < w:
avec[k] = source[iy * irow + ix * cin + ci] - zin
obase = oy * orow + ox * cout
for group in range(0, cout, 8):
lanes = min(8, cout - group) # masked tail lanes carry zero W
wgroup = (group // 8) * full_k * 8
for lane in range(lanes):
channel = group + lane
woffset = wgroup + lane
accumulator = bias[channel]
for s0 in range(0, full_k, k_slice):
for k in range(s0, min(s0 + k_slice, full_k)):
accumulator = _accumulate(
accumulator,
avec[k] * weights[woffset + k * 8],
checked,
"conv2d accumulation",
)
value = (
multiply_by_quantized_multiplier(
accumulator, multipliers[channel], shifts[channel]
)
+ zout
)
out[obase + channel] = _requantize_out(value, desc)
mem.write(desc.output_base, bytes(out))
return bytes(out)
def _execute_depthwise(mem: _Memory, desc: Descriptor) -> bytes:
"""DEPTHWISE3X3: eight channel lanes at one output position at a time."""
h, w, cin = desc.h, desc.w, desc.cin
oh, ow = desc.oh, desc.ow
sh, sw = desc.sh, desc.sw
pad_top, pad_left = desc.pad_top, desc.pad_left
irow, orow = desc.input0_row_bytes, desc.output_row_bytes
zin, zout = desc.input0_zero, desc.output_zero
k_slice = desc.k_slice
source = _signed(mem.read(desc.input0_base, used_span(h, w, cin, irow)))
weights = _signed(mem.read(desc.weight_base, desc.weight_bytes))
bias, multipliers, shifts = _read_channel_params(mem, desc)
checked = max(abs(value) for value in bias) + 255 * 127 * 9 > INT32_MAX
out = bytearray(used_span(oh, ow, cin, orow))
tile_h, tile_w = desc.tile_h, desc.tile_w
for tile_y in range(0, oh, tile_h):
rows = min(tile_h, oh - tile_y)
for tile_x in range(0, ow, tile_w):
cols = min(tile_w, ow - tile_x)
for position in range(rows * cols):
oy = tile_y + position // cols
ox = tile_x + position % cols
iy0 = oy * sh - pad_top
ix0 = ox * sw - pad_left
obase = oy * orow + ox * cin
for group in range(0, cin, 8):
lanes = min(8, cin - group)
wgroup = (group // 8) * 72
for lane in range(lanes):
channel = group + lane
accumulator = bias[channel]
for s0 in range(0, 9, k_slice):
for kk in range(s0, min(s0 + k_slice, 9)):
iy = iy0 + kk // 3
ix = ix0 + kk % 3
if 0 <= iy < h and 0 <= ix < w:
accumulator = _accumulate(
accumulator,
(source[iy * irow + ix * cin + channel] - zin)
* weights[wgroup + kk * 8 + lane],
checked,
"depthwise accumulation",
)
value = (
multiply_by_quantized_multiplier(
accumulator, multipliers[channel], shifts[channel]
)
+ zout
)
out[obase + channel] = _requantize_out(value, desc)
mem.write(desc.output_base, bytes(out))
return bytes(out)
def _execute_global_average_pool(mem: _Memory, desc: Descriptor) -> bytes:
"""GLOBAL_AVERAGE_POOL: raw-byte sum over H*W, ties-away, count=H*W."""
h, w, cin = desc.h, desc.w, desc.cin
irow = desc.input0_row_bytes
k_slice = desc.k_slice
count = h * w
source = _signed(mem.read(desc.input0_base, used_span(h, w, cin, irow)))
out = bytearray(cin)
for channel in range(cin):
total = 0
for s0 in range(0, count, k_slice):
for position in range(s0, min(s0 + k_slice, count)):
iy = position // w
ix = position % w
total = _accumulate(
total,
source[iy * irow + ix * cin + channel],
True,
"global average sum",
)
averaged = (abs(total) + count // 2) // count
if total < 0:
averaged = -averaged
out[channel] = max(desc.act_min, min(desc.act_max, averaged)) & 0xFF
mem.write(desc.output_base, bytes(out))
return bytes(out)
def _execute_add(mem: _Memory, desc: Descriptor) -> bytes:
"""ADD: pinned 2^20 pre-scaled INT8 path with checked sum."""
h, w, cin = desc.h, desc.w, desc.cin
raw = mem.read(desc.param_base, desc.param_bytes)
left_shift, m0, s0, m1, s1, mout, sout, reserved = struct.unpack("<IiiiiiiI", raw)
if reserved or left_shift != ADD_LEFT_SHIFT:
raise ExecutorFault(FAULT_DESCRIPTOR, "ADD parameter encoding is not the pinned form")
for multiplier, shift, name in ((m0, s0, "m0"), (m1, s1, "m1"), (mout, sout, "mout")):
if multiplier < 0:
raise ExecutorFault(FAULT_DESCRIPTOR, f"ADD {name} multiplier must be nonnegative")
if not -31 <= shift <= 30:
raise ExecutorFault(FAULT_DESCRIPTOR, f"ADD {name} shift outside -31..30")
in0 = _signed(mem.read(desc.input0_base, used_span(h, w, cin, desc.input0_row_bytes)))
in1 = _signed(mem.read(desc.input1_base, used_span(h, w, cin, desc.input1_row_bytes)))
z0, z1, zout = desc.input0_zero, desc.input1_zero, desc.output_zero
out = bytearray(used_span(desc.oh, desc.ow, desc.cout, desc.output_row_bytes))
for iy in range(h):
for ix in range(w):
i0 = iy * desc.input0_row_bytes + ix * cin
i1 = iy * desc.input1_row_bytes + ix * cin
io = iy * desc.output_row_bytes + ix * desc.cout
for channel in range(cin):
x0 = (in0[i0 + channel] - z0) << left_shift
x1 = (in1[i1 + channel] - z1) << left_shift
term0 = multiply_by_quantized_multiplier(x0, m0, s0)
term1 = multiply_by_quantized_multiplier(x1, m1, s1)
total = term0 + term1
if total > INT32_MAX or total < INT32_MIN:
raise ArithmeticFault("checked INT32 overflow in ADD sum")
value = multiply_by_quantized_multiplier(total, mout, sout) + zout
out[io + channel] = _requantize_out(value, desc)
mem.write(desc.output_base, bytes(out))
return bytes(out)
def _activation_tensor(mem: _Memory, desc: Descriptor, *, input1: bool = False) -> Tensor:
base = desc.input1_base if input1 else desc.input0_base
row = desc.input1_row_bytes if input1 else desc.input0_row_bytes
zero = desc.input1_zero if input1 else desc.input0_zero
raw = _signed(mem.read(base, used_span(desc.h, desc.w, desc.cin, row)))
packed = tuple(
raw[y * row + x * desc.cin + channel]
for y in range(desc.h) for x in range(desc.w) for channel in range(desc.cin)
)
return Tensor((desc.h, desc.w, desc.cin), packed, 1.0, zero)
def _execute_pool(mem: _Memory, desc: Descriptor) -> bytes:
source = _activation_tensor(mem, desc)
kwargs = {
"kernel_h": desc.kh, "kernel_w": desc.kw,
"stride_h": desc.sh, "stride_w": desc.sw,
"pad_top": desc.pad_top, "pad_bottom": desc.pad_bottom,
"pad_left": desc.pad_left, "pad_right": desc.pad_right,
"act_min": desc.act_min, "act_max": desc.act_max,
}
result = (max_pool(source, **kwargs) if desc.opcode == OP_MAX_POOL
else average_pool(source, **kwargs))
block = bytes(value & 0xFF for value in result.data)
mem.write(desc.output_base, block)
return block
def _execute_clamp(mem: _Memory, desc: Descriptor) -> bytes:
result = clamp(_activation_tensor(mem, desc), desc.act_min, desc.act_max)
block = bytes(value & 0xFF for value in result.data)
mem.write(desc.output_base, block)
return block
def _execute_validated(mem: _Memory, desc: Descriptor) -> bytes:
try:
if desc.opcode in (OP_CONV2D, OP_FULLY_CONNECTED):
return _execute_dense(mem, desc)
if desc.opcode == OP_DEPTHWISE3X3:
return _execute_depthwise(mem, desc)
if desc.opcode == OP_GLOBAL_AVERAGE_POOL:
return _execute_global_average_pool(mem, desc)
if desc.opcode == OP_ADD:
return _execute_add(mem, desc)
if desc.opcode in (OP_MAX_POOL, OP_AVERAGE_POOL):
return _execute_pool(mem, desc)
if desc.opcode == OP_CLAMP:
return _execute_clamp(mem, desc)
raise ExecutorFault(
FAULT_UNSUPPORTED,
f"opcode {desc.opcode} is defined by the ABI but not placed by NPU-P0",
)
except ArithmeticFault as error:
raise ExecutorFault(FAULT_ARITHMETIC, str(error)) from error
def execute_job(
job, input_bytes: bytes, *, bases: dict[str, int] | None = None
) -> ExecutionResult:
"""Execute one compiled job over fresh regions and return all layer tensors.
input_bytes are the raw INT8 model input bytes placed at job.input_offset
in the arena. bases may override the documented DEFAULT_BASES map.
"""
merged = dict(DEFAULT_BASES)
if bases:
merged.update(bases)
expected = job.report.get("input", {}).get("bytes")
if expected is not None and len(input_bytes) != expected:
raise ExecutorFault(
FAULT_RANGE, f"input is {len(input_bytes)} bytes, expected {expected}"
)
arena = bytearray(job.arena_bytes)
if job.input_offset + len(input_bytes) > len(arena):
raise ExecutorFault(FAULT_RANGE, "input image does not fit the arena")
arena[job.input_offset : job.input_offset + len(input_bytes)] = input_bytes
mem = _Memory(
{
"arena": (merged["arena"], arena),
"weights": (merged["weights"], bytearray(job.weights)),
"params": (merged["params"], bytearray(job.params)),
}
)
relocated = relocate_descriptors(job, merged)
region = (merged["descriptors"], len(relocated) * DESCRIPTOR_BYTES)
finalized: list[Descriptor] = []
for index, desc in enumerate(relocated):
try:
wire = Descriptor.from_bytes(desc.to_bytes())
validate_descriptor(wire, descriptor_region=region)
except DescriptorError as error:
raise ExecutorFault(error.code, f"descriptor {index}: {error}") from error
finalized.append(wire)
metadata = {}
for entry in job.report.get("operators", ()):
descriptor_index = entry.get("descriptor_index")
if descriptor_index is not None:
metadata[descriptor_index] = entry
for index in range(len(finalized)):
if index not in metadata:
raise ExecutorFault(
FAULT_DESCRIPTOR, f"job report lacks metadata for descriptor {index}"
)
layers: list[Tensor] = []
digests: list[str] = []
descriptor_layers: dict[int, Tensor] = {}
for step in job.steps:
kind = step.get("kind")
if kind == "npu":
first = step["first_descriptor"]
for index in range(first, first + step["count"]):
desc = finalized[index]
block = _execute_validated(mem, desc)
entry = metadata.get(index)
quant = entry["quantization"]
tensor = Tensor(
(desc.oh, desc.ow, desc.cout),
tuple(_signed(block)),
float(quant["output_scale"]),
desc.output_zero,
)
layers.append(tensor)
descriptor_layers[index] = tensor
digests.append(hashlib.sha256(block).hexdigest())
elif kind == "cpu" and step.get("op") == "softmax":
source_index = step["source_descriptor"]
if (not isinstance(source_index, int) or source_index < 0 or
source_index >= len(finalized)):
raise ExecutorFault(FAULT_DESCRIPTOR, "invalid softmax source descriptor")
desc = finalized[source_index]
entry = metadata.get(source_index)
quant = entry["quantization"]
span = used_span(desc.oh, desc.ow, desc.cout, desc.output_row_bytes)
block = mem.read(desc.output_base, span)
logits = Tensor(
(desc.oh, desc.ow, desc.cout),
tuple(_signed(block)),
float(quant["output_scale"]),
desc.output_zero,
)
try:
result = softmax_int8(
logits,
input_multiplier=step["input_multiplier"],
input_left_shift=step["input_left_shift"],
diff_min=step["diff_min"],
)
except ArithmeticFault as error:
raise ExecutorFault(FAULT_ARITHMETIC, str(error)) from error
except (KeyError, ValueError, TypeError) as error:
raise ExecutorFault(FAULT_DESCRIPTOR, f"invalid softmax step: {error}") from error
layers.append(result)
digests.append(
hashlib.sha256(bytes(value & 0xFF for value in result.data)).hexdigest()
)
else:
raise ExecutorFault(FAULT_UNSUPPORTED, f"unsupported execution step {step!r}")
if not layers:
raise ExecutorFault(FAULT_DESCRIPTOR, "job produced no layers")
output = layers[-1]
output_descriptor = job.report.get("output", {}).get("source_descriptor")
if not any(step.get("kind") == "cpu" for step in job.steps):
output = descriptor_layers.get(output_descriptor, output)
return ExecutionResult(layers=layers, digests=digests, output=output)
+175
View File
@@ -0,0 +1,175 @@
"""Independent locked TFLite integer-kernel oracle; host verification only.
Only the raw model reader is shared with compilation. Quantization, geometry,
unpacked model weights and graph execution do not use NPU lowering or numerics.
"""
from __future__ import annotations
import hashlib
import json
import shutil
import struct
import subprocess
import time
from pathlib import Path
from npu_model import GraphInfo
ROOT = Path(__file__).resolve().parents[1]
SOURCES = ("apu_tensorflow", "apu_gemmlowp")
class OracleError(RuntimeError):
"""Missing/stale oracle input or failed framework execution."""
def checked_sources() -> dict[str, Path]:
lock = json.loads((ROOT / "dependencies/dependencies.lock.json").read_text())
result = {}
for name in SOURCES:
spec = lock["sources"][name]
path = ROOT / spec["destination"]
try:
revision = subprocess.check_output(
["git", "-C", str(path), "rev-parse", "HEAD"], text=True,
stderr=subprocess.PIPE,
).strip()
dirty = subprocess.check_output(
["git", "-C", str(path), "status", "--porcelain"], text=True,
stderr=subprocess.PIPE,
).strip()
except (OSError, subprocess.CalledProcessError) as error:
raise OracleError(f"missing locked oracle source: {name}") from error
if revision != spec["revision"] or dirty:
raise OracleError(f"stale or dirty locked oracle source: {name}")
result[name] = path
return result
def build_oracle(directory: Path, cxx: str = "g++") -> tuple[Path, dict]:
sources = checked_sources()
compiler = shutil.which(cxx)
if compiler is None:
raise OracleError(f"missing host C++ compiler: {cxx}")
directory.mkdir(parents=True, exist_ok=True)
adapter = ROOT / "tests/cpp/npu_framework_reference.cc"
quant = sources["apu_tensorflow"] / "tensorflow/lite/kernels/internal/quantization_util.cc"
executable = directory / "npu_framework_reference"
command = [compiler, "-std=c++11", "-O2", "-ffp-contract=off",
*(f"-I{path}" for path in sources.values()), str(adapter), str(quant),
"-o", str(executable)]
completed = subprocess.run(command, capture_output=True, text=True, check=False)
(directory / "compile.log").write_text(completed.stdout + completed.stderr)
if completed.returncode:
raise OracleError(f"framework oracle compilation failed: {directory / 'compile.log'}")
lock = json.loads((ROOT / "dependencies/dependencies.lock.json").read_text())
evidence = {
"command": command,
"compiler_version": subprocess.check_output([compiler, "--version"], text=True),
"sources": {name: lock["sources"][name]["revision"] for name in SOURCES},
"adapter_sha256": hashlib.sha256(adapter.read_bytes()).hexdigest(),
"binary_sha256": hashlib.sha256(executable.read_bytes()).hexdigest(),
"shared_boundary": "raw npu_model parser only; no shared lowering or arithmetic",
}
(directory / "build.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n")
return executable, evidence
def encode_graph(graph: GraphInfo) -> bytes:
"""Encode raw, unpacked TFLite graph metadata for the host adapter."""
data = bytearray(b"NPM1")
def u32(value):
data.extend(struct.pack("<I", value))
def indices(values):
u32(len(values))
for value in values:
u32(value)
u32(len(graph.tensors))
for tensor in graph.tensors:
indices(tensor.shape)
u32(tensor.dtype)
u32(len(tensor.scales))
for value in tensor.scales:
data.extend(struct.pack("<d", value))
u32(len(tensor.zero_points))
for value in tensor.zero_points:
data.extend(struct.pack("<i", value))
raw = tensor.data or b""
u32(len(raw))
data.extend(raw)
indices(graph.inputs)
u32(len(graph.operators))
for op in graph.operators:
u32(op.opcode)
indices(op.inputs)
indices(op.outputs)
o = op.options
values = [
{"SAME": 0, "VALID": 1}[o.get("padding", "VALID")],
o.get("stride_h", 1), o.get("stride_w", 1),
o.get("dilation_h", 1), o.get("dilation_w", 1),
o.get("depth_multiplier", 1),
o.get("filter_height", 1), o.get("filter_width", 1),
{"NONE": 0, "RELU": 1, "RELU6": 3}[o.get("fused_activation", "NONE")],
]
data.extend(struct.pack("<9id", *values, o.get("beta", 1.0)))
return bytes(data)
def decode_outputs(graph: GraphInfo, blob: bytes) -> list[bytes]:
ops = [op for op in graph.operators if op.op_name != "RESHAPE"]
if len(blob) < 8 or blob[:4] != b"NPO1" or struct.unpack_from("<I", blob, 4)[0] != len(ops):
raise OracleError("invalid/truncated framework output header")
cursor = 8
layers = []
for op in ops:
if cursor + 8 > len(blob):
raise OracleError("truncated framework layer header")
index, size = struct.unpack_from("<II", blob, cursor)
cursor += 8
expected = 1
for dim in graph.tensors[op.outputs[0]].shape:
expected *= dim
if index != op.outputs[0] or size != expected or cursor + size > len(blob):
raise OracleError("framework tensor identity/length mismatch")
layers.append(blob[cursor:cursor + size])
cursor += size
if cursor != len(blob):
raise OracleError("unexpected trailing framework output")
return layers
class FrameworkOracle:
def __init__(self, graph: GraphInfo, executable: Path, directory: Path):
self.graph = graph
self.executable = executable.resolve()
self.directory = directory.resolve()
self.directory.mkdir(parents=True, exist_ok=True)
self.model = self.directory / "raw-model.bin"
self.model.write_bytes(encode_graph(graph))
def evaluate(self, image: bytes, case: str) -> list[bytes]:
if Path(case).name != case or case in ("", ".", ".."):
raise OracleError("invalid oracle case identifier")
folder = self.directory / case
folder.mkdir(exist_ok=True)
inp, out = folder / "input.bin", folder / "layers.bin"
inp.write_bytes(image)
command = [str(self.executable), str(self.model), str(inp), str(out)]
started = time.monotonic()
try:
completed = subprocess.run(command, capture_output=True, timeout=120, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise OracleError(f"framework execution unavailable: {case}: {error}") from error
(folder / "oracle.log").write_bytes(completed.stdout + completed.stderr)
(folder / "run.json").write_text(json.dumps({
"command": command, "returncode": completed.returncode,
"duration_seconds": time.monotonic() - started,
"model_sha256": hashlib.sha256(self.model.read_bytes()).hexdigest(),
}, indent=2, sort_keys=True) + "\n")
if completed.returncode:
raise OracleError(f"framework execution failed: {case}: {completed.stderr.decode()}")
return decode_outputs(self.graph, out.read_bytes())
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Generate the committed NPU-P0 workload qualification manifests."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.dependency_lock import archive, source # noqa: E402
from scripts.setup_helpers import sha256 # noqa: E402
from scripts.setup_npu_reference import ( # noqa: E402
KWS_FEATURE_COUNT,
KWS_FEATURES,
VWW_CLASS_DIRS,
VWW_CORPUS_COUNT,
vww_label_entries,
)
CONTRACT_REVISION = "npu-p0/1.0.0"
KWS_CORPUS_TSV = ROOT / "docs/ip/apu-kws-corpus.tsv"
KWS_MANIFEST_OUT = ROOT / "docs/ip/npu-kws-manifest.json"
VWW_MANIFEST_OUT = ROOT / "docs/ip/npu-vww-manifest.json"
VWW_CORPUS_TSV_OUT = ROOT / "docs/ip/npu-vww-corpus.tsv"
KWS_MODEL_REL = "benchmark/training/keyword_spotting/trained_models/kws_ref_model.tflite"
VWW_MODEL_REL = "benchmark/training/visual_wake_words/trained_models/vww_96_int8.tflite"
VWW_LABELS_REL = "benchmark/evaluation/datasets/vww01/y_labels.csv"
RTL_SAMPLE_COUNT = 10
def _tiny_root() -> Path:
return ROOT / source("apu_mlperf_tiny")["destination"]
def _mfcc_root() -> Path:
return ROOT / source("apu_kws_mfcc")["destination"]
def _vww_corpus_root() -> Path:
return ROOT / ".cache/retrosoc/sources/npu-vww-corpus"
def _compiler_identity() -> dict[str, object]:
return {"contract_revision": CONTRACT_REVISION}
def _aggregate(digests: list[str]) -> str:
return hashlib.sha256("".join(digests).encode("ascii")).hexdigest()
def _rtl_samples(names: list[str]) -> list[str]:
return sorted(names)[:RTL_SAMPLE_COUNT]
def kws_inputs() -> list[dict[str, object]]:
mfcc = _mfcc_root() / KWS_FEATURES
inputs = []
for line in KWS_CORPUS_TSV.read_text(encoding="utf-8").splitlines():
index, name, _wav, class_id, _wav_sha, _pcm_sha = line.split("\t")
feature = mfcc / name
if not feature.is_file():
raise RuntimeError(f"missing KWS feature input: {feature}")
inputs.append(
{
"index": int(index),
"name": name,
"class_id": int(class_id),
"sha256": sha256(feature),
}
)
if len(inputs) != KWS_FEATURE_COUNT or [item["index"] for item in inputs] != list(
range(KWS_FEATURE_COUNT)
):
raise RuntimeError("KWS corpus manifest must hold 1000 ordered inputs")
return inputs
def build_kws_manifest() -> dict[str, object]:
tiny = _tiny_root()
model = tiny / KWS_MODEL_REL
inputs = kws_inputs()
return {
"schema": 1,
"workload": "kws",
"model": {
"path": KWS_MODEL_REL,
"bytes": model.stat().st_size,
"sha256": sha256(model),
},
"sources": {
"apu_mlperf_tiny": source("apu_mlperf_tiny")["revision"],
"apu_kws_mfcc": source("apu_kws_mfcc")["revision"],
},
"corpus": {
"definition": "docs/ip/apu-kws-corpus.tsv",
"definition_sha256": sha256(KWS_CORPUS_TSV),
"count": len(inputs),
"inputs_aggregate_sha256": _aggregate([item["sha256"] for item in inputs]),
},
"preprocessing": {
"kind": "mfcc-49x10-int8",
"note": "Locked APU-P7 MFCC frontend output reused unchanged; outside the NPU graph.",
},
"input_quantization": {"scale": 0.5847029089927673, "zero_point": 83},
"compiler": _compiler_identity(),
"rtl_sample_ids": _rtl_samples([item["name"] for item in inputs]),
"inputs": inputs,
}
def vww_rows() -> list[tuple[str, str, str, str, str, str]]:
corpus = _vww_corpus_root()
rows = []
for index, (name, label) in enumerate(vww_label_entries(_tiny_root())):
stem = name[:12]
jpg_rel = f"{VWW_CLASS_DIRS[label]}/COCO_val2014_{stem}.jpg"
jpg = corpus / jpg_rel
raw = corpus / VWW_CLASS_DIRS[label] / name
for candidate in (jpg, raw):
if not candidate.is_file():
raise RuntimeError(f"missing VWW corpus file: {candidate}")
rows.append(
(f"{index:06d}", name, jpg_rel, str(label), sha256(jpg), sha256(raw))
)
if len(rows) != VWW_CORPUS_COUNT:
raise RuntimeError("VWW corpus must hold 1000 inputs")
return rows
def vww_corpus_tsv_text() -> str:
return "".join("\t".join(row) + "\n" for row in vww_rows())
def build_vww_manifest(corpus_tsv_sha256: str) -> dict[str, object]:
tiny = _tiny_root()
model = tiny / VWW_MODEL_REL
rows = vww_rows()
labels = tiny / VWW_LABELS_REL
return {
"schema": 1,
"workload": "vww",
"model": {
"path": VWW_MODEL_REL,
"bytes": model.stat().st_size,
"sha256": sha256(model),
},
"sources": {"apu_mlperf_tiny": source("apu_mlperf_tiny")["revision"]},
"archives": {"npu_vww_dataset": archive("npu_vww_dataset")["sha256"]},
"corpus": {
"definition": "docs/ip/npu-vww-corpus.tsv",
"definition_sha256": corpus_tsv_sha256,
"labels_index": VWW_LABELS_REL,
"labels_index_sha256": sha256(labels),
"count": len(rows),
"class_counts": {"person": 500, "non_person": 500},
"jpeg_aggregate_sha256": _aggregate([row[4] for row in rows]),
"bin_aggregate_sha256": _aggregate([row[5] for row in rows]),
},
"preprocessing": {
"kind": "jpeg-rgb-96x96-minus-128",
"note": (
"Decode COCO_val2014 96x96 RGB JPEG to U8C3 bytes ([0]=ulc, [9215]=lrc), "
"then quantize input = pixel - 128; preprocessing is outside the NPU graph."
),
},
"input_quantization": {"scale": 1.0 / 255.0, "zero_point": -128},
"compiler": _compiler_identity(),
"rtl_sample_ids": _rtl_samples([row[1] for row in rows]),
}
def _write(path: Path, content: str) -> bool:
if path.is_file() and path.read_text(encoding="utf-8") == content:
return False
path.write_text(content, encoding="utf-8")
return True
def _dump(manifest: dict[str, object]) -> str:
return json.dumps(manifest, indent=2, sort_keys=True) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="verify committed manifests")
args = parser.parse_args()
kws = _dump(build_kws_manifest())
tsv = vww_corpus_tsv_text()
vww = _dump(build_vww_manifest(hashlib.sha256(tsv.encode("utf-8")).hexdigest()))
outputs = ((KWS_MANIFEST_OUT, kws), (VWW_CORPUS_TSV_OUT, tsv), (VWW_MANIFEST_OUT, vww))
if args.check:
stale = [str(path) for path, content in outputs if path.read_text(
encoding="utf-8"
) != content] if all(path.is_file() for path, _ in outputs) else [
str(path) for path, _ in outputs if not path.is_file()
]
if stale:
raise SystemExit(f"stale or missing NPU manifests: {stale}")
print("NPU-P0 manifests verified")
return 0
changed = [str(path) for path, content in outputs if _write(path, content)]
print(f"NPU-P0 manifests written: {changed if changed else 'already up to date'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+418
View File
@@ -0,0 +1,418 @@
"""Independent TFLite flatbuffer reader and NPU workload model extraction."""
from __future__ import annotations
import hashlib
import struct
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from typing import Any, Final
class FlatbufferError(ValueError):
"""A buffer is not a well-formed TFLite v3 flatbuffer."""
class Padding(IntEnum):
SAME = 0
VALID = 1
class FusedActivation(IntEnum):
NONE = 0
RELU = 1
RELU_N1_TO_1 = 2
RELU6 = 3
OP_ADD: Final = 0
OP_AVERAGE_POOL_2D: Final = 1
OP_CONV_2D: Final = 3
OP_DEPTHWISE_CONV_2D: Final = 4
OP_FULLY_CONNECTED: Final = 9
OP_MAX_POOL_2D: Final = 17
OP_RELU: Final = 19
OP_RELU6: Final = 21
OP_RESHAPE: Final = 22
OP_SOFTMAX: Final = 25
OP_CUSTOM: Final = 32
BUILTIN_OP_NAMES: Final = {
OP_ADD: "ADD",
OP_AVERAGE_POOL_2D: "AVERAGE_POOL_2D",
OP_CONV_2D: "CONV_2D",
OP_DEPTHWISE_CONV_2D: "DEPTHWISE_CONV_2D",
OP_FULLY_CONNECTED: "FULLY_CONNECTED",
OP_MAX_POOL_2D: "MAX_POOL_2D",
OP_RELU: "RELU",
OP_RELU6: "RELU6",
OP_RESHAPE: "RESHAPE",
OP_SOFTMAX: "SOFTMAX",
OP_CUSTOM: "CUSTOM",
}
TENSOR_TYPE_NAMES: Final = {
0: "FLOAT32",
1: "FLOAT16",
2: "INT32",
3: "UINT8",
4: "INT64",
5: "STRING",
6: "BOOL",
7: "INT16",
8: "COMPLEX64",
9: "INT8",
10: "FLOAT64",
11: "COMPLEX128",
12: "UINT64",
13: "RESOURCE",
14: "VARIANT",
15: "UINT32",
16: "UINT16",
}
REPO_ROOT: Final = Path(__file__).resolve().parents[1]
_MLPERF_TINY: Final = REPO_ROOT / ".cache/retrosoc/sources/apu-mlperf-tiny/benchmark/training"
KWS_TFLITE_PATH: Final = _MLPERF_TINY / "keyword_spotting/trained_models/kws_ref_model.tflite"
VWW_TFLITE_PATH: Final = _MLPERF_TINY / "visual_wake_words/trained_models/vww_96_int8.tflite"
KWS_TFLITE_SHA256: Final = "aeea436800704fce17b17292e4412630ad856e9d777c044c64ef748a880bd0ae"
VWW_TFLITE_SHA256: Final = "597a384c8c2c8a1276f04702f25013b7838f2f814f1ca7c174d295b73e3d6b7b"
def _check(condition: bool, message: str) -> None:
if not condition:
raise FlatbufferError(message)
def builtin_op_name(opcode: int) -> str:
return BUILTIN_OP_NAMES.get(opcode, f"UNKNOWN_{opcode}")
class _Cursor:
"""Bounds-checked read-only view of one flatbuffer table."""
__slots__ = ("_image", "_base", "_vtable", "_vtable_size")
def __init__(self, image: bytes, base: int) -> None:
_check(0 <= base <= len(image) - 4, "table base outside the buffer")
vtable = base - struct.unpack_from("<i", image, base)[0]
_check(0 <= vtable <= len(image) - 4, "vtable outside the buffer")
vtable_size = struct.unpack_from("<H", image, vtable)[0]
_check(4 <= vtable_size <= len(image) - vtable, "vtable size outside the buffer")
self._image = image
self._base = base
self._vtable = vtable
self._vtable_size = vtable_size
def _entry(self, field: int) -> int | None:
slot = self._vtable + 4 + field * 2
if slot + 2 > self._vtable + self._vtable_size:
return None
delta = struct.unpack_from("<H", self._image, slot)[0]
if delta == 0:
return None
return self._base + delta
def has(self, field: int) -> bool:
return self._entry(field) is not None
def scalar(self, field: int, fmt: str, default: int | float = 0) -> int | float:
at = self._entry(field)
if at is None:
return default
size = struct.calcsize("<" + fmt)
_check(at + size <= len(self._image), "scalar field outside the buffer")
return struct.unpack_from("<" + fmt, self._image, at)[0]
def offset(self, field: int) -> int | None:
at = self._entry(field)
if at is None:
return None
_check(at + 4 <= len(self._image), "offset field outside the buffer")
target = at + struct.unpack_from("<I", self._image, at)[0]
_check(4 <= target <= len(self._image), "offset target outside the buffer")
return target
def child(self, field: int) -> _Cursor | None:
target = self.offset(field)
return None if target is None else _Cursor(self._image, target)
def vector(self, field: int, fmt: str) -> tuple[int | float, ...]:
target = self.offset(field)
if target is None:
return ()
_check(target + 4 <= len(self._image), "vector length outside the buffer")
count = struct.unpack_from("<I", self._image, target)[0]
size = struct.calcsize("<" + fmt)
_check(count * size <= len(self._image) - target - 4, "vector body outside the buffer")
return struct.unpack_from(f"<{count}{fmt}", self._image, target + 4)
def children(self, field: int) -> tuple[_Cursor, ...]:
target = self.offset(field)
if target is None:
return ()
_check(target + 4 <= len(self._image), "table vector length outside the buffer")
count = struct.unpack_from("<I", self._image, target)[0]
_check(count * 4 <= len(self._image) - target - 4, "table vector outside the buffer")
return tuple(
_Cursor(
self._image,
target + 4 + item * 4 + struct.unpack_from("<I", self._image, target + 4 + item * 4)[0],
)
for item in range(count)
)
def text(self, field: int) -> str:
target = self.offset(field)
if target is None:
return ""
_check(target + 4 <= len(self._image), "string length outside the buffer")
length = struct.unpack_from("<I", self._image, target)[0]
_check(length <= len(self._image) - target - 4, "string body outside the buffer")
return self._image[target + 4 : target + 4 + length].decode("utf-8", "replace")
def _root(image: bytes) -> _Cursor:
_check(len(image) >= 8, "buffer too small for a TFLite header")
_check(image[4:8] == b"TFL3", "missing TFL3 file identifier")
return _Cursor(image, struct.unpack_from("<I", image, 0)[0])
@dataclass(frozen=True)
class TensorInfo:
name: str
shape: tuple[int, ...]
dtype: int
scales: tuple[float, ...]
zero_points: tuple[int, ...]
quantized_dimension: int
buffer_index: int
data: bytes | None
@property
def dtype_name(self) -> str:
return TENSOR_TYPE_NAMES.get(self.dtype, f"UNKNOWN_{self.dtype}")
@property
def per_channel(self) -> bool:
return len(self.scales) > 1
@dataclass(frozen=True)
class OperatorInfo:
opcode: int
op_name: str
inputs: tuple[int, ...]
outputs: tuple[int, ...]
options: dict[str, Any]
@dataclass(frozen=True)
class GraphInfo:
tensors: tuple[TensorInfo, ...]
operators: tuple[OperatorInfo, ...]
inputs: tuple[int, ...]
outputs: tuple[int, ...]
@dataclass(frozen=True)
class ModelInfo:
version: int
description: str
operator_codes: tuple[int, ...]
subgraphs: tuple[GraphInfo, ...]
def main_graph(self) -> GraphInfo:
_check(len(self.subgraphs) > 0, "model has no subgraphs")
return self.subgraphs[0]
def _enum_name(kind: type[IntEnum], value: int) -> str:
try:
return kind(value).name
except ValueError:
return f"UNKNOWN_{value}"
def _read_options(opcode: int, table: _Cursor | None) -> dict[str, Any]:
if table is None:
return {}
if opcode == OP_CONV_2D:
return {
"padding": _enum_name(Padding, int(table.scalar(0, "b"))),
"stride_w": int(table.scalar(1, "i")),
"stride_h": int(table.scalar(2, "i")),
"fused_activation": _enum_name(FusedActivation, int(table.scalar(3, "b"))),
"dilation_w": int(table.scalar(4, "i", 1)),
"dilation_h": int(table.scalar(5, "i", 1)),
}
if opcode == OP_DEPTHWISE_CONV_2D:
return {
"padding": _enum_name(Padding, int(table.scalar(0, "b"))),
"stride_w": int(table.scalar(1, "i")),
"stride_h": int(table.scalar(2, "i")),
"depth_multiplier": int(table.scalar(3, "i")),
"fused_activation": _enum_name(FusedActivation, int(table.scalar(4, "b"))),
"dilation_w": int(table.scalar(5, "i", 1)),
"dilation_h": int(table.scalar(6, "i", 1)),
}
if opcode in (OP_AVERAGE_POOL_2D, OP_MAX_POOL_2D):
return {
"padding": _enum_name(Padding, int(table.scalar(0, "b"))),
"stride_w": int(table.scalar(1, "i")),
"stride_h": int(table.scalar(2, "i")),
"filter_width": int(table.scalar(3, "i")),
"filter_height": int(table.scalar(4, "i")),
"fused_activation": _enum_name(FusedActivation, int(table.scalar(5, "b"))),
}
if opcode == OP_FULLY_CONNECTED:
return {
"fused_activation": _enum_name(FusedActivation, int(table.scalar(0, "b"))),
"weights_format": int(table.scalar(1, "b")),
}
if opcode == OP_SOFTMAX:
return {"beta": float(table.scalar(0, "f"))}
if opcode == OP_RESHAPE:
return {"new_shape": tuple(int(dim) for dim in table.vector(0, "i"))}
if opcode == OP_ADD:
return {"fused_activation": _enum_name(FusedActivation, int(table.scalar(0, "b")))}
return {}
def _read_operator_code(code: _Cursor) -> int:
if code.has(3):
return int(code.scalar(3, "i"))
return int(code.scalar(0, "b"))
def _read_tensor(tensor: _Cursor, buffers: tuple[bytes, ...]) -> TensorInfo:
buffer_index = int(tensor.scalar(2, "I"))
_check(buffer_index < len(buffers), "tensor buffer index out of range")
quant = tensor.child(4)
payload = buffers[buffer_index]
return TensorInfo(
name=tensor.text(3),
shape=tuple(int(dim) for dim in tensor.vector(0, "i")),
dtype=int(tensor.scalar(1, "b")),
scales=tuple(float(item) for item in quant.vector(2, "f")) if quant else (),
zero_points=tuple(int(item) for item in quant.vector(3, "q")) if quant else (),
quantized_dimension=int(quant.scalar(6, "i")) if quant else 0,
buffer_index=buffer_index,
data=payload if payload else None,
)
def _read_operator(operator: _Cursor, codes: tuple[int, ...]) -> OperatorInfo:
opcode_index = int(operator.scalar(0, "I"))
_check(opcode_index < len(codes), "operator-code index out of range")
opcode = codes[opcode_index]
return OperatorInfo(
opcode=opcode,
op_name=builtin_op_name(opcode),
inputs=tuple(int(item) for item in operator.vector(1, "i")),
outputs=tuple(int(item) for item in operator.vector(2, "i")),
options=_read_options(opcode, operator.child(4)),
)
def _read_graph(
graph: _Cursor, codes: tuple[int, ...], buffers: tuple[bytes, ...]
) -> GraphInfo:
return GraphInfo(
tensors=tuple(_read_tensor(tensor, buffers) for tensor in graph.children(0)),
operators=tuple(_read_operator(operator, codes) for operator in graph.children(3)),
inputs=tuple(int(item) for item in graph.vector(1, "i")),
outputs=tuple(int(item) for item in graph.vector(2, "i")),
)
def parse_tflite(data: bytes) -> ModelInfo:
"""Parse raw TFLite v3 bytes into a ModelInfo, rejecting malformed input."""
image = bytes(data)
model = _root(image)
operator_codes = tuple(_read_operator_code(code) for code in model.children(1))
buffers = tuple(bytes(buffer.vector(0, "B")) for buffer in model.children(4))
subgraphs = tuple(
_read_graph(subgraph, operator_codes, buffers) for subgraph in model.children(2)
)
return ModelInfo(
version=int(model.scalar(0, "I")),
description=model.text(3),
operator_codes=operator_codes,
subgraphs=subgraphs,
)
def load_model(path: str | Path, expected_sha256: str) -> ModelInfo:
"""Read a TFLite file, pin its SHA-256, and parse it."""
candidate = Path(path)
try:
payload = candidate.read_bytes()
except OSError as error:
raise FlatbufferError(f"cannot read {candidate}: {error}") from error
digest = hashlib.sha256(payload).hexdigest()
if digest != expected_sha256:
raise FlatbufferError(
f"SHA-256 mismatch for {candidate.name}: got {digest}, expected {expected_sha256}"
)
return parse_tflite(payload)
def load_kws_model() -> ModelInfo:
"""Load the hash-pinned MLPerf Tiny keyword-spotting workload model."""
return load_model(KWS_TFLITE_PATH, KWS_TFLITE_SHA256)
def load_vww_model() -> ModelInfo:
"""Load the hash-pinned MLPerf Tiny visual-wake-words workload model."""
return load_model(VWW_TFLITE_PATH, VWW_TFLITE_SHA256)
def _tensor_summary(tensor: TensorInfo) -> dict[str, Any]:
return {
"name": tensor.name,
"shape": list(tensor.shape),
"dtype": tensor.dtype_name,
"quantization": {
"per_channel": tensor.per_channel,
"scales": list(tensor.scales),
"zero_points": list(tensor.zero_points),
"quantized_dimension": tensor.quantized_dimension,
},
"buffer_bytes": len(tensor.data) if tensor.data is not None else 0,
}
def graph_summary(graph: GraphInfo) -> dict[str, Any]:
"""Build a JSON-serializable per-operator summary of one graph."""
tensors = graph.tensors
def view(indices: tuple[int, ...]) -> list[dict[str, Any]]:
return [_tensor_summary(tensors[item]) for item in indices if 0 <= item < len(tensors)]
operators: list[dict[str, Any]] = []
for index, operator in enumerate(graph.operators):
constants = [
tensors[item]
for item in operator.inputs
if 0 <= item < len(tensors) and tensors[item].data is not None
]
operators.append(
{
"index": index,
"op": operator.op_name,
"opcode": operator.opcode,
"inputs": view(operator.inputs),
"outputs": view(operator.outputs),
"options": dict(operator.options),
"constants": [
{"name": item.name, "bytes": len(item.data)} for item in constants
],
}
)
return {
"inputs": view(graph.inputs),
"outputs": view(graph.outputs),
"operators": operators,
}
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env python3
"""Assemble NPU-P5 V013/V014 evidence from completed structured results."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REJECTED = re.compile(
r"(?:\bFAIL(?:ED)?\b|\bFATAL\b|assertion failed|%Error|SIM_TEST_FAIL|SIM_TEST_TIMEOUT)",
re.IGNORECASE,
)
DEPLOYMENT_CONTRACT = "npu-p5/1.0.1"
MODEL_SHA256 = {
"kws": "aeea436800704fce17b17292e4412630ad856e9d777c044c64ef748a880bd0ae",
"vww": "597a384c8c2c8a1276f04702f25013b7838f2f814f1ca7c174d295b73e3d6b7b",
}
RTL_IMPLEMENTATION_REQUIRED = {
"rtl/ip/multimedia/npu_accumulator.sv",
"rtl/ip/multimedia/npu_core.sv",
"rtl/ip/multimedia/npu_dma.sv",
"rtl/ip/multimedia/npu_job_decoder.sv",
"rtl/ip/multimedia/npu_local_sram.sv",
"rtl/ip/multimedia/npu_mac_array.sv",
"rtl/ip/multimedia/npu_patch_packer.sv",
"rtl/ip/multimedia/npu_pkg.sv",
"rtl/ip/multimedia/npu_requantizer.sv",
"rtl/ip/multimedia/npu_scheduler.sv",
"rtl/ip/multimedia/npu_vector.sv",
"scripts/npu_compiler.py",
"scripts/npu_compiler_p0.py",
"scripts/npu_executor.py",
"scripts/npu_model.py",
"scripts/npu_reference.py",
"scripts/qualify_npu_p5.py",
"tests/rtl/npu_operator_tb.sv",
}
P0_IMPLEMENTATION_REQUIRED = {
"scripts/qualify_npu_p0.py",
"scripts/npu_framework_reference.py",
"scripts/npu_model.py",
"scripts/npu_reference.py",
"scripts/npu_compiler_p0.py",
"scripts/npu_executor.py",
"scripts/setup_npu_reference.py",
"tests/cpp/npu_framework_reference.cc",
}
def load(path: Path) -> dict:
if not path.is_file():
raise ValueError(f"missing evidence: {path}")
return json.loads(path.read_text())
def file_identity(path: Path) -> dict[str, int | str]:
payload = path.read_bytes()
return {"path": str(path), "bytes": len(payload),
"sha256": hashlib.sha256(payload).hexdigest()}
def verify_identity(identity: dict, *, path: Path | None = None) -> None:
payload = path if path is not None else Path(identity["path"])
if (not payload.is_file() or payload.stat().st_size != identity["bytes"] or
hashlib.sha256(payload.read_bytes()).hexdigest() != identity["sha256"]):
raise ValueError(f"artifact identity mismatch: {payload}")
def frozen_rtl_samples() -> dict[tuple[str, str], str]:
kws = load(ROOT / "docs/ip/npu-kws-manifest.json")
kws_hashes = {item["name"]: item["sha256"] for item in kws["inputs"]}
vww_hashes = {}
for line in (ROOT / "docs/ip/npu-vww-corpus.tsv").read_text().splitlines():
_index, name, _jpeg, _label, _jpeg_sha, bin_sha = line.split("\t")
vww_hashes[name] = bin_sha
expected = {}
for workload, manifest, hashes in (
("kws", kws, kws_hashes),
("vww", load(ROOT / "docs/ip/npu-vww-manifest.json"), vww_hashes),
):
for name in manifest["rtl_sample_ids"]:
expected[(workload, name)] = hashes[name]
return expected
def validate_implementation(implementation: dict) -> None:
if not RTL_IMPLEMENTATION_REQUIRED <= set(implementation):
raise ValueError("RTL qualification lacks required implementation identities")
for name, identity in implementation.items():
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"implementation name is not repository-relative: {name}")
expected = (ROOT / name).resolve()
if Path(identity.get("path", "")).resolve() != expected:
raise ValueError(f"implementation path mismatch: {name}")
verify_identity(identity, path=expected)
def validate_p0(report: dict) -> None:
if (report.get("verdict") != "PASS" or report.get("skipped") != 0 or
not report.get("acceptance_run")):
raise ValueError("P0 dependency evidence is not a full zero-skip PASS")
results = report.get("results", [])
if Counter(item.get("workload") for item in results) != {"kws": 1, "vww": 1}:
raise ValueError("P0 dependency evidence lacks both workloads")
for item in results:
if (item.get("inputs") != 1000 or not item.get("full_corpus") or
item.get("layer_mismatches") != 0):
raise ValueError(f"P0 workload is incomplete: {item.get('workload')}")
implementation = report.get("implementation_sha256", {})
if set(implementation) != P0_IMPLEMENTATION_REQUIRED:
raise ValueError("P0 dependency evidence lacks required implementation identities")
for name, digest in implementation.items():
path = (ROOT / name).resolve()
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
raise ValueError(f"P0 implementation identity mismatch: {name}")
def validate_required_command(result: dict, expected: list[str], label: str) -> None:
if result.get("command") != expected:
raise ValueError(f"{label} result command is not the required gate")
def validate_variant_path(manifest_path: Path, profile: str, digest: str) -> None:
variant = manifest_path.resolve().parent.parent.name
if not variant.startswith(f"{profile}-") or not variant.endswith(f"-{digest}"):
raise ValueError(f"manifest path does not bind {profile} digest {digest}")
def validate_rtl_cases(cases: list[dict]) -> dict[tuple[str, str], dict]:
expected_cases = frozen_rtl_samples()
case_keys = [(case.get("workload"), case.get("input")) for case in cases]
if len(cases) != 20 or len(set(case_keys)) != 20 or set(case_keys) != set(expected_cases):
raise ValueError("RTL qualification does not contain ten cases per model")
for case in cases:
key = (case.get("workload"), case.get("input"))
if case.get("source_input_sha256") != expected_cases[key]:
raise ValueError(f"RTL case input hash is not frozen: {key[0]}/{key[1]}")
if len(case.get("layer_sha256", [])) != case.get("descriptors"):
raise ValueError(f"RTL case lacks per-layer hashes: {case.get('input')}")
if set(case.get("artifacts", {})) != {"memory", "final", "write_trace"}:
raise ValueError(f"RTL case artifact set is incomplete: {case.get('input')}")
for identity in case["artifacts"].values():
verify_identity(identity)
if not case.get("plusargs"):
raise ValueError(f"RTL case has no command arguments: {case.get('input')}")
return {key: case for key, case in zip(case_keys, cases, strict=True)}
def validate_simulator_records(
simulator: str, records: list[dict], case_map: dict[tuple[str, str], dict], prefix: list[str]
) -> None:
record_keys = [(record.get("workload"), record.get("input")) for record in records]
if len(records) != 20 or len(set(record_keys)) != 20 or set(record_keys) != set(case_map):
raise ValueError(f"{simulator} did not execute all twenty cases")
if not prefix:
raise ValueError(f"{simulator} build lacks a run prefix")
for record in records:
if (record.get("status") != "PASS" or record.get("exit_code") != 0 or
record.get("timed_out") or record.get("rejected_markers") or
not record.get("command")):
raise ValueError(f"failed or incomplete RTL case: {simulator}/{record.get('input')}")
case = case_map[(record["workload"], record["input"])]
if record["command"] != [*prefix, *case["plusargs"]]:
raise ValueError(
f"RTL command does not match case artifacts: {simulator}/{record.get('input')}"
)
verify_identity(record["log"])
def validate_deployment(directory: Path, *, workload: str, prefix: str) -> dict:
manifest = load(directory / "npu.json")
required_files = {
"descriptors.bin", "weights.bin", "params.bin",
f"{prefix}_npu.c", f"{prefix}_npu.h",
}
if (manifest.get("schema") != 1 or manifest.get("workload") != workload or
manifest.get("numeric_profile") != 1 or
manifest.get("source_model_sha256") != MODEL_SHA256[workload] or
manifest.get("compiler", {}).get("contract") != DEPLOYMENT_CONTRACT or
set(manifest.get("files", {})) != required_files):
raise ValueError(f"invalid deployment schema: {directory}")
for name, identity in manifest["files"].items():
payload = directory / name
if not payload.is_file() or payload.stat().st_size != identity["bytes"]:
raise ValueError(f"deployment size mismatch: {payload}")
if hashlib.sha256(payload.read_bytes()).hexdigest() != identity["sha256"]:
raise ValueError(f"deployment hash mismatch: {payload}")
return manifest
def validate_rtl(report: dict) -> None:
required = ("profile", "config_digest", "pdk", "command", "git", "lock",
"implementation", "coverage", "tools", "simulator_builds", "summary")
if report.get("verdict") != "PASS" or any(key not in report for key in required):
raise ValueError("RTL qualification lacks required provenance or PASS verdict")
if report.get("verification_ids") != ["NPU-V014"]:
raise ValueError("RTL qualification verification IDs are invalid")
validate_implementation(report["implementation"])
verify_identity(report["lock"], path=ROOT / "dependencies/dependencies.lock.json")
cases = report.get("cases", [])
if report.get("coverage", {}).get("skipped") != 0:
raise ValueError("RTL qualification contains skipped coverage")
case_map = validate_rtl_cases(cases)
simulators = report.get("simulators", {})
if set(simulators) != {"iverilog", "verilator"}:
raise ValueError("RTL qualification must contain Icarus and Verilator")
if set(report.get("simulator_builds", {})) != set(simulators):
raise ValueError("RTL qualification lacks simulator build provenance")
for simulator, records in simulators.items():
prefix = report["simulator_builds"][simulator].get("run_prefix")
validate_simulator_records(simulator, records, case_map, prefix)
summary = report["summary"]
if summary != {"cases": 20, "passes": 40, "failures": 0, "skipped": 0}:
raise ValueError("RTL qualification summary is inconsistent")
def validate_build(manifest: dict, *, app: str, acceptance: str) -> None:
config = manifest.get("configuration", {})
if (manifest.get("schema_version") != 1 or config.get("PDK") != "IHP130" or
config.get("APP") != app or config.get("NPU_P5_ACCEPTANCE") != acceptance or
config.get("SYNTH") != "NONE" or config.get("STA") != "NONE"):
raise ValueError(f"unexpected build manifest for APP={app}")
def same_deployment(reference: dict, candidate: dict, label: str) -> None:
keys = ("source_model_sha256", "numeric_profile", "required_capability_mask",
"required_opcode_mask", "arena_bytes", "files", "relocations", "steps",
"preprocessing")
if any(candidate.get(key) != reference.get(key) for key in keys):
raise ValueError(f"{label} KWS deployment differs from the qualified package")
def firmware_identity(result: dict, firmware: Path) -> dict[str, int | str]:
resolved = firmware.resolve()
if str(resolved) not in result.get("command", []):
raise ValueError(f"simulator command does not identify firmware: {resolved}")
try:
started = datetime.fromisoformat(result["started_at"]).timestamp()
except (KeyError, TypeError, ValueError) as error:
raise ValueError("simulator result lacks a valid start timestamp") from error
# The managed builders can observe up to roughly 12 seconds of host clock
# skew. Anything newer than that tolerance was not the retained run input.
if resolved.stat().st_mtime > started + 30.0:
raise ValueError(f"firmware changed after simulator start: {resolved}")
identity = file_identity(resolved)
identity["mtime"] = resolved.stat().st_mtime
return identity
def validate_simulation_evidence(
result_path: Path,
check_path: Path,
log_path: Path,
firmware: Path,
required_markers: list[str],
) -> tuple[dict, str, dict]:
result = passed_result(result_path)
check = passed_result(check_path)
resolved_result = result_path.resolve()
resolved_check = check_path.resolve()
resolved_log = log_path.resolve()
resolved_firmware = firmware.resolve()
simulator_dir = resolved_result.parent
emulator = (simulator_dir / "emu").resolve()
expected_command = [
str(emulator), "-i", str(resolved_firmware), "--fast-flash", "-t", "3600"
]
if (result.get("schema_version") != 1 or result.get("tool") != "verilator-sim" or
result.get("exit_code") != 0 or
Path(result.get("cwd", "")).resolve() != simulator_dir or
Path(result.get("log", "")).resolve() != resolved_log or
result.get("command") != expected_command):
raise ValueError("bare-metal simulation result provenance is invalid")
if (check.get("schema_version") != 1 or
Path(check.get("log", "")).resolve() != resolved_log or
Counter(check.get("required_markers", [])) != Counter(required_markers) or
check.get("missing_markers") != [] or check.get("rejected_matches") != []):
raise ValueError("bare-metal simulation check is not bound to the required log verdict")
log_text = resolved_log.read_text(errors="replace")
missing = [marker for marker in required_markers if marker not in log_text]
rejected = sorted({match.group(0) for match in REJECTED.finditer(log_text)})
if missing or rejected:
raise ValueError(
f"bare-metal simulation log failed validation: missing={missing}, rejected={rejected}"
)
emulator_record = firmware_identity(result, emulator)
firmware_record = firmware_identity(result, resolved_firmware)
evidence = {
"result_identity": file_identity(resolved_result),
"check_identity": file_identity(resolved_check),
"log": file_identity(resolved_log),
"emulator": emulator_record,
"firmware": firmware_record,
"required_markers": required_markers,
}
return result, log_text, evidence
def passed_result(path: Path) -> dict:
result = load(path)
if result.get("status") != "passed" or (
"exit_code" in result and result["exit_code"] != 0
):
raise ValueError(f"failed structured result: {path}")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--kws", type=Path, required=True)
parser.add_argument("--vww", type=Path, required=True)
parser.add_argument("--host-result", type=Path, required=True)
parser.add_argument("--c-quality-result", type=Path, required=True)
parser.add_argument("--p0-report", type=Path, required=True)
parser.add_argument("--rtl-report", type=Path, required=True)
parser.add_argument("--build-manifest", type=Path, required=True)
parser.add_argument("--config-digest", required=True)
parser.add_argument("--lp-result", type=Path, required=True)
parser.add_argument("--lp-check", type=Path, required=True)
parser.add_argument("--lp-log", type=Path, required=True)
parser.add_argument("--lp-manifest", type=Path, required=True)
parser.add_argument("--lp-config-digest", required=True)
parser.add_argument("--lp-kws", type=Path, required=True)
parser.add_argument("--lp-firmware", type=Path, required=True)
parser.add_argument("--hp-result", type=Path, required=True)
parser.add_argument("--hp-check", type=Path, required=True)
parser.add_argument("--hp-log", type=Path, required=True)
parser.add_argument("--hp-manifest", type=Path, required=True)
parser.add_argument("--hp-config-digest", required=True)
parser.add_argument("--hp-kws", type=Path, required=True)
parser.add_argument("--hp-firmware", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
report = {
"schema": 1, "phase": "NPU-P5", "verification_ids": ["NPU-V013", "NPU-V014"],
"verdict": "FAIL", "command": [sys.executable, *sys.argv],
"implementation": {
"scripts/npu_p5_report.py": file_identity(Path(__file__).resolve()),
"scripts/qualify_npu_p5.py": file_identity(
Path(__file__).resolve().with_name("qualify_npu_p5.py")
),
},
}
try:
deployments = {
"kws": validate_deployment(args.kws, workload="kws", prefix="kws"),
"vww": validate_deployment(args.vww, workload="vww", prefix="vww"),
}
lp_kws = validate_deployment(args.lp_kws, workload="kws", prefix="kws")
hp_kws = validate_deployment(args.hp_kws, workload="kws", prefix="kws")
same_deployment(deployments["kws"], lp_kws, "LP")
same_deployment(deployments["kws"], hp_kws, "HP")
host = passed_result(args.host_result)
c_quality = passed_result(args.c_quality_result)
p0 = load(args.p0_report)
validate_p0(p0)
rtl = load(args.rtl_report)
validate_rtl(rtl)
build_manifest = load(args.build_manifest)
lp_manifest = load(args.lp_manifest)
hp_manifest = load(args.hp_manifest)
validate_build(build_manifest, app="bringup", acceptance="NO")
validate_build(lp_manifest, app="ci_smoke", acceptance="YES")
validate_build(hp_manifest, app="hp_boot", acceptance="YES")
validate_variant_path(args.build_manifest, build_manifest["profile"], args.config_digest)
validate_variant_path(args.lp_manifest, lp_manifest["profile"], args.lp_config_digest)
validate_variant_path(args.hp_manifest, hp_manifest["profile"], args.hp_config_digest)
revisions = {item["repository"]["commit"]
for item in (build_manifest, lp_manifest, hp_manifest)}
dirty_states = {item["repository"]["dirty"]
for item in (build_manifest, lp_manifest, hp_manifest)}
locks = {item["dependency_lock"]["sha256"]
for item in (build_manifest, lp_manifest, hp_manifest)}
if len(revisions) != 1 or len(dirty_states) != 1 or len(locks) != 1:
raise ValueError("build manifests disagree on revision or dependency lock")
revision = next(iter(revisions))
lock_sha256 = next(iter(locks))
if (rtl["profile"] != build_manifest["profile"] or
rtl["config_digest"] != args.config_digest or
rtl["pdk"] != build_manifest["configuration"]["PDK"] or
rtl["git"]["revision"] != revision or
rtl["lock"]["sha256"] != lock_sha256):
raise ValueError("RTL report provenance disagrees with the selected build")
for deployment in (deployments["kws"], deployments["vww"]):
if deployment["compiler"]["git_revision"] != revision:
raise ValueError("deployment compiler revision disagrees with the build")
validate_required_command(host, [
"python3", "-m", "pytest", "-q",
str(ROOT / "tests/test_npu_compiler.py"),
str(ROOT / "tests/test_npu_executor.py"),
str(ROOT / "tests/test_npu_register_parity.py"),
], "host")
validate_required_command(c_quality, [
"make", "sw-format-check", "sw-policy-check", "sw-host-test"
], "C quality")
lp, lp_log, lp_evidence = validate_simulation_evidence(
args.lp_result, args.lp_check, args.lp_log, args.lp_firmware,
["NPU_P5_LP model=kws", "SIM_TEST_PASS code=0"],
)
hp, hp_log, hp_evidence = validate_simulation_evidence(
args.hp_result, args.hp_check, args.hp_log, args.hp_firmware,
["HP_NPU_PASS", "SIM_TEST_PASS code=0"],
)
marker = re.search(
r"NPU_P5_LP model=kws active=(\d+) read=(\d+) write=(\d+) softmax=(\d+)", lp_log
)
if marker is None:
raise ValueError("bare-metal log markers are incomplete")
report.update({
"verdict": "PASS",
"provenance": {
"git_revision": revision, "git_dirty": next(iter(dirty_states)),
"dependency_lock_sha256": lock_sha256,
"profile": build_manifest["profile"], "config_digest": args.config_digest,
"pdk": build_manifest["configuration"]["PDK"],
"tools": build_manifest["tools"],
"lp": {"profile": lp_manifest["profile"],
"config_digest": args.lp_config_digest},
"hp": {"profile": hp_manifest["profile"],
"config_digest": args.hp_config_digest},
},
"deployments": {name: {
"path": str(path), "source_model_sha256": deployments[name]["source_model_sha256"],
"arena_bytes": deployments[name]["arena_bytes"],
"files": deployments[name]["files"],
"preprocessing": deployments[name]["preprocessing"],
"cpu_steps": [step for step in deployments[name]["steps"] if step["kind"] == "cpu"],
} for name, path in (("kws", args.kws), ("vww", args.vww))},
"host": {"path": str(args.host_result), "identity": file_identity(args.host_result),
"command": host["command"]},
"c_quality": {"path": str(args.c_quality_result),
"identity": file_identity(args.c_quality_result),
"command": c_quality["command"]},
"p0": {"path": str(args.p0_report), "identity": file_identity(args.p0_report),
"implementation_sha256": p0["implementation_sha256"]},
"rtl": {"path": str(args.rtl_report), "identity": file_identity(args.rtl_report),
"simulators": {
name: len(cases) for name, cases in rtl["simulators"].items()},
"model_inputs": len(rtl["cases"])},
"lp": {"result": str(args.lp_result), "check": str(args.lp_check),
"command": lp["command"], "active_cycles": int(marker.group(1)),
"dma_read_bytes": int(marker.group(2)), "dma_write_bytes": int(marker.group(3)),
"softmax_cycles": int(marker.group(4)),
**lp_evidence},
"hp": {"result": str(args.hp_result), "check": str(args.hp_check),
"command": hp["command"], "mode": "polling-zicbom",
**hp_evidence},
"summary": {
"required_checks": 47,
"passes": 47,
"failures": 0,
"blocked": 0,
"skipped": 0,
"breakdown": {
"deployments": 2,
"p0": 1,
"host": 1,
"c_quality": 1,
"rtl_simulations": 40,
"lp_bare_metal": 1,
"hp_bare_metal": 1,
},
},
"constraints": {"synthesis": "DEFERRED_TO_P6", "sta": "DEFERRED_TO_P6",
"netlist": "DEFERRED_TO_P6"},
})
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
report["error"] = str(error)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
print(f"NPU-P5 qualification: {report['verdict']} ({args.output})")
return 0 if report["verdict"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Build deterministic NPU-P6 corpus shards from retained P0 oracle output."""
from __future__ import annotations
import argparse
import hashlib
import json
import struct
import subprocess
import zlib
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MAGIC = b"NP6S"
SCHEMA = 2
HEADER_BYTES = 128
HEADER = struct.Struct("<4s10I32s")
CASE_HEADER = struct.Struct("<II")
WORKLOAD_IDS = {"kws": 1, "vww": 2}
INPUT_BYTES = {"kws": 490, "vww": 27648}
OUTPUT_BYTES = {"kws": 12, "vww": 2}
@dataclass(frozen=True)
class CorpusCase:
index: int
name: str
label: int
input_data: bytes
terminal_data: bytes
softmax_data: bytes
input_sha256: str
terminal_sha256: str
softmax_sha256: str
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def file_identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {"path": str(path.resolve()), "bytes": len(data), "sha256": sha256(data)}
def _tsv_rows(path: Path) -> list[list[str]]:
return [line.split("\t") for line in path.read_text(encoding="utf-8").splitlines() if line]
def _decode_layers(data: bytes, metadata: list[dict[str, object]]) -> list[bytes]:
if (
len(data) < CASE_HEADER.size
or data[:4] != b"NPO1"
or struct.unpack_from("<I", data, 4)[0] != len(metadata)
):
raise ValueError("invalid P0 layer file header")
cursor = CASE_HEADER.size
layers = []
for position, record in enumerate(metadata):
if cursor + CASE_HEADER.size > len(data):
raise ValueError(f"truncated P0 layer header at {position}")
_tensor_id, size = CASE_HEADER.unpack_from(data, cursor)
cursor += CASE_HEADER.size
if size != record.get("bytes") or cursor + size > len(data):
raise ValueError(f"P0 layer size changed at {position}")
payload = data[cursor : cursor + size]
cursor += size
if sha256(payload) != record.get("framework_sha256"):
raise ValueError(f"P0 layer hash changed at {position}")
layers.append(payload)
if cursor != len(data):
raise ValueError("P0 layers contain trailing data")
return layers
def _load_cases(p0_dir: Path, workload: str) -> list[CorpusCase]:
oracle = p0_dir / "oracle" / workload
index = json.loads((oracle / "index.json").read_text(encoding="utf-8"))
tsv = ROOT / ("docs/ip/apu-kws-corpus.tsv" if workload == "kws" else "docs/ip/npu-vww-corpus.tsv")
rows = _tsv_rows(tsv)
if len(index) != 1000 or len(rows) != 1000:
raise ValueError(f"{workload} P6 qualification requires exactly 1000 cases")
cases: list[CorpusCase] = []
for position, (record, row) in enumerate(zip(index, rows, strict=True)):
case_index = int(row[0])
name = row[1]
label = int(row[3])
if case_index != position or record.get("input") != name:
raise ValueError(f"{workload} oracle index does not match frozen corpus at {position}")
case_dir = oracle / name
input_data = (case_dir / "input.bin").read_bytes()
layers = record.get("layers", [])
if not isinstance(layers, list):
raise ValueError(f"{workload} case {position} layer metadata changed")
output_bytes = OUTPUT_BYTES[workload]
if len(input_data) != INPUT_BYTES[workload]:
raise ValueError(f"{workload} case {position} input byte count changed")
if len(layers) < 2 or any(layer.get("bytes") != output_bytes for layer in layers[-2:]):
raise ValueError(f"{workload} case {position} terminal layer metadata changed")
decoded_layers = _decode_layers((case_dir / "layers.bin").read_bytes(), layers)
terminal_data = decoded_layers[-2]
softmax_data = decoded_layers[-1]
input_digest = sha256(input_data)
terminal_digest = sha256(terminal_data)
softmax_digest = sha256(softmax_data)
if input_digest != record.get("input_sha256"):
raise ValueError(f"{workload} case {position} input hash mismatch")
if terminal_digest != layers[-2].get("framework_sha256"):
raise ValueError(f"{workload} case {position} terminal hash mismatch")
if softmax_digest != layers[-1].get("framework_sha256"):
raise ValueError(f"{workload} case {position} Softmax hash mismatch")
cases.append(
CorpusCase(
index=position,
name=name,
label=label,
input_data=input_data,
terminal_data=terminal_data,
softmax_data=softmax_data,
input_sha256=input_digest,
terminal_sha256=terminal_digest,
softmax_sha256=softmax_digest,
)
)
return cases
def encode_shard(
workload: str, shard_index: int, shard_count: int, cases: list[CorpusCase], manifest_sha256: str
) -> bytes:
if workload not in WORKLOAD_IDS or not cases:
raise ValueError("P6 shard requires a known workload and at least one case")
first = cases[0].index
if [case.index for case in cases] != list(range(first, first + len(cases))):
raise ValueError("P6 shard cases must be contiguous and ordered")
raw_record_bytes = CASE_HEADER.size + INPUT_BYTES[workload] + (2 * OUTPUT_BYTES[workload])
record_bytes = (raw_record_bytes + 3) & ~3
payload = b"".join(
CASE_HEADER.pack(case.index, case.label)
+ case.input_data
+ case.terminal_data
+ case.softmax_data
+ bytes(record_bytes - raw_record_bytes)
for case in cases
)
header = HEADER.pack(
MAGIC,
SCHEMA,
WORKLOAD_IDS[workload],
shard_index,
shard_count,
first,
len(cases),
INPUT_BYTES[workload],
OUTPUT_BYTES[workload],
record_bytes,
zlib.crc32(payload) & 0xFFFFFFFF,
bytes.fromhex(manifest_sha256),
)
return header + bytes(HEADER_BYTES - len(header)) + payload
def build_workload(
p0_dir: Path, output_dir: Path, workload: str, cases_per_shard: int
) -> dict[str, object]:
if cases_per_shard <= 0:
raise ValueError("cases per shard must be positive")
manifest_path = ROOT / f"docs/ip/npu-{workload}-manifest.json"
manifest_digest = sha256(manifest_path.read_bytes())
cases = _load_cases(p0_dir, workload)
chunks = [cases[index : index + cases_per_shard] for index in range(0, len(cases), cases_per_shard)]
workload_dir = output_dir / workload
workload_dir.mkdir(parents=True, exist_ok=True)
shards = []
for shard_index, chunk in enumerate(chunks):
path = workload_dir / f"shard-{shard_index:03d}.bin"
path.write_bytes(encode_shard(workload, shard_index, len(chunks), chunk, manifest_digest))
shards.append(
{
"index": shard_index,
"first_case": chunk[0].index,
"case_count": len(chunk),
"cases": [
{
"index": case.index,
"name": case.name,
"label": case.label,
"input_sha256": case.input_sha256,
"terminal_sha256": case.terminal_sha256,
"softmax_sha256": case.softmax_sha256,
}
for case in chunk
],
"file": file_identity(path),
}
)
return {
"workload": workload,
"manifest": file_identity(manifest_path),
"case_count": len(cases),
"cases_per_shard": cases_per_shard,
"shard_count": len(shards),
"input_bytes": INPUT_BYTES[workload],
"output_bytes": OUTPUT_BYTES[workload],
"shards": shards,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--p0-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--cases-per-shard", type=int, default=100)
args = parser.parse_args()
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
report = {
"schema": SCHEMA,
"phase": "NPU-P6",
"verification_ids": ["NPU-V016", "NPU-V018"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"implementation": {
"scripts/npu_p6_corpus.py": file_identity(ROOT / "scripts/npu_p6_corpus.py")
},
"p0_dir": str(args.p0_dir.resolve()),
"workloads": {
workload: build_workload(
args.p0_dir.resolve(), output_dir, workload, args.cases_per_shard
)
for workload in ("kws", "vww")
},
"summary": {"cases": 2000, "skipped": 0},
"verdict": "PASS",
}
report_path = output_dir / "corpus-shards.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 corpus shards: PASS ({report_path})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Assemble the bounded NPU-P6 formal closure report."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TARGETS = ("npu_dma", "npu_context", "npu_control")
def identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {"path": str(path.resolve()), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
def task(target_dir: Path, name: str) -> dict[str, object]:
status_path = target_dir / name / "status"
result_path = target_dir / f"result-{name}.json"
config_path = target_dir / f"{name}.sby"
if status_path.read_text(encoding="utf-8").split()[0] != "PASS":
raise ValueError(f"formal task failed: {status_path}")
result = json.loads(result_path.read_text(encoding="utf-8"))
if result.get("status") != "passed" or result.get("exit_code") != 0:
raise ValueError(f"formal structured result failed: {result_path}")
config = config_path.read_text(encoding="utf-8")
depth_match = re.search(r"(?m)^depth\s+(\d+)$", config)
mode_match = re.search(r"(?m)^mode\s+(\w+)$", config)
if depth_match is None or mode_match is None:
raise ValueError(f"formal task lacks mode/depth: {config_path}")
return {
"mode": mode_match.group(1),
"depth": int(depth_match.group(1)),
"status": "PASS",
"result": identity(result_path),
"log": identity(target_dir / f"{name}.log"),
"config": identity(config_path),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--formal-dir", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
records = {}
for target in TARGETS:
target_dir = args.formal_dir / target
records[target] = {
"prove": task(target_dir, "prove"),
"cover": task(target_dir, "cover"),
}
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V015"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"scope": {
"npu_dma": "AXI accepted-transfer conservation and bounded pause/flush safety",
"npu_context": "two-context ownership, backpressure and exactly-once 64-beat drain",
"npu_control": "launch/result/snapshot mailbox and lifecycle reconciliation",
},
"assumptions": {
"liveness": "no unbounded liveness claim",
"environment": "bounded responder stalls and explicitly modeled mailbox consumers",
},
"implementation": {
name: identity(ROOT / name)
for name in (
"scripts/npu_p6_formal_report.py",
"rtl/mini/formal/generate_formal_filelist.py",
"rtl/mini/formal/npu_context_formal.sv",
"rtl/mini/formal/npu_context_formal_props.sv",
"rtl/mini/formal/npu_control_formal.sv",
"rtl/mini/formal/npu_control_formal_props.sv",
"rtl/mini/mk/formal.mk",
)
},
"targets": records,
"summary": {"required_tasks": 6, "passes": 6, "failures": 0, "skipped": 0},
"verdict": "PASS",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 formal closure: PASS ({args.output})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Validate and assemble fail-closed NPU-P6 V015..V018 evidence."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REJECTED = ("FAILED", "FATAL", "assertion failed", "%Error", "SIM_TEST_FAIL", "SIM_TEST_TIMEOUT")
def load(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"evidence is not an object: {path}")
return data
def identity(path: Path) -> dict[str, object]:
payload = path.read_bytes()
return {
"path": str(path.resolve()),
"bytes": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
}
def require_pass(path: Path, *, phase: str | None = None) -> dict[str, Any]:
data = load(path)
status = data.get("verdict", data.get("status"))
if status not in ("PASS", "passed"):
raise ValueError(f"required evidence did not pass: {path}")
if phase is not None and data.get("phase") != phase:
raise ValueError(f"required evidence has wrong phase: {path}")
summary = data.get("summary", {})
if data.get("skipped", 0) or (isinstance(summary, dict) and summary.get("skipped", 0)):
raise ValueError(f"required evidence contains skipped cases: {path}")
return data
def validate_identity(record: dict[str, Any]) -> None:
path = Path(record.get("path", ""))
if not path.is_file():
raise ValueError(f"missing retained P6 artifact: {path}")
actual = identity(path)
if actual["bytes"] != record.get("bytes") or actual["sha256"] != record.get("sha256"):
raise ValueError(f"retained P6 artifact identity changed: {path}")
def validate_implementation(data: dict[str, Any], label: str) -> None:
implementation = data.get("implementation")
if not isinstance(implementation, dict) or not implementation:
raise ValueError(f"{label} evidence lacks implementation identities")
for name, record in implementation.items():
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"{label} implementation path is not repository-relative: {name}")
expected = (ROOT / relative).resolve()
if Path(record.get("path", "")).resolve() != expected:
raise ValueError(f"{label} implementation path changed: {name}")
validate_identity(record)
def validate_corpus(path: Path) -> dict[str, Any]:
data = require_pass(path, phase="NPU-P6")
workloads = data.get("workloads")
if data.get("schema") != 2 or not isinstance(workloads, dict) or set(workloads) != {"kws", "vww"}:
raise ValueError("P6 corpus report has the wrong schema or workload set")
for workload, record in workloads.items():
shards = record.get("shards") if isinstance(record, dict) else None
if (
record.get("case_count") != 1000
or record.get("cases_per_shard") != 100
or record.get("shard_count") != 10
or not isinstance(shards, list)
or len(shards) != 10
):
raise ValueError(f"{workload} corpus report is incomplete")
for shard_index, shard in enumerate(shards):
cases = shard.get("cases")
expected = list(range(shard_index * 100, (shard_index + 1) * 100))
if (
shard.get("index") != shard_index
or shard.get("first_case") != expected[0]
or shard.get("case_count") != 100
or not isinstance(cases, list)
or [case.get("index") for case in cases] != expected
):
raise ValueError(f"{workload} corpus shard {shard_index} changed")
validate_identity(shard["file"])
if any(
set(case) != {
"index",
"name",
"label",
"input_sha256",
"terminal_sha256",
"softmax_sha256",
}
for case in cases
):
raise ValueError(f"{workload} corpus shard {shard_index} case schema changed")
return data
def validate_performance(path: Path) -> dict[str, Any]:
data = require_pass(path, phase="NPU-P6")
if data.get("verification_ids") != ["NPU-V016"]:
raise ValueError("P6 performance verification IDs changed")
workloads = data.get("workloads")
if not isinstance(workloads, dict) or set(workloads) != {"kws", "vww"}:
raise ValueError("P6 performance report must contain KWS and VWW")
for name, record in workloads.items():
cases = record.get("cases") if isinstance(record, dict) else None
if not isinstance(cases, list) or len(cases) != 1000:
raise ValueError(f"{name} performance report must contain 1000 cases")
if [case.get("index") for case in cases] != list(range(1000)):
raise ValueError(f"{name} performance cases are incomplete or reordered")
for case in cases:
index = case.get("index")
expected_cache = "cold" if index % 100 == 0 else "steady"
expected_contention = "ga2d-copy-32x32768-rgb565" if index < 10 else "none"
if (
case.get("status") != "PASS"
or case.get("reference_cycles", 0) <= 0
or case.get("npu_cycles", 0) <= 0
or case.get("npu_cycles")
!= case.get("npu_wait_cycles", -1) + case.get("softmax_cycles", -1)
or case.get("reference_class") != case.get("npu_class")
or case.get("cache") != expected_cache
or case.get("contention") != expected_contention
):
raise ValueError(f"{name} performance case failed or has invalid cycles")
reference = sum(case["reference_cycles"] for case in cases)
npu = sum(case["npu_cycles"] for case in cases)
speedup = reference / npu
if abs(float(record.get("speedup", 0.0)) - speedup) > 1e-9 or speedup < 2.0:
raise ValueError(f"{name} does not satisfy the 2.0-times P6 target")
if record.get("skipped", 0) or record.get("mismatches", 0):
raise ValueError(f"{name} contains skipped or mismatching cases")
comparison = record.get("compiler_comparison")
metrics = comparison.get("metrics") if isinstance(comparison, dict) else None
counter_names = {
"active_cycles",
"dma_read_bytes",
"dma_write_bytes",
"useful_macs",
"pack_cycles",
"retired_descriptors",
}
if (
not isinstance(comparison, dict)
or comparison.get("material_threshold_percent") != 10
or not isinstance(comparison.get("cycle_model"), str)
or not comparison["cycle_model"]
or not isinstance(metrics, dict)
or set(metrics) != counter_names
):
raise ValueError(f"{name} compiler comparison is incomplete")
validate_identity(comparison["artifact"])
for counter_name in counter_names:
metric = metrics[counter_name]
expected_per_inference = metric.get("estimated_per_inference", 0)
expected_total = expected_per_inference * len(cases)
measured_total = sum(case[counter_name] for case in cases)
ratio = measured_total / expected_total if expected_total > 0 else 0.0
material = abs(ratio - 1.0) > 0.10
if (
not isinstance(expected_per_inference, int)
or expected_per_inference <= 0
or metric.get("expected_total") != expected_total
or metric.get("measured_total") != measured_total
or abs(float(metric.get("measured_mean", 0.0)) - (measured_total / len(cases)))
> 1e-9
or abs(float(metric.get("measured_to_estimated_ratio", 0.0)) - ratio) > 1e-9
or metric.get("material_difference") is not material
or not isinstance(metric.get("explanation"), str)
or not metric["explanation"]
):
raise ValueError(f"{name} compiler comparison changed for {counter_name}")
return data
def validate_log(path: Path, required: tuple[str, ...]) -> None:
text = path.read_text(encoding="utf-8", errors="replace")
if any(marker not in text for marker in required) or any(marker in text for marker in REJECTED):
raise ValueError(f"P6 log markers failed: {path}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--p0-report", type=Path, required=True)
parser.add_argument("--p5-report", type=Path, required=True)
parser.add_argument("--corpus-report", type=Path, required=True)
parser.add_argument("--performance-report", type=Path, required=True)
parser.add_argument("--formal-report", type=Path, required=True)
parser.add_argument("--netlist-report", type=Path, required=True)
parser.add_argument("--physical-report", type=Path, required=True)
parser.add_argument("--regression-report", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
revision = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip()
p0 = require_pass(args.p0_report, phase="NPU-P0")
p5 = require_pass(args.p5_report, phase="NPU-P5")
corpus = validate_corpus(args.corpus_report)
performance = validate_performance(args.performance_report)
formal = require_pass(args.formal_report, phase="NPU-P6")
netlist = require_pass(args.netlist_report, phase="NPU-P6")
physical = require_pass(args.physical_report, phase="NPU-P6")
regression = require_pass(args.regression_report, phase="NPU-P6")
expected_ids = {
"formal": ["NPU-V015"],
"netlist": ["NPU-V017", "NPU-V018"],
"physical": ["NPU-V017"],
"regression": ["NPU-V018"],
}
for name, data in (
("formal", formal),
("netlist", netlist),
("physical", physical),
("regression", regression),
):
if data.get("verification_ids") != expected_ids[name]:
raise ValueError(f"{name} evidence has the wrong verification IDs")
if netlist.get("physical_reused") or physical.get("product_reused"):
raise ValueError("P6 aggregate report requires fresh physical and netlist flows")
for name, data in (
("p0", p0),
("p5", p5),
("corpus", corpus),
("performance", performance),
("formal", formal),
("netlist", netlist),
("physical", physical),
("regression", regression),
):
evidence_revision = data.get("git_revision", data.get("provenance", {}).get("git_revision"))
if evidence_revision != revision:
raise ValueError(f"{name} evidence revision {evidence_revision!r} != {revision}")
if name not in {"p0", "p5"}:
validate_implementation(data, name)
if corpus.get("summary") != {"cases": 2000, "skipped": 0}:
raise ValueError("P6 corpus report is incomplete")
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V015", "NPU-V016", "NPU-V017", "NPU-V018"],
"git_revision": revision,
"evidence": {
name: identity(path)
for name, path in (
("p0", args.p0_report), ("p5", args.p5_report),
("corpus", args.corpus_report), ("performance", args.performance_report),
("formal", args.formal_report), ("netlist", args.netlist_report),
("physical", args.physical_report), ("regression", args.regression_report),
)
},
"performance": performance["workloads"],
"implementation": {
"scripts/npu_p6_report.py": identity(ROOT / "scripts/npu_p6_report.py")
},
"maturity": {
"functional": "qualified",
"performance": "qualified",
"physical_integration": "qualified",
"rtl_readiness": "prototype_metadata_verified",
"fpga": "NOT_RUN_OPTIONAL",
"silicon": "NOT_RUN",
},
"remaining_commercial_gaps": [
"post-layout extracted MMMC/PVT closure",
"qualified CDC/RDC signoff",
"DFT, scan, MBIST and SRAM repair",
"power and activity characterization",
"optional FPGA/board characterization",
"silicon characterization",
],
"summary": {"required_checks": 8, "passes": 8, "failures": 0, "skipped": 0},
"verdict": "PASS",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 qualification: PASS ({args.output})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+840
View File
@@ -0,0 +1,840 @@
"""Independent scalar numerical reference for the Mini NPU numerical profile 1."""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
from fractions import Fraction
from typing import Final
INT32_MIN: Final = -(1 << 31)
INT32_MAX: Final = (1 << 31) - 1
INT8_MIN: Final = -128
INT8_MAX: Final = 127
# Pinned TFLite reference Softmax parameters (docs/ip/apu.md, quantization profile 1).
SOFTMAX_INPUT_MULTIPLIER: Final = 1242899200
SOFTMAX_INPUT_LEFT_SHIFT: Final = 24
SOFTMAX_DIFF_MIN: Final = -124
SOFTMAX_OUTPUT_SCALE: Final = 1.0 / 256.0
SOFTMAX_OUTPUT_ZERO_POINT: Final = -128
# Pinned gemmlowp fixed-point constants, Q31 unless noted.
_EXP_CONSTANT_TERM: Final = 1895147668 # exp(-1/8)
_EXP_ONE_OVER_THREE: Final = 715827883
_EXP_BARREL_MULTIPLIERS: Final = (
(-2, 1672461947), # exp(-1/4)
(-1, 1302514674), # exp(-1/2)
(0, 790015084), # exp(-1)
(1, 290630308), # exp(-2)
(2, 39332535), # exp(-4)
(3, 720401), # exp(-8)
(4, 242), # exp(-16)
)
_RECIPROCAL_48_OVER_17: Final = 1515870810 # Q2.29 fixed point
_RECIPROCAL_NEG_32_OVER_17: Final = -1010580540 # Q2.29 fixed point
class ArithmeticFault(ArithmeticError):
"""Checked INT32 arithmetic overflow in a numerical profile 1 operation."""
@dataclass(frozen=True)
class Tensor:
"""Immutable batch-one NHWC INT8 activation tensor with quantization metadata."""
shape: tuple[int, int, int]
data: tuple[int, ...]
scale: float
zero_point: int
def __post_init__(self) -> None:
shape = tuple(self.shape)
if len(shape) != 3 or any(not isinstance(dim, int) for dim in shape):
raise ValueError("tensor shape must be an (H, W, C) integer triple")
if any(dim < 1 or dim > 4096 for dim in shape):
raise ValueError("tensor dimensions must each be within 1..4096")
data = tuple(self.data)
if len(data) != shape[0] * shape[1] * shape[2]:
raise ValueError("tensor data length does not match the (H, W, C) shape")
if any(not isinstance(value, int) for value in data):
raise ValueError("tensor data must contain integers")
if any(value < INT8_MIN or value > INT8_MAX for value in data):
raise ValueError("tensor data must contain INT8 values in -128..127")
scale = self.scale
if isinstance(scale, bool) or not isinstance(scale, (int, float)):
raise ValueError("tensor scale must be a positive finite float")
scale = float(scale)
if not math.isfinite(scale) or scale <= 0.0:
raise ValueError("tensor scale must be a positive finite float")
if not isinstance(self.zero_point, int) or isinstance(self.zero_point, bool):
raise ValueError("tensor zero point must be an INT8 integer")
if self.zero_point < INT8_MIN or self.zero_point > INT8_MAX:
raise ValueError("tensor zero point must be an INT8 integer")
object.__setattr__(self, "shape", shape)
object.__setattr__(self, "data", data)
object.__setattr__(self, "scale", scale)
@property
def height(self) -> int:
return self.shape[0]
@property
def width(self) -> int:
return self.shape[1]
@property
def channels(self) -> int:
return self.shape[2]
def value_at(self, h: int, w: int, c: int) -> int:
"""Return the raw INT8 value at NHWC coordinate (h, w, c)."""
return self.data[(h * self.width + w) * self.channels + c]
def _trunc_div(numerator: int, denominator: int) -> int:
quotient = abs(numerator) // denominator
return quotient if numerator >= 0 else -quotient
def _require_int(value: int, name: str) -> int:
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"{name} must be an integer")
return value
def _require_int32(value: int, name: str) -> int:
_require_int(value, name)
if value < INT32_MIN or value > INT32_MAX:
raise ValueError(f"{name} must be within signed INT32")
return value
def _require_int8(value: int, name: str) -> int:
_require_int(value, name)
if value < INT8_MIN or value > INT8_MAX:
raise ValueError(f"{name} must be within -128..127")
return value
def _require_scale(value: float, name: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{name} must be a positive finite float")
scale = float(value)
if not math.isfinite(scale) or scale <= 0.0:
raise ValueError(f"{name} must be a positive finite float")
return scale
def _require_weight(value: int, name: str) -> int:
_require_int(value, name)
if value < -127 or value > 127:
raise ValueError(f"{name} must be a symmetric INT8 weight in -127..127; -128 is illegal")
return value
def _require_multiplier(value: int, name: str) -> int:
_require_int(value, name)
if value < 0 or value > INT32_MAX:
raise ValueError(f"{name} must be a nonnegative INT32 value")
return value
def _require_shift(value: int, name: str) -> int:
_require_int(value, name)
if value < -31 or value > 30:
raise ValueError(f"{name} must be within -31..30")
return value
def _checked_accumulate(accumulator: int, term: int, context: str) -> int:
total = accumulator + term
if total < INT32_MIN or total > INT32_MAX:
raise ArithmeticFault(f"checked INT32 overflow in {context}")
return total
def _validate_act_bounds(act_min: int, act_max: int) -> tuple[int, int]:
_require_int8(act_min, "ACT_MIN")
_require_int8(act_max, "ACT_MAX")
if act_min > act_max:
raise ValueError("ACT_MIN must not exceed ACT_MAX")
return act_min, act_max
def _validate_strides(stride_h: int, stride_w: int) -> None:
_require_int(stride_h, "stride_h")
_require_int(stride_w, "stride_w")
if stride_h not in (1, 2) or stride_w not in (1, 2):
raise ValueError("strides must be 1 or 2")
def _validate_pads(pads: Sequence[int], kernel_h: int, kernel_w: int) -> None:
names = ("pad_top", "pad_bottom", "pad_left", "pad_right")
limits = (kernel_h, kernel_h, kernel_w, kernel_w)
for name, pad, limit in zip(names, pads, limits, strict=True):
_require_int(pad, name)
if pad < 0 or pad >= limit:
raise ValueError(f"{name} must be nonnegative and strictly less than the kernel")
def _output_extent(input_size: int, pad_before: int, pad_after: int, kernel: int, stride: int) -> int:
extent = (input_size + pad_before + pad_after - kernel) // stride + 1
if extent < 1:
raise ValueError("output extent must be at least one in-range input position")
return extent
def _level_length(node: object, name: str) -> int:
if not isinstance(node, Sequence) or isinstance(node, (str, bytes)) or len(node) == 0:
raise ValueError(f"{name} must be a nonempty nested sequence of integers")
return len(node)
def _flatten_weights(weights: Sequence, shape: tuple[int, ...], name: str) -> tuple[int, ...]:
flat: list[int] = []
def visit(node: object, depth: int) -> None:
if depth == len(shape):
flat.append(_require_weight(node, name))
return
if not isinstance(node, Sequence) or isinstance(node, (str, bytes)):
raise ValueError(f"{name} must be a rectangular nested sequence of shape {shape}")
if len(node) != shape[depth]:
raise ValueError(f"{name} must be a rectangular nested sequence of shape {shape}")
for child in node:
visit(child, depth + 1)
visit(weights, 0)
return tuple(flat)
def _validate_channel_words(values: Sequence[int], cout: int, name: str) -> tuple[int, ...]:
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
raise ValueError(f"{name} array must be a sequence with one word per output channel")
if len(values) != cout:
raise ValueError(f"{name} array must provide exactly one word per output channel")
return tuple(values)
def _validate_bias(bias: Sequence[int], cout: int) -> tuple[int, ...]:
return tuple(
_require_int32(value, "bias") for value in _validate_channel_words(bias, cout, "bias")
)
def _validate_multipliers(multipliers: Sequence[int], cout: int) -> tuple[int, ...]:
return tuple(
_require_multiplier(value, "multiplier")
for value in _validate_channel_words(multipliers, cout, "multiplier")
)
def _validate_shifts(shifts: Sequence[int], cout: int) -> tuple[int, ...]:
return tuple(
_require_shift(value, "shift")
for value in _validate_channel_words(shifts, cout, "shift")
)
def _validate_conv_weights(
weights: Sequence[Sequence[Sequence[Sequence[int]]]], cin: int
) -> tuple[int, int, int, tuple[int, ...]]:
cout = _level_length(weights, "weights")
kh = _level_length(weights[0], "weights")
kw = _level_length(weights[0][0], "weights")
if kh > 16 or kw > 16:
raise ValueError("kernel dimensions must be within 1..16")
flat = _flatten_weights(weights, (cout, kh, kw, cin), "weights")
return cout, kh, kw, flat
def _validate_depthwise_weights(weights: Sequence[Sequence[Sequence[int]]], cin: int) -> tuple[int, int, tuple[int, ...]]:
kh = _level_length(weights, "weights")
kw = _level_length(weights[0], "weights")
if kh > 16 or kw > 16:
raise ValueError("kernel dimensions must be within 1..16")
flat = _flatten_weights(weights, (kh, kw, cin), "weights")
return kh, kw, flat
def _validate_fc_weights(weights: Sequence[Sequence[int]], cin: int) -> tuple[int, tuple[int, ...]]:
cout = _level_length(weights, "weights")
flat = _flatten_weights(weights, (cout, cin), "weights")
return cout, flat
def quantize_multiplier(real_multiplier: float) -> tuple[int, int]:
"""Convert a nonnegative real multiplier with pinned TFLite QuantizeMultiplier semantics."""
if isinstance(real_multiplier, bool) or not isinstance(real_multiplier, (int, float)):
raise ValueError("real multiplier must be a nonnegative finite number")
value = float(real_multiplier)
if not math.isfinite(value) or value < 0.0:
raise ValueError("real multiplier must be a nonnegative finite number")
if value == 0.0:
return (0, 0)
fraction, shift = math.frexp(value)
q31 = Fraction(fraction) * (1 << 31)
q_fixed = (2 * q31.numerator + q31.denominator) // (2 * q31.denominator)
if q_fixed == 1 << 31:
q_fixed >>= 1
shift += 1
if shift < -31:
return (0, 0)
return (int(q_fixed), shift)
def saturating_rounding_doubling_high_mul(a: int, b: int) -> int:
"""Pinned Q31 product with the INT32_MIN-times-INT32_MIN saturation special case."""
_require_int32(a, "left operand")
_require_int32(b, "right operand")
if a == INT32_MIN and b == INT32_MIN:
return INT32_MAX
product = a * b
nudge = (1 << 30) if product >= 0 else 1 - (1 << 30)
return _trunc_div(product + nudge, 1 << 31)
def rounding_divide_by_pot(x: int, r: int) -> int:
"""Pinned correctly rounded division by 2^r using an arithmetic right shift."""
_require_int32(x, "dividend")
_require_int(r, "exponent")
if r < 0 or r > 31:
raise ValueError("exponent must be within 0..31")
if r == 0:
return x
mask = (1 << r) - 1
remainder = x & mask
threshold = (mask >> 1) + (1 if x < 0 else 0)
return (x >> r) + (1 if remainder > threshold else 0)
def multiply_by_quantized_multiplier(x: int, multiplier: int, shift: int) -> int:
"""Pinned double-rounding QM(x, m, s) requantization from numerical profile 1."""
_require_int32(x, "input")
_require_multiplier(multiplier, "multiplier")
_require_shift(shift, "shift")
shifted = x << max(shift, 0)
if shifted < INT32_MIN or shifted > INT32_MAX:
raise ArithmeticFault("checked left shift overflow in requantization")
product = saturating_rounding_doubling_high_mul(shifted, multiplier)
return rounding_divide_by_pot(product, max(-shift, 0))
def _requantize(
accumulator: int,
multiplier: int,
shift: int,
zero_point: int,
act_min: int,
act_max: int,
) -> int:
quantized = multiply_by_quantized_multiplier(accumulator, multiplier, shift) + zero_point
return max(act_min, min(act_max, quantized))
def _validate_output_quantization(
output_scale: float, output_zero_point: int
) -> tuple[float, int]:
scale = _require_scale(output_scale, "output scale")
zero_point = _require_int8(output_zero_point, "output zero point")
return scale, zero_point
def conv2d(
inputs: Tensor,
weights: Sequence[Sequence[Sequence[Sequence[int]]]],
bias: Sequence[int],
multipliers: Sequence[int],
shifts: Sequence[int],
*,
stride_h: int,
stride_w: int,
pad_top: int,
pad_bottom: int,
pad_left: int,
pad_right: int,
output_scale: float,
output_zero_point: int,
act_min: int,
act_max: int,
) -> Tensor:
"""Execute profile-1 CONV2D with checked INT32 accumulation over (Cout, Kh, Kw, Cin) weights."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
cout, kh, kw, flat_weights = _validate_conv_weights(weights, inputs.channels)
bias_values = _validate_bias(bias, cout)
multiplier_values = _validate_multipliers(multipliers, cout)
shift_values = _validate_shifts(shifts, cout)
act_low, act_high = _validate_act_bounds(act_min, act_max)
scale, zout = _validate_output_quantization(output_scale, output_zero_point)
_validate_strides(stride_h, stride_w)
_validate_pads((pad_top, pad_bottom, pad_left, pad_right), kh, kw)
oh_extent = _output_extent(inputs.height, pad_top, pad_bottom, kh, stride_h)
ow_extent = _output_extent(inputs.width, pad_left, pad_right, kw, stride_w)
cin = inputs.channels
zin = inputs.zero_point
output: list[int] = []
for oh in range(oh_extent):
for ow in range(ow_extent):
for c in range(cout):
accumulator = bias_values[c]
weight_base = c * kh * kw * cin
for kernel_h in range(kh):
ih = oh * stride_h + kernel_h - pad_top
if ih < 0 or ih >= inputs.height:
continue
for kernel_w in range(kw):
iw = ow * stride_w + kernel_w - pad_left
if iw < 0 or iw >= inputs.width:
continue
weight_index = weight_base + (kernel_h * kw + kernel_w) * cin
for ci in range(cin):
centered = inputs.value_at(ih, iw, ci) - zin
product = centered * flat_weights[weight_index + ci]
accumulator = _checked_accumulate(
accumulator, product, "conv2d accumulation"
)
output.append(
_requantize(
accumulator,
multiplier_values[c],
shift_values[c],
zout,
act_low,
act_high,
)
)
return Tensor((oh_extent, ow_extent, cout), tuple(output), scale, zout)
def depthwise_conv2d(
inputs: Tensor,
weights: Sequence[Sequence[Sequence[int]]],
bias: Sequence[int],
multipliers: Sequence[int],
shifts: Sequence[int],
*,
stride_h: int,
stride_w: int,
pad_top: int,
pad_bottom: int,
pad_left: int,
pad_right: int,
output_scale: float,
output_zero_point: int,
act_min: int,
act_max: int,
) -> Tensor:
"""Execute profile-1 depthwise convolution (multiplier one) with (Kh, Kw, C) weights."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
kh, kw, flat_weights = _validate_depthwise_weights(weights, inputs.channels)
cout = inputs.channels
bias_values = _validate_bias(bias, cout)
multiplier_values = _validate_multipliers(multipliers, cout)
shift_values = _validate_shifts(shifts, cout)
act_low, act_high = _validate_act_bounds(act_min, act_max)
scale, zout = _validate_output_quantization(output_scale, output_zero_point)
_validate_strides(stride_h, stride_w)
_validate_pads((pad_top, pad_bottom, pad_left, pad_right), kh, kw)
oh_extent = _output_extent(inputs.height, pad_top, pad_bottom, kh, stride_h)
ow_extent = _output_extent(inputs.width, pad_left, pad_right, kw, stride_w)
zin = inputs.zero_point
output: list[int] = []
for oh in range(oh_extent):
for ow in range(ow_extent):
for c in range(cout):
accumulator = bias_values[c]
for kernel_h in range(kh):
ih = oh * stride_h + kernel_h - pad_top
if ih < 0 or ih >= inputs.height:
continue
for kernel_w in range(kw):
iw = ow * stride_w + kernel_w - pad_left
if iw < 0 or iw >= inputs.width:
continue
centered = inputs.value_at(ih, iw, c) - zin
product = centered * flat_weights[(kernel_h * kw + kernel_w) * cout + c]
accumulator = _checked_accumulate(
accumulator, product, "depthwise accumulation"
)
output.append(
_requantize(
accumulator,
multiplier_values[c],
shift_values[c],
zout,
act_low,
act_high,
)
)
return Tensor((oh_extent, ow_extent, cout), tuple(output), scale, zout)
def fully_connected(
inputs: Tensor,
weights: Sequence[Sequence[int]],
bias: Sequence[int],
multipliers: Sequence[int] | int,
shifts: Sequence[int] | int,
*,
output_scale: float,
output_zero_point: int,
act_min: int,
act_max: int,
) -> Tensor:
"""Execute profile-1 FULLY_CONNECTED with (Cout, Cin) weights and per-channel scales."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
if inputs.height != 1 or inputs.width != 1:
raise ValueError("FULLY_CONNECTED requires a H=W=1 input tensor")
cin = inputs.channels
cout, flat_weights = _validate_fc_weights(weights, cin)
bias_values = _validate_bias(bias, cout)
if isinstance(multipliers, int):
multipliers = [multipliers] * cout
if isinstance(shifts, int):
shifts = [shifts] * cout
multiplier_values = _validate_multipliers(multipliers, cout)
shift_values = _validate_shifts(shifts, cout)
act_low, act_high = _validate_act_bounds(act_min, act_max)
scale, zout = _validate_output_quantization(output_scale, output_zero_point)
zin = inputs.zero_point
output: list[int] = []
for c in range(cout):
accumulator = bias_values[c]
for ci in range(cin):
centered = inputs.data[ci] - zin
product = centered * flat_weights[c * cin + ci]
accumulator = _checked_accumulate(accumulator, product, "fc accumulation")
output.append(
_requantize(
accumulator, multiplier_values[c], shift_values[c], zout, act_low, act_high
)
)
return Tensor((1, 1, cout), tuple(output), scale, zout)
def add(
input0: Tensor,
input1: Tensor,
*,
input0_multiplier: int,
input0_shift: int,
input1_multiplier: int,
input1_shift: int,
output_multiplier: int,
output_shift: int,
output_scale: float,
output_zero_point: int,
act_min: int,
act_max: int,
) -> Tensor:
"""Execute the pinned INT8 ADD path with the 2^20 input pre-scaling and checked sum."""
if not isinstance(input0, Tensor) or not isinstance(input1, Tensor):
raise ValueError("ADD inputs must be Tensors")
if input0.shape != input1.shape:
raise ValueError("ADD inputs must have identical shapes")
_require_multiplier(input0_multiplier, "input0 multiplier")
_require_multiplier(input1_multiplier, "input1 multiplier")
_require_multiplier(output_multiplier, "output multiplier")
_require_shift(input0_shift, "input0 shift")
_require_shift(input1_shift, "input1 shift")
_require_shift(output_shift, "output shift")
act_low, act_high = _validate_act_bounds(act_min, act_max)
scale, zout = _validate_output_quantization(output_scale, output_zero_point)
output: list[int] = []
for raw0, raw1 in zip(input0.data, input1.data, strict=True):
x0 = (raw0 - input0.zero_point) * (1 << 20)
x1 = (raw1 - input1.zero_point) * (1 << 20)
term0 = multiply_by_quantized_multiplier(x0, input0_multiplier, input0_shift)
term1 = multiply_by_quantized_multiplier(x1, input1_multiplier, input1_shift)
total = term0 + term1
if total < INT32_MIN or total > INT32_MAX:
raise ArithmeticFault("checked INT32 overflow in ADD sum")
output.append(
_requantize(total, output_multiplier, output_shift, zout, act_low, act_high)
)
return Tensor(input0.shape, tuple(output), scale, zout)
def _pool_geometry(
inputs: Tensor,
kernel_h: int,
kernel_w: int,
stride_h: int,
stride_w: int,
pads: Sequence[int],
) -> tuple[int, int]:
_require_int(kernel_h, "kernel_h")
_require_int(kernel_w, "kernel_w")
if kernel_h < 1 or kernel_h > 16 or kernel_w < 1 or kernel_w > 16:
raise ValueError("pool kernel dimensions must be within 1..16")
_validate_strides(stride_h, stride_w)
_validate_pads(pads, kernel_h, kernel_w)
oh_extent = _output_extent(inputs.height, pads[0], pads[1], kernel_h, stride_h)
ow_extent = _output_extent(inputs.width, pads[2], pads[3], kernel_w, stride_w)
return oh_extent, ow_extent
def max_pool(
inputs: Tensor,
*,
kernel_h: int,
kernel_w: int,
stride_h: int,
stride_w: int,
pad_top: int = 0,
pad_bottom: int = 0,
pad_left: int = 0,
pad_right: int = 0,
act_min: int = INT8_MIN,
act_max: int = INT8_MAX,
) -> Tensor:
"""Execute profile-1 MAX_POOL over valid raw positions only, starting from -128."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
act_low, act_high = _validate_act_bounds(act_min, act_max)
oh_extent, ow_extent = _pool_geometry(
inputs,
kernel_h,
kernel_w,
stride_h,
stride_w,
(pad_top, pad_bottom, pad_left, pad_right),
)
output: list[int] = []
for oh in range(oh_extent):
for ow in range(ow_extent):
for c in range(inputs.channels):
best = INT8_MIN
for offset_h in range(kernel_h):
ih = oh * stride_h + offset_h - pad_top
if ih < 0 or ih >= inputs.height:
continue
for offset_w in range(kernel_w):
iw = ow * stride_w + offset_w - pad_left
if iw < 0 or iw >= inputs.width:
continue
best = max(best, inputs.value_at(ih, iw, c))
output.append(max(act_low, min(act_high, best)))
return Tensor(
(oh_extent, ow_extent, inputs.channels), tuple(output), inputs.scale, inputs.zero_point
)
def _round_ties_away(total: int, count: int) -> int:
quotient = (abs(total) + count // 2) // count
return quotient if total >= 0 else -quotient
def average_pool(
inputs: Tensor,
*,
kernel_h: int,
kernel_w: int,
stride_h: int,
stride_w: int,
pad_top: int = 0,
pad_bottom: int = 0,
pad_left: int = 0,
pad_right: int = 0,
act_min: int = INT8_MIN,
act_max: int = INT8_MAX,
) -> Tensor:
"""Execute profile-1 AVERAGE_POOL with ties-away division by the valid-position count."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
act_low, act_high = _validate_act_bounds(act_min, act_max)
oh_extent, ow_extent = _pool_geometry(
inputs,
kernel_h,
kernel_w,
stride_h,
stride_w,
(pad_top, pad_bottom, pad_left, pad_right),
)
output: list[int] = []
for oh in range(oh_extent):
for ow in range(ow_extent):
for c in range(inputs.channels):
total = 0
count = 0
for offset_h in range(kernel_h):
ih = oh * stride_h + offset_h - pad_top
if ih < 0 or ih >= inputs.height:
continue
for offset_w in range(kernel_w):
iw = ow * stride_w + offset_w - pad_left
if iw < 0 or iw >= inputs.width:
continue
total = _checked_accumulate(
total, inputs.value_at(ih, iw, c), "average pool sum"
)
count += 1
averaged = _round_ties_away(total, count)
output.append(max(act_low, min(act_high, averaged)))
return Tensor(
(oh_extent, ow_extent, inputs.channels), tuple(output), inputs.scale, inputs.zero_point
)
def global_average_pool(
inputs: Tensor,
*,
act_min: int = INT8_MIN,
act_max: int = INT8_MAX,
) -> Tensor:
"""Execute profile-1 GLOBAL_AVERAGE_POOL over the whole HxW input per channel."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
act_low, act_high = _validate_act_bounds(act_min, act_max)
count = inputs.height * inputs.width
output: list[int] = []
for c in range(inputs.channels):
total = 0
for h in range(inputs.height):
for w in range(inputs.width):
total = _checked_accumulate(total, inputs.value_at(h, w, c), "global average sum")
averaged = _round_ties_away(total, count)
output.append(max(act_low, min(act_high, averaged)))
return Tensor((1, 1, inputs.channels), tuple(output), inputs.scale, inputs.zero_point)
def clamp(inputs: Tensor, act_min: int, act_max: int) -> Tensor:
"""Apply only the activation bounds, preserving shape, scale and zero point."""
if not isinstance(inputs, Tensor):
raise ValueError("inputs must be a Tensor")
act_low, act_high = _validate_act_bounds(act_min, act_max)
output = tuple(max(act_low, min(act_high, value)) for value in inputs.data)
return Tensor(inputs.shape, output, inputs.scale, inputs.zero_point)
def _exp_on_interval_q31(a: int) -> int:
"""Pinned gemmlowp exp on the Q31 interval [-1/4, 0) via the 4-term Taylor series."""
x = a + (1 << 28)
x2 = saturating_rounding_doubling_high_mul(x, x)
x3 = saturating_rounding_doubling_high_mul(x2, x)
x4 = saturating_rounding_doubling_high_mul(x2, x2)
x4_over_4 = rounding_divide_by_pot(x4, 2)
series = rounding_divide_by_pot(
saturating_rounding_doubling_high_mul(x4_over_4 + x3, _EXP_ONE_OVER_THREE) + x2, 1
)
return _EXP_CONSTANT_TERM + saturating_rounding_doubling_high_mul(
_EXP_CONSTANT_TERM, x + series
)
def _exp_on_negative_values(a: int) -> int:
"""Pinned gemmlowp exp for Q5.26 raw input a <= 0, returning a Q31 result."""
quarter = 1 << 24
a_mod_quarter = (a & (quarter - 1)) - quarter
result = _exp_on_interval_q31(a_mod_quarter << 5)
remainder = a_mod_quarter - a
for exponent, multiplier in _EXP_BARREL_MULTIPLIERS:
if remainder & (1 << (26 + exponent)):
result = saturating_rounding_doubling_high_mul(result, multiplier)
return INT32_MAX if a == 0 else result
def _saturating_left_shift(value: int, exponent: int) -> int:
if exponent <= 0:
return value
threshold = (1 << (31 - exponent)) - 1
if value > threshold:
return INT32_MAX
if value < -threshold:
return INT32_MIN
return value << exponent
def _one_over_one_plus_x(a: int) -> int:
"""Pinned gemmlowp Newton-Raphson 1/(1+x) for Q31 x in [0, 1), returning Q31."""
total = a + INT32_MAX
half_denominator = _trunc_div(total + (1 if total >= 0 else -1), 2)
estimate = _RECIPROCAL_48_OVER_17 + saturating_rounding_doubling_high_mul(
half_denominator, _RECIPROCAL_NEG_32_OVER_17
)
for _ in range(3):
product = saturating_rounding_doubling_high_mul(half_denominator, estimate)
one_minus = (1 << 29) - product
increment = saturating_rounding_doubling_high_mul(estimate, one_minus)
estimate += _saturating_left_shift(increment, 2)
return _saturating_left_shift(estimate, 1)
def softmax_parameters(input_scale: float, beta: float) -> dict[str, int]:
"""Pinned PreprocessSoftmaxScaling/CalculateInputRadius for Q5.26."""
for name, value in (("input scale", input_scale), ("beta", beta)):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"softmax {name} must be positive and finite")
if not math.isfinite(value) or value <= 0:
raise ValueError(f"softmax {name} must be positive and finite")
scaled = min(float(beta) * float(input_scale) * (1 << 26), float(INT32_MAX))
if scaled <= 1.0:
raise ValueError("softmax scaling must exceed one for the pinned integer kernel")
multiplier, shift = quantize_multiplier(scaled)
return {
"input_multiplier": multiplier,
"input_left_shift": shift,
"diff_min": -((31 * (1 << 26)) // (1 << shift)),
}
def softmax_int8(
logits: Tensor,
*,
input_multiplier: int,
input_left_shift: int,
diff_min: int,
) -> Tensor:
"""Execute the pinned TFLite reference_ops::Softmax<int8_t, int8_t> integer path."""
if not isinstance(logits, Tensor):
raise ValueError("logits must be a Tensor")
_require_multiplier(input_multiplier, "input multiplier")
_require_int(input_left_shift, "input left shift")
if input_multiplier == 0:
raise ValueError("softmax input multiplier must be positive")
if input_left_shift < 0 or input_left_shift > 31:
raise ValueError("input left shift must be within 0..31")
_require_int(diff_min, "diff_min")
radius = (31 * (1 << 26)) // (1 << input_left_shift)
if diff_min < -radius or diff_min > 0:
raise ValueError("diff_min exceeds the safe Q5.26 input radius")
depth = logits.channels
output: list[int] = []
for row in range(logits.height * logits.width):
values = logits.data[row * depth : (row + 1) * depth]
maximum = max(values)
exponentials: list[int | None] = []
for value in values:
difference = value - maximum
if difference >= diff_min:
scaled = saturating_rounding_doubling_high_mul(
difference * (1 << input_left_shift), input_multiplier
)
exponentials.append(_exp_on_negative_values(scaled))
else:
exponentials.append(None)
accumulation = sum(
rounding_divide_by_pot(exp, 12) for exp in exponentials if exp is not None
)
if accumulation >= 1 << 31:
raise ArithmeticFault("checked INT32 overflow in softmax accumulation")
headroom = 32 - accumulation.bit_length()
num_bits_over_unit = 12 - headroom
shifted_sum = ((accumulation << headroom) & 0xFFFFFFFF) - (1 << 31)
scale = _one_over_one_plus_x(shifted_sum)
for exp in exponentials:
if exp is None:
output.append(INT8_MIN)
continue
unsaturated = rounding_divide_by_pot(
saturating_rounding_doubling_high_mul(scale, exp), num_bits_over_unit + 23
)
output.append(max(INT8_MIN, min(INT8_MAX, unsaturated + INT8_MIN)))
return Tensor(
logits.shape, tuple(output), SOFTMAX_OUTPUT_SCALE, SOFTMAX_OUTPUT_ZERO_POINT
)
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""NPU-P0 full-corpus qualification against dependency-locked TFLite kernels."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from contextlib import nullcontext
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import npu_compiler_p0 # noqa: E402
import npu_executor # noqa: E402
import npu_model # noqa: E402
import setup_npu_reference as setup_npu # noqa: E402
from npu_framework_reference import FrameworkOracle, OracleError, build_oracle # noqa: E402
KWS_FEATURES = ROOT / ".cache/retrosoc/sources/apu-kws-mfcc/datasets/kws01"
VWW_CORPUS = ROOT / ".cache/retrosoc/sources/npu-vww-corpus"
KWS_MANIFEST = ROOT / "docs/ip/npu-kws-manifest.json"
VWW_MANIFEST = ROOT / "docs/ip/npu-vww-manifest.json"
VWW_TSV = ROOT / "docs/ip/npu-vww-corpus.tsv"
LOCK = ROOT / "dependencies/dependencies.lock.json"
class QualificationBlocked(RuntimeError):
pass
def _require(condition: bool, message: str) -> None:
if not condition:
raise QualificationBlocked(message)
def _git_revision() -> str:
return subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip()
def _argmax(data: tuple[int, ...] | list[int]) -> int:
best = 0
for index, value in enumerate(data):
if value > data[best]:
best = index
return best
def _check_assets() -> None:
try:
setup_npu.doctor()
except RuntimeError as error:
raise QualificationBlocked(str(error)) from error
for path in (KWS_MANIFEST, VWW_MANIFEST, VWW_TSV):
_require(path.is_file(), f"missing committed NPU manifest: {path}")
completed = subprocess.run(
[sys.executable, str(ROOT / "scripts/npu_manifest.py"), "--check"],
capture_output=True,
text=True,
)
_require(completed.returncode == 0, f"manifest check failed: {completed.stderr.strip()}")
def _load_kws_corpus() -> list[tuple[str, int, Path]]:
manifest = json.loads(KWS_MANIFEST.read_text(encoding="utf-8"))
rows = []
for item in manifest["inputs"]:
feature = KWS_FEATURES / item["name"]
_require(feature.is_file(), f"missing KWS feature input: {feature}")
rows.append((item["name"], int(item["class_id"]), feature))
_require(len(rows) == 1000, f"KWS corpus must hold 1000 inputs: {len(rows)}")
return rows
def _load_vww_corpus() -> list[tuple[str, int, Path]]:
rows = []
for line in VWW_TSV.read_text(encoding="utf-8").splitlines():
_index, name, jpg_rel, label, _jpg_sha, _bin_sha = line.split("\t")
raw = VWW_CORPUS / Path(jpg_rel).parent / name
_require(raw.is_file(), f"missing VWW corpus input: {raw}")
rows.append((name, int(label), raw))
_require(len(rows) == 1000, f"VWW corpus must hold 1000 inputs: {len(rows)}")
return rows
def compare_layers(executed: list[bytes], reference: list[bytes],
framework: list[bytes]) -> list[dict]:
"""Neither Python path may certify the other; both must match the oracle."""
if not framework or len(executed) != len(framework) or len(reference) != len(framework):
raise RuntimeError("framework/reference/executor layer count mismatch")
mismatches = []
for index, expected in enumerate(framework):
for name, layers in (("executor", executed), ("reference", reference)):
got = layers[index]
if got == expected:
continue
first = next((i for i, (a, b) in enumerate(zip(got, expected)) if a != b),
min(len(got), len(expected)))
mismatches.append({"layer": index, "path": name, "first_byte": first,
"expected_bytes": len(expected), "actual_bytes": len(got),
"expected": expected[first] if first < len(expected) else None,
"actual": got[first] if first < len(got) else None})
return mismatches
_WORKER_CONTEXT = None
def _worker_init(graph, job, oracle, prepare_input):
global _WORKER_CONTEXT
_WORKER_CONTEXT = graph, job, oracle, prepare_input
def _qualify_input(item):
graph, job, oracle, prepare_input = _WORKER_CONTEXT
name, label, path = item
input_bytes = prepare_input(path.read_bytes())
result = npu_executor.execute_job(job, input_bytes)
reference_layers = npu_compiler_p0.evaluate_reference(graph, input_bytes)
framework_layers = oracle.evaluate(input_bytes, name)
executed = [bytes(v & 255 for v in tensor.data) for tensor in result.layers]
reference = [bytes(v & 255 for v in tensor.data) for tensor in reference_layers]
differences = compare_layers(executed, reference, framework_layers)
record = {
"input": name, "input_sha256": hashlib.sha256(input_bytes).hexdigest(),
"layers": [
{"layer": i, "bytes": len(golden),
"framework_sha256": hashlib.sha256(golden).hexdigest(),
"reference_sha256": hashlib.sha256(reference[i]).hexdigest(),
"executor_sha256": hashlib.sha256(executed[i]).hexdigest()}
for i, golden in enumerate(framework_layers)
],
"mismatches": differences,
"correct": _argmax(list(result.output.data)) == label,
}
(oracle.directory / name / "comparison.json").write_text(
json.dumps(record, indent=2, sort_keys=True) + "\n"
)
return record
def _qualify_model(
workload: str,
graph: "npu_model.GraphInfo",
job: "npu_compiler_p0.CompiledJob",
corpus: list[tuple[str, int, Path]],
prepare_input,
*,
limit: int | None,
oracle: FrameworkOracle,
jobs: int = 1,
) -> dict[str, object]:
selected = corpus if limit is None else corpus[:limit]
mismatches: list[dict[str, object]] = []
correct = 0
started = time.monotonic()
records = []
context = (graph, job, oracle, prepare_input)
_worker_init(*context)
pool = (ProcessPoolExecutor(max_workers=jobs, initializer=_worker_init, initargs=context)
if jobs > 1 else nullcontext())
with pool as executor:
results = executor.map(_qualify_input, selected) if executor else map(_qualify_input, selected)
for index, record in enumerate(results):
records.append(record)
mismatches.extend({"input": record["input"], **item} for item in record["mismatches"])
correct += int(record["correct"])
if (index + 1) % 10 == 0:
print(f"[{workload}] {index + 1}/{len(selected)} inputs compared", flush=True)
total = len(selected)
(oracle.directory / "index.json").write_text(json.dumps(records, indent=2, sort_keys=True) + "\n")
return {
"workload": workload,
"inputs": total,
"full_corpus": limit is None,
"layer_mismatches": len(mismatches),
"first_mismatches": mismatches[:8],
"tensor_comparisons": sum(len(item["layers"]) for item in records),
"index": str(oracle.directory / "index.json"),
"accuracy": {"correct": correct, "total": total, "ratio": correct / total},
"duration_seconds": round(time.monotonic() - started, 3),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--cxx", default="g++")
parser.add_argument("--profile", default="host")
parser.add_argument("--config-digest", default="not-supplied")
parser.add_argument("--jobs", type=int, default=1)
parser.add_argument(
"--limit",
type=int,
default=None,
help="debug-only input cap per model; a limited run is NOT an acceptance run",
)
args = parser.parse_args()
if not 1 <= args.jobs <= 16:
parser.error("--jobs must be within 1..16")
if args.limit is not None and not 1 <= args.limit <= 1000:
parser.error("--limit must be within 1..1000; limited runs are never acceptance")
args.output_dir = args.output_dir.resolve()
args.output_dir.mkdir(parents=True, exist_ok=True)
report = {
"schema": 1,
"phase": "NPU-P0",
"verification_ids": ["NPU-V001"],
"git_revision": _git_revision(),
"lock_sha256": hashlib.sha256(LOCK.read_bytes()).hexdigest(),
"command": " ".join([sys.executable, *sys.argv]),
"acceptance_run": args.limit is None,
"profile": args.profile,
"config_digest": args.config_digest,
"jobs": args.jobs,
"compiler_contract": npu_compiler_p0.CONTRACT_REVISION,
"implementation_sha256": {
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
for name in (
"scripts/qualify_npu_p0.py", "scripts/npu_framework_reference.py",
"scripts/npu_model.py", "scripts/npu_reference.py",
"scripts/npu_compiler_p0.py", "scripts/npu_executor.py",
"scripts/setup_npu_reference.py", "tests/cpp/npu_framework_reference.cc",
)
},
"git_dirty": bool(subprocess.check_output(
["git", "-C", str(ROOT), "status", "--porcelain"], text=True).strip()),
"method": "TFLite integer kernels vs Python graph reference vs compiled executor",
"supersedes": "build/npu-p0-qualification/qualification-p0.json (shared-arithmetic comparison only)",
"skipped": 0,
"results": [],
"verdict": "BLOCKED",
}
report_path = args.output_dir / "qualification-p0.json"
def save():
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
save()
try:
_check_assets()
executable, evidence = build_oracle(args.output_dir / "oracle", args.cxx)
report["oracle"] = evidence
cases = [
("kws", npu_model.load_kws_model, npu_compiler_p0.compile_kws,
_load_kws_corpus(), bytes),
("vww", npu_model.load_vww_model, npu_compiler_p0.compile_vww,
_load_vww_corpus(), npu_compiler_p0.vww_input_bytes),
]
report["verdict"] = "INCOMPLETE"
save()
for name, load, compile_model, corpus, prepare in cases:
graph = load().main_graph()
job = compile_model()
npu_compiler_p0.write_artifacts(job, args.output_dir / "deployments" / name)
oracle = FrameworkOracle(graph, executable, args.output_dir / "oracle" / name)
report["results"].append(_qualify_model(
name, graph, job, corpus, prepare, limit=args.limit, oracle=oracle, jobs=args.jobs,
))
save()
passed = all(item["layer_mismatches"] == 0 for item in report["results"])
report["verdict"] = ("PASS" if args.limit is None else "SMOKE-ONLY") if passed else "FAIL"
except (QualificationBlocked, OracleError, OSError, ValueError, RuntimeError,
ArithmeticError, npu_executor.ExecutorFault) as error:
if report["verdict"] != "BLOCKED":
report["verdict"] = "FAIL"
report["error"] = str(error)
except KeyboardInterrupt:
report["verdict"] = "INCOMPLETE"
report["error"] = "interrupted before qualification completed"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
for item in report["results"]:
accuracy = item["accuracy"]
print(
f"[{item['workload']}] inputs={item['inputs']} "
f"layer_mismatches={item['layer_mismatches']} "
f"accuracy={accuracy['correct']}/{accuracy['total']} ({accuracy['ratio']:.4f})"
)
print(f"NPU-P0 qualification verdict: {report['verdict']} ({report_path})")
return 0 if report["verdict"] == "PASS" else (2 if report["verdict"] == "BLOCKED" else 1)
if __name__ == "__main__":
raise SystemExit(main())
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Run the frozen KWS/VWW deployment sets through both production RTL simulators."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT / "tests"))
from npu_compiler_p0 import compile_kws, compile_vww, vww_input_bytes # noqa: E402
from npu_executor import execute_job, relocate_descriptors # noqa: E402
from test_npu_rtl_p3 import COMMON_SOURCES, MEM_BASE, _build_sim, _tools # noqa: E402
from test_npu_rtl_p4 import OPERATOR_TB, P4_SOURCES, _image_hex # noqa: E402
MEMORY_BYTES = 0x80000
KWS_FEATURES = ROOT / ".cache/retrosoc/sources/apu-kws-mfcc/datasets/kws01"
VWW_CORPUS = ROOT / ".cache/retrosoc/sources/npu-vww-corpus"
REJECTED = re.compile(
r"(?:\bFAIL(?:ED)?\b|\bFATAL\b|assertion failed|%Error|SIM_TEST_FAIL|SIM_TEST_TIMEOUT)",
re.IGNORECASE,
)
def file_identity(path: Path) -> dict[str, int | str]:
payload = path.read_bytes()
return {"path": str(path), "bytes": len(payload),
"sha256": hashlib.sha256(payload).hexdigest()}
def git_identity() -> dict[str, object]:
revision = subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip()
dirty = bool(subprocess.check_output(
["git", "-C", str(ROOT), "status", "--porcelain"], text=True
).strip())
return {"revision": revision, "dirty": dirty}
def tool_identity(path: str, version_arg: str) -> dict[str, str]:
completed = subprocess.run(
[path, version_arg], text=True, capture_output=True, check=False
)
version = (completed.stdout + completed.stderr).strip()
if completed.returncode != 0 or not version:
raise RuntimeError(f"cannot identify simulator tool: {path}")
return {"path": path, "version": version}
def rejected_markers(log: str) -> list[str]:
return sorted({match.group(0) for match in REJECTED.finditer(log)})
def align(value: int, boundary: int = 64) -> int:
return (value + boundary - 1) & ~(boundary - 1)
def samples() -> dict[str, list[Path]]:
kws = json.loads((ROOT / "docs/ip/npu-kws-manifest.json").read_text())
vww = json.loads((ROOT / "docs/ip/npu-vww-manifest.json").read_text())
kws_hashes = {item["name"]: item["sha256"] for item in kws["inputs"]}
paths = {"kws": [KWS_FEATURES / name for name in kws["rtl_sample_ids"]], "vww": []}
by_name = {}
vww_hashes = {}
for line in (ROOT / "docs/ip/npu-vww-corpus.tsv").read_text().splitlines():
_index, name, jpeg, _label, _jpeg_sha, bin_sha = line.split("\t")
by_name[name] = VWW_CORPUS / Path(jpeg).parent / name
vww_hashes[name] = bin_sha
paths["vww"] = [by_name[name] for name in vww["rtl_sample_ids"]]
frozen = {
"kws": [line.split("\t")[1]
for line in (ROOT / "docs/ip/apu-kws-corpus.tsv").read_text().splitlines()],
"vww": [line.split("\t")[1]
for line in (ROOT / "docs/ip/npu-vww-corpus.tsv").read_text().splitlines()],
}
for workload, selected in paths.items():
if len(selected) != 10 or len({path.name for path in selected}) != 10:
raise RuntimeError(f"{workload} must provide ten distinct frozen RTL samples")
if [path.name for path in selected] != sorted(frozen[workload])[:10]:
raise RuntimeError(f"{workload} RTL samples are not the first ten bytewise IDs")
for path in selected:
if not path.is_file():
raise RuntimeError(f"missing required {workload} input: {path}")
expected_hash = (kws_hashes if workload == "kws" else vww_hashes)[path.name]
actual_hash = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_hash != expected_hash:
raise RuntimeError(
f"{workload} frozen input hash mismatch for {path.name}: {actual_hash}"
)
return paths
def emit_case(directory: Path, workload: str, path: Path, case_index: int) -> tuple[list[str], dict]:
job = compile_kws() if workload == "kws" else compile_vww()
source_data = path.read_bytes()
input_data = source_data if workload == "kws" else vww_input_bytes(source_data)
descriptor_offset = 0
weights_offset = align(len(job.descriptors) * 128)
params_offset = align(weights_offset + len(job.weights))
arena_offset = align(params_offset + len(job.params))
end = arena_offset + job.arena_bytes
if end > MEMORY_BYTES:
raise RuntimeError(f"{workload} deployment needs {end} bytes, BFM has {MEMORY_BYTES}")
bases = {
"descriptors": MEM_BASE + descriptor_offset,
"weights": MEM_BASE + weights_offset,
"params": MEM_BASE + params_offset,
"arena": MEM_BASE + arena_offset,
}
descriptors = relocate_descriptors(job, bases)
memory = bytearray([0xA5] * MEMORY_BYTES)
for index, descriptor in enumerate(descriptors):
memory[index * 128:(index + 1) * 128] = descriptor.to_bytes()
memory[weights_offset:weights_offset + len(job.weights)] = job.weights
memory[params_offset:params_offset + len(job.params)] = job.params
memory[arena_offset + job.input_offset:arena_offset + job.input_offset + len(input_data)] = input_data
result = execute_job(job, input_data, bases=bases)
npu_layers = result.layers[:len(descriptors)]
trace = []
for descriptor, tensor in zip(descriptors, npu_layers, strict=True):
block = bytes(value & 0xFF for value in tensor.data)
for tile_y in range(0, descriptor.oh, descriptor.tile_h):
rows = min(descriptor.tile_h, descriptor.oh - tile_y)
for tile_x in range(0, descriptor.ow, descriptor.tile_w):
cols = min(descriptor.tile_w, descriptor.ow - tile_x)
for position in range(rows * cols):
oy = tile_y + position // cols
ox = tile_x + position % cols
offset = (oy * descriptor.output_row_bytes) + (ox * descriptor.cout)
for channel in range(descriptor.cout):
trace.append((descriptor.output_base + offset + channel,
block[(oy * descriptor.ow + ox) * descriptor.cout + channel]))
folder = directory / workload / f"{case_index:02d}-{path.name}"
folder.mkdir(parents=True, exist_ok=True)
image = folder / "memory.hex"
golden = folder / "final.hex"
trace_path = folder / "writes.hex"
_image_hex(image, bytes(memory))
final = bytes(value & 0xFF for value in npu_layers[-1].data)
_image_hex(golden, final)
trace_path.write_text("".join(f"{address:08x}{value:02x}\n" for address, value in trace))
plusargs = [
f"+IMG={image}", f"+GOLD={golden}", f"+BASE={bases['descriptors']:x}",
f"+COUNT={len(descriptors)}", f"+JOBID={0x50000000 + case_index:x}",
"+TIMEOUT=72000000", "+SCEN=0", "+FCODE=0", "+FDESC=ffffffff",
"+FADDR=0", "+CODE=1", f"+MACS={job.report['totals']['useful_macs']}",
f"+GOLD_BASE={descriptors[-1].output_base:x}", f"+GOLD_BYTES={len(final)}",
f"+TRACE={trace_path}", f"+TRACE_BYTES={len(trace)}",
]
record = {
"workload": workload, "input": path.name,
"source_input_sha256": hashlib.sha256(source_data).hexdigest(),
"input_sha256": hashlib.sha256(input_data).hexdigest(),
"descriptors": len(descriptors), "write_trace_bytes": len(trace),
"final_sha256": hashlib.sha256(final).hexdigest(),
"layer_sha256": [hashlib.sha256(bytes(value & 0xFF for value in tensor.data)).hexdigest()
for tensor in npu_layers],
"artifacts": {name: file_identity(artifact) for name, artifact in (
("memory", image), ("final", golden), ("write_trace", trace_path)
)},
"artifact_dir": str(folder), "plusargs": plusargs,
}
(folder / "case.json").write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
return plusargs, record
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--timeout-seconds", type=int, default=3600)
parser.add_argument("--jobs", type=int, default=4)
parser.add_argument("--profile", required=True)
parser.add_argument("--config-digest", required=True)
parser.add_argument("--pdk", required=True)
parser.add_argument("--lock", type=Path, required=True)
args = parser.parse_args()
if not 1 <= args.jobs <= 8:
parser.error("--jobs must be within 1..8")
output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=True)
lock = args.lock.resolve()
source_paths = sorted({path.resolve() for path in [
Path(__file__), ROOT / "scripts/npu_compiler.py",
ROOT / "scripts/npu_compiler_p0.py", ROOT / "scripts/npu_executor.py",
ROOT / "scripts/npu_model.py", ROOT / "scripts/npu_reference.py",
OPERATOR_TB, *COMMON_SOURCES, *P4_SOURCES,
]})
report = {
"schema": 1, "phase": "NPU-P5", "verification_ids": ["NPU-V014"],
"verdict": "INCOMPLETE", "simulators": {}, "cases": [],
"profile": args.profile, "config_digest": args.config_digest, "pdk": args.pdk,
"command": [sys.executable, *sys.argv], "git": git_identity(),
"lock": file_identity(lock),
"implementation": {str(path.relative_to(ROOT)): file_identity(path)
for path in source_paths},
"coverage": {
"selection": "first ten bytewise corpus IDs per frozen model manifest",
"comparison": "every accepted output byte in production tile-write order",
"dma": "production npu_dma with integrated MaxBurstBeats=8",
"skipped": 0,
},
"simulator_builds": {},
}
report_path = output / "qualification-p5-rtl.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
try:
case_args = []
for workload, selected in samples().items():
for index, path in enumerate(selected):
plusargs, record = emit_case(output / "artifacts", workload, path, index)
case_args.append((workload, path.name, plusargs))
report["cases"].append(record)
tools = _tools()
report["tools"] = {
"iverilog": tool_identity(tools["iverilog"], "-V"),
"vvp": tool_identity(tools["vvp"], "-V"),
"verilator": tool_identity(tools["verilator"], "--version"),
}
run_id = str(int(time.time()))
for simulator in ("iverilog", "verilator"):
build = output / f"build-{simulator}-{run_id}"
build.mkdir()
command = _build_sim(tools, simulator, build, f"npu_p5_{simulator}",
"npu_operator_tb", OPERATOR_TB, P4_SOURCES)
report["simulator_builds"][simulator] = {
"directory": str(build), "run_prefix": command,
"sources": [str(path) for path in [*COMMON_SOURCES, *P4_SOURCES, OPERATOR_TB]],
}
def run_case(case):
workload, name, plusargs = case
started = time.monotonic()
full_command = [*command, *plusargs]
timed_out = False
try:
completed = subprocess.run(
full_command, text=True, capture_output=True,
timeout=args.timeout_seconds, check=False,
)
returncode = completed.returncode
log_text = completed.stdout + completed.stderr
except subprocess.TimeoutExpired as error:
timed_out = True
returncode = None
stdout = error.stdout or ""
stderr = error.stderr or ""
log_text = ((stdout.decode() if isinstance(stdout, bytes) else stdout) +
(stderr.decode() if isinstance(stderr, bytes) else stderr))
log = output / f"{simulator}-{workload}-{name}.log"
log.write_text(log_text)
rejected = rejected_markers(log_text)
passed = (not timed_out and returncode == 0 and
"NPU operator test passed" in log_text and not rejected)
record = {"workload": workload, "input": name,
"status": "PASS" if passed else "FAIL", "exit_code": returncode,
"timed_out": timed_out, "rejected_markers": rejected,
"duration_seconds": round(time.monotonic() - started, 3),
"command": full_command, "log": file_identity(log)}
return record
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
simulator_records = list(executor.map(run_case, case_args))
report["simulators"][simulator] = simulator_records
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
failed = [record for record in simulator_records if record["status"] != "PASS"]
if failed:
raise RuntimeError(
f"{simulator} model qualification failed: {failed[0]['workload']}/"
f"{failed[0]['input']}"
)
report["summary"] = {
"cases": len(report["cases"]),
"passes": sum(len(records) for records in report["simulators"].values()),
"failures": 0, "skipped": 0,
}
report["verdict"] = "PASS"
except Exception as error: # record every required-input/tool/simulator failure
report["verdict"] = "FAIL"
report["error"] = str(error)
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
print(f"NPU-P5 RTL qualification: {report['verdict']} ({report_path})")
return 0 if report["verdict"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -36,6 +36,7 @@ TEST_SOURCES = (
"crt/src/hal/apu.c",
"crt/src/hal/ga2d_math.c",
"crt/src/hal/ga2d.c",
"crt/src/hal/npu.c",
"crt/src/hal/fabric_monitor.c",
"crt/src/hal/sysctrl.c",
"crt/src/hal/clock.c",
@@ -48,6 +49,7 @@ TEST_SOURCES = (
"rtl/managed/clusterip/rtc/sw/src/rtc.c",
"app/media/src/video_player.c",
"app/media/src/wav_audio.c",
"app/benchmark/npu/npu_p6_reference.c",
"tests/c/test_runtime.c",
)
@@ -99,6 +101,7 @@ def main() -> int:
"-fno-builtin",
"-DRS_APU_TEST_MMIO",
"-DRS_GA2D_TEST_MMIO",
"-DRS_NPU_TEST_MMIO",
"-DRS_FABRIC_MONITOR_TEST_MMIO",
"-DRS_RESOURCE_TEST_MMIO",
"-I",
@@ -113,6 +116,8 @@ def main() -> int:
str(root / "crt/include"),
"-I",
str(root / "app/media/include"),
"-I",
str(root / "app/benchmark/npu"),
"-o",
str(executable),
*(str(root / source) for source in TEST_SOURCES),
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""Run the isolated NPU IHP130 synthesis and 72 MHz STA flow (P1/P3 modules)."""
from __future__ import annotations
import argparse
import glob
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
SYNTH_TCL = ROOT / "physical/smoke/syn/yosys/script/synth.tcl"
STA_TCL = ROOT / "physical/smoke/sta/opensta/npu_p1.tcl"
STA_SDC = ROOT / "physical/smoke/sta/opensta/npu_p1.sdc"
RUN_FLOW = ROOT / "scripts/run_flow.py"
PDK_ROOT = ROOT / "physical/pdk/IHP-Open-PDK/ihp-sg13g2/libs.ref"
STA_LIBERTY = PDK_ROOT / "sg13g2_stdcell/lib/sg13g2_stdcell_slow_1p08V_125C.lib"
STA_LINK_LIB = PDK_ROOT / "sg13g2_io/lib/sg13g2_io_slow_1p08V_3p0V_125C.lib"
TOPS = (
"npu_local_sram",
"npu_patch_packer",
"npu_mac_array",
"npu_accumulator",
"npu_vector",
"npu_requantizer",
"npu_dma",
"npu_job_decoder",
"npu_scheduler",
"npu_core",
"apb4_npu",
"npu_block_top",
)
NPU_RTL = (
"npu_pkg.sv",
"npu_local_sram.sv",
"npu_patch_packer.sv",
"npu_mac_array.sv",
"npu_accumulator.sv",
"npu_vector.sv",
"npu_requantizer.sv",
"npu_dma.sv",
"npu_job_decoder.sv",
"npu_scheduler.sv",
"npu_reg.sv",
"npu_control_cdc.sv",
"npu_core.sv",
"apb4_npu.sv",
)
# Tops whose only non-logic port is an AXI4 master interface get a generated
# synthesis wrapper in the build tree (never tracked): the interface is
# instantiated inside the wrapper so the DUT has no unconnected ports.
INTERFACE_WRAPPED = {
"npu_dma": """
module npu_dma_synth_top (
input logic clk_hp_i,
input logic rst_hp_n_i,
input logic clear_i,
input logic block_new_i,
output logic pause_ack_o,
input logic read_req_valid_i,
output logic read_req_ready_o,
input logic [31:0] read_addr_i,
input logic [31:0] read_bytes_i,
output logic read_data_valid_o,
input logic read_data_ready_i,
output logic [63:0] read_data_o,
output logic [ 7:0] read_keep_o,
output logic read_last_o,
input logic write_req_valid_i,
output logic write_req_ready_o,
input logic [31:0] write_addr_i,
input logic [31:0] write_bytes_i,
input logic write_data_valid_i,
output logic write_data_ready_o,
input logic [63:0] write_data_i,
input logic [ 7:0] write_keep_i,
input logic write_last_i,
output logic write_done_o,
output logic busy_o,
output logic read_busy_o,
output logic write_busy_o,
output logic [63:0] read_bytes_o,
output logic [63:0] write_bytes_o,
output logic [63:0] stall_cycles_o,
output logic fault_o,
output logic [ 3:0] fault_code_o,
output logic [31:0] fault_addr_o,
output logic [ 1:0] fault_resp_o,
output logic read_cmd_err_o,
output logic write_cmd_err_o
);
axi4_if #(
.ADDR_WIDTH(32),
.DATA_WIDTH(64),
.ID_WIDTH (3),
.USER_WIDTH(1)
) u_axi4 (
.aclk (clk_hp_i),
.aresetn(rst_hp_n_i)
);
npu_dma u_dut (
.clk_hp_i (clk_hp_i),
.rst_hp_n_i (rst_hp_n_i),
.clear_i (clear_i),
.block_new_i (block_new_i),
.pause_ack_o (pause_ack_o),
.read_req_valid_i (read_req_valid_i),
.read_req_ready_o (read_req_ready_o),
.read_addr_i (read_addr_i),
.read_bytes_i (read_bytes_i),
.read_data_valid_o (read_data_valid_o),
.read_data_ready_i (read_data_ready_i),
.read_data_o (read_data_o),
.read_keep_o (read_keep_o),
.read_last_o (read_last_o),
.write_req_valid_i (write_req_valid_i),
.write_req_ready_o (write_req_ready_o),
.write_addr_i (write_addr_i),
.write_bytes_i (write_bytes_i),
.write_data_valid_i(write_data_valid_i),
.write_data_ready_o(write_data_ready_o),
.write_data_i (write_data_i),
.write_keep_i (write_keep_i),
.write_last_i (write_last_i),
.write_done_o (write_done_o),
.busy_o (busy_o),
.read_busy_o (read_busy_o),
.write_busy_o (write_busy_o),
.read_bytes_o (read_bytes_o),
.write_bytes_o (write_bytes_o),
.stall_cycles_o (stall_cycles_o),
.fault_o (fault_o),
.fault_code_o (fault_code_o),
.fault_addr_o (fault_addr_o),
.fault_resp_o (fault_resp_o),
.read_cmd_err_o (read_cmd_err_o),
.write_cmd_err_o (write_cmd_err_o),
.axi4 (u_axi4)
);
endmodule
""",
"apb4_npu": """
module apb4_npu_synth_top (
input logic clk_i,
input logic rst_n_i,
input logic clk_hp_i,
input logic rst_hp_n_i,
input logic [1:0] resource_owner_i,
input logic resource_owner_lock_i,
input logic resource_quiesce_i,
input logic resource_reset_i,
output logic idle_o,
output logic block_ack_o,
output logic irq_o,
input logic hp_block_new_i,
output logic hp_pause_ack_o,
input logic hp_flush_i,
output logic hp_flush_busy_o,
output logic hp_idle_o
);
apb4_if u_apb4 (.pclk(clk_i), .presetn(rst_n_i));
axi4_if #(
.ADDR_WIDTH(32),
.DATA_WIDTH(64),
.ID_WIDTH (3),
.USER_WIDTH(1)
) u_npu_axi4 (
.aclk (clk_hp_i),
.aresetn(rst_hp_n_i)
);
apb4_npu u_dut (
.clk_i (clk_i),
.rst_n_i (rst_n_i),
.clk_hp_i (clk_hp_i),
.rst_hp_n_i (rst_hp_n_i),
.resource_owner_i (resource_owner_i),
.resource_owner_lock_i(resource_owner_lock_i),
.resource_quiesce_i (resource_quiesce_i),
.resource_reset_i (resource_reset_i),
.apb4 (u_apb4),
.idle_o (idle_o),
.block_ack_o (block_ack_o),
.irq_o (irq_o),
.hp_block_new_i (hp_block_new_i),
.hp_pause_ack_o (hp_pause_ack_o),
.hp_flush_i (hp_flush_i),
.hp_flush_busy_o (hp_flush_busy_o),
.hp_idle_o (hp_idle_o),
.npu_axi4 (u_npu_axi4)
);
endmodule
""",
}
SRAM_MACRO = "RM_IHPSG13_1P_1024x32_c2_bm_bist"
EXPECTED_SRAM_MACROS = 16
TARGET_PERIOD_PS = 13889
VIOLATED = re.compile(r"VIOLATED")
LATCH = re.compile(r"\$dlatch|latch", re.IGNORECASE)
def _run_flow(tool: str, log: Path, result: Path, env: dict[str, str], command: list[str]) -> None:
log.parent.mkdir(parents=True, exist_ok=True)
args = [sys.executable, str(RUN_FLOW), "--tool", tool, "--log", str(log), "--result", str(result)]
for name, value in env.items():
args += ["--env", f"{name}={value}"]
args += ["--", *command]
completed = subprocess.run(args, cwd=ROOT)
if completed.returncode != 0:
raise RuntimeError(f"{tool} flow failed for {env.get('TOP_DESIGN', env.get('OPENSTA_TOP'))}: {log}")
def _synth_top(top: str) -> str:
return f"{top}_synth_top" if top in INTERFACE_WRAPPED else top
def _write_filelist(path: Path) -> None:
lines = [
"+define+PDK_IHP130",
"+define+HAVE_SRAM_MACRO",
"+define+SYNTHESIS",
"+define+SV_ASSRT_DISABLE",
f"+incdir+{ROOT / 'rtl/ip/multimedia'}",
f"+incdir+{ROOT / 'rtl/managed/clusterip/common/rtl'}",
]
for name in NPU_RTL:
source = ROOT / "rtl/ip/multimedia" / name
if not source.is_file():
raise RuntimeError(f"missing NPU RTL source: {source}")
lines.append(str(source))
lines.append(str(ROOT / "physical/smoke/syn/yosys/npu_block_top.sv"))
common_rtl = ROOT / "rtl/managed/clusterip/common/rtl"
lines.append(str(ROOT / "rtl/tech/tc_sram.sv"))
for extra in (
"interface/apb4_if.sv",
"interface/axi4_if.sv",
"utils/register.sv",
"utils/xchecker.sv",
"utils/fifo.sv",
"clkrst/rst_sync.sv",
"cdc/cdc_sync.sv",
"cdc/cdc_rst_ctrlr.sv",
"cdc/async_reqack.sv",
):
source = common_rtl / extra
if not source.is_file():
raise RuntimeError(f"missing Common source: {source}")
lines.append(str(source))
path.parent.mkdir(parents=True, exist_ok=True)
for top, wrapper in INTERFACE_WRAPPED.items():
wrapper_path = path.parent / f"{_synth_top(top)}.sv"
wrapper_path.write_text(wrapper, encoding="utf-8")
lines.append(str(wrapper_path))
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _synth(top: str, build_root: Path, filelist: Path) -> dict[str, object]:
base = build_root / "syn/yosys-npu" / top
synth_top = _synth_top(top)
for sub in ("out", "tmp", "rpt"):
(base / sub).mkdir(parents=True, exist_ok=True)
env = {
"PDK": "IHP130",
"SOC": "MINI",
"SYNTH_RECIPE": "balanced",
"HAVE_SRAM_MACRO": "YES",
"SRAM_SIZE_KIB": "32",
"YOSYS_TARGET_PERIOD_PS": str(TARGET_PERIOD_PS),
"SV_FLIST": str(filelist),
"TOP_DESIGN": synth_top,
"PROJ_NAME": synth_top,
"BUILD": str(base / "out"),
"WORK": str(base / "tmp"),
"REPORTS": str(base / "rpt"),
"NETLIST": str(base / "out" / f"{synth_top}_yosys.v"),
"CONFIG": str(base / "out" / f"{synth_top}_yosys.config"),
}
log = build_root / "syn/yosys-npu" / f"{top}.log"
_run_flow("yosys", log, base / "result-synth.json", env, ["yosys", "-c", str(SYNTH_TCL)])
area = json.loads((base / "rpt" / f"{synth_top}_area.json").read_text(encoding="utf-8"))
design = area["design"]
macro_count = design.get("num_cells_by_type", {}).get(SRAM_MACRO, 0)
latch_cells = {
name: count
for name, count in design.get("num_cells_by_type", {}).items()
if "dlatch" in name.lower() or "latch" in name.lower()
}
log_text = log.read_text(encoding="utf-8", errors="replace")
latch_lines = [
line
for line in log_text.splitlines()
if LATCH.search(line) and "PROC_DLATCH pass" not in line
]
return {
"top": top,
"area_um2": design.get("area"),
"num_cells": design.get("num_cells"),
"sram_macro_count": macro_count,
"latch_cells": latch_cells,
"latch_log_lines": latch_lines[:10],
"log": str(log),
"netlist": env["NETLIST"],
}
def _sta(top: str, build_root: Path, netlist: Path) -> dict[str, object]:
base = build_root / "sta/opensta-npu" / top
base.mkdir(parents=True, exist_ok=True)
sram_libs = sorted(
glob.glob(str(PDK_ROOT / "sg13g2_sram/lib/*_slow_1p08V_125C.lib"))
)
env = {
"OPENSTA_NETLIST": str(netlist),
"OPENSTA_LIBERTY": str(STA_LIBERTY),
"OPENSTA_LINK_LIBS": str(STA_LINK_LIB),
"OPENSTA_SRAM_LIBS": " ".join(sram_libs),
"OPENSTA_SDC": str(STA_SDC),
"OPENSTA_REPORT": str(base / f"{top}_checks.rpt"),
"OPENSTA_METRICS": str(base / f"{top}_timing_metrics.rpt"),
"OPENSTA_TOP": _synth_top(top),
}
log = base / f"{top}.log"
_run_flow("opensta", log, base / "result-sta.json", env, ["sta", "-no_init", "-exit", str(STA_TCL)])
metrics = {}
for line in (base / f"{top}_timing_metrics.rpt").read_text(encoding="utf-8").splitlines():
name, _, value = line.partition("=")
metrics[name] = float(value)
checks = (base / f"{top}_checks.rpt").read_text(encoding="utf-8", errors="replace")
return {
"top": top,
"wns_setup_ns": metrics.get("wns_max"),
"tns_setup_ns": metrics.get("tns_max"),
"setup_paths": metrics.get("paths_max"),
"wns_hold_ns": metrics.get("wns_min"),
"tns_hold_ns": metrics.get("tns_min"),
"hold_paths": metrics.get("paths_min"),
"violated_paths": len(VIOLATED.findall(checks)),
"log": str(log),
}
def _tool_version(command: list[str]) -> str:
completed = subprocess.run(command, capture_output=True, text=True)
return (completed.stdout or completed.stderr).splitlines()[0].strip()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-root", type=Path, default=ROOT / "build/npu-p1-flow")
parser.add_argument("--top", action="append", choices=TOPS, help="limit to one top module")
parser.add_argument("--synth", action="store_true")
parser.add_argument("--sta", action="store_true")
parser.add_argument("--evidence", type=Path, help="evidence JSON output path")
args = parser.parse_args()
if not args.synth and not args.sta:
args.synth = args.sta = True
tops = tuple(args.top) if args.top else TOPS
build_root = args.build_root.resolve()
filelist = build_root / "npu_p1.fl"
_write_filelist(filelist)
evidence: dict[str, object] = {
"phase": "NPU-P1",
"verification_ids": ["NPU-V017"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"tools": {
"yosys": _tool_version(["yosys", "--version"]),
"opensta": _tool_version(["sta", "-version"]),
},
"target_period_ps": TARGET_PERIOD_PS,
"filelist": str(filelist),
"tops": {},
"failures": [],
}
failures: list[str] = evidence["failures"]
for top in tops:
record: dict[str, object] = {}
if args.synth:
record["synth"] = _synth(top, build_root, filelist)
synth = record["synth"]
if top == "npu_local_sram" and synth["sram_macro_count"] != EXPECTED_SRAM_MACROS:
failures.append(
f"npu_local_sram maps {synth['sram_macro_count']} {SRAM_MACRO}, "
f"expected {EXPECTED_SRAM_MACROS}"
)
if synth["latch_log_lines"] or synth["latch_cells"]:
failures.append(
f"{top} synthesis shows latches: {synth['latch_log_lines']} {synth['latch_cells']}"
)
netlist = build_root / "syn/yosys-npu" / top / "out" / f"{_synth_top(top)}_yosys.v"
if args.sta:
if not netlist.is_file():
raise RuntimeError(f"missing netlist for {top}: {netlist}; run --synth first")
record["sta"] = _sta(top, build_root, netlist)
sta = record["sta"]
if not (sta["wns_setup_ns"] >= 0.0):
failures.append(f"{top} setup WNS {sta['wns_setup_ns']} ns < 0")
if not (sta["tns_setup_ns"] == 0.0):
failures.append(f"{top} setup TNS {sta['tns_setup_ns']} ns != 0")
if sta["violated_paths"]:
failures.append(f"{top} has {sta['violated_paths']} violating paths")
if not sta["setup_paths"]:
failures.append(f"{top} has no constrained setup paths (vacuous STA)")
evidence["tops"][top] = record
evidence["verdict"] = "PASS" if not failures else "FAIL"
payload = json.dumps(evidence, indent=2, sort_keys=True) + "\n"
evidence_path = args.evidence or (build_root / "evidence-p1.json")
evidence_path.parent.mkdir(parents=True, exist_ok=True)
evidence_path.write_text(payload, encoding="utf-8")
print(payload, end="")
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Run IHP130 NPU block synthesis/STA and KWS/VWW netlist workloads."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT / "tests"))
from test_npu_rtl_p4 import _operator_cases # noqa: E402
SRAM_MACRO = "RM_IHPSG13_1P_1024x32_c2_bm_bist"
DIRECTED_CASES = ("conv_kws_10x4", "dw33_s2_c7", "rej_stride3", "arith_acc_overflow")
REJECTED = re.compile(r"FAILED|FATAL|assertion failed|%Error|SIM_TEST_FAIL|SIM_TEST_TIMEOUT")
SPECIFY_BLOCK = re.compile(r"(?ms)^\s*specify\b.*?^\s*endspecify\s*$")
DELAYED_SIGNAL = re.compile(r"\bdelayed_([A-Za-z][A-Za-z0-9_]*)\b")
def identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {"path": str(path.resolve()), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
def run(command: list[str], *, log: Path, timeout: int | None = None) -> None:
completed = subprocess.run(
command,
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
env={**os.environ, "CCACHE_DISABLE": "1"},
)
log.parent.mkdir(parents=True, exist_ok=True)
log.write_text(completed.stdout, encoding="utf-8")
if completed.returncode != 0:
raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(command)}\n{completed.stdout[-4000:]}")
def write_functional_stdcell(source: Path, output: Path) -> None:
text = source.read_text(encoding="utf-8")
transformed, specify_count = SPECIFY_BLOCK.subn("", text)
transformed = DELAYED_SIGNAL.sub(r"\1", transformed)
transformed, notifier_count = re.subn(
r"(?m)^\s*reg notifier;\s*$", "\twire notifier = 1'b0;", transformed
)
if specify_count == 0 or notifier_count == 0 or "delayed_" in transformed:
raise ValueError("IHP standard-cell functional transformation was incomplete")
output.write_text(transformed, encoding="utf-8")
def plusarg_value(arguments: list[str], name: str) -> str:
prefix = f"+{name}="
matches = [argument[len(prefix) :] for argument in arguments if argument.startswith(prefix)]
if len(matches) != 1:
raise ValueError(f"directed netlist case has invalid {name} plusarg")
return matches[0]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--timeout-seconds", type=int, default=7200)
parser.add_argument("--jobs", type=int, default=4)
parser.add_argument(
"--reuse-physical",
action="store_true",
help="reuse an existing isolated-npu.json after validating it (debug only)",
)
args = parser.parse_args()
if args.jobs <= 0:
parser.error("jobs must be positive")
output = args.output_dir.resolve()
physical = output / "physical"
evidence_path = physical / "isolated-npu.json"
if not args.reuse_physical:
run(
[
sys.executable,
str(ROOT / "scripts/run_npu_p1_flow.py"),
"--build-root",
str(physical),
"--top",
"npu_block_top",
"--evidence",
str(evidence_path),
],
log=physical / "driver.log",
timeout=args.timeout_seconds,
)
elif not evidence_path.is_file():
raise ValueError("--reuse-physical requires an existing isolated-npu.json")
physical_evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
top = physical_evidence["tops"]["npu_block_top"]
if physical_evidence.get("verdict") != "PASS" or top["synth"]["sram_macro_count"] != 16:
raise ValueError("NPU block physical evidence did not pass with exactly 16 SRAM macros")
netlist = Path(top["synth"]["netlist"])
transaction_netlist = netlist.with_name(netlist.name.replace("_yosys.v", "_yosys_debug.v"))
if (
not transaction_netlist.is_file()
or transaction_netlist.read_text(encoding="utf-8", errors="replace").count(
f"{SRAM_MACRO} u_mem"
)
!= 16
):
raise ValueError("technology-mapped transaction netlist does not contain 16 SRAM macros")
pdk = ROOT / "physical/pdk/IHP-Open-PDK/ihp-sg13g2/libs.ref"
stdcell_source = pdk / "sg13g2_stdcell/verilog/sg13g2_stdcell.v"
stdcell_functional = output / "sg13g2_stdcell_functional.v"
write_functional_stdcell(stdcell_source, stdcell_functional)
compile_dir = output / "verilator-netlist"
simulator = compile_dir / "npu_netlist_tb"
compile_log = output / "compile.log"
run(
[
"verilator",
"--binary",
"--timing",
"--top-module",
"npu_operator_tb",
"-Wno-fatal",
"--x-initial",
"0",
"--x-assign",
"0",
"-j",
str(args.jobs),
"-o",
simulator.name,
"--Mdir",
str(compile_dir),
"-I" + str(ROOT / "rtl/managed/clusterip/common/rtl/interface"),
"-I" + str(ROOT / "rtl/ip/multimedia"),
str(ROOT / "rtl/managed/clusterip/common/rtl/interface/axi4_if.sv"),
str(pdk / "sg13g2_stdcell/verilog/sg13g2_udp.v"),
str(stdcell_functional),
str(ROOT / "tests/rtl/ihp130_npu_sram_model.sv"),
str(transaction_netlist),
str(ROOT / "tests/rtl/npu_operator_tb.sv"),
],
log=compile_log,
timeout=args.timeout_seconds,
)
artifact_dir = output / "artifacts"
artifact_dir.mkdir(parents=True, exist_ok=True)
generated = _operator_cases(artifact_dir, selected=set(DIRECTED_CASES))
by_name = {plusarg_value(arguments, "NAME"): arguments for arguments in generated}
if not set(DIRECTED_CASES) <= set(by_name):
raise ValueError("P4 fixture generator omitted a required P6 netlist case")
cases = []
for name in DIRECTED_CASES:
plusargs = by_name[name]
log = output / f"{name}.log"
command = [str(simulator), *plusargs]
run(command, log=log, timeout=args.timeout_seconds)
text = log.read_text(encoding="utf-8", errors="replace")
if "NPU operator test passed" not in text or REJECTED.search(text):
raise ValueError(f"{name} netlist workload did not produce a clean pass")
artifacts = {
label.lower(): identity(Path(plusarg_value(plusargs, label)))
for label in ("IMG", "GOLD")
}
cases.append({
"case": name,
"status": "PASS",
"command": command,
"artifacts": artifacts,
"log": identity(log),
})
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V017", "NPU-V018"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"physical_reused": args.reuse_physical,
"physical": identity(evidence_path),
"netlist": identity(netlist),
"transaction_netlist": identity(transaction_netlist),
"stdcell_source": identity(stdcell_source),
"stdcell_functional": identity(stdcell_functional),
"simulator": identity(simulator),
"compile_log": identity(compile_log),
"sram_functional_model": identity(ROOT / "tests/rtl/ihp130_npu_sram_model.sv"),
"sram_macro_count": 16,
"simulation_policy": {
"simulator": "Verilator",
"state_model": "2-state",
"x_initial": "0",
"x_assign": "0",
"timing_evidence": False,
},
"implementation": {
name: identity(ROOT / name)
for name in (
"scripts/run_npu_p6_netlist.py",
"scripts/run_npu_p1_flow.py",
"physical/smoke/syn/yosys/npu_block_top.sv",
"tests/rtl/npu_operator_tb.sv",
"tests/rtl/ihp130_npu_sram_model.sv",
"tests/test_npu_rtl_p4.py",
)
},
"cases": cases,
"summary": {"required_cases": 4, "passes": 4, "failures": 0, "skipped": 0},
"verdict": "PASS",
}
report_path = output / "qualification-p6-netlist.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 netlist qualification: PASS ({report_path})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Run and validate isolated-NPU plus full-PRODUCT IHP130 physical evidence."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / "configs/ci/ihp130.mk"
SRAM_MACRO = "RM_IHPSG13_1P_1024x32_c2_bm_bist"
LATCH = re.compile(r"(?:\$dlatch|\blatch inferred)", re.IGNORECASE)
BLACKBOX = re.compile(r"(?:unresolved|unknown module)", re.IGNORECASE)
def identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {
"path": str(path.resolve()),
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
}
def load(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"physical evidence is not an object: {path}")
return data
def make_command(timestamp: str, *targets: str) -> list[str]:
return [
"make",
f"CONFIG={CONFIG}",
"SIMU=IVERILOG",
"SYNTH=YOSYS",
"STA=OPENSTA",
"SYNTH_RECIPE=balanced",
f"BUILD_TIMESTAMP={timestamp}",
*targets,
]
def run(command: list[str], *, log: Path) -> None:
completed = subprocess.run(
command,
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
env={**os.environ, "CCACHE_DISABLE": "1"},
)
log.parent.mkdir(parents=True, exist_ok=True)
log.write_text(completed.stdout, encoding="utf-8")
if completed.returncode != 0:
raise RuntimeError(f"P6 physical command failed: {log}")
def variant_root(timestamp: str) -> Path:
completed = subprocess.run(
make_command(timestamp, "-s", "config"),
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
if completed.returncode != 0:
raise RuntimeError(f"failed to resolve P6 physical variant\n{completed.stdout}")
match = re.search(r"(?m)^VARIANT_ROOT\s+(.+)$", completed.stdout)
if match is None:
raise ValueError("P6 physical config omitted VARIANT_ROOT")
return Path(match.group(1)).resolve()
def timing_metrics(path: Path) -> dict[str, float]:
values: dict[str, float] = {}
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
name, separator, value = line.partition("=")
if separator and name in {"wns_min", "wns_max", "tns_min", "tns_max"}:
values[name] = float(value)
if set(values) != {"wns_min", "wns_max", "tns_min", "tns_max"}:
raise ValueError("PRODUCT STA did not report all WNS/TNS metrics")
if values["wns_max"] < 0.0 or values["tns_max"] != 0.0:
raise ValueError("PRODUCT setup timing did not close")
if values["wns_min"] < 0.0 or values["tns_min"] != 0.0:
raise ValueError("PRODUCT hold timing did not close")
return values
def npu_macro_count(netlist: Path) -> int:
text = netlist.read_text(encoding="utf-8", errors="replace")
count = 0
for match in re.finditer(r"(?ms)^module\s+([^\s(]+).*?^endmodule", text):
module_name = match.group(1).lower()
if "npu" in module_name and "tc_sram_1024x32" in module_name:
count += match.group(0).count(f"{SRAM_MACRO} u_mem")
return count
def validate_result(path: Path) -> dict[str, Any]:
result = load(path)
if result.get("status") != "passed" or result.get("exit_code") != 0:
raise ValueError(f"required physical flow failed: {path}")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--isolated-report", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument(
"--build-timestamp", default=datetime.now().astimezone().strftime("%Y-%m-%d-%H-%M")
)
parser.add_argument("--reuse-product", action="store_true", help="reuse existing PRODUCT flow")
args = parser.parse_args()
isolated = load(args.isolated_report)
if (
isolated.get("verdict") != "PASS"
or isolated.get("sram_macro_count") != 16
or isolated.get("verification_ids") != ["NPU-V017", "NPU-V018"]
):
raise ValueError("isolated NPU synthesis/STA/netlist evidence is incomplete")
output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=True)
variant = variant_root(args.build_timestamp)
if not args.reuse_product:
run(
make_command(
args.build_timestamp,
"manifest",
"synth",
"sta",
"check-warnings",
"metrics",
"check-metrics",
),
log=output / "product-physical.log",
)
manifest_path = variant / "meta/manifest.json"
synth_result_path = variant / "syn/yosys/result-synth.json"
sta_result_path = variant / "sta/opensta/result-sta.json"
warning_path = variant / "meta/warnings.json"
metrics_path = variant / "meta/metrics.json"
netlist = variant / "syn/yosys/out/retrosoc_asic_yosys.v"
for required in (
manifest_path,
synth_result_path,
sta_result_path,
warning_path,
metrics_path,
netlist,
):
if not required.is_file():
raise ValueError(f"missing PRODUCT physical artifact: {required}")
synth_result = validate_result(synth_result_path)
sta_result = validate_result(sta_result_path)
manifest = load(manifest_path)
config = manifest.get("configuration", {})
expected_config = {
"MINI_MODE": "PRODUCT",
"PDK": "IHP130",
"HAVE_HP": "YES",
"HAVE_SRAM_MACRO": "YES",
"EXT_CLK_HZ": "72000000",
"SYNTH": "YOSYS",
"STA": "OPENSTA",
"SYNTH_RECIPE": "balanced",
}
if any(config.get(name) != value for name, value in expected_config.items()):
raise ValueError("PRODUCT physical configuration differs from the P6 contract")
warnings = load(warning_path)
if warnings.get("status") != "passed" or warnings.get("failed_tools"):
raise ValueError("PRODUCT warning review did not pass")
metrics = load(metrics_path)
synthesis = metrics.get("synthesis", {})
if synthesis.get("top_area", 0) <= 0 or synthesis.get("top_cells", 0) <= 0:
raise ValueError("PRODUCT metrics omit positive area or cell count")
timing = timing_metrics(variant / "sta/opensta/timing_metrics.rpt")
synth_log = variant / "syn/yosys/retrosoc_asic.log"
synth_text = synth_log.read_text(encoding="utf-8", errors="replace")
suspicious = [
line
for line in synth_text.splitlines()
if (LATCH.search(line) or BLACKBOX.search(line))
and "PROC_DLATCH pass" not in line
and "no latch inferred" not in line.lower()
]
if suspicious:
raise ValueError(f"PRODUCT synthesis reported latches or unresolved black boxes: {suspicious[:3]}")
product_npu_macros = npu_macro_count(netlist)
if product_npu_macros != 16:
raise ValueError(f"PRODUCT netlist contains {product_npu_macros} NPU SRAM macros, expected 16")
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V017"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"profile": "configs/ci/ihp130.mk",
"target_frequency_hz": 72_000_000,
"product_reused": args.reuse_product,
"implementation": {
name: identity(ROOT / name)
for name in (
"scripts/run_npu_p6_physical.py",
"physical/smoke/syn/yosys/script/synth.tcl",
"physical/smoke/sta/opensta/opensta.tcl",
"physical/smoke/sta/opensta/generate_sdc.py",
)
},
"isolated": identity(args.isolated_report),
"product": {
"variant_root": str(variant),
"manifest": identity(manifest_path),
"synthesis_result": {**identity(synth_result_path), "duration_seconds": synth_result.get("duration_seconds")},
"sta_result": {**identity(sta_result_path), "duration_seconds": sta_result.get("duration_seconds")},
"warnings": identity(warning_path),
"metrics": identity(metrics_path),
"netlist": identity(netlist),
"area_um2": synthesis["top_area"],
"cells": synthesis["top_cells"],
"npu_sram_macros": product_npu_macros,
"timing": timing,
},
"summary": {"required_checks": 2, "passes": 2, "failures": 0, "skipped": 0},
"verdict": "PASS",
}
report_path = output / "qualification-p6-physical.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 physical qualification: PASS ({report_path})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Run the full PR and nightly regressions required by NPU-P6."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {
"path": str(path.resolve()),
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
}
def run_suite(suite: str, output: Path, timestamp: str) -> dict[str, object]:
log = output / f"regress-{suite}.log"
started = datetime.now().astimezone().isoformat()
with log.open("w", encoding="utf-8") as stream:
completed = subprocess.run(
["make", f"regress-{suite}"],
cwd=ROOT,
text=True,
stdout=stream,
stderr=subprocess.STDOUT,
check=False,
env={**os.environ, "BUILD_TIMESTAMP": timestamp, "CCACHE_DISABLE": "1"},
)
if completed.returncode != 0:
raise RuntimeError(f"P6 {suite} regression failed: {log}")
return {
"suite": suite,
"status": "PASS",
"command": ["make", f"regress-{suite}"],
"started_at": started,
"log": identity(log),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument(
"--build-timestamp", default=datetime.now().astimezone().strftime("%Y-%m-%d-%H-%M")
)
args = parser.parse_args()
output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=True)
suites = [run_suite(suite, output, args.build_timestamp) for suite in ("pr", "nightly")]
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V018"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"implementation": {
name: identity(ROOT / name)
for name in ("scripts/run_npu_p6_regression.py", "scripts/regress.py")
},
"suites": suites,
"summary": {"required_suites": 2, "passes": 2, "failures": 0, "skipped": 0},
"verdict": "PASS",
}
report_path = output / "qualification-p6-regression.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 regressions: PASS ({report_path})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""Run complete NPU-P6 corpora on the PRODUCT Verilator HP model."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / "configs/ci/ihp130.mk"
REJECTED = re.compile(
r"(?:\bFAIL(?:ED)?\b|\bFATAL\b|assertion failed|%Error|SIM_TEST_FAIL|SIM_TEST_TIMEOUT)",
re.IGNORECASE,
)
CASE_PREFIX = "NPU_P6_CASE "
INTEGER_FIELDS = {
"shard",
"index",
"label",
"reference_cycles",
"npu_cycles",
"input_copy_cycles",
"npu_wait_cycles",
"softmax_cycles",
"active_cycles",
"clock_pause_cycles",
"useful_macs",
"pack_cycles",
"bank_stall_cycles",
"dma_read_bytes",
"dma_write_bytes",
"dma_stall_cycles",
"requant_stall_cycles",
"retired_descriptors",
"reference_class",
"npu_class",
}
REQUIRED_FIELDS = INTEGER_FIELDS | {"workload", "cache", "contention", "status"}
def identity(path: Path) -> dict[str, object]:
data = path.read_bytes()
return {
"path": str(path.resolve()),
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
}
def run(command: list[str], *, timeout: int | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
env={**os.environ, "CCACHE_DISABLE": "1"},
)
def make_command(workload: str, timestamp: str, *targets: str) -> list[str]:
return [
"make",
f"CONFIG={CONFIG}",
"APP=hp_boot",
"SIMU=VERILATOR",
"NPU_P6_ACCEPTANCE=YES",
f"NPU_P6_WORKLOAD={workload}",
f"BUILD_TIMESTAMP={timestamp}",
*targets,
]
def variant_root(workload: str, timestamp: str) -> Path:
completed = run(make_command(workload, timestamp, "-s", "config"))
if completed.returncode != 0:
raise RuntimeError(f"failed to resolve P6 {workload} variant\n{completed.stdout}")
match = re.search(r"(?m)^VARIANT_ROOT\s+(.+)$", completed.stdout)
if match is None:
raise ValueError(f"P6 {workload} config omitted VARIANT_ROOT")
return Path(match.group(1)).resolve()
def build_variants(timestamp: str, log_dir: Path) -> dict[str, Path]:
roots = {workload: variant_root(workload, timestamp) for workload in ("kws", "vww")}
for workload in ("kws", "vww"):
targets = ["manifest", "hp-smoke-bundle"]
if workload == "kws":
targets.append("comp")
completed = run(make_command(workload, timestamp, *targets))
log = log_dir / f"build-{workload}.log"
log.parent.mkdir(parents=True, exist_ok=True)
log.write_text(completed.stdout, encoding="utf-8")
if completed.returncode != 0:
raise RuntimeError(f"P6 {workload} build failed: {log}")
return roots
def parse_case_line(line: str) -> dict[str, Any]:
if not line.startswith(CASE_PREFIX):
raise ValueError("not an NPU-P6 case record")
fields: dict[str, Any] = {}
for token in line[len(CASE_PREFIX) :].split():
name, separator, value = token.partition("=")
if not separator or not name or name in fields:
raise ValueError(f"malformed NPU-P6 case token: {token}")
fields[name] = int(value, 0) if name in INTEGER_FIELDS else value
if set(fields) != REQUIRED_FIELDS:
missing = sorted(REQUIRED_FIELDS - set(fields))
extra = sorted(set(fields) - REQUIRED_FIELDS)
raise ValueError(f"NPU-P6 case fields changed: missing={missing}, extra={extra}")
if fields["status"] != "PASS":
raise ValueError("NPU-P6 firmware reported a failed case")
if fields["npu_cycles"] != fields["npu_wait_cycles"] + fields["softmax_cycles"]:
raise ValueError("NPU-P6 total does not match wait plus Softmax cycles")
return fields
def parse_log(path: Path, workload: str, shard_index: int) -> list[dict[str, Any]]:
content = path.read_text(encoding="utf-8", errors="replace")
required = (
"SIM_TEST_PASS code=0",
"HP_NPU_P6_PASS",
f"NPU_P6_SHARD_PASS workload={workload} shard={shard_index} cases=100",
)
rejected = REJECTED.search(content)
if any(marker not in content for marker in required) or rejected is not None:
raise ValueError(f"P6 simulator markers failed: {path}")
cases = [parse_case_line(line) for line in content.splitlines() if line.startswith(CASE_PREFIX)]
expected = list(range(shard_index * 100, (shard_index + 1) * 100))
if len(cases) != 100 or [case["index"] for case in cases] != expected:
raise ValueError(f"P6 {workload} shard {shard_index} is incomplete or reordered")
if any(case["workload"] != workload or case["shard"] != shard_index for case in cases):
raise ValueError(f"P6 {workload} shard {shard_index} record identity changed")
for offset, case in enumerate(cases):
expected_cache = "cold" if offset == 0 else "steady"
expected_contention = "ga2d-copy-32x32768-rgb565" if case["index"] < 10 else "none"
if case["cache"] != expected_cache or case["contention"] != expected_contention:
raise ValueError(f"P6 case policy changed: {workload}/{case['index']}")
if case["reference_cycles"] <= 0 or case["npu_cycles"] <= 0:
raise ValueError(f"P6 case has a nonpositive cycle interval: {workload}/{case['index']}")
if case["reference_class"] != case["npu_class"]:
raise ValueError(f"P6 classification mismatch: {workload}/{case['index']}")
return cases
def package_shard(
workload: str,
shard: dict[str, Any],
variant: Path,
output: Path,
) -> tuple[Path, Path]:
shard_index = int(shard["index"])
shard_dir = output / workload / f"shard-{shard_index:03d}"
images = shard_dir / "images"
images.mkdir(parents=True, exist_ok=True)
shutil.copy2(variant / "hp-smoke/images/fw_jump.bin", images / "fw_jump.bin")
shutil.copy2(Path(shard["file"]["path"]), images / "Image")
(images / "retrosoc_hp.dtb").write_bytes(b"SMOK")
(images / "rootfs.cpio.gz").write_bytes(b"SMOK")
bundle = shard_dir / "retrosoc_npu_p6.bin"
manifest = shard_dir / "bundle.json"
completed = run(
[
"python3",
str(ROOT / "scripts/package_hp_boot.py"),
"--firmware",
str(variant / "sw/retrosoc_fw.bin"),
"--images",
str(images),
"--output",
str(bundle),
"--manifest",
str(manifest),
]
)
if completed.returncode != 0:
raise RuntimeError(f"failed to package P6 {workload} shard {shard_index}\n{completed.stdout}")
return bundle, manifest
def simulate_shard(
workload: str,
shard: dict[str, Any],
variant: Path,
emulator: Path,
output: Path,
sim_time: int,
timeout: int,
) -> dict[str, Any]:
shard_index = int(shard["index"])
bundle, bundle_manifest = package_shard(workload, shard, variant, output)
shard_dir = bundle.parent
log = shard_dir / "sim.log"
command = [str(emulator), "-i", str(bundle), "--fast-flash", "-t", str(sim_time)]
started = time.monotonic()
try:
with log.open("w", encoding="utf-8") as stream:
completed = subprocess.run(
command,
cwd=shard_dir,
text=True,
stdout=stream,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as error:
raise RuntimeError(f"P6 {workload} shard {shard_index} timed out: {log}") from error
if completed.returncode != 0:
raise RuntimeError(f"P6 {workload} shard {shard_index} failed: {log}")
cases = parse_log(log, workload, shard_index)
return {
"index": shard_index,
"first_case": int(shard["first_case"]),
"case_count": int(shard["case_count"]),
"command": command,
"host_seconds": time.monotonic() - started,
"bundle": identity(bundle),
"bundle_manifest": identity(bundle_manifest),
"log": identity(log),
"cases": cases,
"status": "PASS",
}
def percentile(values: list[int], percentage: float) -> int:
ordered = sorted(values)
return ordered[max(0, math.ceil(len(ordered) * percentage) - 1)]
def load_compiler_estimate(variant: Path, workload: str) -> dict[str, Any]:
manifest = variant / f"npu/p5/deployments/{workload}/npu.json"
deployment = json.loads(manifest.read_text(encoding="utf-8"))
compiler_report = deployment.get("report")
if not isinstance(compiler_report, dict):
raise ValueError(f"P6 {workload} deployment omits the compiler report")
totals = compiler_report.get("totals")
operators = compiler_report.get("operators")
if not isinstance(totals, dict) or not isinstance(operators, list):
raise ValueError(f"P6 {workload} compiler report is incomplete")
values = {
"active_cycles": totals.get("estimated_cycles"),
"dma_read_bytes": totals.get("dma_read_bytes"),
"dma_write_bytes": totals.get("dma_write_bytes"),
"useful_macs": totals.get("useful_macs"),
"pack_cycles": sum(
operator.get("pack_cycles", 0)
for operator in operators
if isinstance(operator, dict) and operator.get("placement") == "npu"
),
"retired_descriptors": totals.get("descriptors"),
}
if any(not isinstance(value, int) or value <= 0 for value in values.values()):
raise ValueError(f"P6 {workload} compiler estimates must be positive integers")
cycle_model = compiler_report.get("cycle_model")
if not isinstance(cycle_model, str) or not cycle_model:
raise ValueError(f"P6 {workload} compiler report omits its cycle model")
return {
"artifact": identity(manifest),
"cycle_model": cycle_model,
"per_inference": values,
}
def compare_compiler_estimates(
estimate: dict[str, Any], counter_totals: dict[str, int], case_count: int
) -> dict[str, Any]:
comparisons = {}
for name, expected_per_inference in estimate["per_inference"].items():
measured_total = counter_totals[name]
expected_total = expected_per_inference * case_count
ratio = measured_total / expected_total
material = abs(ratio - 1.0) > 0.10
if not material:
explanation = "measured value is within 10 percent of the compiler estimate"
elif name == "active_cycles":
explanation = (
"the compiler uses an analytical no-overlap cycle model; measured active cycles "
"include implemented scheduling, transfer handshakes, packing, requantization, "
"and recorded RTL stalls"
)
elif name in {"dma_read_bytes", "dma_write_bytes"}:
explanation = (
"measured DMA traffic includes the implemented edge, reload, and descriptor "
"transactions while the compiler estimate follows its static traffic model"
)
else:
explanation = (
"the measured hardware counter differs materially from the compiler's static "
"operator accounting and requires review before a qualification claim"
)
comparisons[name] = {
"estimated_per_inference": expected_per_inference,
"expected_total": expected_total,
"measured_total": measured_total,
"measured_mean": measured_total / case_count,
"measured_to_estimated_ratio": ratio,
"material_difference": material,
"explanation": explanation,
}
return {
"artifact": estimate["artifact"],
"cycle_model": estimate["cycle_model"],
"material_threshold_percent": 10,
"metrics": comparisons,
}
def summarize_workload(
workload: str, shards: list[dict[str, Any]], estimate: dict[str, Any]
) -> dict[str, Any]:
ordered_shards = sorted(shards, key=lambda record: record["index"])
cases = [case for shard in ordered_shards for case in shard["cases"]]
if len(cases) != 1000 or [case["index"] for case in cases] != list(range(1000)):
raise ValueError(f"P6 {workload} corpus is incomplete or reordered")
reference = [case["reference_cycles"] for case in cases]
npu = [case["npu_cycles"] for case in cases]
speedup = sum(reference) / sum(npu)
uncontended = [case for case in cases if case["contention"] == "none"]
contended = [case for case in cases if case["contention"] != "none"]
counter_totals = {
name: sum(case[name] for case in cases)
for name in (
"active_cycles",
"clock_pause_cycles",
"useful_macs",
"pack_cycles",
"bank_stall_cycles",
"dma_read_bytes",
"dma_write_bytes",
"dma_stall_cycles",
"requant_stall_cycles",
"retired_descriptors",
)
}
return {
"case_count": len(cases),
"mismatches": 0,
"skipped": 0,
"reference_cycles": sum(reference),
"npu_cycles": sum(npu),
"speedup": speedup,
"target_speedup": 2.0,
"meets_target": speedup >= 2.0,
"reference_median_cycles": percentile(reference, 0.5),
"reference_p99_cycles": percentile(reference, 0.99),
"npu_median_cycles": percentile(npu, 0.5),
"npu_p99_cycles": percentile(npu, 0.99),
"npu_median_latency_ms_72mhz": percentile(npu, 0.5) / 72_000.0,
"npu_p99_latency_ms_72mhz": percentile(npu, 0.99) / 72_000.0,
"cache_policy": {"cold_cases": 10, "steady_cases": 990},
"contention": {
"master": "GA2D private DMA, 32x32768 RGB565 copy",
"cases": len(contended),
"uncontended_cases": len(uncontended),
"reference_cycles": sum(case["reference_cycles"] for case in contended),
"npu_cycles": sum(case["npu_cycles"] for case in contended),
},
"counter_totals": counter_totals,
"compiler_comparison": compare_compiler_estimates(estimate, counter_totals, len(cases)),
"shards": [{key: value for key, value in shard.items() if key != "cases"} for shard in ordered_shards],
"cases": cases,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--corpus-report", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--build-timestamp", default=datetime.now().astimezone().strftime("%Y-%m-%d-%H-%M"))
parser.add_argument("--jobs", type=int, default=2)
parser.add_argument("--timeout-seconds", type=int, default=86400)
parser.add_argument("--sim-time", type=int, default=86400)
args = parser.parse_args()
if args.jobs <= 0 or args.timeout_seconds <= 0 or args.sim_time <= 0:
parser.error("jobs, timeout and simulation time must be positive")
corpus = json.loads(args.corpus_report.read_text(encoding="utf-8"))
workloads = corpus.get("workloads")
if corpus.get("verdict") != "PASS" or not isinstance(workloads, dict):
raise ValueError("P6 corpus shard report did not pass")
output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=True)
variants = build_variants(args.build_timestamp, output / "build-logs")
emulator = variants["kws"] / "sim/verilator/emu"
if not emulator.is_file():
raise ValueError("P6 PRODUCT Verilator emulator is missing")
records: dict[str, list[dict[str, Any]]] = {"kws": [], "vww": []}
futures = []
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
for workload in ("kws", "vww"):
workload_record = workloads.get(workload)
shards = workload_record.get("shards") if isinstance(workload_record, dict) else None
if not isinstance(shards, list) or len(shards) != 10:
raise ValueError(f"P6 {workload} requires exactly ten shards")
for shard in shards:
futures.append(
executor.submit(
simulate_shard,
workload,
shard,
variants[workload],
emulator,
output / "runs",
args.sim_time,
args.timeout_seconds,
)
)
for future in as_completed(futures):
record = future.result()
workload = record["cases"][0]["workload"]
records[workload].append(record)
estimates = {
workload: load_compiler_estimate(variants[workload], workload)
for workload in ("kws", "vww")
}
summaries = {
workload: summarize_workload(workload, records[workload], estimates[workload])
for workload in ("kws", "vww")
}
passes = all(record["meets_target"] for record in summaries.values())
report = {
"schema": 1,
"phase": "NPU-P6",
"verification_ids": ["NPU-V016"],
"git_revision": subprocess.check_output(
["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True
).strip(),
"profile": "configs/ci/ihp130.mk",
"configuration": {
"MINI_MODE": "PRODUCT",
"APP": "hp_boot",
"SIMU": "VERILATOR",
"HP_CONFIG": "rv32imafdc_zicbom_max",
"EXT_CLK_HZ": 72_000_000,
},
"measurement": {
"counter": "HP rdcycle",
"reference": "portable INT8 C through CPU Softmax",
"npu": "submit, production private DMA, synchronization, wait, CPU Softmax",
"excluded": ["corpus transport", "input copy/preprocessing", "host elapsed time"],
"cache_policy": "cold first case and steady subsequent cases in each 100-case shard",
},
"implementation": {
name: identity(ROOT / name)
for name in (
"scripts/run_npu_p6_verilator.py",
"app/benchmark/npu/npu_p6_runner.c",
"app/benchmark/npu/npu_p6_runner.h",
"app/benchmark/npu/npu_p6_reference.c",
"app/benchmark/npu/npu_p6_reference.h",
"app/ports/linux/smoke/start.S",
"app/apps/hp_boot/main.c",
)
},
"corpus": identity(args.corpus_report),
"variants": {
workload: {
"root": str(variant),
"manifest": identity(variant / "meta/manifest.json"),
"firmware": identity(variant / "hp-smoke/images/fw_jump.bin"),
}
for workload, variant in variants.items()
},
"emulator": identity(emulator),
"workloads": summaries,
"summary": {
"cases": 2000,
"passes": 2000 if passes else 0,
"mismatches": 0,
"skipped": 0,
"performance_targets": 2,
"performance_passes": sum(record["meets_target"] for record in summaries.values()),
},
"verdict": "PASS" if passes else "FAIL",
}
report_path = output / "qualification-p6-verilator.json"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"NPU-P6 PRODUCT Verilator qualification: {report['verdict']} ({report_path})")
return 0 if passes else 1
if __name__ == "__main__":
raise SystemExit(main())
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Install and verify the locked NPU-P0 workload reference assets."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.dependency_lock import archive, source # noqa: E402
from scripts.setup_helpers import atomic_write, download_file, ensure_git_repo, sha256 # noqa: E402
MLCOMMONS_TINY = "apu_mlperf_tiny"
KWS_MFCC = "apu_kws_mfcc"
VWW_DATASET = "npu_vww_dataset"
ORACLE_SOURCES = ("apu_tensorflow", "apu_gemmlowp")
KWS_MODEL = Path("benchmark/training/keyword_spotting/trained_models/kws_ref_model.tflite")
KWS_MODEL_SHA256 = "aeea436800704fce17b17292e4412630ad856e9d777c044c64ef748a880bd0ae"
VWW_MODEL = Path("benchmark/training/visual_wake_words/trained_models/vww_96_int8.tflite")
VWW_MODEL_SHA256 = "597a384c8c2c8a1276f04702f25013b7838f2f814f1ca7c174d295b73e3d6b7b"
VWW_LABELS = Path("benchmark/evaluation/datasets/vww01/y_labels.csv")
KWS_FEATURES = Path("datasets/kws01")
KWS_FEATURE_COUNT = 1000
VWW_CORPUS_COUNT = 1000
VWW_IMAGE_BYTES = 96 * 96 * 3
VWW_CLASS_DIRS = {1: "person", 0: "non_person"}
VWW_CORPUS_TSV = ROOT / "docs/ip/npu-vww-corpus.tsv"
KWS_MANIFEST = ROOT / "docs/ip/npu-kws-manifest.json"
def tiny_root() -> Path:
return ROOT / source(MLCOMMONS_TINY)["destination"]
def mfcc_root() -> Path:
return ROOT / source(KWS_MFCC)["destination"]
def vww_corpus_root() -> Path:
return ROOT / ".cache/retrosoc/sources/npu-vww-corpus"
def vww_label_entries(tiny: Path) -> list[tuple[str, int]]:
labels_path = tiny / VWW_LABELS
if not labels_path.is_file():
raise RuntimeError(f"missing locked VWW label index: {labels_path}")
entries: list[tuple[str, int]] = []
for line in labels_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
name, _classes, label = [part.strip() for part in line.split(",")]
if not name.endswith(".bin") or len(name) != 16 or not name[:12].isdigit():
raise RuntimeError(f"unexpected VWW label entry name: {name}")
entries.append((name, int(label)))
if len(entries) != VWW_CORPUS_COUNT:
raise RuntimeError(f"VWW label index must list {VWW_CORPUS_COUNT} entries: {len(entries)}")
if len({name for name, _ in entries}) != VWW_CORPUS_COUNT:
raise RuntimeError("VWW label index contains duplicate entries")
person = sum(1 for _, label in entries if label == 1)
if person != VWW_CORPUS_COUNT // 2:
raise RuntimeError(f"VWW label index must be 500/500 balanced: person={person}")
if any(label not in VWW_CLASS_DIRS for _, label in entries):
raise RuntimeError("VWW label index carries a label outside {0, 1}")
return entries
def verify_models(tiny: Path) -> dict[str, str]:
records = {}
for relative, expected in ((KWS_MODEL, KWS_MODEL_SHA256), (VWW_MODEL, VWW_MODEL_SHA256)):
model = tiny / relative
if not model.is_file() or sha256(model) != expected:
raise RuntimeError(f"NPU locked model hash mismatch: {model}")
records[relative.as_posix()] = expected
return records
def verify_kws_features(mfcc: Path) -> list[str]:
features = mfcc / KWS_FEATURES
names = sorted(path.name for path in features.glob("tst_*.bin"))
if len(names) != KWS_FEATURE_COUNT:
raise RuntimeError(f"KWS feature set must hold {KWS_FEATURE_COUNT} bins: {len(names)}")
return names
def _extract_vww_jpegs(archive_path: Path, corpus: Path, entries: list[tuple[str, int]]) -> None:
wanted = {}
for name, label in entries:
stem = name[:12]
member = f"vw_coco2014_96/{VWW_CLASS_DIRS[label]}/COCO_val2014_{stem}.jpg"
wanted[member] = corpus / VWW_CLASS_DIRS[label] / f"COCO_val2014_{stem}.jpg"
remaining = dict(wanted)
with tarfile.open(archive_path, "r:gz") as bundle:
for item in bundle:
target = remaining.pop(item.name, None)
if target is None:
continue
if not item.isfile():
raise RuntimeError(f"unexpected non-file member: {item.name}")
payload = bundle.extractfile(item)
if payload is None:
raise RuntimeError(f"unreadable member: {item.name}")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload.read())
if not remaining:
break
if remaining:
missing = sorted(remaining)[:5]
raise RuntimeError(f"VWW dataset misses {len(remaining)} inputs, first: {missing}")
def _decode_vww_bins(corpus: Path, entries: list[tuple[str, int]]) -> str:
try:
from PIL import Image
except ImportError as error:
raise RuntimeError("Pillow is required to decode the VWW corpus") from error
for name, label in entries:
stem = name[:12]
source_jpg = corpus / VWW_CLASS_DIRS[label] / f"COCO_val2014_{stem}.jpg"
target = corpus / VWW_CLASS_DIRS[label] / name
if target.is_file() and target.stat().st_size == VWW_IMAGE_BYTES:
continue
with Image.open(source_jpg) as image:
if image.size != (96, 96) or image.mode != "RGB":
raise RuntimeError(f"unexpected VWW image geometry: {source_jpg}")
target.write_bytes(image.tobytes())
import PIL
return PIL.__version__
def _git_revision(path: Path) -> str:
return subprocess.check_output(
["git", "-C", str(path), "rev-parse", "HEAD"], text=True
).strip()
def install(build_dir: Path, *, update: bool) -> dict[str, object]:
tiny = tiny_root()
mfcc = mfcc_root()
for name in (MLCOMMONS_TINY, KWS_MFCC, *ORACLE_SOURCES):
spec = source(name)
ensure_git_repo(spec["url"], ROOT / spec["destination"], spec["revision"], update=update)
dataset = archive(VWW_DATASET)
archive_path = ROOT / dataset["destination"]
download_file(dataset["url"], archive_path, dataset["sha256"], update=update, timeout=600)
models = verify_models(tiny)
kws_names = verify_kws_features(mfcc)
entries = vww_label_entries(tiny)
corpus = vww_corpus_root()
jpegs = [
corpus / VWW_CLASS_DIRS[label] / f"COCO_val2014_{name[:12]}.jpg" for name, label in entries
]
if update or not all(path.is_file() for path in jpegs):
_extract_vww_jpegs(archive_path, corpus, entries)
pillow_version = _decode_vww_bins(corpus, entries)
records: dict[str, object] = {
"target": "npu-p0",
"sources": {
name: {"revision": source(name)["revision"], "destination": source(name)["destination"]}
for name in (MLCOMMONS_TINY, KWS_MFCC, *ORACLE_SOURCES)
},
"archives": {VWW_DATASET: dataset["sha256"]},
"models": models,
"kws_feature_count": len(kws_names),
"vww_corpus_count": len(entries),
"pillow_version": pillow_version,
}
build_dir.mkdir(parents=True, exist_ok=True)
atomic_write(build_dir / "provenance.json", json.dumps(records, indent=2, sort_keys=True) + "\n")
return records
def doctor() -> None:
tiny = tiny_root()
mfcc = mfcc_root()
for name, path in ((MLCOMMONS_TINY, tiny), (KWS_MFCC, mfcc)):
if not (path / ".git").is_dir():
raise RuntimeError(f"missing NPU managed source: {path}")
revision = _git_revision(path)
if revision != source(name)["revision"]:
raise RuntimeError(f"NPU source revision mismatch for {name}: {revision}")
for name in ORACLE_SOURCES:
path = ROOT / source(name)["destination"]
if not (path / ".git").is_dir() or _git_revision(path) != source(name)["revision"]:
raise RuntimeError(f"missing or stale NPU oracle source: {name}")
if subprocess.check_output(["git", "-C", str(path), "status", "--porcelain"], text=True).strip():
raise RuntimeError(f"dirty NPU oracle source: {name}")
dataset = archive(VWW_DATASET)
archive_path = ROOT / dataset["destination"]
if not archive_path.is_file() or sha256(archive_path) != dataset["sha256"]:
raise RuntimeError(f"NPU VWW dataset archive mismatch: {archive_path}")
verify_models(tiny)
verify_kws_features(mfcc)
entries = vww_label_entries(tiny)
corpus = vww_corpus_root()
for name, label in entries:
stem = name[:12]
for candidate in (
corpus / VWW_CLASS_DIRS[label] / f"COCO_val2014_{stem}.jpg",
corpus / VWW_CLASS_DIRS[label] / name,
):
if not candidate.is_file():
raise RuntimeError(f"missing VWW corpus file: {candidate}")
if KWS_MANIFEST.is_file():
manifest = json.loads(KWS_MANIFEST.read_text(encoding="utf-8"))
inputs = manifest.get("inputs", {})
if len(inputs) != KWS_FEATURE_COUNT:
raise RuntimeError("NPU KWS manifest input count mismatch")
for item in inputs:
feature = mfcc / KWS_FEATURES / item["name"]
if sha256(feature) != item["sha256"]:
raise RuntimeError(f"NPU KWS feature hash mismatch: {item['name']}")
if VWW_CORPUS_TSV.is_file():
for line in VWW_CORPUS_TSV.read_text(encoding="utf-8").splitlines():
index, name, jpg, _label, jpg_sha256, bin_sha256 = line.split("\t")
del index
jpg_path = corpus / jpg
bin_path = jpg_path.parent / name
if sha256(jpg_path) != jpg_sha256:
raise RuntimeError(f"NPU VWW JPEG hash mismatch: {jpg}")
if sha256(bin_path) != bin_sha256:
raise RuntimeError(f"NPU VWW bin hash mismatch: {name}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-dir", type=Path, required=True)
parser.add_argument("--doctor", action="store_true")
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
if args.doctor:
doctor()
print("NPU-P0 locked references verified")
return 0
records = install(args.build_dir.resolve(), update=args.update)
print(f"NPU-P0 KWS/VWW references: {args.build_dir.resolve() / 'provenance.json'}")
print(f"KWS features: {records['kws_feature_count']}, VWW corpus: {records['vww_corpus_count']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())