Skip to content

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.

ValueError: f16 kernels require a Cortex-M55-class/FP16 platform; got
'apollo4p_evb' (cpu=cortex-m4)

How it works in heliaAOT

  1. Frontend (LiteRT → AIR). The converter reads tensor dtypes directly from the flatbuffer. FLOAT16 maps to np.float16 and FLOAT32 to np.float32 (see helia_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.

  2. Kernel selection by output dtype. Float operators pick their CMSIS function suffix from the tensor dtype via the dtype_func_suffix_map template macro (float16 → f16, float32 → f32, int8 → s8, …). For example the transpose-conv template emits arm_transpose_conv_<suffix>(...) keyed on the output tensor dtype. A fully-float layer therefore emits a single native float kernel call.

  3. 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.

  4. 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 _f16 kernels use Armv8.1-M MVE-FP (Helium) half-precision vector instructions, which is why they are restricted to Cortex-M55-class cores. The float16_t / float32_t types come from heliaCore's arm_nn_math_types_flt.h and 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, n must be SPLIT outputs 0, 1, 2 of the input projection and [k·H, (k+1)·H) slices of the recurrent projection.
  • No matched ADD, MUL, SUB, or FULLY_CONNECTED carries a fused activation.
  • The complement constant is exactly 1, giving (1 - z).
  • The WHILE cond subgraph provably runs one iteration per frame: each bound is LESS(counter, time_steps), every counter starts at zero and increments by one, and the body's GATHER indexes 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.npz fixtures (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 .tflite from 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_2D is unsupported. The CMSIS-NN float convolution kernels require filter_input_channels == input_channels; conversion fails fast. Split into per-group ops or use a depthwise/quantized path.
  • Float DEPTHWISE_CONV_2D usually 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 defining ARM_MATH_AUTOVECTORIZE disables 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 before ctx->buf is read. It is nonzero only for single-input-channel layers with a depth multiplier above one on MVE targets, and the default DEPTHWISE_TO_CONV transform rewrites exactly those to CONV_2D before codegen, so the larger allocation appears only when that transform is disabled.
  • Float transpose-convolution stays on the native arm_transpose_conv_f32/_f16 kernel. It is never rewritten to CONV_2D, because the float convolution kernels have no upscale argument and would silently drop the stride upscaling.
  • Float MEAN uses native reduction. Positive rank-1 through rank-4 inputs dispatch arm_nn_mean_f32 / _f16 from 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_dims output ranks are preserved and validated. The kernels accumulate FP16 inputs in FP32 before rounding the result to FP16.
  • Float MUL supports native NHWC broadcasting. Equal-shape operands retain the existing flat kernel path. Differing shapes use arm_elementwise_mul_broadcast_f32 / _f16 from 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: even NONE uses 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 ADD is still elementwise-only. Its AOT lowering requires identical shapes. SUB retains its separate scalar-broadcast behavior described below.
  • Float SUB supports elementwise and scalar broadcasting only. This covers the 1 - update_gate expression emitted by unrolled GRU lowering; general multidimensional broadcasting remains unsupported. The two shapes lower differently: equal-shape operands dispatch the heliaCore kernel arm_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 walk block_size contiguous 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 sentinels ADD/MUL use (-FLT_MAX/FLT_MAX, or ARM_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 SPLIT on FP32 is a memcpy, not a kernel. ns-cmsis-nn exports arm_split_f16 but no arm_split_f32, so an FP32 SPLIT keeps the generic arm_memcpy_s8 copy loop that split.c.j2 already used for int32. The result is identical (a split is a pure copy) and FP32 SPLIT therefore declares no ARM_NN_ENABLE_F32 dependency; only the FP16 path reaches the float API.
  • Float ABS, PRELU and SUM require 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 through MIN_CMSIS_NN_VERSION_FLOAT, and the module handler raises the floor it writes into <prefix>_common.h and the .pdsc pack dependency to match — so the requirement is visible to a pack resolver, not only as a per-kernel #error. The generated #error compares 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; integer ABS/PRELU/SUM build against that same repo-wide floor.
  • Float PRELU alpha must be broadcastable, not arbitrary. The float arm_prelu_{f16,f32} kernels take alpha_dims as a cmsis_nn_dims alongside input_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's PRELU lowering is int8-only on the quantized path — ns-cmsis-nn does ship an arm_prelu_s16 kernel, but it is not wired up here.
  • Float LSTM carries recurrent state across invocations. arm_lstm_unidirectional_* seeds each run from cmsis_nn_lstm_context_{f32,f16}::hidden_state, which codegen points at the persistent output_state slot, so state persists exactly as in the LiteRT reference interpreter. The float contexts gained hidden_state in 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 initial output_state/cell_state values are rejected, because context_init() resets every persistent slot on each model_init(). Call model_init() to reset the state between independent sequences; within a sequence the state carries across model_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-seeds output_state to its zero point for exactly this reason).
  • SVDF state also carries through its persistent slot. Its state tensor 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-compatible reset_after=True equations are supported. This mirrors arm_gru_unidirectional_f16, whose buffers->hidden_state "seeds the initial state and receives the final hidden state on return". The e2e suite covers that streaming contract with test.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 every model_run (see op_rolled_gru_stateful_f16 and op_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 on supports_fp16; on non-FP16 targets it emits a toolchain-independent IEEE half→float expansion instead of the ns-cmsis-nn float16_t cast.