Skip to content
heliaAOT
HELIA HUB

Custom operator

Lower a LiteRT custom operator end to end: a parser that turns the node into AIR, an operator class that emits its C, and the one key that ties the two together. Nothing here patches the compiler; it all goes through a registry customizer.

Field Value
Model one node whose customCode is custom-noop, int8
Target apollo510_evb
Shows RegistryContext, a LiteRT parser, an AotOperator subclass, and the Python-only path a custom operator takes
CI Conversion and host compilation are declared in the examples CI job

The model is built by make_model.py as a controlled byte-copy fixture. No downloaded model is required.

Use a repository checkout and activate its environment after uv sync --frozen --group ci. The model-building script imports the repository’s Python helpers; an isolated CLI installation alone is not enough. See Running examples.

Terminal window
./run.sh

There is no helia-aot call. The command line always builds the default, frozen registry, so a custom operator has no flag and no configuration key: convert.py is the entry point, and config.yaml is an ordinary conversion configuration it loads.

registry_context = build_default_registry_context(
customizers=[customize_registry],
allow_override=True,
)
AotConverter(config, registry_context=registry_context).convert()

This checked-in script enables allow_override=True, which permits a registration to replace a built-in. The fixture only adds a new key, so an addition-only application should omit that argument to retain replacement protection. Deliberate replacement is a separate, more permissive choice.

custom_noop.py derives the key from the customCode with custom_code_to_op_key, which trims, uppercases and replaces hyphens, so custom-noop becomes CUSTOM_NOOP. The same string is used for the parser’s registry key, for the op_type the parser writes onto the AIR operator, and for the operator class’s registry key. A mismatch prevents the parser or emitter from resolving the custom node.

out/custom_noop/src/aot_custom_noop_0.c is what the operator class wrote:

int32_t
aot_custom_noop_0_run(aot_model_context_t *ctx)
{
const int8_t *__restrict input = (const int8_t *)ctx->tensor_ptrs[aot_tensor_0];
int8_t *__restrict output = (int8_t *)ctx->tensor_ptrs[aot_tensor_1];
if (input == output) { return 0; }
arm_memcpy_s8(output, input, 16);
return 0;
}

The fixture copies raw bytes; its equal-byte-count check is not a general proof of dtype or quantization compatibility. The supplied synthetic model uses matching int8 input/output metadata. When adapting it, validate the semantic contract instead of assuming every equally sized tensor is a no-op.

The module dispatches <prefix>_<name>_run and, when has_init is true, <prefix>_<name>_init. Operators without initialization can declare has_init = False; generated model code omits their init call. Tensors are reached through ctx->tensor_ptrs with the enumerator the module generated for that tensor, which is what lets the planner place the buffer wherever it likes.

The header the class writes keeps its #include outside the extern "C" guard, so a C++ consumer links against the C definition.

From here the next steps are a typed options class so the parser can carry a decoded payload, real kernel calls instead of the copy, and tests: one for parser dispatch, one for resolve and emit, and one full conversion with the customizer applied.

Conversion should create out/custom_noop/ with its model and custom-node sources. The host compile check compiles each generated translation unit against the selected headers. It does not link the firmware or verify numerical custom semantics. For numerical validation, provide trusted outputs or an independent implementation; label verification-skipped runs as smoke checks.