Adding Operators
This guide walks through the practical steps required to add a brand-new LiteRT operator to heliaAOT. Follow the sequence end-to-end to avoid the common pitfalls that typically derail new operators.
1. Ensure an NS-CMSIS-NN Reference Exists
- heliaAOT ultimately emits kernels that call into
ns-cmsis-nn, so every LiteRT operator you introduce must have a matching reference there. - If an implementation does not exist yet, upstream it first; otherwise any generated kernel will have nothing to invoke.
2. Build a Minimal LiteRT Test Model
- Create a small LiteRT/TFLite flatbuffer that contains a single instance of the new op.
- This model serves two purposes: it validates your parser and provides golden data for regression tests later.
3. Lower the Operator into AIR
-
Enumerations - Add the new op to
helia_aot/air/enums.py. - Search existing enums (op type, padding mode, activations, etc.) for required additions. -
Options schema - Define the operator-specific options class in
helia_aot/air/options.py. - Export it fromhelia_aot/air/__init__.py. - Mirror LiteRT fields fromhelia_aot/litert/schema_py_generated.py. -
Parser wiring - Add the parser function in
helia_aot/converters/litert/op_parsers.py. - Built-ins are registered viaregister_default_litert_parsers(...). - Custom parser additions are injected throughRegistryContextcustomizers. - Validate attribute ranges early so conversion fails fast.
4. Implement the AOT Operator Class
- Create
helia_aot/aot/operators/<op>.py, subclassingAotOperator.
Responsibilities:
- Validate
Check tensor rank/layout/dtype, quantization constraints, and platform requirements.
- Compute Values
Populate template inputs (sub_values) including tensor metadata, quant params, and scratch sizing.
- Registration
Include class in built-in registration path (register_default_aot_operators(...)) or inject via RegistryContext customizer for plugin-style custom ops.
5. Author the Code-Gen Templates
- Add
helia_aot/aot/templates/<op>.c.j2and<op>.h.j2. - Keep conventions aligned with existing templates (headers, naming, const placement).
- Pass required params through Jinja vars; avoid hidden recomputation inside templates.
- Preserve layout and platform feature branches (
#ifdef) where needed.
Optional: Route Through a Shared Operator Kernel
Most operators inline their CMSIS-NN call site per node. When many nodes of the
same operator share an identical call signature, you can collapse that call site
into a single shared runtime (emitted once into {prefix}_kernels.c/.h) and have
each node emit only a compact rodata descriptor plus a thin _run wrapper. ADD,
MUL and per-channel int8 FULLY_CONNECTED already do this.
This seam has a strict three-part contract — all three must agree or generated code breaks:
- Describe the helper. Add a factory in
helia_aot/aot/operators/kernel_runtime.pyreturning aKernelHelper(a frozen dataclass keyed by signature). Use a single source-of-truth method on the operator (e.g._kernel_helper()) so the helper is computed once. - Declare it. Override
shared_kernel_helpers()to return the helper(s), and pass the helper intocompute_values()for the_runbody. - Match
has_init. If the shared helper makes per-node init a no-op, overridehas_initto returnFalseand omit the_initfunction/declaration from your.c.j2/.h.j2. Derivehas_initfrom the same predicate as the helper so they cannot drift.
Failure modes if the parts disagree:
has_init=Truebut the template omits_init→aot_model.creferences a missing{prefix}_{name}_initsymbol → link error.has_init=Falsebut the template still emits_init→ a dead init function compiles in silently, defeating the size optimization.
The invariant is pinned by tests/unit/aot/test_shared_kernels.py
(test_has_init_matches_rendered_init); add your operator there when you opt in.
The kernel descriptor struct in kernels.h.j2 and its implementation branch in
kernels.c.j2 are selected by the helper's struct_kind; the struct field names
must match the .field = {{ value }} literals in your operator .c.j2.
6. Testing
- Unit / functional tests Add focused conversion tests for the new op model and generated values.
- End-to-end tests
Add coverage in
tests/to catch integration issues (buffers, quantization, runtime wiring). - Re-run relevant platform-specific suites (e.g., Ethos-U, MVE).
Common Pitfalls Checklist
- ns-CMSIS-NN signature mismatch (dims, offsets, activation clamps).
- Missing enum/options export causing delayed parser/runtime failures.
- Wrong tensor role mapping (input/output/named/scratch).
- Missing per-channel quant arrays where required.
- Inadequate edge-shape test coverage (broadcast, dilation, unusual ranks).
Note on Config Overrides
Operator/tensor YAML attribute rules (config.operators, memory.tensors) are independent of RegistryContext and continue to work as before.