Toolkit for CMSIS-NN testing: generate test assets, build for FVP, run tests, and publish coverage.
uv sync
uv run helia_core_tester --helpuv run helia_core_tester generateuv run helia_core_tester builduv run helia_core_tester runuv run helia_core_tester fulluv run helia_core_tester cleanuv run helia_core_tester clean-alluv run helia_core_tester doctoruv run helia_core_tester coverage-mergeuv run helia_core_tester boards/probes list/probes matchuv run helia_core_tester hardware run|build|flash|stream|memory-reportuv run helia_core_tester explain <bundle> [--case ID] [--op OP] [--json]uv run helia_core_tester score <baseline...> --candidate <bundle> --check <check.json>(--no-checkinstead, for humans scoring harness changes;candidate evalruns this for you)uv run helia_core_tester candidate check <tree> --base <sha>,candidate baseline,candidate eval(see Agent loop)uv run helia_core_tester agent-loop init|selftest|launch|status|stop(see Running an agent campaign)
Removed interfaces:
gap-checksubcommand--skip-conversion--skip-runners--regen-generated-tests-after-cleanup- report-dir override flags
--include-float(replaced by--suite floator--suite both)- the
perf-streamcommand group andscripts/run_hardware_perf_suite.sh(replaced byhardware run, see below)
INT32 predicate tests are available through the FVP pipeline with
generate --op Comparison,SelectV2 --dtype s32. They cover all six
comparisons and SELECT_V2, including scalar operands, broadcasting, tails and
signed integer boundaries. S32 references come from LiteRT and use exact output
validation; generation fails if that reference cannot run. These descriptors
require their corresponding arm_*_s32 declarations and are reported as
capability skips with an older Core checkout. This adds generated FVP tests;
hardware streaming adapters for these S32 operators are not added here.
The FVP commands above simulate; hardware runs the generated kernel tests on a
real Ambiq board over SEGGER RTT (one universal hct_benchmark_server firmware,
per-case data streamed from the host). The whole pipeline is one command:
uv run helia_core_tester hardware run --board apollo510_evbThat generates the tests for the board's CPU, builds the firmware, flashes it only
unless the board already confirms (via its TARGET_INFO build id) that it runs this exact
build, streams every bridged case,
writes the result bundle under artifacts/reports/hardware/<session-id>/,
and prints the pass/fail summary (--json prints one JSON document on stdout
instead, with the human output on stderr). Exit codes match score: 0 pass,
1 a case failed correctness, 2 bad flags, 3 refused before running (dirty
tester, a --skip-flash or --golden-from mismatch, no case matches the
selection), 5 error (cmake, J-Link, transport, probe, or a tester bug), 130 interrupted (Ctrl-C). Useful narrowing flags: --suite int|float|both,
--family, --test-name, --limit, --precision fp16|fp32 (float-only shortcut,
not combinable with --suite both or --test-name), --fvp-gate off|advisory|strict,
--op/--dtype (repeatable, matched like generate --op/--dtype; a case's dtype is its
activation dtype, or S4 for s4-weight cases),
--case-id/--cases-from <file> (exact case ids, e.g. a rerun list; not combinable with --limit;
with --suite int or float, --op/--dtype/--case-id also narrow generation; other cases stay in the tree),
--skip-generate, --skip-flash, --force-flash.
Held-out shapes: generate --random-shapes N --shape-seed S draws N cases per
generator: s8 Convolve, s8 and s16 DepthwiseConv, s16, FP32 and FP16
TransposeConv, and s8 Softmax (rs<S>_conv_*, rs<S>_dw_*, rs<S>_dw16_*,
rs<S>_tc16_*, rs<S>_tcf32_*, rs<S>_tcf16_*, rs<S>_sm8_*) instead of the
assets/ descriptors. --op/--dtype
limit the draw to matching generators (e.g. --op Convolve draws only
rs<S>_conv_*, the same cases as an unfiltered draw; --op DepthwiseConv --dtype S16
draws only rs<S>_dw16_*). Every --op token must name a registered op by operator,
descriptor stem or path; case-name prefixes such as rs7_conv are refused,
since drawn names (and hidden ids) cannot be filtered. Each op draws
from its own seeded stream, cycling through every wrapper route and sized to fit
the smallest board workspace. The descriptors, the drawn ops (ops) and a per-route count land in
artifacts/random_shapes/s<S>/<cpu>/; the cases join the generated tree beside
the fixed ones. Run them with
hardware run --skip-generate --test-name rs<S>_. A draw with a flat golden is
dropped and counted as skipped_degenerate. To add an op, register its route
list and layer sampler in GENERATORS (generation/random_shapes.py), keyed by
(op, dtype), with a new stream id and tag, and teach the op-specific helpers
(layer_route, footprint, layer_macs, _relu6_gain, _descriptor) its layout.
An op without layers sets layerless and draws whole case bodies (Softmax: _sm_draw).
Hidden shapes: generate --random-shapes N --hidden-dir DIR draws the same
kind of cases from a secret seed instead (env HCT_HIDDEN_SEED, or
--hidden-seed-file F; 16+ characters, e.g. python -c "import secrets; print(secrets.token_hex(16))"). DIR and
F must sit outside the tester tree. DIR mirrors a tester root: descriptors in
DIR/artifacts/random_shapes/<cpu>/, cases in DIR/artifacts/generated_tests/,
reports in DIR/artifacts/reports/. Case ids are opaque keyed hashes
(h<12 hex>), and summaries record only seed_commitment, a SHA-256 of the secret.
hardware run --hidden-set DIR adds every DIR case for the board's CPU to
the run, whatever the other filters; it needs only DIR, not the secret. Pass the
same DIR to baseline and candidate runs. The bundle marks them with a hidden
column (true/false) in case_summary.csv and cases.json, and records
selection.hidden_set (seed_commitment, cases) in session_summary.json. A DIR
with no set for the board's CPU, or with a case that cannot run, is refused.
Correctness: int cases use the per-operator LSB tolerance in
generation/io/dtypes.py, and every case records max_abs_diff and diff_count
(elements differing at all) in case_summary.csv and cases.json; both are null
when unknown (unvalidated outputs, wrong output size).
--strict-compare drops the tolerance so int outputs must match the golden
exactly. --golden-from <bundle dir> judges each int case against that past
run's outputs/ instead, bit for bit, which pins the current kernels' rounding
where the TFLite golden differs by design. Float cases (FP32/FP16 outputs, float
compare mode) keep the generated TFLite golden and their tolerance, so a
candidate's error never stacks on the baseline's; under --golden-from they
also fail when the error spends more than 0.8 of the tolerance (the headroom
rule in assets/descriptors/ConvolutionFunctions/convolve_float.yaml). Every
float case records tolerance_fraction, the worst
|out - ref| / (atol + rtol * |ref|), in case_summary.csv, cases.json and
correctness/<case>.json; the nightly run records it without the 0.8 gate.
--golden-from is the compare mode for kernel changes: run the baseline
kernels once, then judge the changed kernels against that bundle. Strict
compare against the generated goldens does not work there, because 40 of 131
s8 conv/depthwise cases differ from the TFLite goldens by 1 LSB on apollo510
(ns-cmsis-nn v7.39.3; issue #220).
Every bundle records a per-case input_digest in cases.json and
correctness/<case>.json (also in each case_manifest.json): the sha256 of
everything streamed to the board except the expected output (input, weight,
bias and quant blobs, scalar parameters, kernel id). Before flashing,
--golden-from refuses a case that the baseline lacks, that the baseline run
failed (unless --golden-allow-failed, which also skips the next check), that
is a float case whose baseline tolerance_fraction is over 0.8 (no candidate
could pass it), whose baseline input_digest differs
or is absent (bundles from before this field must be rerun), or whose output is
missing or wrong-sized. The session manifest's compare block records which
mode ran, plus the baseline path (golden_from), golden_session_id and
goldens (case ids per golden: baseline, generator or status); each
case's expected_output_sha256 is the digest of the output it was compared
against. The steps are also available individually as
hardware build, hardware flash [--force], hardware stream and
hardware memory-report.
The firmware builds as a neuralspotx (NSX) app rendered into
build/hardware/<board>/nsx_app. Which kernels it builds:
- Nested layout (the tester at
ns-cmsis-nn/Tests/helia-core-tester): the enclosing ns-cmsis-nn checkout, working-tree edits included. - Standalone clone: the pinned ns-cmsis-nn release (
v7.47.0). --cmsis-nn-ref REFbuilds another tag or commit;--cmsis-nn-root PATHbuilds another local checkout.
Edit kernels in the checkout. Every build recopies a local checkout's
Include/, Source/ and cmake/ trees, plus nsx/nsx-module.yaml and
nsx/CMakeLists.txt, into nsx_app/modules/nsx-cmsis-nn, so edits made
there are overwritten. Other files under nsx/ are not used.
hardware run generates the tests from the same kernel tree: the local
checkout, or for a ref the clone NSX syncs into nsx_app/modules/ns-cmsis-nn.
--no-inline-asm builds requantize without inline assembly (--inline-asm
turns it back on); --update-dependencies re-resolves the NSX modules into
nsx.lock. Each build saves its kernel options in nsx_app/.hct-options.json.
hardware build, flash and run reuse the saved kernel source
(--cmsis-nn-root or --cmsis-nn-ref) when you leave it out. Switches
(--inline-asm, --placement) do not persist: an unpassed switch builds its
default, so pass the same switches to build, flash and run. An option that
differs from the saved build rebuilds and prints one line naming the change.
--skip-flash keeps the flashed build's options and refuses a different flag. A
saved ref you did not pass with --cmsis-nn-ref follows the pinned release, so
a pin bump rebuilds those build dirs the same way. Every build, flash, run and
stream prints the kernel source, inline asm setting, placement and toolchain.
--toolchain atfe builds with Arm Toolchain for Embedded clang (from
$ATFE_ROOT/bin) instead of gcc, the default. Like placement it is a switch.
Each toolchain has its own default build dir (build/hardware/<board>-atfe for
atfe), and a build dir whose CMake cache holds the other compiler drops that
cache and reconfigures. The bundle records the compiler in
session_manifest.json build.toolchain.
--placement picks where operands live. tcm (the default) keeps every operand
in one workspace: DTCM on Apollo510 and Apollo330P, and SRAM (RWMEM) on
Apollo3P, whose 64 KiB TCM can't hold it. Neither path touches a D-cache, so it
measures kernel-only cost. mram (Apollo510, Apollo330P)
programs each case's weights and bias into a reserved MRAM pool past the image
and evicts them from the D-cache before every timed call, as a large model's
layer sees them; activations, scratch, multipliers and shifts stay in DTCM, as
hpx places a TCM-sized arena. The packed fully-connected kernel reads a stream
packed from its weights, so that stream is packed on the device once per case,
before any timing, and placed in the pool after the bias; the per-call pack
still runs, so prepare_cycles matches tcm. Neither programming nor eviction counts toward
the timed cycles or prepare_cycles. Give each placement its own --build-dir. The
bundle records it in session_manifest.json target.placement.
On boards with blob_store_bytes in assets/hardware_boards.yaml (apollo510_evb),
the firmware caches every streamed blob in MRAM and skips blobs it already holds,
across runs, placements and reflashes; the timing line reports store hits.
HCT_BLOB_STORE=0 turns it off. See docs/performance-streaming-design.md.
PMU counters are selected with --pmu-counters GROUP:SELECTION (repeatable, on
hardware run and hardware stream; hpx syntax). GROUP is cpu, memory or
mve; SELECTION is all, default, or a comma-separated list of ARM_PMU_*
names from assets/pmu/armv8m_pmu_events.json:
uv run helia_core_tester hardware run --board apollo510_evb --family ConvolutionFunctions \
--pmu-counters mve:all --pmu-counters cpu:default
uv run helia_core_tester hardware stream --pmu-counters mve:ARM_PMU_MVE_STALL,ARM_PMU_MVE_PREDThe default is every group at its default selection. Each group runs in passes of
up to four chained 32-bit event counters (the Cortex-M55 PMU has eight 16-bit slots),
so mve:all costs nine passes per case. One run takes up to 32 passes (the firmware's
HCT_SERVER_MAX_PASSES), so the full catalog (--pmu-counters all, i.e.
cpu:all memory:all mve:all, 18 passes) fits one run and one bundle.
ARM_PMU_CPU_CYCLES is always reported from the PMU cycle counter alongside the DWT
cycles. case_summary.csv gets one column per counter (median per invocation) plus
overflow_detected, valid_for_regression and timing_status. valid_for_regression
is true only when the output matched and timing_status is valid or
degenerate_output; the other statuses are error_path (expects any non-success status, such as an argument or no-impl error), overflow,
zero_cycles, below_floor (median under 3x the board's empty-call floor, which
session_manifest.json records as timing_floor). degenerate_output is
informational only: the golden is constant, has at most two values, or is at
least 90 % saturated, which weakens the correctness check, not the cycles.
Goldens under 8 elements are never judged, BOOL goldens are degenerate only when
constant, and a descriptor's degenerate_golden_reason marks the shape as intended.
session_summary.json records the passes, counters and per-stage/per-case timing.
--pmu-groups a,b still works as a deprecated alias for --pmu-counters a:default --pmu-counters b:default.
Identity resolution rules:
--boardis the only identity flag. The CPU, NSX board name, SEGGER device name, SWD speed, build dir (build/hardware/<board>), default session id (<board>-<UTC timestamp>) and the linker-script SoC and flash/RAM region nameshardware memory-reportmeasures against all come from the row inassets/hardware_boards.yaml(helia_core_tester boardslists it). Default:$HPX_BOARD, elseapollo510_evb.- DWT-only boards (
pmu_tier: dwt, such as the Cortex-M4apollo3p_evb) report DWT cycles only: the counter default becomes cycles only and event counters are refused. Cortex-M4 runs the int suite and FP32; FP16 cases are skipped and--precision fp16is refused. - The apollo330mP_evb J-Link device
Apollo330P_510Lis an Ambiq-supplied entry, not in SEGGER's stock database; install it under~/.config/SEGGER/JLinkDevices/(NSX flashes through it too). - Session sizing comes from the target: every RTT session starts with the firmware's
TARGET_INFO(cases and PMU passes per plan, receive-buffer bytes, PMU width) and the host batches the bridged cases from it, so a board with different firmware limits needs no host change. --serial-nois optional: the flag wins, then$HPX_JLINK_SERIAL, then the connected J-Link probes enumerated through pylink. Exactly one connected probe is used as-is; zero or several is an error naming what was found.helia_core_tester probes listshows the probes,probes match --board Bprints the serial the hardware commands would pick.- The J-Link shared library pylink loads is resolved from
$HPX_JLINK_DLL(the library file), then$JLINK_PATH(theJLinkExebinary or its directory), then the directory ofJLinkExeon PATH, then pylink's own search (ldconfig,/opt/SEGGER). These are the same variables the lab runners export for hpx;helia_core_tester doctorprints which one resolved the library.
Run one selection on several boards and get one summary:
uv run python -m helia_core_tester.scripts.board_matrix run \
--board apollo510_evb:1160003180 --board apollo330mP_evb:1160003409 \
--suite int --limit 2 --pmu-counters mve:default [--family F] [--precision fp16|fp32] \
[-- <hardware run args>]
uv run python -m helia_core_tester.scripts.board_matrix summarize <bundle>... --out <dir>Each board gets its own hardware run and its usual result bundle. Boards run in
parallel when every --board names a distinct probe serial, else one at a time
(--sequential forces that). A DWT-only board runs its default counters when
--pmu-counters asks for events, and its summary row says so. Both commands write
board_matrix.json (schema hct.hardware.board_matrix v1: per-board status,
golden passed/failed/rejected, boot status and clock, build id, kernel source,
bundle; per shared case, median_cycles and ARM_PMU_MVE_INST_RETIRED per board)
and board_matrix.md into artifacts/reports/hardware/matrix-<UTC stamp> (or
--out), and exit 1 unless every board passed (2 for bad input, a
matrix-owned option such as --build-dir after --, or an unreadable bundle). Concurrent runs from one checkout
stage their cases under artifacts/stream_cases/<suite>/<board> and take turns
generating into a shared CPU tree.
helia_core_tester doctor reports the hardware toolchain (arm-none-eabi-gcc,
cmake, ninja, the neuralspotx version, the J-Link library) as
informational checks; missing hardware tools do not fail doctor.
.github/workflows/hardware-nightly.yml runs hardware run --suite both over
the full catalog every night at 05:00 UTC on the lab runners, one job per board
(default apollo510_evb, apollo330mP_evb, apollo3p_evb; --pmu-counters all
on PMU boards), and uploads each board's result bundle with the --json
document as hardware-nightly-run.json (schemas and version policy:
docs/performance-streaming-design.md, "Result bundle").
Under the same board lock each job then runs an MRAM-placement leg (MRAM
boards; hct-mram-<board>-...) and a kernel leg built from ns-cmsis-nn main,
resolved to one SHA at plan time (hct-main-<board>-..., session
nightly-main-<run>-<board>). -f cmsis_nn_ref=<branch, tag or full SHA> swaps the
ref; any ref but main uploads as hct-ref-<board>-.... The kernel leg does not
gate the run: its failures show only in the summary table. A last leg builds
the TCM suite with --toolchain atfe (pinned kernels, $ATFE_ROOT from the
runner) and uploads hct-atfe-<board>-... in the hct-hardware-* layout; it
does not gate the run either.
The summary job also lists, per board, the TCM leg's skipped cases per family
and its untimed deployed entry points (scripts/nightly_skip_report.py). Each
board is compared with the newest of the last three non-cancelled scheduled runs
on main that has a readable result with the same selection (limit, suite,
...); nightly_skip_report.py select-baselines pages the run history until it
has three earlier runs (also on a rerun), at most 10 pages. A family whose skip count grew, or a newly untimed entry point, gets a
workflow warning, as does a board whose growth went unchecked: no current result,
an unreadable result, or no baseline because the earlier runs left no result.
gh workflow run hardware-nightly.yml -f boards=apollo510_evb -f limit=2 runs
it by hand.
helia_core_tester explain <bundle> reads a hardware bundle (or a directory of
bundles) and prints at most four lines per MAC case: cycles/MAC against a ceiling
from assets/scoring/ceilings.yaml, where the cycles go (IPC, MVE share, MVE MAC
instructions against the ideal count, stall shares, L1D refills, prepare share), a
diagnosis and up to three ranked hints. --op takes conv, depthwise or fc. --json emits schema hct.pmu_explain
v3: bundles (board, cpu, placement per bundle) and one flat cases list,
each case naming its bundle and its pct_of_peak (0-100); pmu_explain.explain_case gives the same result for one case row. Rules live
in helia_core_tester/hardware/pmu_explain.py (_rule_*); each ranks by the share
of cycles it explains. Cycles-only bundles (Cortex-M4) get % of peak only.
The nightly captures the full catalog (all, 18 passes). For a tuning loop, request
only what the rules read (4 passes, pmu_explain.AGENT_PMU_SELECTION):
--pmu-counters cpu:ARM_PMU_INST_RETIRED,ARM_PMU_STALL_FRONTEND,ARM_PMU_STALL_BACKEND
--pmu-counters memory:ARM_PMU_L1D_CACHE_REFILL
--pmu-counters mve:ARM_PMU_MVE_INST_RETIRED,ARM_PMU_MVE_INT_MAC_RETIRED,ARM_PMU_MVE_FP_MAC_RETIRED,ARM_PMU_MVE_PRED,ARM_PMU_MVE_STALL_RESOURCE_MEM,ARM_PMU_MVE_STALL_DEPENDENCY
An optimization agent edits ns-cmsis-nn kernels. It submits each candidate with one command and gets one JSON verdict back. Everything trusted (this tester, the baseline bundles, hidden cases and their seed) stays outside the agent's view.
- The agent runs in a sandbox where only its own ns-cmsis-nn worktree is writable. It cannot read this tester tree, the baseline dir, the hidden set, its seed file, result bundles or logs.
- The evaluator runs
candidate evalas the human user, outside the sandbox, and hands the agent only stdout and the exit code.-
On the bench host, wrap it in
bench-agent run. The command runs in$HOME, so use absolute paths:bench-agent run apollo510_evb --reason <sha> -- timeout 30m \ uv --directory /abs/helia-core-tester run helia_core_tester \ candidate eval --kernels /abs/agent-tree --baseline /abs/baseline
-
From elsewhere, use the
hct-runclient from nixos-config (itscandidate evalmode is a follow-up).
-
candidate evalreads the agent's tree as plain files and never runs git in it. It copiesSource/,Include/,cmake/andnsx/into a fresh checkout of the base commit, then checks and builds that copy. Edits made after the copy, and anything in the agent's.git, do not reach the build. The copy walks by directory handle and never follows a symlink: symlinks are copied as links, and the check rejects them. FIFOs, sockets and devices are skipped. The copy refuses past 4 MiB per file (sparse files count at their full size), 64 MiB copied in total, 5000 files and dirs, or 64 levels of dirs; the real trees are about 4.4 MiB in 448 files. Bytes count as they are read, so a file that grows mid-copy is caught.--max-file-bytes,--max-total-bytesand--max-fileschange the limits.hardware runstderr names every case, so it goes to<baseline>/logs/, never to the agent.- Hidden case ids never print. Hidden cases still count in the verdict and in the family totals.
- The tester must be committed:
candidate baselineandcandidate evalrefuse a dirty or unknown tester (exit 3, stagetester) before any check or run, with no opt-out. - Run one eval at a time per baseline dir: each eval rebuilds
<baseline>/snapshot.
-
Baseline, once per base commit, by the human. Use a clean checkout at that commit:
uv run helia_core_tester candidate baseline --kernels ~/ns-cmsis-nn \ --board apollo510_evb --out ~/hct-eval/dw-s8 --repeats 3 \ --op DepthwiseConv --dtype S8
- The first run generates, builds, flashes and streams. The other runs stream the same build again, so the scorer gets a noise band.
- Every eval reuses the baseline's placement and inline asm (defaults
tcmand on), the agent PMU counters (see PMU feedback; none on DWT boards) and--fvp-gate off. --outreceives the bundles,baseline.json(base commit and run options),code_graph.json(a digest and references per kernel function and data object, from the built objects),kernels.git(the base commit, fetched from the clean tree) andlogs/.- Hidden cases (
--hidden-set DIR, made bygenerate --hidden-dir DIR --hidden-seed-file F) need the hidden-set PRs; until they merge, the flag fails the first run. Keep DIR and F outside the sandbox.
-
Candidate, as often as needed, one at a time. The agent edits
Source/andInclude/in its worktree, and the evaluator runs:uv run helia_core_tester candidate eval --kernels <agent worktree> \ --baseline ~/hct-eval/dw-s8
It does five things:
- Snapshot the agent's trees.
- Run
candidate checkagainst the base commit. - Run
hardware runwith the baseline's options,--golden-fromits first run (bit-exact) and--skip-generate. - Run
scoreagainst every baseline repeat. - Print the verdict.
On apollo330mP, 50 DW s8 cases took about 1 minute per eval and the two-repeat baseline about 2 minutes (measured).
The verdict (schema hct.candidate_eval v3) has these fields:
verdict,exit_code, andstage:tester(dirty tester),baseline(unusable baseline dir),check,run,objects(oncecandidate checkscans built objects),score, orevalfor an unexpected error.findings: the check's findings, on rejection.score,familiesandfailures. Prepare cycles move with code layout, soprepare_regressionfires only when they go missing, passprepare_max_ratiotimes the baseline, or grow enough to pay for the case's timed gain (prepare_share_pctof the cycles saved; seeassets/scoring/noise_floors.yaml). Under case gate scopetouched, untouched cases fail only on missing prepare cycles.cases: one entry per public case, with cycles, speedup, delta, noise band andtouched: whether code reachable from the case's inner symbol, or its timed symbol itself, changed in the built objects.case_gate:{scope, reason}. Code layout moves untouched kernels by a few percent, so with scopetouchedonly touched cases face the case and family regression gates and count in the score and family geomeans. Untouched drift shows per family asuntouched_casesanduntouched_geomeanand never fails; correctness gates still cover every case. Scopeall(with a reason) when the baseline predatescode_graph.jsonor the candidate objects are unreadable.hints:pct_of_peak(0-100), a diagnosis and ranked hints for each public MAC case (fromexplain).hidden: null without hidden cases; otherwise case, touched and failure counts, plus the scorer's hiddensubscoreswhen it reports them.failedlists each hidden failure as{kind, symbol, via, touched, count}: the inner kernel, the timed symbol when it differs, and how many cases share the entry. It never names a case, shape or cycle count.
| Exit | Verdict | Meaning |
|---|---|---|
| 0 | pass |
Correct, no regression, score above --min-score |
| 1 | fail |
Output mismatch, regression, lost timing or changed inputs |
| 2 | Bad flags, such as a non-finite --min-score or a file as --out |
|
| 3 | rejected |
The diff leaves Source//Include/ or uses a banned construct |
| 3 | refused / not_comparable |
Bad baseline dir, oversized candidate, dirty tester, golden misfit, moved cases, other build |
| 4 | no_gain |
Correct but not faster |
| 5 | error |
Build, board, transport or tester error; see <baseline>/logs/ |
candidate evalalways passes--skip-generate. The baseline generated the cases into this tester'sartifacts/generated_tests, and--golden-fromrefuses any case whose inputs changed.- Do not regenerate between the baseline and its evals, for example with a
plain
hardware runon the same tester. A baseline case that disappears makes eval refuse (missing_case, exit 3); reruncandidate baseline.
agent-loop wraps the agent loop into one campaign: a
headless Claude agent edits one kernel family in its own ns-cmsis-nn tree,
and three wrapper scripts are its only way to build, disassemble and run
on a board. Every eval runs candidate eval once per leg (tcm, mram)
and appends a row to the campaign ledger.
Needs, on the bench host: uv, git, patch, bench-agent, the
claude CLI (logged in), the Arm toolchain (arm-none-eabi-size,
arm-none-eabi-objdump), and a committed tester checkout.
Start from assets/campaigns/conv-s8.example.yaml, dw-s8.example.yaml, dw-s16.example.yaml
or softmax-s8.example.yaml:
name: conv-s8 # lowercase, digits, dashes
board: apollo510_evb # tester board id
bench_id: apollo510_evb # bench-agent board id (default: board)
legs: [tcm, mram] # default: both when the board has MRAM
toolchains: [gcc] # or [gcc, atfe]; atfe needs ATFE_ROOT
target:
op: Convolve # `hardware run --op`
dtype: S8 # `hardware run --dtype`
case_ids: [] # optional `--case-id` filters
kernels:
repo: ~/ns-cmsis-nn # any checkout that has the ref
ref: v7.47.0 # tag, branch or SHA
evals: 12 # charged board evals
cost_usd: 25 # claude --max-budget-usd
model: claude-opus-5-5
hidden_shapes: 12 # random target-op shapes; 0 for none
repeats: 3 # baseline runs per leg
secrets_dir: ~/hct-secrets/conv-s8 # outside the workspace
# start_patch: ~/campaigns/conv-s8-1/ledger/007.diff
# start_notes: |
# - 1x1 path vectorized; generic path untouched.Optional: min_score (passed to candidate eval), lock_timeout_s
(board lock wait, default 600), eval_timeout_s (per leg, default 300),
submit_deadline_s (whole submit, default 540, at most 570, under the
agent's 10 minute Bash limit), retries (per leg, default 1) and max_infra_errors (busy or
failing board results in a row, default 5) and size_evals (the last evals,
which go to cutting code size once something passed; default a third of
evals, below evals, 0 for none). The hidden set holds only the
target op and dtype. Hidden shapes exist for Convolve S8, DepthwiseConv
S8 and S16, TransposeConv S16, FP32 and FP16, NNActivationFloat FP32 and
FP16, and Softmax S8 only; set
hidden_shapes: 0 for other targets. Ops without MACs (Softmax) report and
score cycles per op (one per element) instead of cycles per MAC.
With toolchains: [gcc, atfe] every placement runs once per compiler:
gcc legs keep their names (tcm, mram), atfe legs are tcm-atfe and
mram-atfe. Each atfe leg has its own baseline, build dir and size
reference (W/size-ref-atfe.json), and pass needs every leg.
agent-loop validate FILE checks a file without side effects.
uv run helia_core_tester agent-loop init conv-s8.yaml -w ~/campaigns/conv-s8init refuses a dirty tester. It then:
- Adds a detached worktree of this tester's HEAD at
W/tester, and linksartifacts/downloadsto save a toolchain download. Every later command runs from that worktree, so the campaign stays on one tester commit while you keep working here. - Clones
W/base(clean) andW/agent(taggedbase) as standalone one-commit repos with no remote, so git commands in the agent's tree cannot reach other repos.start_patch(a ledger diff, or any unified diff ofSource//Include/) is applied toW/agent. It is parsed strictly: every header pair must name a path underSource/orInclude/, and any line outside a counted hunk is refused. - Writes a fresh secret seed and hidden set in
secrets_dir(mode 0700, seed 0600). The dir must be new or empty. The workspace records that it owns the dir, and only that workspace's rerun reuses its seed. - Records one
candidate baselineper leg underbench-agent run. - Builds the base kernel library once per toolchain and saves per-object
code sizes (
W/size-ref.json,W/size-ref-atfe.json). It records each compiler's version; a baseline recorded with another version is re-recorded, and submit refuses, uncharged, once a compiler changes. - Renders
W/prompt.mdfrom a template filled with the routes seen in the first leg's baseline (timed and inner symbols, median and best cycles per MAC) and the ceiling fromassets/scoring/ceilings.yaml. WritesW/agent-settings.json(absolute paths) andW/bin/{submit,check,disasm}.
Init saves the campaign first, skips any step whose output exists, and
marks the workspace ready at the end; the other commands refuse a
workspace that is not ready. After a failure rerun the same command (a rerun reuses the campaign's
seed). To change the campaign file, start a new workspace. On apollo330mP, a DepthwiseConv S8 campaign (50 public and 24
hidden cases, repeats: 2) took about 6 minutes to initialize, and each
submit about 3.5 minutes for both legs (measured).
The baselines are tied to the tester commit through the harness digest:
moving W/tester to another commit makes every eval not_comparable.
uv run helia_core_tester agent-loop selftest -w ~/campaigns/conv-s8A cheap model (--model haiku, no saved session, $1 cap) tries a fixed
list of tool calls. It should be allowed to read the agent tree, write
Source/, and run bin/disasm. It should be denied ledger, campaign,
baseline, tester and secrets reads, writes outside Source//Include/,
and cat, touch, curl and git outside the agent tree in the shell.
Exit 0 when every call matches.
uv run helia_core_tester agent-loop launch -w ~/campaigns/conv-s8
uv run helia_core_tester agent-loop status -w ~/campaigns/conv-s8 [--tail 20] [--json]
uv run helia_core_tester agent-loop stop -w ~/campaigns/conv-s8launchstartsclaude -pinW/agentin its own process session with--permission-mode dontAsk, only the Read, Edit, Write, Glob, Grep and Bash tools,--setting-sources project,--strict-mcp-config,--max-budget-usd cost_usdand a fixed--session-id. Each run writes its own stream log underW/logs/;W/agent-run.jsonholds the real claude pid, the session id and the logs.launch --resumecontinues that session with the same flags and caps it atcost_usdminus the cost of the finished runs. A run killed before itsresultevent counts as $0, so set the cap with margin.statusprints the ledger, whether the pid is alive, recent tool calls from the newest log, its cost and turns once it has aresultevent, and the cost of all finished runs.stopsends SIGTERM to the agent's process group.
W/bin/check: copies the agent'sSource/,Include/,cmake/andnsx/into a fresh clone of the base, runscandidate check, builds the kernels withhardware buildand reports code size against the base, once per toolchain. No board, no eval.W/bin/disasm FN [--toolchain gcc|atfe]: one function from the last check build, up to 600 lines (llvm-objdumpfrom$ATFE_ROOT/binfor atfe).W/bin/submit: stages a copy of the agent's trees inW/submit/treeand runs check on it. A tree that fails, or a check that runs past the deadline, is rejected and costs no eval. Every leg then judges that frozen copy, so edits made during a submit wait for the next one. Each leg runs underbench-agent run --timeoutandtimeout, both cut to fitsubmit_deadline_s. Once a leg ends below stagescoreor with a verdict worse thanno_gain, later legs are skipped. Anmramleg runs only after thetcmleg on the same toolchain passes; other toolchains still run. Skipped legs show as{"verdict": "skipped", "reason": ...}in the view and the ledger. The overall verdict is the worst leg that ran, andpassneeds every leg.- A submit that cannot take the submit lock in time (another submit is
running) returns a free
errorand a ledger row without an id. - No verdict at all (board busy past the lock wait, bench-agent or
tester failure,
refusedat stagetesterorbaseline) is retried, then recorded withinfra: true. It costs no eval, and the view hides any earlier leg's scores. Aftermax_infra_errorssuch submits in a row, submit tells the agent to stop. - Verdict
errorfromcandidate eval(often a kernel fault) is retried once, then charged, even when the retry finds the board busy. A leg killed bytimeout(a hang) is charged without a retry. - The printed view keeps touched case rows only, a count and speedup
range for untouched cases, and hints for the ten slowest touched cases
(first leg only). The same text goes to
W/agent-results/NNN.json, since Claude Code truncates long Bash output. - Exit codes follow
candidate eval; 6 means the budget is spent.
- A submit that cannot take the submit lock in time (another submit is
running) returns a free
W/ledger/ holds, per eval NNN: the diff of the judged copy (NNN.diff),
each leg attempt's full verdict (NNN.<leg>.<attempt>.json, may name hidden cases' kernels
but never their shapes) and stderr. ledger.jsonl has one row per submit
with verdict, charged and infra flags, per-family geomeans and code size
delta. Ids come from a counter under the submit lock. To continue from the
best eval, start a new campaign with start_patch: W/ledger/NNN.diff and
a few lines of start_notes.
- The permission rules are not an OS sandbox. The agent runs as you, and
dontAskdenies only what Claude Code recognizes. Read-only shell commands inside the agent tree (cwd) are auto-allowed, and so is git in that tree; the standalone clone keeps them from reaching other repos. - Deny rules cover the workspace's trusted entries,
secrets_dir, the source ns-cmsis-nn and tester checkouts, and~/.claude. Paths outside these are not listed. Do not run a campaign on a host with secrets you would not show the agent. - The trusted side runs as you too.
candidate evalcopies the agent's trees into a fresh checkout and never runs git in them, but a compiled kernel still runs on the board. bench-agentserializes board use by flock; it does not reserve a board for the whole campaign.
Run integer-only (default):
uv run helia_core_tester full --cpu cortex-m0,cortex-m4,cortex-m55 --suite intRun float-only:
uv run helia_core_tester full --cpu cortex-m4,cortex-m55 --suite float --float-precision bothRun both suites as separate flows:
uv run helia_core_tester full --cpu cortex-m0,cortex-m4,cortex-m55 --suite bothhelia_core_tester/scripts/setup_dependencies.py installs one Arm GNU toolchain release into artifacts/downloads/arm_gcc_download; the default is 14.3.rel1.
--gcc-version 13.2.rel1(orHELIA_GCC_VERSION=13.2.rel1; flag wins over env) selects the release. Case-insensitive, must match<major>.<minor>.rel<N>.full/build/runcall the script with the inherited environment, so exportingHELIA_GCC_VERSIONbefore them is enough.- SHA-256 pins ship for 13.2, 13.3, 14.2, 14.3, 15.2 and 15.3.rel1 (both x86_64 and aarch64). Releases up to 15.2.rel1 download from
developer.arm.com; 15.3.rel1 and later come from Arm's GitLab package registry (gitlab.arm.com), where Arm moved them. For any other release the digest comes from--gcc-sha256 <hex>/HELIA_GCC_SHA256, or, failing that, from Arm's published<archive>.sha256ascsidecar (same origin as the archive, so it guards against corruption but is not an independent pin; the script says so when it does this). - The installed release is recorded in
arm_gcc_download/.hct-arm-gcc-version. An install of another release, or one with no marker, is replaced: the new release is downloaded, verified and extracted next to it first, so a failed download leaves the old install in place.--forcereinstalls the same release.
uv run python -m helia_core_tester.scripts.setup_dependencies --gcc-version 13.2.rel1
HELIA_GCC_VERSION=15.2.rel1 uv run helia_core_tester full --cpu cortex-m55setup_dependencies.py fetches CMSIS-5 and ethos-u-core-platform at the fixed commits listed in SOURCE_PINS in that script and checks git rev-parse HEAD after checkout. Run directly, it moves an existing clean checkout at another commit to the pin. It rejects a checkout with local changes (edits, untracked files, or assume-unchanged/skip-worktree entries) or a directory that is not a git checkout; --force replaces it, and the old checkout is restored if the replacement fails. The setup that build, run and full start enforces the pins the same way, so a run fails on a modified checkout until it is cleaned or replaced. Run directly with --source-pins warn, setup fetches missing sources at the pin but only reports an existing checkout that is off its pin. Bumping a pin means editing SOURCE_PINS.
build, run and full take --toolchain gcc|armclang (default gcc; also toolchain in helia_core_tester.toml and HELIA_CORE_TESTER_TOOLCHAIN). armclang selects ethos-u-core-platform's armclang.cmake and needs Arm Compiler 6 (armclang) on PATH; it is not fetched by setup_dependencies.py. Build output goes to artifacts/build-<suite>-<cpu>-arm-compiler/. --toolchain armclang is mutually exclusive with --coverage (gcov instrumentation is GCC-only). doctor reports which of arm-none-eabi-gcc / armclang it finds.
The FVP build compiles the ns-cmsis-nn checkout two levels up (../.., the nested layout) by default. generate, build and full take --cmsis-nn-root <path> to use another checkout; the environment form is HELIA_CORE_TESTER_CMSIS_NN_ROOT (also cmsis_nn_root in helia_core_tester.toml). A bare CMSIS_NN_ROOT environment variable does not reach the build. A path with no CMakeLists.txt fails before CMake runs.
Generated tests:
artifacts/generated_tests/<suite>/<cpu>/manifest.jsonartifacts/generated_tests/<suite>/<cpu>/tests.cmakeartifacts/generated_tests/<suite>/<cpu>/<Family>/<descriptor_name>/...
Build outputs:
artifacts/build-<suite>-<cpu>-<compiler>/tests/<Family>/<descriptor_name>.elf
Reports:
- generation:
artifacts/reports/generation/<suite>/<cpu>/ - test execution:
artifacts/reports/tests/<suite>/<cpu>/ - per-suite CPU coverage:
artifacts/reports/coverage/<suite>/<cpu>/ - merged coverage:
artifacts/reports/coverage/merged/
Generation report files (always emitted):
generation_summary.jsongeneration_failures.jsonconversion_failures.jsonmanifest_pointer.jsoncapability_skips.json
Use tensor_dtypes for new float-aware descriptors. Legacy activation_dtype and weight_dtype
still work, but the loader now normalizes both styles into resolved_tensor_dtypes and
resolved_comparison.
Every descriptor document read from YAML (load_descriptor / load_all_descriptors), and every
base+variation merge in it, is validated against
helia_core_tester/generation/descriptors/schema.json; a violation fails the load with the
descriptor name, file and JSON path. Descriptors built as dicts in code skip the schema and rely on
the loader's and operators' own checks. Every document needs a name. An unusable schema raises
DescriptorSchemaError once, even with best_effort=True. The operator and required_capabilities
enums are taken from the op catalog and known_capabilities() when the validator is built.
Integer-suite descriptors (no suite: float and no _float file stem) must give
act_min/act_max as integers.
Example:
name: quantize_fp32_to_s8_basic
operator: Quantize
tensor_dtypes:
input: FP32
output: S8
input_shape: [1, 4]Reference ops for float infrastructure:
Quantizeis the source of truth forFP32 -> S8/S16Dequantizeis the source of truth forS8/S16 -> FP32Dequantizewithentry: arm_dequantize_f16_bits_f32widens binary16 bit patterns and checks every output bit against the NaN rule its build compiles. Tag the inputFP16to run it on the f16 legs orU16(binary16 storage, accepted for this entry only) to run it on the f32 legs. A case holds 1 to 65536 halves: the special classes, random patterns, then three NaNs so the vector and scalar tails meet the NaN rule. Keys its path would not use are refused.
Future ops should consume resolved tensor roles rather than raw legacy dtype fields:
self.tensor_dtype("input")self.tensor_c_type("output")self.tensor_litert_dtype("input")self.comparison_config()
A float-suite descriptor can set input_mode: nonfinite_sweep to overwrite the leading flat
elements of its input with non-finite tokens, leaving the remaining elements as ordinary uniform
draws. nonfinite_tokens names the tokens and their order; it defaults to [nan, inf, -inf].
Naming a subset is how a descriptor stays inside what ns-cmsis-nn guarantees: sigmoid declares NaN
unsupported and the MVE tanh legs destroy it by design, so those descriptors sweep [inf, -inf]
only. Expected outputs come from the same reference path the descriptor already uses, so
propagation cases (NaN in, NaN out) and clamping cases (tanh(+Inf) is 1, relu6(+Inf) is 6) are
both expressed as normal goldens; the clamping ones are finite and compare under ordinary
tolerance. Serialized arrays carry the C99 NAN and INFINITY macros. In static initializers
these are observed to keep their bit pattern at -Ofast on the toolchains this project gates
(arm-none-eabi-gcc 15 and Apple clang 21), but they are not guaranteed to: clang documents the
macros as undefined behaviour under -ffinite-math-only and diagnoses them with
-Wnan-infinity-disabled. tests/test_nonfinite_input_mode.py compiles the emitted literals and
checks the bit patterns, so the observation is re-established on whatever compiler is in use rather
than assumed. The mode applies to the input tensor only, so weights, alpha and second operands stay
finite and each swept element isolates a single token. Requesting the mode on an op that samples
outside the shared helpers is a generation error rather than a silently finite case.
cortex-m0 is the only soft-float leg in the target matrix: it runs the f32 suite through
-mfloat-abi=soft, so float-to-integer conversion goes through __aeabi_* rather than a VFP
instruction, and the two differ on non-finite operands. A descriptor whose contract holds only
there declares required_capabilities: [soft_float] and is capability-skipped, with a manifest
entry, on every hard-float target. Generating and building that leg is supported here, but nothing
runs it: no workflow in this repo does, and ns-cmsis-nn's helia-core-tester.yml runs cortex-m0
under --suite int only, with its float legs on cortex-m4 and cortex-m55. The
ns-cmsis-nn#314 guard case therefore has no runner until that consumer workflow adds a
--cpu cortex-m0 --suite float --float-precision f32 leg.
Spreading operators with a strict golden take one token per case. A reduction, a pooling
window, a softmax row and a convolution accumulator all fold many input elements into one output
element, so a case carrying [nan, inf, -inf] would put +Inf and -Inf in the same group and
the golden would then be a statement about (+Inf) + (-Inf) rather than about the kernel. Those
descriptors set nonfinite_tokens to a single token and there is one case per token, which also
leaves the groups the token does not reach finite and fully asserted -- that is what catches a
vector leg that poisons a whole register instead of one lane. Elementwise and pure-data-movement
operators keep all three tokens in one case. A mask case may carry several tokens in one group,
because the group it lands in is don't-care anyway; mean_float_nonfinite_two_token_* and
reduce_sum_float_nonfinite_two_token_* do exactly that to build a +Inf with -Inf
reduction group, which is adjacent to AmbiqAI/ns-cmsis-nn#429 but not its input. #429 puts one
token per group, so the *_nonfinite_issue429_flatten_* and *_nonfinite_issue429_generic_*
cases carry that placement instead: [inf, nan] at flat positions 1 and 3 of four
three-element groups on the innermost axis, and [nan, inf] at flat positions 0 and 7 of a
[1, 2, 2, 2] input reduced over H, which is the non-innermost axis #429's generic case uses.
A recurrent operator takes one token per case at the first time step of the first batch row.
The recurrence carries whatever that produces into every later step of that row, so the token
reaches the whole row; the descriptors therefore use batch_size: 2 (SVDF: input_batches: 2)
so the other row stays finite and fully asserted, which is what catches a vector leg that
poisons a whole register rather than one lane. Under mask (LSTM, SVDF) reachability masks
the token's row; under strict (GRU) the row is asserted, NaN lanes by class and the rest by
tolerance. The LSTM and GRU gates swallow an infinity by saturating -- sigmoid of an infinity
is 1 or 0 and tanh of one is ±1 -- so those cases have a finite reference; the LSTM ones are
masked purely by measured reachability, not by the finiteness of the golden. SVDF's default
±1e30 activation clamps do the same to the
value, and its time_batches exceeds its sequence_steps so the step-0 column is still in the
state ring when the last step produces the output.
nonfinite_positions is what places them. It defaults to the leading run 0..k-1 and pairs
element for element with nonfinite_tokens; a descriptor sets it where that run cannot express
the placement, either one token per reduction group or a pooling window that SAME padding only
partly covers (avg_pool_float_nonfinite_nan_same_odd_f32 and its max-pool twin put the token
at flat index 72 of a [1, 5, 5, 3] input, the one real element of the bottom-right window).
nonfinite_policy decides how the golden is compared. It is required whenever input_mode is
nonfinite_sweep and rejected otherwise; there is no default, because whether an uncontracted
non-finite output may be pinned is a per-kernel question.
strictasserts the reference value on every lane. It is only legitimate where ns-cmsis-nn documents the behaviour -- the elementwise family, the standalone hard swish,arm_nn_gelu_f32/_f16, the RELU/RELU6/LEAKY_RELU activations,arm_reduce_sum_*andarm_nn_mean_*, andarm_gru_unidirectional_f32/_f16, whose public declarations carry a NaN contract for a token in the input, the previous state or the candidate gate's weight or bias, and state that Inf follows the arithmetic (a token confined to the update or reset gate's weight or bias is absorbed by the kernel while the reference still produces NaN, so such a case staysmask) -- or where the operator is pure data movement (pad, reshape, transpose, strided slice, concatenation, split), since a copy has no freedom to specify.maskmarks as don't-care every lane whose reference output is non-finite and every lane a swept token can reach. The second set is the larger one: a pooling window that sees+Infreduces to a finite number at the other lanes of its row, and which of them the token moves is the kernel's own fold order, not a contract. Reachability is measured rather than declared -- the generator re-runs the op's reference with the token positions replaced by finite probes and marks every output lane that moves. Every remaining lane still has to match, and the case still has to returnARM_CMSIS_NN_SUCCESSwithout faulting or timing out. This is for kernels whose doxygen block says nothing about non-finite input (thearm_softmax_f32block specifies arguments and return status only, and abs, batch norm,arm_lstm_unidirectional_f32/_f16andarm_svdf_f32are the same), that declare the result unspecified outright (thearm_minimum_f32andarm_maximum_f32blocks: "The result ... for any non-finite input, is unspecified"), or that document legs which disagree: thearm_max_pool_f16note calls the scalar leg's NaN behaviour unspecified at the shipped-Ofastand declines to promise NaN propagation end to end, thearm_avg_pool_f16note has NaN propagating at every optimization level on non-MVE while the MVE clamp resolves it to a bound, and thearm_svdf_f16note has NaN propagating through the input-activation clamp on every build while the MVE output-activation clamp resolves it to a bound, or that are simply undocumented (thearm_elementwise_squared_difference_f16block of ns-cmsis-nn#490 says nothing about non-finite input). It asserts robustness and non-corruption of the neighbouring lanes without encoding an uncontracted value as a golden. Two generation-time guards keep the measurement honest: a case that ends up masking every lane fails, since it would assert nothing beyondSUCCESS, and so does a case where no lane moves between the probes, since a two-sided activation clamp that saturates all three probes to the same bound is not evidence that the token is confined.
A masked case emits the mask alongside the golden, whose masked entries are written as 0.0f so
the golden stays finite -- the input arrays still carry the tokens -- and the harness prints
HELIA_MASKED_LANES: k of n. The reporting parser records both k and n, because "passed with
one lane masked" and "passed with every lane but one masked" are different claims; a capture
reporting k > n cannot have come from the harness, so it is recorded as a failed case with a
corrupted-capture reason rather than raising.
Hardware streaming carries the exact generated mask as host-only comparison metadata; masked lanes are skipped before float classification and finite tolerance checks. Rebridge previously saved masked case bundles from their generated test sources before replaying them: older manifests lack the bitmap, and zeroed goldens cannot reconstruct it. Existing strict bundles need no migration. Matching NaNs and same-sign infinities pass strict float comparison; other non-finite pairings fail. This does not impose NaN-payload or signed-zero bit equality.
nonfinite_policy is required whenever input_mode is nonfinite_sweep: the descriptor loader
enforces the if/then gate in schema.json, and OperationBase.nonfinite_policy() checks it
again at generation time.
To scaffold a new tester op, start from helia_core_tester/scripts/scaffold_operator.py.
Generated LiteRT-only ops should route through build_<op>_op() and resolve tensor roles from
tensor_dtypes or the normalized descriptor metadata instead of hand-parsing legacy dtype fields.
Float outputs are compared element by element against atol + rtol * |expected|, but each
element is classified before that tolerance is computed. A NaN or infinite operand is never
run through the tolerance, because rtol * |Inf| is Inf and 0 * |Inf| is NaN, and
diff > tol is false against either. Matched non-finite operands pass: NaN against NaN, or
two infinities of the same sign. For the families whose ns-cmsis-nn header notes state it
(elementwise add/sub/mul, arm_nn_activation RELU/RELU6/LEAKY_RELU, hard_swish,
arm_nn_gelu_f32/_f16, and mean/reduce_sum), Include/arm_nnfunctions_flt.h guarantees the NaN-ness of an element and not
its payload, so a matched NaN passes regardless of sign or payload (see AmbiqAI/ns-cmsis-nn#333).
Minimum and maximum are documented as unspecified for non-finite inputs, so a matched NaN there
is a property of the implementation rather than a guarantee. Every other pairing fails, including
infinities of
opposite sign and a non-finite value against a finite one, and is reported on a
HELIA_NONFINITE_MISMATCH[i] line that prints nan/+inf/-inf symbolically; the reporting
parser classifies those results as nonfinite_mismatch.
A mismatched non-finite element is also counted on a HELIA_NONFINITE_MISMATCHES n=<k> line,
emitted once per tensor when k > 0. That line, not the headroom sentinel, is what the parser
classifies on, because the sentinel has a second cause.
Matched non-finite elements are excluded from the headroom measurement rather than voiding it,
so a tensor with a few NaN lanes and finite values elsewhere still reports real maxdiff and
maxfrac for the finite elements. The maxdiff=-1.0 maxfrac=-2.0 headroom sentinel is
reported only when a non-finite element mismatched or when no finite element was compared at
all, which includes a zero-length validation: it compares nothing, so it passes and records the
sentinel rather than a headroom number it never measured.
The classification decodes the IEEE-754 exponent and significand fields out of the element's
own storage bytes, at the element's own width (binary16, binary32 or binary64, selected by
_Generic on the element type), before the element is converted to double for the tolerance
arithmetic. Doing it in that order is what makes it immune to -ffinite-math-only, which both
-Ofast and -O3 -ffast-math imply: under that flag every floating-point instruction is
emitted with nnan/ninf, so a class test applied to a value produced by a widening
conversion may be deleted as provably false, and isnan/isinf may fold to a constant false.
An integer test on bytes read from the array asks nothing of the optimizer.
The host driver in helia_core_tester/tests/c_host/ is built and executed at both -Ofast and
-O3 -ffast-math, for float and _Float16, by
helia_core_tester/tests/test_float_nonfinite_compare.py. Alongside it,
helia_core_tester/tests/test_float_nonfinite_fold_harness.py builds and runs the full generated
harness shape at the same two flag sets: a file-scope static const golden carrying
NAN/INFINITY literals, the kernel output produced in a second translation unit, and the
validation macro expanded in a *_test_case_run(). That shape is the one where a
classify-after-widening validator actually loses lanes, so it is what holds the fix in place;
smaller probes stay correct on the same compiler either way. The Arm targets are compiled and
their classification call sites counted, not executed, with the toolchains and results recorded
in the pull request.
Where ns-cmsis-nn states a numerical contract of the form |out - ref| <= rtol * |ref| + atol, a
descriptor can assert it directly with contract_interval: {rtol, atol} (Gelu only today). The
generator turns the float64 reference into the lowest and highest output bit patterns the contract
allows for each element, deciding candidates near the edge in exact rational arithmetic and
refusing any that stay within the reference's own error, and helia_test_float_interval compares
the output's bits against them in sign-magnitude order. There is no golden rounding term to derive,
an input zero must keep its sign, and NaN and infinite results are classified by their bits. Two
companion keys serve these cases: input_bits lists [start, stop) bit-pattern ranges to use as
inputs instead of uniform draws (the float16 GELU cases sweep every finite input this way), and
fpscr_fz16 sets FPSCR.FZ16 around the kernel call on targets with half-precision arithmetic,
where a result whose reference is float16-subnormal may also be a zero of the reference's sign.
Int cases for the operators wired to the rule (Abs, Add, Sub, Mul, SquaredDifference,
Minimum, Maximum, PReLU) must feed each operand data that spans negative, near-zero and
positive values after the input offset is applied, i.e. value - zero_point. A one-signed
operand cannot discriminate the sign-dependent kernel paths: the packed DSP loop of
ns-cmsis-nn#343 dropped the sign of value + input_offset, and abs, PReLU and min/max branch
on it directly. Uniform [-1, 1] float data plus a TFLite zero point does not guarantee the
span, so generation enforces it (OperationBase._enforce_int_operand_sign_span, issue #81
property 2).
"Near-zero" is absolute, not a fraction of the operand's own range: the operand must contain a post-offset value within one count of zero. A relative rule let a large-magnitude s16 operand whose closest approach was thousands of counts count as covered.
When a runtime input operand the generator owns does not span, generation steers it: one post-offset value at half the operand's own magnitude is planted per missing region, into the elements whose post-offset magnitude is smallest. Least-extreme rather than leading, because a full-scale element carries saturation coverage a mid-range one does not, and on a short operand the leading elements are the whole case. Missing-only rather than all three, because most operands lack only the near-zero boundary and replacing the negative and positive elements as well would discard data the case was written around. The span is re-checked after planting; in the rare case where a planted element was the sole carrier of a region that was present, the full negative / zero / positive triple is planted instead. Steering is deterministic and independent of the RNG stream, and the golden is computed after it, so a re-run reproduces the same data and the same expected output.
Operands with fewer than three elements cannot hold all three regions and are out of scope entirely -- not steered, not refused, not requiring a waiver. That covers broadcast scalars and one- and two-element rows; the operand they broadcast against still has to span. PReLUScalar's cases are all one- or two-pixel and sit below this floor, which is why that operator is not wired to the rule.
Two kinds of operand are check-only: the generator never steers them, so a failing one must be
waived. An operand baked into the TFLite model (a PReLU alpha) cannot move, because the
reference interpreter would keep using the model's copy and the golden would stop matching the
emitted array. An operand the descriptor pins explicitly (hint.extras.input_values, or
input_1_values / input_2_values for the float squared difference) must not
move, because the pinned values are the case.
An operand that is intentionally one-signed opts out in its descriptor under
operand_sign_span_exempt, naming the operand and the reason:
operand_sign_span_exempt:
input: pinned uniformly negative input to hold the alpha branch on every lane (hct#81)
alpha: PReLU's alpha is the positive slope constant baked into the TFLite model; steering it
would leave the reference interpreter using the model's copy, and the kernel branches on
the sign of the input, not of alpha (hct#81)The reason is required, and the key must name an operand the operator actually submits to the
rule: each wired operator declares those labels as SIGN_SPAN_OPERANDS, and a waiver on
anything else fails generation instead of silently waiving nothing. operand_sign_span_exempt
is also declared in helia_core_tester/generation/descriptors/schema.json, which the loader
enforces, but the operand check needs the operator's labels, so it lives in code.
python -m helia_core_tester.mutation run --cmsis-nn-root <checkout> generates cases, applies
each catalogued mutant to a copy of the kernel source, rebuilds, and reports which cases kill
which mutant (issue #76; catalog in helia_core_tester/mutation/catalog.py).
--cpu defaults to cortex-m55, the widest capability set the int corpus uses, because some
killers are capability-gated descriptors: generating for a narrower CPU removes them from the
corpus. The corpus CPU's capabilities are passed to the scorer, and a mutant that declares
requires_capabilities the corpus does not have is reported NOT_APPLICABLE instead of
SURVIVED. That distinction matters: SURVIVED is a claim about the suite ("no case detects
this bug class"), while NOT_APPLICABLE says the run never sampled the question.
--fail-on-survivor fires only on a real survivor. requantize_tail_drop is the current
example -- its only killers are the MVE-gated chunked-equivalence requantize cases, so
--cpu cortex-m4 reports it not applicable.
The host kernel library is the DSP build (ARM_MATH_DSP, no ARM_MATH_MVEI), so a cortex-m55
case's *_get_buffer_size_mve call is compiled as the plain sizer, which answers for the build it
runs in. Otherwise the DSP kernel gets MVE-sized scratch, and the 1xN convolve cases (whose MVE
route needs less) overrun it. Cases that assert an MVE-only guard, such as the
null_weight_sum_ctx faults, still fail the host baseline (the DSP kernel never reads that
context and returns success) and are excluded from scoring; the board and FVP runs cover them.
With --cases-root, the corpus CPU is read from the tree on disk (its manifest.json, or an
artifacts/generated_tests/<suite>/<cpu>/ path) rather than from --cpu, so a --cpu that
does not match the cases cannot excuse a mutant whose killers are in that tree. A --cpu that
contradicts the tree is an error, and a tree that records no CPU requires --cpu explicitly.
Defaults chosen so a repeat run is cheap and a hung kernel cannot wedge a leg.
Generation reuse:
- each generated case carries a
.stampover its descriptor document, the case name, target CPU, suite, seed, the identity of the ns-cmsis-nn checkout (commit when the checkout is a clean git tree, a content digest of itsInclude/and UnitTest TestData otherwise), and a generator-version hash (the generation sources,core/cpu_targets.py,core/path_layout.py, the templates underassets/templates, a SHA-256 ofuv.lockfor the resolved dependency set, and the Python version and machine architecture). Float precision is not a stamp input: it selects which descriptors a run generates, not what any one of them emits. - a case whose stamp still matches is reused: no TFLite conversion, no inference, no file emission. Its manifest entry is rebuilt from the on-disk sidecar, so build and run see the same tree either way.
- a case whose stamp does not match has its directory removed before regeneration, so output a previous descriptor emitted under a different file name cannot survive into the new build.
- capability and kernel-symbol skips are re-evaluated every run, because a different ns-cmsis-nn checkout can add or remove a symbol.
generation_summary.jsonandmanifest.jsonrecord generated, reused and pruned counts.--force-generate(alsoforce_generateinhelia_core_tester.toml,HELIA_CORE_TESTER_FORCE_GENERATE) regenerates everything.- cases outside the active filter are pruned from the tree at the end of a run.
Parallel FVP runs:
--run-jobsdefaults tomin(host cores, 4). FVP boot dominates per-case time, so parallelism is the lever that matters, but an unbounded default on a shared or metered runner is a cost risk.--run-jobs 0is the explicit opt-in for every host core;HELIA_CORE_TESTER_RUN_JOBSoverrides either way.
Per-case timeout:
--timeoutdefaults to 300 seconds per case. A timed-out case is reported as aTIMEOUTcase and rendered as a JUnit failure with its own message, rather than blocking the run until the CI job cap with no per-case result.--timeout 0disables it: the value is always forwarded to the run step, so0really does hand the run back to the CI job cap as the only backstop. The masked non-finite policy's "returns SUCCESS and does not time out" only holds with a timeout in force.
Compiler cache (opt-in):
- when
ccacheorsccacheis onPATH, the CMake configure addsCMAKE_C_COMPILER_LAUNCHERand, at verbosity 1 or higher, logs which launcher it picked. HELIA_CORE_TESTER_COMPILER_LAUNCHERnames a specific tool; a name that is not onPATHfails the configure rather than building uncached. Set it tonone(or empty) to build without a launcher even where one is installed, which is what a reproducibility build wants.- no image ships either tool; a host without one builds exactly as before.
- With
ENABLE_COVERAGE=ON(including--coverage), the instrumentedcmsis-nntarget bypassesCMAKE_C_COMPILER_LAUNCHER: cached objects can contain another build's profile-output paths. Uninstrumented harness targets and non-coverage builds retain their launcher. This does not bypass caches hidden inside compiler wrappers or custom compile rules. - A coverage build compiles
cmsis-nnat-O0whatever--optasks for, because the last-Owins. The run report's metadata and the JUnit<properties>record both levels:optimizationis the requestedCMSIS_OPTIMIZATION_LEVEL, andcmsis_nn_optimizationis the level the kernels were compiled at.coveragerecords whether the build was instrumented (true/falsein both JSON and JUnit). All three are read from the build directory'sCMakeCache.txt, falling back to the run's own flags when there is no cache.
uv run helia_core_tester coverage-merge --cpu cortex-m0,cortex-m4,cortex-m55 --suite bothOutputs:
artifacts/reports/coverage/merged/coverage_merged.infoartifacts/reports/coverage/merged/coverage_merged_summary.jsonartifacts/reports/coverage/merged/coverage_merged_summary.mdartifacts/reports/coverage/merged/index.html
Behavior:
- for a single suite (
--suite intor--suite float), merge is strict and fails if any requested CPU input is missing. - for
--suite both, merge requires both int and float inputs for every requested CPU; missing pairs are named in the failure output and reports. --include-mve-floatadds optional cortex-m55 float-MVE coverage; it cannot replace a missing required int/float input. Reports are still written when required inputs are missing.--include-mve-intadds optional cortex-m55 integer-MVE coverage from a--coverage --coverage-mve-intrun (artifacts/reports/coverage/int-mve), under the same rules. The default coverage build definesARM_MATH_AUTOVECTORIZE, which compiles out integer MVE paths guarded by!ARM_MATH_AUTOVECTORIZE;--coverage-mve-intbuilds integer sources without it, exceptarm_nn_mat_mul_core_4x_s8.c. Float sources get the same define unless the run adds--coverage-mve-float; such a build setsHELIA_CMSIS_NN_FLOAT_AUTOVECTORIZEfor the harness, and a float case markedautovectorize_declinesthen expectsARM_CMSIS_NN_NO_IMPL_ERROR, as it does on a core without MVE float.
clean: removes selected CPU artifacts for generated tests, reports (generation/tests/coverage), and matching build dirs.clean-all: removes allartifacts/generated_tests, allartifacts/reports, and allartifacts/build-*directories.
- Pull request titles should use conventional commit prefixes such as
feat:,fix:,perf:,refactor:,chore:,docs:,test:,ci:, orbuild: - Pushes to
mainupdate a release PR through release-please, which updates the version files and changelog. - Merging the release PR makes release-please create the
vX.Y.Ztag and GitHub Release on the merge commit, with that version's changelog section as notes, and mark the release PRautorelease: tagged. - Merging any other PR creates no tag or release; no other workflow creates them (see
helia_core_tester/tests/test_release_workflow.py). - The release workflow manages
CHANGELOG.md,pyproject.toml,helia_core_tester/__init__.pyand the package version inuv.lock. - To force a specific version, add a
Release-As: 1.2.3footer to the merged commit body.
Resolved config order:
- code defaults
helia_core_tester.toml- environment (
HELIA_CORE_TESTER_*) - CLI options
After validation, resolved config is immutable.