← TinyGPT · docs · devlog · roadmap · speedup
source: docs/learn/ane-research/m6-findings.md · view on GitHub ↗

M6 — ANE Bisect Findings (2026-06-08)

Empirical diagnostic of the ANECCompile / runtime failure on the Qwen3-0.6B stateful CoreML path.

Method

Truncated the existing scripts/ane/qwen3_to_coreml.py to produce N-layer Qwen3 stateful variants for N ∈ {1, 2, 3, 4} at max_seq_len=64. For each: convert with --mode stateful --precision fp16 --compute-units ane, then attempt MLModel(..., CPU_AND_NE) load + a single decode step via predict({...}, state=...).

Bisect harness: scripts/ane/m6_layer_bisect.py.

Result table

N layersConvert.mlpackage sizeANE loadANE predictGPU predict
1OK (8.4s)343 MBOK (1.6s)OK (6ms)
2OK (10.1s)364 MBOK (1.2s)SIGTRAP (exit 133)OK (1562ms)
3OK (8.1s)392 MBOK (1.5s)SIGTRAP (exit 133)
4OK (~9s)417 MBOK (1.6s)SIGTRAP (exit 133)
28 (full)OK (~26s)~1.1 GB(varies)FAIL (ANECCompile -14)OK (~30 tok/s)

The 28-layer ship-note failure was ANECCompile -14 at load. With smaller N (2-4), load succeeds but predict crashes hard with SIGTRAP — the ANE runtime is doing the crash, the Python process exits with signal 5.

The actual finding

The threshold is exactly N = 1. Only the 1-layer stateful Qwen3 graph actually runs on ANE. Any number of layers ≥ 2 fails at runtime, not at convert or load.

This is not a graph-size or op-count limit. A 2-layer model has only ~2× the ops of a 1-layer model — well within any conceivable ANE op budget. The failure mode is specific to the multi-layer consolidated-state pattern.

The 28-layer ship note reported ANECCompile error -14 (resource exhaustion language), but at N=2 the package compiles, loads, and the runtime fails only when the second layer’s forward fires. So “too many state slots” is at most a contributing factor; “any multi-layer stateful Qwen3 graph” is the real constraint we observed.

GPU CPU+CPU_AND_GPU predict on the same N=2 mlpackage works fine (~1.5s for one step). So the graph is mathematically correct — only the ANE runtime lowering crashes.

Hypothesis on the root cause

Most likely culprit: the consolidated KV cache write/read pattern across layers within a single forward pass.

forward(input_ids, mask, position_offset):
    x = embed(input_ids)
    for blk in self.layers:        # ← multi-layer iteration
        # block reads from cache rows [i*n_kv .. (i+1)*n_kv)
        # block writes new K/V to those rows in-place
        x = blk(x, ..., self.k_cache, self.v_cache, past_len, end_step)
    ...

Hypotheses, in order of suspicion:

  1. Cross-layer state aliasing: ANE’s lowering treats consecutive reads/writes to the same MLState differently from how the trace expresses them. Layer 0’s write to cache rows [0..n_kv) may not visibly land in time for the next iteration of the loop, or vice versa.
  2. Loop-unrolling failure: the Python for blk in self.layers gets unrolled at trace time into a long chain of state ops. ANE may handle 1 state read/write per package gracefully but choke on >1.
  3. State slot reuse across blocks: per-block dispatch reuses the same two MLState slots. The ANE runtime may require a fresh state slot per access, or be unable to update an MLState slot in-place mid-graph.

Each hypothesis points in the same direction architecturally: ANE likes graphs that touch each MLState slot at most once per forward.

Implications for M7 / M8

M8 layer-chunked conversion is now the obvious path

Per the dossier, M8 #1 is “Layer-chunked conversion: most practical, matches Apple Stable Diffusion modular packaging precedent.” This bisect data confirms it.

Concrete proposal:

Cost: ~28 mlpackage dispatch calls per token. CoreML predict overhead is ~1ms each, so ~28ms/token overhead — far less than the current MLX path. Net win: ANE engages for every block at 3-5W instead of GPU at 25W.

M7 (ml-ane-transformers layout port) is now lower-priority

The B,C,1,S layout rewrite addresses op-shape compatibility with ANE — that matters for ops, but our bisect shows even the already-converted graph runs on ANE at N=1. So op-shape isn’t the binding constraint. The binding constraint is multi-layer state.

