Float Kernels (FP16 / FP32)
Experimental
Floating-point operator support is experimental. The API surface, the set of supported operators, and kernel selection heuristics may change without notice. Do not depend on it for production builds yet. Prefer quantized (int8/int16) deployment for anything shipping to customers.
Overview
heliaAOT can compile models whose tensors are float16 (FP16) or
float32 (FP32) and emit native floating-point kernels from
heliaCore (the customer-facing name for our optimized ns-cmsis-nn fork)
instead of the usual quantized int8/int16 paths. When a layer's tensors are
float, AOT selects the arm_*_f16 / arm_*_f32 kernel variant and links it
directly — there is no dequantize/requantize glue and no scale/zero-point
bookkeeping in the generated code.
This is useful when a model (or a specific layer) needs more dynamic range or numerical fidelity than int8 quantization provides, and the target SoC has the hardware to run float math efficiently.
Choosing a numeric format
Float support is not a free upgrade. Pick a format per the trade-offs below.
| Aspect | int8 / int16 (quantized) | float16 (FP16) | float32 (FP32) |
|---|---|---|---|
| Model size / flash | Smallest (~4× smaller than FP32) | ~2× smaller than FP32 | Largest |
| Runtime memory (arena) | Smallest | Medium | Largest |
| Throughput | Fastest on all cores (DSP/MVE int paths) | Fast only on MVE-FP cores; otherwise unusable | Slower than int8; needs an FPU |
| Numerical accuracy | Lowest; needs calibration / QAT | Good dynamic range, ~3 decimal digits mantissa | Full single precision |
| Author effort | Requires a representative calibration dataset (PTQ) or QAT | None — no calibration needed | None |
| Hardware requirement | Any supported Cortex-M | Cortex-M55-class (Armv8.1-M MVE-FP) | Single-precision FPU |
| Energy per inference | Lowest | Medium | Highest |
Rule of thumb
Reach for quantized int8 first — it is the smallest, fastest, and most broadly supported. Use FP16 when a layer is quantization-sensitive and you are targeting an FP16-capable core (e.g. Apollo510). Use FP32 when you need reference-quality numerics or are bringing up / debugging a model before quantizing.
Hardware requirements
| Format | Required CPU feature | Example Ambiq targets |
|---|---|---|
| FP32 | Hardware single-precision FPU | Apollo3/Apollo4 (Cortex-M4F), Apollo510 (Cortex-M55) |
| FP16 | Armv8.1-M MVE-FP (half-precision vector FP) | Apollo510, Atomiq110 (Cortex-M55) |
FP16 requires a Cortex-M55-class core
heliaCore's _f16 kernels require Armv8.1-M MVE-FP. Emitting them for a
core without it would fail to build or silently misbehave. heliaAOT gates
this at compile time: every float-capable operator calls a shared
_validate_dtype_supported() check, and conversion fails fast with a
clear error if a float16 tensor is scheduled onto a platform whose
SocPlatform.supports_fp16 is False. FP32 has no such gate — any target
with an FPU can run it.
How it works in heliaAOT
-
Frontend (LiteRT → AIR). The converter reads tensor dtypes directly from the flatbuffer.
FLOAT16maps tonp.float16andFLOAT32tonp.float32(seehelia_aot/converters/litert/utils.py), so the AIR model carries the true element type — including for constants (weights/biases), activations, and the graph's inputs/outputs. -
Kernel selection by output dtype. Float operators pick their CMSIS function suffix from the tensor dtype via the
dtype_func_suffix_maptemplate macro (float16 → f16,float32 → f32,int8 → s8, …). For example the transpose-conv template emitsarm_transpose_conv_<suffix>(...)keyed on the output tensor dtype. A fully-float layer therefore emits a single native float kernel call. -
FP16 platform gate. During
resolve()/validate(), float-capable operators call the shared_validate_dtype_supported()gate described above, keeping the "FP16 needs MVE-FP" rule in one place rather than re-implemented per operator. -
No quantization plumbing. Because the tensors are already float, the generated code carries no multipliers, shifts, or zero-points. The float CMSIS signatures are clamp-only (no offsets), so float operator families use their own template branches distinct from the quantized ones.
Relationship to LiteRT "FP16" conversion
Standard LiteRT FP16 conversion is weight-only
TFLite/LiteRT's built-in FP16 conversion
(optimizations=[DEFAULT], supported_types=[tf.float16]) is a storage
optimization: it keeps weight constants as FP16 but inserts a
DEQUANTIZE (float16 → float32) op in front of each layer, and leaves
activations and I/O in FP32. It cannot lower an FP16-activation graph and
cannot set FP16 inference I/O.
Consequently, a model produced that way does not exercise the
arm_*_f16 kernels — heliaAOT sees DEQUANTIZE(f16→f32) → arm_*_f32. For
weightless ops (elementwise, softmax, pooling, transpose) there is nothing
to quantize at all, so the op arrives as plain FP32.
To exercise genuine end-to-end FP16 (FP16 inputs, weights, bias, and
outputs → arm_*_f16), the FP16 e2e test models are produced by a
FlatBuffer ObjectAPI rewrite that drops the FP16→FP32 DEQUANTIZE nodes and
promotes the remaining FP32 tensors/buffers to FP16. See
tests/e2e/gen/_fp16.py.
How it works in heliaCore (ns-cmsis-nn)
heliaCore provides hand-optimized floating-point kernel variants alongside
the quantized ones, e.g. arm_convolve_f16 / arm_convolve_f32,
arm_transpose_conv_f16/_f32, arm_elementwise_{add,mul}_{f16,f32},
arm_nn_activation_{f16,f32}, and float pooling/softmax paths. Key points:
- Homogeneous typing. A CMSIS-NN kernel is typed by a single dtype suffix. There is no kernel that mixes FP16 weights with FP32 activations — that combination only exists as a LiteRT storage trick and must be resolved (dequantized) before a kernel runs. A native float layer is float end to end.
- FP16 vectorization. The
_f16kernels use Armv8.1-M MVE-FP (Helium) half-precision vector instructions, which is why they are restricted to Cortex-M55-class cores. Thefloat16_t/float32_ttypes come fromheliaCore'sarm_nn_math_types_flt.hand match the public float kernel API. - Registry-driven selection. As with quantized ops, the Kernel Registry filters candidate kernels by data type and CPU features and ranks them, so float layers only consider kernels the target can legally run.
Enabling the float kernels in the library
The float kernel objects only exist when ns-cmsis-nn itself was configured to
build them, so the switch belongs on the library, not on the generated module:
| Build system | Switch to set |
|---|---|
| CMake / NSX | ARM_NN_ENABLE_F32, ARM_NN_ENABLE_F16, set before add_subdirectory(<ns-cmsis-nn>) |
| Zephyr | CONFIG_NS_CMSIS_NN_ENABLE_F32, CONFIG_NS_CMSIS_NN_ENABLE_F16 in the app's prj.conf |
When the module links a kernel library it does not define these itself. It
inherits them from that library's usage interface via ns::cmsis-nn
(nsx::cmsis_nn on the NSX backend), and its CMakeLists.txt asks the library
what it was built with through ns_cmsis_nn_float_support(), falling back to
reading the definitions off the kernel target on releases that do not export
the query. Either way it fails configure when a float type the module needs is
missing. Asking the library rather than the CMake cache is what makes the check
work for a prebuilt library pulled in with find_package(ns-cmsis-nn), which
publishes nothing to the cache.
On the standalone CMake backend a parent may bring its own kernel target instead
of ns::cmsis-nn. The module links nothing in that case and has no library to
ask, so it defines ARM_NN_ENABLE_F32 (and ARM_NN_ENABLE_F16 when it needs
FP16) on its own target, keeping the float entry points declared by the public
headers. Set those switches to ON, or leave them unset to take that default;
setting either to OFF fails configure, because the module cannot dispatch
float kernels without them. The NSX backend has no such path: it always links
nsx::cmsis_nn, so the library is always there to answer.
The old NSX float-enable switches are gone
ns-cmsis-nn 7.32.0 and later reject the removed switch names at configure
time. Set ARM_NN_ENABLE_F32 and ARM_NN_ENABLE_F16 instead; the Zephyr
Kconfig symbols are unchanged.
Supported operators
The following operators have float coverage exercised by the e2e suite. FP16 additionally requires an MVE-FP target.
| Category | Operators | FP32 | FP16 |
|---|---|---|---|
| Convolution | Conv2D, DepthwiseConv2D, TransposeConv | ✅ | ✅ |
| Dense | FullyConnected | ✅ | ✅ |
| Elementwise | Add, Mul, Maximum, Minimum | ✅ | ✅ |
| Elementwise | Sub | ✅ | ✅ |
| Elementwise | Sqrt | ✅ | — |
| Elementwise | Abs | ✅ | ✅ |
| Activations | ReLU/ReLU6, Logistic, Tanh, Softmax | ✅ | ✅ |
| Activations | PReLU | ✅ | ✅ |
| Reduction | Sum (ReduceSum) | ✅ | ✅ |
| Pooling | AveragePool, MaxPool | ✅ | ✅ |
| MatMul | BatchMatMul | ✅ | ✅ |
| Tensor manip | Reshape, Pad, Transpose, Concatenation | ✅ | ✅ |
| Tensor manip | Pack | ✅ | ✅ |
| Tensor manip | Unpack | — | ✅ |
| Tensor manip | Slice, StridedSlice | ✅ | ✅ |
| Tensor manip | Split | ✅ | ✅ |
| Tensor manip | Squeeze, Dilate | ✅ | ✅ |
| Tensor creation | Fill, ZerosLike | ✅ | ✅ |
| Stateful | SVDF, UnidirectionalSequenceLSTM | ✅ | ✅ |
| Stateful | Fused rolled GRU, unrolled GRU graphs | — | ✅ |
TensorFlow GRU(unroll=False) converts to a standard LiteRT WHILE graph.
heliaAOT recognizes the Keras reset-after loop before AIR parsing and lowers it
internally to the canonical GRU tensor contract below. Model authors do not
need to create a custom LiteRT operator. Packed parameter rows are ordered
update, reset, candidate (z, r, n).
Rolled-GRU input must be fully FP16
Rolled-GRU fusion accepts only native FP16 models: inputs, activations,
weights, biases, and outputs must all be FLOAT16. The stock TensorFlow
FP16 export is weight-only (FLOAT16 constants behind DEQUANTIZE, with
FP32 activations), so heliaAOT rejects it at the fusion boundary with an
actionable UnsupportedModelError; it cannot run the FP16 GRU kernel.
The fusion pass is the default fuse_rolled_gru LiteRT model hook. Python
API callers supplying their own WHILE lowering may unregister that hook
with an override-enabled RegistryContext. Leaving the WHILE untouched
is not sufficient by itself because AIR does not represent control flow.
What the matcher requires
Because fully-FP16 models cannot come from the stock TensorFlow converter, incoming graphs are customer rewrites of its output. The matcher therefore verifies the GRU equations by dataflow rather than by tensor names, and a loop is fused only when all of the following hold:
- Gates read the slots the kernel hardcodes:
z,r,nmust beSPLIToutputs 0, 1, 2 of the input projection and[k·H, (k+1)·H)slices of the recurrent projection. - No matched
ADD,MUL,SUB, orFULLY_CONNECTEDcarries a fused activation. - The complement constant is exactly
1, giving(1 - z). - The
WHILEcond subgraph provably runs one iteration per frame: each bound isLESS(counter, time_steps), every counter starts at zero and increments by one, and the body'sGATHERindexes by one of them.
A loop that misses any of these is left as an ordinary WHILE, which then
fails as an unsupported operator. This is deliberate: the emitted kernel
call is identical for all of these variants, so fusing one would produce
silently wrong numerics rather than an error.
| Position | Tensor | Shape |
|---|---|---|
| Input 0 | Sequence | [1, time, input_size] |
| Input 1 | Initial hidden state | [1, hidden_size] |
| Input 2 | Packed input weights | [3 * hidden_size, input_size] |
| Input 3 | Packed recurrent weights | [3 * hidden_size, hidden_size] |
| Input 4 | Packed input bias | [3 * hidden_size] |
| Input 5 | Packed recurrent bias | [3 * hidden_size] |
| Output 0 | Hidden-state sequence | [1, time, hidden_size] |
| Output 1 | Final hidden state | [1, hidden_size] |
Operator order is not model order
The table above is the fused CUSTOM(GRU) operator's tensor order, which
is why the weights and biases appear as inputs. The converted model's
graph outputs are ordered the other way round: output 0 is the final hidden
state and output 1 is the sequence. Anything addressing the module by graph
index — a caller wiring the streaming loop, or an e2e case setting
test.state_feedback — must use the model order, so the state pairing is
[0, 1] (final state into initial state), not [1, 1].
GRU state is explicit graph I/O, unlike LSTM
The GRU inverts the LSTM state contract. LSTM state is an implicit
persistent variable tensor that context_init() zero-fills and the kernel
carries on its own; GRU state is an ordinary input and output, and carrying
it between model_run() calls is the caller's job. A nonzero initial state
is therefore a supported, tested feature.
Two consequences are enforced at conversion time. The initial- and
final-state tensors must not be PERSISTENT — a LiteRT variable tensor,
i.e. the partially-lowered Keras stateful=True form, which
context_init() would reset and for which no GRU codegen path is defined.
They must also be distinct tensors: seeding copies the initial state into
the final-state buffer, which is not well defined when the two alias.
Note
This table reflects current test coverage, not a hard capability boundary.
An empty cell (—) means there is no e2e case for that precision today; it
does not necessarily imply the op cannot be supported. Verify against the
e2e cases under tests/e2e/cases/**/op_*_f16.yaml and *_f32.yaml.
Byte movers do not need the float kernel build
Squeeze, Fill, ZerosLike, Dilate, Pack and Unpack move bytes without
arithmetic, so they call no arm_*_f32 / arm_*_f16 kernel. Their FP32
lowering therefore builds against an integer-only ns-cmsis-nn; their FP16
lowering depends on the float API only because the generated bindings name
float16_t, a type arm_nnfunctions_flt.h introduces behind
ARM_NN_ENABLE_F16. They declare that narrower dependency with
CMSIS_FLOAT_KERNEL_DTYPES = frozenset({np.float16}). Squeeze is the
exception: it binds int8_t like Reshape, so its declaration is
conservative rather than required.
Operators that declare their own floor
These operators carry an explicit float-kernel version requirement:
| Category | Operators | FP32 | FP16 | Declares |
|---|---|---|---|---|
| Elementwise | Abs | ✅ | ✅ | ns-cmsis-nn v7.31.0 |
| Activation | PReLU | ✅ | ✅ | ns-cmsis-nn v7.31.0 |
| Reduction | Sum (ReduceSum) | ✅ | ✅ | ns-cmsis-nn v7.31.0 |
| Reduction | Mean | ✅ | ✅ | ns-cmsis-nn v7.32.0 |
| Elementwise | Mul (including native broadcasting) | ✅ | ✅ | ns-cmsis-nn v7.32.0 |
A module renders the highest of the repo-wide floor and every operator's
declared requirement — into the generated <prefix>_common.h, the module's own
README, and the CMSIS-Pack .pdsc dependency — so a too-old library is caught
at pack resolution or at the first compile rather than at link time. The
repo-wide floor is v7.32.0, which satisfies these declarations, so a
module containing these operators declares v7.32.0 today.
Pin v7.32.0
These kernels first appeared in ns-cmsis-nn v7.30.0, but do not pin that release: it carries buffer-sizer and undefined-behaviour defects whose fixes shipped in v7.31.0 (released 2026-09-01). There is no v7.30.1 — the fix release was cut as a minor because features landed alongside it. The repo-wide floor is v7.32.0, one release higher again, so that is the version to pin for any module. Tracked in #304 and #385.
Testing & validation
Float operators are validated by the e2e harness (tests/e2e/test_e2e.py),
which converts a per-op model and asserts the generated module invokes the
expected native kernel with no dequantize inserted. Two reference paths exist:
- Interpreter path — ops the ai-edge-litert interpreter can run in FP16 via XNNPACK (ReLU, Logistic, Tanh, Pad, Reshape, Concatenation, Min/Max) produce their golden output on the fly.
- Golden-data path — ops the interpreter rejects in FP16 (conv family,
FullyConnected, Softmax, Mul, BatchMatMul, pooling, Transpose, SVDF,
UnidirectionalSequenceLSTM, fused rolled GRU, unrolled GRU, Abs, PReLU,
and ReduceSum graphs) ship pre-computed
golden.npzfixtures (golden_data:in the case YAML), so conversion is still verified without needing the interpreter to execute the op.
At the unit level, tests/unit/converters/test_convert_backend_model.py
round-trips FP16 constants and I/O through the LiteRT→AIR frontend, and the AIR
codegen contract tests build FP16 models directly at the AIR level.
Limitations & caveats
- Experimental — see the banner at the top of this page.
- FP16 is unavailable on non-MVE-FP cores; conversion fails fast rather than producing a broken build.
- You generally cannot obtain a genuine all-FP16
.tflitefrom stock LiteRT conversion (it is weight-only). Producing true FP16 activation graphs currently requires a FlatBuffer-level rewrite (see the test helper above). - Float paths trade flash, RAM, and (for FP32) throughput for accuracy and author convenience. Quantize when footprint and energy matter.
- Grouped float
CONV_2Dis unsupported. The CMSIS-NN float convolution kernels requirefilter_input_channels == input_channels; conversion fails fast. Split into per-group ops or use a depthwise/quantized path. - Float
DEPTHWISE_CONV_2Dusually allocates no scratch at all. From ns-cmsis-nn v7.32.0 the float depthwise kernel is bufferless on every route but one: on an MVE target, a single-input-channel shape with a depth multiplier above one, whose output-channel count clears the convert-to-convolution threshold, is rerouted through the float convolution wrapper and needs a packed-kernel buffer. heliaAOT sizes that buffer as an upper bound, not an exact match for every build. The threshold (CONVERT_DW_CONV_WITH_ONE_INPUT_CH_AND_OUTPUT_CH_ABOVE_THRESHOLD) is 1 under most compilers and 8 under armclang, and definingARM_MATH_AUTOVECTORIZEdisables the reroute outright; codegen sees neither, so it sizes for the widest admitted set and an armclang or autovectorized build can carry a buffer it never reads. Scratch is zero for depth multiplier 1 on every target, because that shape takes the direct channel-vectorized kernel, which returns beforectx->bufis read. It is nonzero only for single-input-channel layers with a depth multiplier above one on MVE targets, and the defaultDEPTHWISE_TO_CONVtransform rewrites exactly those toCONV_2Dbefore codegen, so the larger allocation appears only when that transform is disabled. - Float transpose-convolution stays on the native
arm_transpose_conv_f32/_f16kernel. It is never rewritten toCONV_2D, because the float convolution kernels have noupscaleargument and would silently drop the stride upscaling. - Float
MEANuses native reduction. Positive rank-1 through rank-4 inputs dispatcharm_nn_mean_f32/_f16from v7.32.0, using a four-axis mask and no quantization parameters or sum-and-scale loop. Constant INT32 scalar or vector axes must be nonempty and in range; negative axes are normalized.keep_dimsoutput ranks are preserved and validated. The kernels accumulate FP16 inputs in FP32 before rounding the result to FP16. - Float
MULsupports native NHWC broadcasting. Equal-shape operands retain the existing flat kernel path. Differing shapes usearm_elementwise_mul_broadcast_f32/_f16from v7.32.0 for positive rank-4 NHWC tensors and rank-zero scalar inputs, in either operand order. This includes[1,1,T,C] * [1,1,1,C]channel gates. Other broadcast ranks, incompatible output shapes, zero extents and overflowing sizes are rejected. Activation bounds match the existing flat path: evenNONEuses finite dtype limits, so overflow and infinities saturate to finite limits. FP16 multiplication rounds before clamping; NaNs remain NaNs, and signed-zero behavior can differ from LiteRT. - Float
ADDis still elementwise-only. Its AOT lowering requires identical shapes.SUBretains its separate scalar-broadcast behavior described below. - Float
SUBsupports elementwise and scalar broadcasting only. This covers the1 - update_gateexpression emitted by unrolled GRU lowering; general multidimensional broadcasting remains unsupported. The two shapes lower differently: equal-shape operands dispatch the heliaCore kernelarm_elementwise_sub_{f16,f32}(and may carry a fused activation, which the kernel clamps), while a scalar operand falls back to a plain-C loop because the float kernels walkblock_sizecontiguous elements per operand and do not broadcast. The fallback emits no clamp, so a fused activation is rejected on that path. The clamp difference is visible even with no fused activation: the kernel path passes the same finite sentinelsADD/MULuse (-FLT_MAX/FLT_MAX, orARM_NN_F16_FINITE_LOWEST/ARM_NN_F16_FINITE_MAX), so an FP16 difference that overflows saturates to 65504 rather than returning an infinity, while the scalar-broadcast loop applies no clamp at all and lets the overflow through. - Float
SPLITon FP32 is amemcpy, not a kernel. ns-cmsis-nn exportsarm_split_f16but noarm_split_f32, so an FP32SPLITkeeps the genericarm_memcpy_s8copy loop thatsplit.c.j2already used forint32. The result is identical (a split is a pure copy) and FP32SPLITtherefore declares noARM_NN_ENABLE_F32dependency; only the FP16 path reaches the float API. - Float
ABS,PRELUandSUMrequire ns-cmsis-nn v7.31.0 or newer. Their float entry points (arm_nn_abs_{f16,f32},arm_prelu_{f16,f32},arm_reduce_sum_{f16,f32}) first shipped in v7.30.0, which is published but carries defects whose fixes shipped in v7.31.0. Operators declare this throughMIN_CMSIS_NN_VERSION_FLOAT, and the module handler raises the floor it writes into<prefix>_common.hand the.pdscpack dependency to match — so the requirement is visible to a pack resolver, not only as a per-kernel#error. The generated#errorcompares all three version components, so a v7.30.0 checkout is rejected rather than accepted as "7.x or newer". The repo-wide floor is v7.32.0, above these declarations, so they raise nothing today; integerABS/PRELU/SUMbuild against that same repo-wide floor. - Float
PRELUalpha must be broadcastable, not arbitrary. The floatarm_prelu_{f16,f32}kernels takealpha_dimsas acmsis_nn_dimsalongsideinput_dims, so each NHWC-padded alpha dimension must either equal the corresponding input dimension or be 1 — the same rule TFLite uses. Anything else is rejected at conversion. Separately, heliaAOT'sPRELUlowering is int8-only on the quantized path — ns-cmsis-nn does ship anarm_prelu_s16kernel, but it is not wired up here. - Float LSTM carries recurrent state across invocations.
arm_lstm_unidirectional_*seeds each run fromcmsis_nn_lstm_context_{f32,f16}::hidden_state, which codegen points at the persistentoutput_stateslot, so state persists exactly as in the LiteRT reference interpreter. The float contexts gainedhidden_statein ns-cmsis-nn v7.29.0 (v7.28.0 has it for int8 only), so that is where the capability starts; the repo-wide floor is higher still at v7.32.0, which is what a generated module actually declares. Recurrent state must not be exposed as a model input, and baked-in initialoutput_state/cell_statevalues are rejected, becausecontext_init()resets every persistent slot on eachmodel_init(). Callmodel_init()to reset the state between independent sequences; within a sequence the state carries acrossmodel_run()calls on its own. Float state needs no zero-point fixup: the raw-zero fill is already real-valued zero (the int8 path re-seedsoutput_stateto its zero point for exactly this reason). - SVDF state also carries through its persistent slot. Its
statetensor must not be exposed as a model input; caller-supplied recurrent state is not implemented and could not be verified, because the generated test harness resets graph inputs between iterations while the LiteRT reference preserves state. - Fused float16 GRU uses explicit state.
CUSTOM(GRU)accepts a batch-major sequence and initial hidden state, processes every frame without resetting that state, and returns both the output sequence and final hidden state. Streaming callers carry state across invocations by feeding the previous final state into the next initial-state input. Batch size is currently 1 and only the Keras-compatiblereset_after=Trueequations are supported. This mirrorsarm_gru_unidirectional_f16, whosebuffers->hidden_state"seeds the initial state and receives the final hidden state on return". The e2e suite covers that streaming contract withtest.state_feedback, which makes the generated harness seed a state input from the stimulus only on the first iteration and feed the named output back into it after everymodel_run(seeop_rolled_gru_stateful_f16andop_unrolled_gru_stateful_f16). - Weight-only FP16
DEQUANTIZE(f16→f32)runs on FP32-only targets. It is a storage conversion (not native FP16 compute), so it is not gated onsupports_fp16; on non-FP16 targets it emits a toolchain-independent IEEE half→float expansion instead of the ns-cmsis-nnfloat16_tcast.