mirror of
https://github.com/retroSoC/retroSoC.git
synced 2026-11-04 22:32:36 +00:00
Merge branch 'feat/npu' into dev
This commit is contained in:
158 files changed
+48983
-232
No files matched your search
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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, ®ions->descriptor_base) != RS_OK) ||
|
||||
(rs_{prefix}_address(workspace->arena, ®ions->arena_base) != RS_OK) ||
|
||||
(rs_{prefix}_address(rs_{prefix}_npu_weights, ®ions->weights_base) != RS_OK) ||
|
||||
(rs_{prefix}_address(rs_{prefix}_npu_params, ®ions->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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user