Output & Results#
heliaPROFILER produces a structured set of output files organized for progressive disclosure — high-level summaries by default, detailed breakdowns on request.
Output directory structure#
Default output#
results/
├── result_manifest.json # Versioned bundle envelope + artifact digests
├── summary.json # Machine-readable high-level summary
├── profile_results.{csv|json} # Selected primary per-layer result
├── run_metadata.json # Config, toolchain, platform, model info
├── nsx.lock # Exact dependency lock used for this profile
├── aot_operator_manifest.json # AOT only: compiled operators + tensor placement
├── aot_memory_layers.csv # AOT only: spreadsheet-friendly per-layer buffers
└── model_explorer/ # Overlay JSONs unless explicitly disabled
├── me_overlay_ARM_PMU_CPU_CYCLES.json
├── me_overlay_ARM_PMU_INST_RETIRED.json
└── ...
The primary profile artifact is CSV or JSON, not both. AOT artifacts appear
only for heliaAOT runs, and Model Explorer overlays are omitted when
--no-model-explorer is set.
Detailed output (with --detailed)#
results/
├── summary.json
├── profile_results.csv
├── run_metadata.json
├── model_explorer/
│ └── ...
└── detailed/
├── memory.json # Memory breakdown (binary, arena, per-layer cache)
├── profile_cpu.csv # Merged CPU group results
├── profile_memory.csv # Merged memory group results
├── profile_mve.csv # Merged MVE group results
├── profile_cpu_0.csv # Per-pass CPU breakdowns
├── profile_cpu_1.csv
├── profile_memory_0.csv
├── profile_mve_0.csv
└── ...
File reference#
result_manifest.json#
The publication marker for a completed result bundle. HPX writes this file after every other report artifact. It records the run identity, validity, structured issues, open provenance and comparability fields, plus each artifact's relative path, media type, size, and SHA-256 digest.
The v1 schema intentionally fixes only the envelope. provenance,
comparability, extensions, issue context, artifact metadata, and unknown
root fields remain open for additive evolution. Consumers must ignore fields
they do not use. Use load_result_manifest(path, verify=True) to reject missing,
modified, or path-escaping artifacts.
Each declared artifact may include additive semantic metadata:
| Field | Meaning |
|---|---|
role |
core, projection, extension, export, or diagnostic |
name |
Semantic artifact name used for discovery |
schema |
Content-schema identity only when a published schema actually exists |
schema_version |
Version of that published content schema, independent of the bundle version |
producer |
Component or exporter that generated the artifact |
optional |
Whether a valid bundle may omit this product |
Complete profile bundles require named core artifacts for summary.json,
run_metadata.json, nsx.lock, and the selected primary profile result. The
dependency provenance records the typed registry hash, baseline identity and
fingerprint, workspace fingerprint, lock mode/update state, requested refs and
tags, peeled commits, content hashes, and explicit overrides. Detailed CSV/JSON files are projections or
diagnostics. heliaAOT files are engine extensions. Model Explorer overlays are
optional exports governed by the Model Explorer format rather than the HPX core
schema. Semantic names do not claim a published content schema. schema and
schema_version remain absent until HPX or an external owner publishes one.
Optional means the product need not be generated; once an artifact is
declared in a manifest, verification still requires its file and digest.
The permissive JSON Schema is shipped as
helia_profiler/data/result_manifest.schema.v1.json.
HPX-owned JSON artifacts carry independent schema identities and versions so consumers can evolve parsers without coupling every file to the bundle schema:
| Artifact | Schema | Packaged JSON Schema |
|---|---|---|
summary.json |
hpx.run-summary v5 |
run_summary.schema.v1.json (root fields; the authoritative shape is the typed model helia_profiler.results.run_summary.RunSummary) |
run_metadata.json |
hpx.run-metadata v1 |
run_metadata.schema.v1.json |
profile_results.json |
hpx.profile-results v1 |
profile_results.schema.v1.json |
These schemas require the stable interpretation fields and remain open to additive measurements and extensions. CSV output retains its semantic artifact name but does not claim a formal content schema yet.
summary.json#
The top-level summary — start here for a quick overview.
{
"schema": "hpx.run-summary",
"schema_version": 5,
"engine": "helia-rt",
"layers": 13,
"total_cycles": 2016376,
"overflow_detected": false,
"top_layers": [
{"op": "CONV_2D", "cycles": 338176, "pct": 16.8},
{"op": "CONV_2D", "cycles": 207749, "pct": 10.3}
],
"memory": {
"arena_size": 131072,
"allocated_arena": 29780,
"model_size": 53936,
"num_tensors": 35,
"input_size": 490,
"output_size": 12
},
"binary": {
"text": 573968,
"data": 14952,
"bss": 163516,
"total": 752436
},
"cache": {
"ARM_PMU_L1D_CACHE": 230224,
"ARM_PMU_L1D_CACHE_RD": 230203,
"ARM_PMU_L1D_CACHE_REFILL": 0,
"ARM_PMU_L1D_CACHE_MISS_RD": 0,
"ARM_PMU_DTCM_ACCESS": 1338037,
"ARM_PMU_MEM_ACCESS": 1568463,
"l1d_hit_rate_pct": 100.0
}
}
| Section | Contents |
|---|---|
| Top-level | Engine, layer count, total cycles, overflow flag |
top_layers |
Top 5 layers by cycle count with percentages |
memory |
Arena allocation, model size, tensor counts |
memory_plan |
The pre-build DECISION RECORD: per-region capacity/used and named consumers — see Planned vs measured memory |
memory_regions |
The MEASURED region occupancy from the linked ELF: per-region used/reserved/free/load_image against the verified per-SoC windows |
binary |
ELF section sizes (text, data, bss, total, and reserved when non-zero) — see Reserved vs bss |
cache |
Aggregated cache/memory PMU counters + derived L1D hit rate |
power |
Power summary (when Joulescope capture is enabled) |
Every summary also includes top-level validity and issues fields. Validity
is valid, degraded, or invalid; issues carry stable codes, severity,
human guidance, and open context. Consumers should inspect these fields before
using headline metrics.
profile_results.csv#
The primary data file — one row per layer with all measured PMU counters.
id,op,ARM_PMU_CPU_CYCLES,ARM_PMU_INST_RETIRED,...,cycles,overflow
0,CONV_2D,338176,270137,...,338176,False
1,DEPTHWISE_CONV_2D,206245,152970,...,206245,False
id— the firmware's execution position: the sequential layer index (TFLM/helia-rt) or the post-compilation layer position (AOT — heliaAOT skips ops such asVAR_HANDLEwithout renumbering, so this is NOT the original TFLite index)source_index— AOT only: the ORIGINAL TFLite operator index this layer corresponds to (from the compiled-operator manifest). Per-layermacs,opsandcycles_per_macare joined on this, never on position; a layer with no resolvable source index gets no MAC attribution rather than a positional guessop— operator type (e.g.CONV_2D;DEPTHWISE_CONV_2D:1for AOT, where:1is the original TFLite index)- Counter columns — averaged across iterations
cycles— dedicated cycle counter valueoverflow—Trueif any counter overflowed (2³² saturation)
run_metadata.json#
Full provenance for the run:
{
"hpx_version": "0.1.1",
"run_id": "a1b2c3d4",
"timestamp": "2025-04-21T10:30:00",
"config": { ... },
"platform": {
"board": "apollo510_evb",
"soc": "apollo510",
"core": "cortex-m55"
},
"model": {
"name": "kws_ref_model.tflite",
"size": 53936,
"sha256": "abc123..."
},
"toolchain": {
"compiler": "arm-none-eabi-gcc",
"compiler_version": "arm-none-eabi-gcc (Arm GNU Toolchain 14.3.Rel1) 14.3.1",
"cmake_version": "cmake version 3.31.6"
},
"build_images": [
{
"role": "profile",
"target_name": "hpx_profiler",
"binary_name": "hpx_profiler",
"sha256": "b8978f0f...",
"size_bytes": 5236108,
"architecture_flags": {
"-mcpu=cortex-m55": 75,
"-mfloat-abi=hard": 75
},
"translation_units": 75
}
],
"firmware": {
"arena_size": 131072,
"allocated_arena": 29780,
"model_size": 53936
}
}
build_images records the binary each run actually built — one entry per
target, so a power run lists both profile and power. sha256 is the
digest of the image itself; architecture_flags counts the instruction-set
flags the compiler received across the build tree, read from the build's own
compile_commands.json.
The flags matter for A/B work. Two builds differing only in
-mcpu=cortex-m55 versus -mcpu=cortex-m55+nomve are otherwise identical in
metadata, which leaves the directory name as the only record of which is
which. hpx compare reports the difference as the informative Architecture
flags dimension: a Helium on/off study is a comparison you meant to run, so
the difference is surfaced rather than blocked.
aot_operator_manifest.json#
For helia-aot runs, this captures the operators emitted by the AOT
compiler after graph transforms. Each operator includes inputs, outputs, and
local tensors such as weights, weight sums, and per-op scratch buffers.
When the installed helia-aot package exposes placement data, tensor entries
also include:
| Field | Meaning |
|---|---|
memory |
Runtime memory used by the kernel (dtcm, sram, mram, psram) |
source_memory |
Cold-storage source for staged constants |
staged |
true when a constant is copied from source_memory into memory |
arena_role |
scratch, persistent, or constant |
arena_region_id |
AOT arena enum value used by bind_arena() |
offset |
Byte offset inside the AOT arena |
allocation_size |
Planned allocation size in bytes |
aot_memory_layers.csv#
For helia-aot runs, this is a flat CSV view of the same placement data.
It is intended for customers who want to sort or filter buffers in a
spreadsheet while experimenting with AOT memory placement.
Example columns:
layer_idx,layer_id,op_type,op_name,tensor_role,tensor_id,tensor_name,tensor_kind,memory,source_memory,staged,arena_role,arena_region_id,offset,size,shape
0,0,CONV_2D,conv_2d_0,local,17,tensor_17,constant,dtcm,dtcm,False,constant,1,0,2560,"[64, 1, 5, 1]"
detailed/memory.json#
Deep memory breakdown (only with --detailed):
{
"binary_sections": {
"text": 573968,
"data": 14952,
"bss": 163516,
"total": 752436
},
"arena": {
"arena_size": 131072,
"allocated_arena": 29780,
"num_tensors": 35,
"num_inputs": 1,
"num_outputs": 1,
"model_size": 53936
},
"per_layer_memory": [
{
"op": "CONV_2D",
"counters": {
"ARM_PMU_L1D_CACHE": 28728,
"ARM_PMU_L1D_CACHE_RD": 28727,
"ARM_PMU_DTCM_ACCESS": 178393,
"ARM_PMU_MEM_ACCESS": 207151
}
}
],
"cache_totals": {
"ARM_PMU_L1D_CACHE": 230224,
"ARM_PMU_L1D_CACHE_MISS_RD": 0,
"ARM_PMU_DTCM_ACCESS": 1338037,
"l1d_hit_rate_pct": 100.0
}
}
Reserved vs bss#
bss counts zero-initialized state the program actually uses. reserved is
separate: linker-reserved NOBITS regions that are never written at runtime.
This matters most on Apollo5 boards, where the NSX linker script deliberately
fills all remaining DTCM as a .heap region so _sbrk has a bounded area
to allocate from. arm-none-eabi-size's default output has no per-section
detail, so before HPX 0.1.6 that reservation was reported as bss — on a
measured build, 392 KB of "bss" for 248 bytes of real state.
HPX now reads the ELF section headers and reports the two separately:
reserved is omitted entirely when a board's linker script does not reserve
anything, and total is unchanged — it remains the size tool's own inclusive
sum, so text + data + bss + reserved reconciles against it.
Comparing against older baselines
Because bss no longer includes the reservation, an Apollo5 run recorded
with HPX 0.1.5 or earlier reports a far larger bss than the same binary
does today. summary.json's schema_version moves from 1 to 2 so the
change is detectable, and hpx compare reports it as a
run_summary_schema_version difference rather than silently as a memory
improvement. Re-record affected baselines.
armclang reports the same split
armclang binaries are measured with fromelf rather than size, and
before #132 landed that path had no per-section detail — an armclang build
folded the linker's heap reservation into bss, so comparing it against
a GCC run of the same source showed a large bss difference that was
entirely an artifact of the measuring tool. fromelf's per-section
listing is now read the same way the section headers are on the GCC/ATfE
path: armlink's ARM_LIB_HEAP region (SHT_NOBITS + SHF_ALLOC) moves
to reserved, while ARM_LIB_STACK stays in bss — like .stack on
GCC, it is the live stack, not a reservation. If the per-section output
is unavailable or unparseable (for example, an older fromelf), the run
degrades to the unadjusted totals — reserved reads 0 and bss again
includes any reservation — rather than failing.
Planned vs measured memory#
Two blocks describe memory, on purpose, because they answer different questions:
memory_planis the decision record — what hpx intended, computed before any compiler ran: per-regioncapacity(datasheet-flavored),used(the sum of what hpx itself placed), and the namedconsumers(weights, arena, scratch). It deliberately carries nofreeoroverflow: the plan only counts what hpx placed, so a plan-side "free" was overstated and a plan-side "overflow" could not fire on real exhaustion (issue #133).memory_regionsis the measured truth — the linked ELF's section inventory classified into per-SoC memory windows characterized from the NSX linker scripts and SDK hardware apertures. Per region:
"memory_regions": {
"link_family": "gnu",
"linker_profile": "default",
"regions": [
{
"region": "DTCM",
"window": {"start": 536870912, "length": 524288},
"app_window": {"start": 536870912, "length": 507904},
"used": 16664,
"reserved": 491240,
"free": 491240,
"load_image": 0,
"window_provenance": "hardware-aperture",
"app_provenance": "linker-script"
}
],
"unattributed": []
}
window is the hardware classification aperture; app_window is the
extent the link family's script gives the app image, and free is
app_window.length − used. used includes the live stack (gcc links);
reserved is the linker's own reservations — the fill-to-end heap plus
armlink's fixed heap/stack regions. Note the two reservation kinds read
differently: gcc's fill-to-end heap sits inside the app window, so its
bytes appear in both reserved and free (the heap is sized to whatever
was left — the example above shows both at 491240 for exactly that
reason); armlink's fixed heap/stack sit outside the app window and are
excluded from both. Because of that, used/free are comparable only
within a link family — hpx compare shows them as per-region rows and
withholds them when the two runs' link_family differs. load_image is the flash bytes that
initialize this region's data (summed from program headers by physical
address, which is correct on both gcc and armlink). unattributed lists
any allocated section that falls outside every verified window — either
the binary put bytes somewhere uncharacterized, or the window table is
wrong for that part; both deserve eyes (unattributed_load_bytes is the
same flag for program-header flash bytes). The block describes the
profile binary — power-only firmware is not measured, matching
binary. The whole block is absent whenever it cannot be true: a
custom SoC, a non-default linker_profile, a failed tool probe, or a
partial section inventory — never guessed.
Plan vs measured reconciliation#
When the symbol table is readable, summary.json also carries
memory_reconciliation — the two views above held against each other:
consumers[]— one row per plan consumer:planned_size,status(matchedwithmeasured_size/delta= measured − planned;missingwhen the planned symbol is absent from the binary;unmatchablewhen no sized symbol can exist — PSRAM-placed weights bind to a runtime pointer, armlink's stack is a scatter region), and thematched_symbolsit summed.regions[]— planusedvs measuredusedper region. The hpx-owned consumers (records, RTT, USB, stack) exist to drive these deltas toward zero.
detailed/memory.json additionally carries memory_symbols: the top 32
sized symbols per region (by size, deduped across aliases, classified by
virtual address — per-symbol load-image attribution is not recoverable on
armlink). Both blocks are additive to schema v3 and absent whenever their
inputs are (no symbol table, partial listing, no measured view).
Schema v3
summary.json's schema_version moves from 2 to 3 with this split:
memory_plan lost its free/overflow/has_overflow keys, so a
consumer comparing across the boundary sees a
run_summary_schema_version difference in hpx compare rather than
silently reading the semantic change. Re-record baselines that
consumed the plan's free.
Schema v4
The gate-duration verdict was re-sourced (#142/#181): a v4 artifact can
carry power.energy_per_inference_j alongside
power.gate_duration_integrity.valid: false — the firmware's own
window clock arbitrates, power.gated_window_reference_drift records
the reclassification, and gated_window_duration_suspect keys on that
arbitration rather than the est×count band alone. Do not treat
valid: false alone as a bad capture; read artifacts through
helia_profiler.results.run_summary.load_run_summary, whose
gate_duration_unarbitrated_failure property applies the arbitration
for you.
Schema v5
Gated power duration now prefers the driver's packet duration, then its
sample span, with fitted UTC used only as fallback (#249). This changes
duration_s, avg_current_a, avg_power_w, and derived TOPS; packet
energy and TOPS-per-watt are unchanged by the duration correction.
Comparing v4 and v5 reports an INFORMATIVE schema difference and still
computes metrics. Re-record baselines for the affected power fields;
those measurements do not have identical semantics across this boundary.
Terminal summary#
Every run prints a summary to the terminal:
============================================================
heliaPROFILER Results
============================================================
arena_size: 131072
allocated_arena: 29780
model_size: 53936
layers: 13
total_cycles: 2,016,376
Top layers by cycles:
CONV_2D 338,176 ( 16.8%)
CONV_2D 207,749 ( 10.3%)
CONV_2D 207,749 ( 10.3%)
Memory: 29,780 / 131,072 bytes arena (22.7%)
Model: 53,936 bytes
Binary: text=573,968 data=14,952 bss=163,516 total=752,436
Cache/Memory:
L1D_CACHE 230,224
L1D_CACHE_RD 230,203
DTCM_ACCESS 1,338,037
MEM_ACCESS 1,568,463
Validity: VALID
============================================================
The validity footer is the run's verdict from the same evaluation that
summary.json and the result manifest record: VALID (quiet), DEGRADED
with one code — message line per warning, or INVALID with errors first.
An INVALID run still exits 0 and writes its artifacts unless
output.fail_on_invalid / --fail-on-invalid is set (exit 3) — the footer
prints a hint recommending it when relevant.
Controlling output#
| Flag | Effect |
|---|---|
--output-dir PATH |
Change output directory (default: ./results) |
--output-format csv |
CSV output (default) |
--output-format json |
JSON output |
--no-model-explorer |
Skip Model Explorer overlays |
--detailed |
Emit per-preset CSVs and memory.json in detailed/ |