Run decoder on Apple Neural Engine #3849
chenqianhe
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
TL;DR: I got an experimental Core ML decoder path running on ANE. The working design uses sharded MLProgram models, self-K/V in MLState, and explicit FP16 cross-K/V inputs. Correctness now matches the tested CPU path, but the end-to-end result is not yet faster than encoder-only Core ML.
This work started from the old limitation discussed in Core ML Support #548: the encoder is a mostly static graph and works well with Core ML/ANE, while the decoder has dynamic token-by-token behavior and KV-cache state, which historically made it hard or inefficient to place on ANE. Newer Core ML stateful model support changes part of that tradeoff. Apple’s Core ML documentation describes MLState as intended for recurrent/stateful workloads such as transformer KV caches, and Apple’s on-device Llama writeup also points out that stateful KV cache can avoid repeated KV I/O.
The goal here was specifically to test whether the decoder can run on ANE (CPU_AND_NE), not merely whether it can run through Core ML. That distinction matters because MLComputeUnits.all and CPU_AND_GPU often place the decoder on GPU/MPSGraph, which can be correct and fast but does not answer the ANE question.
Environment used in the main experiments
(Failed) full stateful Core ML decoder graph with KV state
The first direct attempt was a full stateful Core ML decoder graph with KV state. That did not work on ANE. With CPU_AND_NE, Core ML failed ANE compilation with an error like:
This was the first major design constraint. The full decoder graph was too large or too awkward for ANE compilation when self-attention, state reads/writes, cross-attention, MLPs, residuals, and logits were kept together.
I then compared placement behavior against the encoder. For large-v2, the encoder Compute Plan placed roughly 2443 ops on MLNeuralEngineComputeDevice in both cpu_ne and all. The decoder did not behave the same way. Full decoder under cpu_gpu or all generally preferred GPU/MPSGraph, while cpu_ne failed for the full graph.
Several reduced experiments were tried:
This led to the current shard design.
Instead of one large decoder graph, the decoder is exported as no-write shards. Each shard reads self-K/V state and emits new K/V outputs, but the runtime writes those outputs back to the corresponding MLState. This avoids asking Core ML to compile the whole state-update decoder as one ANE graph.
8-layer shards working large-v2 experiment
The first working large-v2 experiment used four 8-layer shards:
The first shard consumes token IDs plus position information. Middle shards consume hidden states. The final shard emits logits. The runtime chains the shards and copies final logits back into whisper.cpp.
This compiled, but the first true cpu_ne run produced wrong logits. A 1-token prompt diagnostic showed:
A direct shard diagnostic showed divergence starting in
s0 x_out, with approximately:At the same time,
allmode matched much better. For example, a smalls0-l1comparison showedallwithx_out max_abs=0.008269 mean_abs=0.001427, whilecpu_nehad much larger error aroundmax_abs=1.754883 mean_abs=0.288577. Butallwas not an ANE solution because the Compute Plan preferred GPU.The root cause was cross-attention KV handling. Keeping cross V in
MLStatecausedCPU_AND_NEdivergence. The fix was to keep self-K/V inMLState, but pass cross-K/V as explicit FP16MLMultiArrayinputs to each shard. After that change, thecpu_nepath matched the CPU path.Difference between MLMultiArray and MLState (My personal analysis and conjecture; may not be correct.)
The key difference between
MLMultiArrayandMLStateis not the dtype, but how the Core ML runtime feeds them into the execution graph.MLMultiArrayis a regular input/output tensor. Each timepredictis called, it is passed as anMLFeatureValue, and Core ML treats it as an input feature for that invocation. Apple’s documentation defines it as a multidimensional numeric array type, with support for data types such as FP16 and FP32.MLState, on the other hand, is a persistent state handle for stateful models. Core ML allocates state buffers for the states declared by the model; during inference, the model is invoked viaprediction(... using: state ...), and the graph reads those buffers internally through ops such asread_state. Both Apple and coremltools documentMLStateas the mechanism for preserving and updating model state across multiple predictions.As a result, even if both eventually contain FP16 buffers underneath, they enter the ANE execution path differently:
MLMultiArrayinput: a regular graph input, lowered according to input-feature shape, stride, and placement rules.MLState: a mutable persistent resource, read inside the graph viaread_state, with its lifecycle, aliasing, caching, device-side state buffers, and possible state synchronization managed by Core ML.MLStatealso implies reuse of the same state across multiple prediction calls, which adds more constraints for the ANE compiler/runtime.In our case, the issue does not look like "FP16 data was stored incorrectly". It looks more like a limitation or bug in how ANE lowers or places certain large
MLStateread patterns.The main reasons I do not believe this is simply a mistake on our side are:
cpu_only.cpu_neproduces aligned top-token/logit results.cpu_newith an ANE compiler-14error, suggesting that this state-graph pattern itself hits an ANE compiler limitation.That said, this does not mean that
MLStateis generally broken in Core ML. A more accurate conclusion is:MLStateis most naturally suited for decoder self-K/V caches, which are true cross-token accumulated states. Cross-K/V, however, is derived from the encoder output and is effectively a per-segment constant. It does not strictly need to be modeled as state. We originally placed it inMLStateto unify KV-cache management and reduce the number of per-step input arguments. In practice, this ANE state-read path turned out to be unstable, so we moved cross-K/V back to regular inputs.This defines the boundary of the current design:
MLState, because it is genuinely cross-token state and has been verified to align undercpu_ne.MLMultiArrayinputs, because it is fixed context for each audio segment, better matches regular input semantics, and avoids the problematic ANEread_statepath.In short, this is not a case of FP16 being unusable, nor is it necessarily an obvious API misuse. It is more likely an implementation limitation or edge-case bug in Core ML stateful MLProgram execution on ANE for large read-only cross states. The current implementation therefore routes cross-K/V through the regular input path, which has been empirically verified to remain stable and aligned during real greedy decoding.
References:
MLMultiArraydocumentation: https://developer.apple.com/documentation/CoreML/MLMultiArrayMLStatedocumentation: https://developer.apple.com/documentation/CoreML/MLState?changes=_5Final parity checks after the explicit cross-K/V input fix:
Compilation and placement results
All four decoder shards compile successfully with
CPU_AND_NE. The resulting preferred placement is overwhelmingly on the Neural Engine:cpu_necompiles0-l8-tokens8-l8s16-l8s24-l8-logitsThe 5 CPU ops in the first token-input shard are small embedding/position-indexing operations, such as
greater_equal,add,select,gather, andcast. The main compute-heavy parts of the decoder — attention, MLP, layer normalization, softmax, andread_state— are all preferred on the Neural Engine.With the corrected sharded decoder running under
CPU_AND_NE, using four shards connected sequentially and a 16-token probe, the observed prediction timings are:The short JFK sample matched the CPU transcript. Representative decoder timings for the final ANE shard path were around:
Compute Plan inspection for the final four-shard path showed the important decoder operations, including
einsum,softmax,conv,layer_norm,read_state, and final logits matmul/reshape, preferredMLNeuralEngineComputeDevice.sequenceDiagram participant App as whisper.cpp decode loop participant CPU as Host CPU / ggml state prep participant S0 as Core ML shard0 L0-7 participant S1 as Core ML shard1 L8-15 participant S2 as Core ML shard2 L16-23 participant S3 as Core ML shard3 L24-31 participant Sampler as greedy sampler App->>CPU: prepare token, pos, slot_mask, self_mask App->>CPU: copy cross-K/V into shard inputs once per segment loop each prompt or generated token CPU->>S0: token/pos or hidden + masks + cross-K/V + MLState self-K/V S0-->>CPU: hidden + k_out/v_out for L0-7 CPU->>CPU: write k_out/v_out back to shard0 MLState CPU->>S1: hidden + masks + cross-K/V + MLState self-K/V S1-->>CPU: hidden + k_out/v_out for L8-15 CPU->>CPU: write k_out/v_out back to shard1 MLState CPU->>S2: hidden + masks + cross-K/V + MLState self-K/V S2-->>CPU: hidden + k_out/v_out for L16-23 CPU->>CPU: write k_out/v_out back to shard2 MLState CPU->>S3: hidden + masks + cross-K/V + MLState self-K/V S3-->>CPU: logits + k_out/v_out for L24-31 CPU->>CPU: write k_out/v_out back to shard3 MLState CPU->>Sampler: copy logits Sampler-->>App: next token endFinal shard plan
The shard plan also changed. The original fixed 8-layer plan was not the best fit across models. A dynamic shard planner now chooses by decoder layer count:
Some measured partition data from the exploration:
The current selected plan favors simpler artifact layout and practical runtime behavior rather than only the absolute best isolated microbenchmark in every case.
The other bottlenecks
Multi-candidate decoding
The first working Core ML decoder path was greedy-only. That was enough to prove that ANE execution and transcript parity were possible, but it was not enough for normal whisper.cpp behavior. The default
whisper_fullpath may use beam search, best-of sampling, temperature fallback, grammar filtering, and multiple candidate decoders. Each candidate has its own token history, and therefore each candidate must also have its own self-attention KV state.The ggml decoder already has logic to copy and reorder KV cache entries when candidates are cloned, pruned, or reassigned. The Core ML decoder needed equivalent state management. Reusing one Core ML
MLStateacross candidates is incorrect: once candidate A and candidate B choose different next tokens, their self-K/V states diverge. If they share the same state, later logits are computed from a mixed history and beam search becomes semantically wrong.The runtime now keeps an array of Core ML decoder contexts, one per active decoder/candidate. After the prompt is evaluated once, the prompt state is copied from candidate 0 into the other active candidates. During beam search, when the best candidates are selected and moved between decoder slots, the runtime copies only the self-state needed for the candidate transition. Cross-K/V does not need to be recopied in those self-state-only moves because it is fixed for the segment and shared by value in the explicit input arrays.
That distinction is reflected in the API shape:
whisper_coreml_decoder_new_like(...)creates a full decoder context copy, including cross inputs;whisper_coreml_decoder_new_like_self(...)creates a decoder context for self-state use without duplicating cross inputs unnecessarily;whisper_coreml_decoder_copy_state(...)copies both self-state and cross inputs;whisper_coreml_decoder_copy_self_state(...)copies only self-attention state.This made the Core ML decoder compatible with the normal logits-based decoding paths: beam search, best-of, temperature sampling, temperature fallback, and grammar penalties.
A related correctness issue was how Core ML state and input arrays were copied. The first implementation treated
MLMultiArraymemory too much like a flat C buffer. That is risky becauseMLMultiArrayhas shape and stride metadata, and the memory layout is not something the runtime should blindly assume in every case. A rawmemcpycan be correct for a contiguous array with the exact expected layout, but it is not a safe general state-copy operation.The fix was to make copying layout-aware. For state-to-state copies, the runtime uses Core ML’s
transferToMultiArray:when available, after checking data type, element count, and shape. For F16 host data copied into Core ML arrays, the runtime checks the expected 4D ANE layout and writes by channel/time strides. The same care is used when writing thek_out_*andv_out_*outputs from a no-write shard back into the correct self-K/V state slot. This avoids silent corruption if the array is valid but not laid out as a trivial contiguous buffer.No-speech probability
no_speech_probis computed immediately after the prompt decode, before logit filtering. In the CPU path, the prompt decode produces logits for each prompt token, and the probability must be computed from the final prompt row. The Core ML prompt path also produces logits for the prompt, but the first version did not consistently read the same row that the CPU path uses. That can give a wrongno_speech_prob, which affects segment rejection/fallback decisions even if the generated transcript looks correct.The fix was to align the Core ML prompt logits layout with the existing
whisper_fulllogic: after prompt decode,state->decoders[0].i_batchis set toprompt.size() - 1, andno_speech_probis computed fromstate->logits + i_batch * n_vocab. This keeps the Core ML decoder path semantically aligned with the CPU decoder path before any sampling, grammar, timestamp, or fallback logic runs.End-to-end results
End-to-end results are more mixed. This path is a functional ANE decoder path, but it is not automatically faster than the existing encoder-only Core ML path.
With default-style decoding parameters (
-bs 5 -bo 5 -tp 0.0) onsamples/jfk.wav, 3-run median:With greedy settings:
the results were:
So the decoder path can be correct and can run on ANE, but on this JFK benchmark it is still slower than the CPU decoder inside the encoder-only Core ML configuration. The main benefit demonstrated here is feasibility and placement correctness, not a clear end-to-end performance win yet.
Current known limitations:
--dtwtoken timestamps are not supported yet because the Core ML decoder shards currently expose logits/state, not the cross-attention alignment tensors needed by DTW.-acpartial audio context is not supported because the shards currently use fixed fulln_audio_ctxcross-K/V inputs. Supporting-accorrectly would need a cross-attention mask, flexible-shape model support, or separate artifacts per audio context.To reproduce the current path for large-v2:
Decoder-only build is also possible:
Code details
#3848
Beta Was this translation helpful? Give feedback.
All reactions