Plans and reports
A conversion writes two JSON files next to the module. <prefix>_plan.json
holds every choice: the value of each knob for each operator and the kernel
entry each layer calls. <prefix>_report.json explains the plan and never
changes it. Their JSON schemas ship with the documentation data as
plan-schema.json and report-schema.json.
The plan
Section titled “The plan”| Field | What it records |
|---|---|
schema_version |
The file’s schema; see Schema versions. |
generator.helia_aot, generator.cmsis_nn_required |
The heliaAOT release that wrote the plan and the ns-cmsis-nn version the module requires. |
model.file, model.sha256, model.subgraph |
The model the plan was made from. |
module_prefix |
The module’s symbol prefix. |
platform.name, platform.cpu, platform.capabilities, platform.supports_fp16 |
The target. The capabilities are what the release that wrote the plan knew of it; a later release can remove one as well as add one. |
optimization.goal, optimization.allow_approximate |
The goal and the approximation gate. |
operators[] |
One entry per operator, below. |
tensors[] |
One entry per constant the operators read, below. |
schedule |
The transfer schedule, below; source: none with no units when nothing streams. |
optimization.knobs |
Reserved and empty today, for model-level choices. |
Operator ids
Section titled “Operator ids”Each operator has a stable id, "<subgraph>:<output tensor name>", taken
from the name the model gives its first output tensor, so it survives edits
that add or remove other operators. index keeps its position in execution
order and node_id its AIR node id.
- Repeated names get
#1,#2in operator order. An operator without a name falls back to"<subgraph>:<op_type>@<position>";id_sourcesays which applied. - A tensor name can itself contain
:(for example0:StatefulPartitionedCall_1:0), so a parser splits these ids on their first colon only. An id withid_source: node_idis the opaque AIR node id and is not split. operators[].idrules match the stable id as well as the node id, so a plan’s ids can be written back as rules.- A stable id that would also select another operator (another operator’s
node id, or a stable id spelled twice) is not matched; the plan then uses
the node id as
id, withid_source: node_id, and the conversion warns.
Per-operator fields
Section titled “Per-operator fields”| Field | What it records |
|---|---|
| each knob | The value used, the value requested, resolved_by (explicit, or auto_table when auto chose it from the goal; auto_heuristic and plan are reserved), whether the value is approximate where it applies, and the source: kind is operator (with rule, the position, type and id of the winning operators entry), model (set under optimization, even to auto), or default (plan is reserved). |
kernel.entry |
The ns-cmsis-nn entry chosen, for layers that choose among candidates (convolution, depthwise, transpose convolution, packed int8 fully connected, precise packed FP16 fully connected); the report repeats it under entry. Average and max pooling record their selected int8, int16, FP16 or FP32 entry. Pooling, convolution, depthwise, transpose convolution, fully connected, batch matmul and SVDF record their single-entry paths explicitly, FP16, FP32 and ordinary fully connected entries included; other unmigrated single-entry layers still have a null entry. Selection follows the rules and knobs; a plan cannot set it yet. |
kernel.provider |
An opaque string naming the built-in CORE planning rules, for pooling, convolution, depthwise, transpose convolution, fully connected, batch matmul and SVDF. Optional and absent from older plans; compare it with another plan’s, do not parse it. |
kernel.candidates |
Every entry whose rules match the layer on its target, in precedence order; entry is the first one the knobs allow, so under kernel: generic a shape-specialized entry stays listed ahead of the generic entry the layer calls, and a plan diff shows which entries a release added or removed. Convolution, depthwise and transpose convolution list their matching records; fully connected lists the packed int8 entry when its gate holds and the precise packed FP16 entry when the weights are packed, ahead of the plain entry; the depthwise FP16 entry accumulation: fast selects is listed where it applies. Null for pooling, batch matmul and SVDF, whose providers do not enumerate alternatives, and in older plans. The list is a fact about the release that wrote the plan: a later release can add an entry or change a rule. |
kernel.requantization |
For int8 and int16 convolution, depthwise and fully connected layers: rshift_only when every output channel has a shift below 0 or a multiplier of 0, so the emitted multipliers and shifts allow arm_nn_requantize_channels_rshift_only’s route, and general otherwise. The int8 MVE kernels read it to skip the left shift and the rounding fixup, and it gates the packed fully connected entry; for depthwise, int16 and targets without MVE it describes the tables and no kernel reads it. Null for float layers, other operators and older plans. |
kernel.local_scratch_bytes |
For operators with a recorded entry, the actual resolved local tensors, excluding output tensors, constant weight sums and arena alignment padding. Not the model’s total scratch-arena requirement. Older plans can omit it; operators without a recorded entry stay unrecorded. |
fixed |
Choices that change numerics or speed but are not knobs yet, with what set them (attribute, default, heuristic, knob or placement; plan is reserved): RSQRT lut_mode (INT16 only), HARD_SWISH use_lut and compat_variant (INT8), and the float weight_layout of 1x1 convolutions, conv1d and fully connected layers, set by the knob for fast packed FP16 fully connected, by placement for eligible batch-one FP32 or precise FP16 fully connected, and by a heuristic otherwise, including precise multirow FC and FP16 convolutions. |
Per-constant fields
Section titled “Per-constant fields”tensors[] lists every constant the operators read, by storage root (a
constant interned onto another’s storage is listed under that root), in the
order of its first reader. Plans from heliaAOT 1.3 and earlier write the list
empty.
| Field | What it records |
|---|---|
id, storage_id |
The id memory.tensors rules match; for a constant repacked at conversion, the id it was packed from, with the storage tensor’s own id in storage_id. |
requested, residency |
The residency the merged rules ask for (direct, pin or stream, or null), and the one the constant got. A stream rule permits streaming: the constant is stream when a schedule unit names it and direct otherwise. |
memory, destination_memory |
Where the bytes are stored, and for pin the memory the kernels read the copy from. |
bytes, alignment |
The size, and the alignment the layout pads the constant to, so a planner computes unit sizes and pool fit with the same rule heliaAOT applies. |
unit, pool_offset |
For stream, the transfer unit and the constant’s offset in the stream pool; null otherwise. |
The transfer schedule
Section titled “The transfer schedule”The schedule section carries the transfer schedule of a conversion whose
constants stream (residency: stream and memory.stream_pool; see
Memory placement). heliaAOT
never searches for a schedule: a planner writes one into a plan, the
conversion reads that plan with optimization.plan, checks the schedule and
writes it back with its derived fields. A conversion with a schedule copies
each unit into the pool before its first reader. This release emits cpu
units, which the module copies itself, and refuses a dma unit.
| Field | Kind | What it records |
|---|---|---|
source |
input | Required: plan with at least one unit, or none with no units when nothing streams. |
pool |
derived | The stream pool’s memory, bytes and alignment, present whenever memory.stream_pool is configured. |
units[].id |
input | The unit’s position in the list, which is issue order: by the position of issue_after (null first), then by id. |
units[].tensors |
input | The stored copies the unit copies, each named by its tensors[].storage_id when the entry has one, else its tensors[].id, in the order the conversion’s tensors[] lists them (by first reader, then in that reader’s order); each streamed copy is in exactly one unit, and only a constant whose rules permit stream may be in one. |
units[].pool_offset |
input | The unit’s offset in the pool, a multiple of its span alignment (the largest alignment among its tensors), with pool_offset + bytes within pool.bytes. |
units[].issue_after |
input | The operators[].id after whose run the copy is issued, or null for the start of the run; it must come before the unit’s first reader. |
units[].engine |
input | dma or cpu, among the engines the source and pool memories offer on the target (MRAM to DTCM offers cpu; PSRAM to DTCM offers both). This release emits cpu units only and refuses dma. |
units[].bytes |
derived | The span size: tensor k starts at the next multiple of its alignment from the end of tensor k-1, and bytes is the end of the last one. |
units[].source_memory, units[].source_offset |
derived | The one memory the unit’s constants are stored in, and the span’s offset in that memory’s constant arena, since the memory planner lays a unit out as one span on both sides of the transfer. A stated source_offset is ignored. |
units[].wait_before, units[].last_reader |
derived | The first and last operator, by position, that reads a constant of the unit. |
A unit is live from its issue point to the end of its last reader’s run, so
two units may share pool bytes only when their live ranges do not meet. The
conversion rejects a schedule, naming the unit or tensor, when two units live
at once overlap in pool bytes, a unit does not fit the pool or does not start
at a multiple of its span alignment, a unit is not issued before its first
reader, a unit streams a constant whose rules do not permit stream or one
the report says cannot be staged (read at init, pinned, exposed as a model
output, or sharing storage through an alias), a unit’s engine is not offered
or its constants are stored in two memories, a derived field is stated
differently, or the schedule holds more than 65535 units. A constant whose rules permit stream and that no unit names
stays direct, and the conversion names it in a warning when it could have
streamed. A permitted constant that cannot be staged (read at init, a model
output, storage shared through an alias, or fixed in place) never streams; a
separate warning names it with its reason, as the report’s
constants[].reason records it. The report’s stream_pool.layout lists each
unit’s span in the pool and pieces splits every unit at multiples of the
pool memory’s DMA boundary counted from the pool base; a provider receives a
dma unit in those pieces, and the module copies a cpu unit in one copy.
Reading a plan as input
Section titled “Reading a plan as input”optimization.plan names a plan to read before the conversion starts: in a
configuration file the path resolves relative to that file, on the command
line relative to the working directory, and either way the validated
configuration stores it absolute. The plan’s schedule section is
applied, so it is read strictly: a field heliaAOT does not know under
schedule, schedule.pool or schedule.units[] is rejected, naming it,
because a later release adds there only fields it applies. Unknown fields
elsewhere in the plan are ignored, so a tool annotates the plan outside
schedule.
optimization.plan <path> is not an optimization plan this release reads: schedule.units.0: unknown field(s) ['pool'] under schedule, which this release would have to apply.hint: The field comes from a later heliaAOT release or a planner written for one: convert with that release, or remove the field.For every other section the plan must describe the conversion the configuration produces, and the conversion rejects the plan naming the first field that differs and both values:
model.sha256andmodel.subgraphmust match the model (a plan whosesha256is null is rejected), andplatform.namethe target;operators[]are compared by position:node_id,op_type, and thevalueof every knob and fixed choice both sides name;tensors[]are compared byidandstorage_idwhen the plan lists any (an entry listed twice is rejected): a constant on one side only is a rejection, andrequested,residency,memory,destination_memory,bytesandalignmentmust match, memory names in any case, exceptresidencyfor a constant whose rules permitstream, wheredirectandstreamboth match because the schedule decides between them. A nullmemoryin the plan is not compared; a nullrequested(no rule) ordestination_memory(not staged) is a value and must match.
generator, module_prefix, the optimization section, operators[].id,
id_source and kernel, tensors[].unit and pool_offset, unknown fields
outside schedule and the report are not compared, so a plan written by
another heliaAOT release is accepted when the comparison passes and its
schedule holds only fields this release knows. A plan whose
schedule.source is none replays the conversion that wrote it, and a plan
heliaAOT wrote with a schedule reads back after only its schedule is edited.
The report
Section titled “The report”<prefix>_report.json carries each operator’s MACs, the alternatives of each
knob and whether they apply on this target and why not, the measurements for
this target and CPU keyed by setting, the constant placement facts, the hints
the results print, the folded_variables list, the constants each operator
reads with whether they could be staged into a stream pool, the stream pool
and the schedule’s pieces. Its content may change between releases, except
the planner facts of RFC 0005 (each operator’s constants[], and the
memory, bytes and alignment of stream_pool), which are stable within
schema version 1; the pool’s configured alignment and layout and the pieces
are explanatory. The choices are in the plan. The optional HTML report
(documentation.html) shows the plan’s kernel.local_scratch_bytes as the
local-scratch column of its execution table.
- A hint appears only when its alternative applies and a single setting would
select it. A knob hint lists the operators where the knob applies that do
not run the value
autopicks forgoal: latencywithallow_approximate: true(for accumulation, operators runningprecisewhere thefastalternative applies), leaving out operators anoperatorsrule pins. - The placement hint needs constants outside TCM that all come from one cold memory and fit in the free DTCM.
- Share of MACs counts convolution, depthwise and fully connected layers; it is not a share of run time unless you profile the model.
Schema versions
Section titled “Schema versions”Both files carry a schema_version. Adding an optional field, a knob, a knob
value or an enum member keeps it; renaming or removing a field, or changing
what one means, increments it.
OptimizationPlanandOptimizationReportinhelia_aot.optimizationread both files back as typed models by that rule: they ignore a field they do not know, keep a knob, knob value or member they do not know as the string written, and reject only a file with anotherschema_version.- The plan’s
schedulesection is the exception when a conversion reads the plan as input (optimization.plan): heliaAOT applies it, so it rejects a field it does not know underschedule,schedule.poolorschedule.units[](validating withcontext=APPLIEDdoes the same). A release adds a field there only when it applies it, keepingschema_version; derived facts go outsideschedule, and an applied field outside it comes only with a newschema_version. - A script that uses them reads the files of older and newer releases with
the same
schema_version. - Plans and reports from heliaAOT 0.30 and earlier name the target’s CPU
core; they still read, ascpu. - Under its
json_schemakey, each exported schema lists every field, knob, value and member the release that exported it writes, and allows no other field (additionalProperties: false) except in one free-form object: a report hint’sfacts. - Validate a file against the schema of the release that wrote it
(
generator.helia_aot) rather than pinning one; a newer file need not validate against an older schema. - Compatibility says what a release promises about each file.
Generated CORE calls
Section titled “Generated CORE calls”The report’s kernel_calls records the CORE functions every operator’s
generated source calls, including batch matmul’s operand transposes, SVDF’s
kernel-sum call for runtime feature weights and the shared ADD/MUL helpers.
Each calls entry holds a symbol and the operator_ids from the plan;
symbols are sorted and attribution follows execution order.
- An operator that calls no CORE function, such as DILATE or an Ethos-U
command stream, records none. A call compiled only under
ARM_MATH_MVEI, such as REVERSE_V2’s row copy, is recorded on every target. Initialization calls are included when emitted; offline precomputation is not a generated call. - The records use the same selected symbols as code generation. They list
direct generated calls, including calls inside shared generated helpers, not
the functions those CORE entries call internally or the final linked image.
unrecorded_operator_idslists operators without call coverage, so a partial list is not the module’s complete dependencies. - Older reports have no
kernel_callsfield, read as null, which differs from an empty, covered call list. The generated HTML report shows the same records and coverage gaps. - The record changes no selection, scratch sizing or numerical behavior, and it is not an upstream kernel manifest.
FP16 overflow thresholds
Section titled “FP16 overflow thresholds”A packed FP16 convolution on arm_convolve_1x1_f16 or arm_convolve_1_x_n_f16
sums each output in float16 lanes: the bias and the first 32 taps, then each
later block of 32 taps, each folded into float32.
A block partial that reaches 65504 overflows whatever a later block subtracts, so unnormalized data on a layer fed straight from a model input can overflow where the standard layout’s shorter sums would not.
The report’s fp16_overflow lists each such layer whose input is a model
input read through value-preserving operators only (RESHAPE, SQUEEZE,
EXPAND_DIMS, TRANSPOSE, SLICE, STRIDED_SLICE, and PAD with a zero fill value).
Each entry names the operator, its entry, the model input, the operator types
on the path, and threshold, the input magnitude below which no sign pattern
can make any lane block reach 65504, allowing for float16 rounding.
- After
nround-to-nearest fused multiply-adds a block partial is at most(1 + g)times the exact sum of magnitudes, withg = n·u / (1 − n·u)andu = 2^-11(nis the block’s taps, plus one for the bias in block 0). - The threshold is
(65504 / (1 + g) − |bias|) / sum|w|over the first block and65504 / (1 + g) / sum|w|over each later one, minimized over output channels and blocks, with thechannel,block,weight_sumandbiasattaining it. - At larger magnitudes the worst sign pattern overflows within about 2% above the threshold; typical data is far below it.
- The threshold describes the lane-block artifact only: below it no lane block overflows, but the layer’s float16 output can still overflow when its exact value exceeds the float16 range (a reduction longer than 32 taps is narrowed from float32 to float16 at the output), as on any float16 kernel.
- A layer on another entry, with standard weights, or fed through any other operator, a PAD with another fill value or a constant is not listed, whatever its exposure. The list is a fact about the weights; it changes no generated code and no conversion decision.