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.
Purpose
Section titled “Purpose”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.
Wire format (TFLite flatbuffer)
Section titled “Wire format (TFLite flatbuffer)”| 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.
Boundary semantics
Section titled “Boundary semantics”- Identity of contents per pair: output bytes equal input bytes; dtype, shape,
quantization unchanged.
byte length = flat size × element sizeand must match on both sides; a kernel must failPrepareon 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.
Identity carried with a fenced model
Section titled “Identity carried with a fenced model”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.
Cost planning caveat
Section titled “Cost planning caveat”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.