Custom Op Starter (LiteRT + AOT)
This guide is a practical starting point for adding a custom LiteRT op to HeliaAOT.
It shows:
- a LiteRT parser function
- an AOT operator class
- a registry customizer that wires both into
RegistryContext
The example uses a minimal pass-through custom op key: CUSTOM_NOOP.
1) Create a Plugin Module
Create a Python module in your project, for example my_plugin/custom_noop.py.
from pathlib import Path
from helia_aot.air import AirOperator, AirOperatorOptions, custom_code_to_op_key
from helia_aot.air.model import AirModel
from helia_aot.aot.operators.operator import AotOperator
from helia_aot.litert import schema_py_generated as litert
from helia_aot.registry.context import RegistryContext
CUSTOM_CODE = "custom-noop"
CUSTOM_OP_KEY = custom_code_to_op_key(CUSTOM_CODE)
def parse_custom_noop(
op_id: int,
model: litert.ModelT,
subgraph: litert.SubGraphT,
air_model: AirModel,
) -> AirOperator:
"""Parse one LiteRT custom op into one AIR operator."""
op = subgraph.operators[op_id]
input_ids = [str(tid) for tid in op.inputs if tid >= 0]
output_ids = [str(tid) for tid in op.outputs if tid >= 0]
return AirOperator(
id=str(op_id),
op_type=CUSTOM_OP_KEY,
input_ids=input_ids,
output_ids=output_ids,
named_tensors={},
options=AirOperatorOptions(),
)
class CustomNoopOperator(AotOperator):
"""Minimal AOT operator that emits a tiny stub implementation."""
TYPE = CUSTOM_OP_KEY
def on_resolve(self):
# Add custom validation/mutations here if needed.
if len(self.input_tensors) != 1 or len(self.output_tensors) != 1:
raise ValueError("CUSTOM_NOOP expects exactly one input and one output")
def emit(self, save_path: Path):
# Keep output minimal. You can replace this with Jinja templates later.
c_name = f"{self.prefix}_{self.name}"
h_path = save_path / "includes-api" / f"{c_name}.h"
c_path = save_path / "src" / f"{c_name}.c"
h_path.write_text(
f"#pragma once\n"
f"#include <stdint.h>\n"
f"int32_t {c_name}(void *ctx);\n"
)
c_path.write_text(
f"#include \"{c_name}.h\"\n"
f"int32_t {c_name}(void *ctx) {{ (void)ctx; return 0; }}\n"
)
def customize_registry(context: RegistryContext) -> None:
"""Hook custom parser + custom AOT operator into conversion."""
context.litert_parsers.register(CUSTOM_OP_KEY, parse_custom_noop, overwrite=True)
context.aot_operator_classes.register(CUSTOM_OP_KEY, CustomNoopOperator, overwrite=True)
2) Build Registry Context With Your Customizer
from helia_aot.converter import AotConverter
from helia_aot.registry.context import build_default_registry_context
from my_plugin.custom_noop import customize_registry
registry_context = build_default_registry_context(
customizers=[customize_registry],
allow_override=True,
)
converter = AotConverter(config, registry_context=registry_context)
converter.convert()
3) Important Key-Matching Rule
Use helper custom_code_to_op_key(...) when you construct your registry key.
Example:
- LiteRT customCode="custom-noop" maps to registry key CUSTOM_NOOP
- custom_code_to_op_key("custom-noop") returns CUSTOM_NOOP
Current normalization behavior:
- trims whitespace
- converts to uppercase
- replaces hyphens (-) with underscores (_)
- does not currently rewrite other separators (for example .); do not use spaces in customCode, as spaces are not rewritten and will lead to invalid C identifiers in AOT-generated operator names
4) How This Flows at Runtime
- LiteRT parser sees custom op and derives
op_namefromcustomCode. context.litert_parsersresolves yourparse_custom_noop.- AIR operator is created with
op_type="CUSTOM_NOOP". - Resolve stage uses
context.aot_operator_classesto pickCustomNoopOperator. - Emit stage writes your custom source/header files.
5) What To Extend Next
- Add a typed options class and parse custom options payload.
- Emit real kernel call(s) instead of a stub return.
- Add unit test for parser dispatch.
- Add unit test for operator resolve/emit.
- Add one end-to-end conversion test with your customizer.
6) Notes
- CLI default path builds default registries only.
- Custom registry customizers are currently supported via Python API only (not CLI flags/config).
- For custom registries/custom ops, use Python API and pass explicit
registry_context. - Operator/tensor YAML attributes (
config.operators,memory.tensors) still apply independently.