Skip to content
heliaEstimator
User guide
HELIA HUB

ETHOS_U_STREAM_SPLIT contract

Owner of the rewrite and this contract: helia-npu (helia_npu.fencing). Consumers: helx’s local FVP runner (kernel in helia_estimator/local_fvp_runtime/src/stream_split.cpp), heliaRT (in-tree registration factory when fenced models become a supported format; app-side AddCustom until then), heliaAOT 1.3.0 and later. All three engines support HELX fenced per-segment profiling. Contract version: 1 (custom-op version 1). Any change below bumps it.

The custom-op identifier replaces the earlier STREAM_SPLIT name. Regenerate fenced models from their original unfenced input before using this runner; the old identifier is not registered as an alias. Version 1 describes the unchanged identity/copy semantics of the new identifier. Python constant names and /stream_split tensor-name suffixes remain unchanged.

Vela ends the current Ethos-U command stream at a custom op it cannot fuse. ETHOS_U_STREAM_SPLIT is an identity op inserted between two original operators so that the graph is compiled as N NPU command streams with a CPU handoff at each fence. It exists to make a boundary, not to compute.

Item Value
OperatorCode.customCode "ETHOS_U_STREAM_SPLIT" (exact, case-sensitive; consumers may canonicalize case on lookup)
OperatorCode.builtinCode / deprecatedBuiltinCode CUSTOM (32)
OperatorCode.version 1
inputs N ≥ 1: all tensors produced at/before after_op and consumed later or returned by the graph
outputs N outputs, paired by index with the inputs; first output is the sidecar join key; additional outputs have cut-specific names
builtinOptionsType NONE (0); builtinOptions absent
customOptions absent (no flexbuffer)
output tensor same shape, type, and a deep copy of quantization (scale, zero point, per-axis fields) as the input; isVariable = false; new empty buffer
rewiring every later operator that consumed the intercepted tensor now consumes the new tensor; if the intercepted tensor was a graph output, the graph output is rewired to the new tensor
placement inserted immediately after operator after_op; fences are applied in descending order so original indices stay valid during the rewrite
existing operator code a ETHOS_U_STREAM_SPLIT entry already in the model is reused only if its effective builtin code is CUSTOM (the larger of builtin_code and deprecated_builtin_code, as TFLite’s GetBuiltinCode reads the v3 and v3a schemas, so 32/32, 32/0 and 0/32 qualify, while a non-CUSTOM builtin_code with a CUSTOM deprecated field does not, since Vela reads builtin_code first) and its version is 1; a reused entry is passed through as written, not rewritten to 32/32; any other same-name entry is rejected before output is written, never reused or duplicated
subgraph 0 only; a model with more than one subgraph is rejected by _primary_subgraph before anything is written

Supported dense storage types: int8, uint8, int16, int32, int64, float16, float32 and bool. Pairs may use different types and shapes; each output must match its corresponding input. Byte counts come from tensor metadata, not a fixed element size. Packed INT4, sparse, string/resource and variable tensors are outside this contract. FP16 copying requires no floating-point arithmetic.

The rewrite carries residual paths and all live outputs, not just the selected operator’s first output. Constants and graph inputs are already available and are not copied. This establishes data dependencies across the cut; it does not impose a global order on unrelated branches with no crossing data dependencies. This is the initial contract for the renamed operator, including multi-tensor support.

  • Identity of contents per pair: output bytes equal input bytes; dtype, shape, quantization unchanged. byte length = flat size × element size and must match on both sides; a kernel must fail Prepare on any count, type, shape or byte-length mismatch rather than truncate.
  • Copy, not alias, by default: the output is a distinct buffer with a distinct lifetime. Exact alias (out == in) is permitted and is a no-op. Outputs overlapping another pair’s inputs or outputs, and partial overlap within a pair, are rejected before copying so no destination clobbers data still needed by another pair.
  • Execution boundary: the op must remain a scheduling point between the NPU region that produces the input and the NPU region that consumes the output. A compiler must not eliminate it as a reshape or no-op even when aliasing would be legal.
  • What it does not promise: no cache maintenance, no coherency, no resumable or cooperative scheduling. Those belong to the runtime’s Ethos-U driver handoff and are qualified on the target, not by this op.

RewriteResult and the <stem>.fenced_boundaries.json sidecar record: source_model, source_sha256 (of the model the fences were cut into), subgraph, num_segments, boundary_after_ops (original operator indices, authoritative), and per segment op_range_start, op_range_end, tensor_pairs (actual input/output names in the final fenced graph), end_tensor_name (the first intercepted tensor in the original source model) and fence_tensor_name (<name>/stream_split). plan_fences attaches the same output_tensor_name, opcode, segment_index to each Fence and records model_sha256 when given the model. A consumer joins across recompilation by (source_sha256, after_op) first and tensor names second; compiled and runtime operator indices are different coordinates and are not carried here.

A plan is bound to the model it was cut for. fence_model (via check_plan_binding) rejects, before writing, a FencePlan whose model_sha256 (when the planner recorded one) or operator count does not match the model it is applied to or whose subgraph is not 0, and any Fence carrying identity whose output_tensor_name or opcode is not what sits at after_op in that model, or whose after_op follows the last operator (FenceBindingError). A plan cut without the model has no hash and is bound by count and names only. The unbound path is positions alone: plan.after_ops, a list of ints, or a FenceConfig (fence_config_for is the deliberate reduction of a plan to positions) given to rewrite_model_with_fences; that is the caller asserting the coordinates apply.

plan_fences budgets on Vela’s estimate attributed to original operators. FenceCoverage reports the denominator (sum of op_cycles over all Vela rows), the matched part, and the unmatched rows. A cycle-based policy raises when coverage is below min_coverage (default 100%) or when there are no rows at all; lowering the threshold records uncertainty, it does not create a latency bound. An operator larger than the budget is isolated, not split.