If we layer-chunk (M8), we never need to put more than one layer in one mlpackage, so the layout rewrite isn’t on the critical path anymore.

Recommended sequence:

  1. First: prototype layer-chunked conversion (~3-5 days). One mlpackage per block. Validate on Qwen3-0.6B end-to-end.
  2. Then, if M8 wins: defer M7 until needed (e.g., for fitting bigger blocks or smaller per-block compute).
  3. Halved-depth distillation (M8 #2) becomes a fallback if layer-chunked overhead is somehow worse than expected.

Reproducer

python3 scripts/ane/m6_layer_bisect.py \
    --hf-dir <Qwen3-0.6B HF snapshot dir> \
    --layers 1,2,3,4 \
    --max-seq 64

Each variant takes ~10s to convert + a few ms to ANE-load. Predict either succeeds in ms or crashes immediately (SIGTRAP).

Artifact list

Recommendation for the ANE elf

When picking up M6/M7/M8, start with M8 layer-chunked conversion. Skip M6 deeper probes (the bisect above is sufficient evidence) and deprioritize M7 layout rewrite until needed.

Implementation outline:

  1. Add --mode block to scripts/ane/qwen3_to_coreml.py — exports a single Qwen3Block as an mlpackage with one (k_cache, v_cache) pair
  2. Write tinygpt coreml-serve-chunked (Swift) that loads N block-mlpackages and orchestrates sequential predict calls
  3. Validate parity (top-1 token agreement vs MLX) on the same prompts
  4. Benchmark: tok/s on ANE-chunked vs MLX vs current coreml-serve

M8 prototype results (2026-06-08)

Shipped during the same session as the M6 bisect.

Architectural prototype: scripts/ane/m8_block_export.py and scripts/ane/m8_chained_decode.py. New Qwen3SingleBlockModel and Qwen3SingleBlockAttention classes added to qwen3_to_coreml.py — each block as a standalone stateful mlpackage with its own private (k_cache, v_cache) MLState pair, shape [1, n_kv_heads, max_seq, head_dim]. Embedding lookup and final norm + tied lm_head stay in Python/Swift, sidestepping ANE entirely for those small ops.

Per-block measurements

MetricValue
Convert time per block~2-3s
Package size per block32 MB
Total disk (28 blocks)1.5 GB (vs 1.1 GB full model — has duplicate norm scaffolding)
ANE load per block0.19s
ANE first predict1.4ms
ANE steady-state predict0.53ms
Parity vs PyTorch fp32 (block 0, T=1, pos=0)cos_sim 0.999995
Parity vs PyTorch fp32 (block 0, T=1, pos=4)cos_sim 0.999996 (state accum works)

Multi-block chain measurements

TestResult
2-block × 1-position chaincos_sim 0.999987
2-block × 5-position chaincos_sim 0.999974
28-block × 1-position chaincos_sim 0.861 (drift!)
28-block × 5-position prefill220ms total → 22.8 tok/s
28-block × 7-position decode300ms total → 23.4 tok/s
Generated text on “The capital of France is”garbage (”.\n.\n.\n.\n”)

Verdict

Structurally feasible, correctness blocked by fp16 precision drift.

Path to correctness — RESOLVED 2026-06-08

Fix shipped: compute_precision=FLOAT32 + state=FLOAT16.

Per-block parity (max diff over a random hidden input):

Per-block predict time:

End-to-end on Qwen3-0.6B + “The capital of France is”:

Package size: 32 MB → 63 MB per block (fp32 weights at rest). 28 blocks = 1.7 GB total. Not a constraint.

Approaches tried that didn’t help:

Next steps (M8 follow-on, not blocking)

  1. Swift orchestrator — port the Python m8_chained_decode.py driver to Swift. Python ml.predict overhead is ~1ms × 28 = 28ms per token; Swift should reclaim most of that → 30-40 tok/s.
  2. powermetrics confirmation — verify ANE actually engages (not GPU fallback) and measure wall-clock power draw.
  3. LoRA bake into per-block weights — fold v6/v6.1/v7 Pace LoRA into each block’s base weights via bake-lora, then re-export. This is what makes “Pace on ANE” not just “Qwen3 on ANE.”
  4. Longer max_seq — current export caps at 128. For real contexts (256-2048), re-export with bigger MLState slots.
  5. Tinygpt CLI wrappingtinygpt serve --coreml-chunked <dir> surface, alongside the existing coreml-serve sibling.

Artifacts (kept on disk)

What an engineer continuing this should know