Speculative decoding across the Apple Neural Engine and GPU on one chip, via public APIs — 1.47× over GPU-only, with the ANE draft fully hidden under GPU verify. Measurement harness, handoff characterization, and ANE deployment recipe.
Swift
1
25 commits
updated Sep 1, 2026
Speculative decoding across the Apple Neural Engine and GPU — on one consumer chip, via public APIs only.
A 1B draft model runs on the ANE (Core ML), an 8B target verifies on the GPU (MLX/Metal), and a measurement harness characterizes the seam between them. Apple M3 · 16 GB · macOS 26.5.1 · July 2026.
| configuration (Llama-3.1-8B-4bit target, greedy) | tok/s | speedup |
|---|---|---|
| GPU-only baseline (MLX) | 19.1 | 1.00× |
| + speculative draft on the same GPU (serial, k=4) | 24.8 | 1.28× |
| + speculative draft on the ANE (serial, k=4) | 18.2 | 0.95× |
| + ANE draft, pipelined (k=4) | 26.4 | 1.28× |
| + ANE draft, pipelined, fused head (k=6) | 28.1 | 1.47× |
T_draft(k=4) = 95.6 ms + T_handoff(p99) = 0.15 ms < T_verify = 144.4 ms (34% margin).
The project ran measurement-first: a synchronization harness with an explicit kill criterion (p99 handoff > 2 ms ⇒ stop) ran before any model existed. Everything is reported as p50/p95/p99/p99.9 over ≥10,000 iterations per cell — means are never a success criterion. Highlights:
MTLTexture, and page-aligned makeBuffer(bytesNoCopy:)), verified by per-iteration backing-identity asserts; statistically indistinguishable at 10k. A coherency canary — computed by the ANE's own matmul, validated by the GPU kernel before reading the payload — has zero stale reads in 26,000+ iterations.preferred=ane — a cliff independently reported by Benazir & Lin and the reason ANEMLL chunks models at ~950 MB).
Getting a real stateful-KV-cache Llama-3.2-1B onto the ANE through public API hit four walls; each was isolated with a discriminating experiment (Tools/convert_draft.py is the recipe as executable code):
| # | wall | fix |
|---|---|---|
| 1 | entire program supported=[cpu] | fixed-window attention — any tensor-valued slice bound makes the graph dynamic and the ANE compiler rejects everything |
| 2 | states suspected | toy-model experiment: MLState is ANE-eligible — not the poison |
| 3 | still all-CPU at fp16 | ≤1 GB compiled-weight ceiling → 4/6-bit LUT palettization |
| 4 | acceptance collapsed to 11% | torch.jit.trace bakes slice bounds to constants — every KV write hit cache slot 0; fix = one-hot blend write cache·(1−w)+onehot(pos)ᵀ·kv |
Wall 4 was invisible to output correctness (the target corrects every wrong draft — only the acceptance rate betrayed it); a torch-vs-Core ML greedy A/B is now a mandatory draft-health assertion. A side effect of the fix: speculation rollback is O(1) — move the position pointer, re-mask, no cache surgery.
Prior art: the static-shape rule and the fixed-window approach were published by Panaro (Oct 2024), and stateful pure-ANE inference was already shipping in ANEMLL. What I could not find documented anywhere: wall 1's all-or-nothing granularity (one dynamic slice bound rejects the entire program — the coremltools FAQ implies per-layer fallback), and wall 4's silent failure signature plus the one-hot fix — Apple's own stateful-models guide publishes the vulnerable cache-write pattern unwarned.
No published work I could find holds all four of: (1) two-model speculation, (2) ANE-draft + GPU-verify on one consumer chip, (3) public API only, (4) rigorous handoff characterization. Every neighbor holds at most two:
| project | API | engines | speculation | handoff measured? |
|---|---|---|---|---|
| Mirror-SD (Apple, Dec 2025) | unspecified (server-scale) | GPU + NPU, cross-device | ✔ two-model | not on consumer hardware |
| ANEForge (Jun 2026) | private aned stack | ANE only | ✔ two-model exact (2.28×) | ✗ |
| ane.cpp | private _ANEClient | ANE only | self-speculative — slower than plain decode | ✗ |
| SqueezeBits "Yetter" (Aug 2025) | public Core ML + MLX | ANE prefill + GPU decode (iPhone) | ✗ (disaggregation) | ✗ |
| maderix/ANE | private _ANEClient | GPU↔ANE demos (IOSurface zero-copy) | ✗ | demo-level |
| CoreML-LLM | public Core ML | ANE only | ✗ | ✗ |
| Orion | private | ANE characterization | ✗ | dispatch-level |
| DuoDecoding / Dovetail | CUDA-class PCs | CPU draft + GPU target | ✔ two-model | different platform |
| ZeroHop | public Core ML + Metal | ANE draft + GPU verify, one chip | ✔ two-model exact | ✔ per-stage, 10k protocol |
Notes:
On a single engine: ane.cpp's self-speculation on the ANE is slower than plain decoding (draft and verify serialize on one engine — the same contention as the same-GPU control above); ANEForge's two-model speculation on the ANE alone reaches 2.28×. ZeroHop runs the draft on an engine that is otherwise idle.
| artifact | what it is |
|---|---|
Sources/HarnessCore | measurement primitives: exact-percentile recorders (zero-alloc loops), CPU/GPU clock fusion, real-time thread policies, thermal gating, raw CSV/JSON export |
Sources/HarnessM0–M2 | the handoff harness: noise floor, shared-event round trip, zero-copy transport A/B, coherency canary, memory-pressure cells |
Sources/HarnessM3 | the end-to-end system: Core ML/ANE draft with O(1) rollback, MLX target lane, pipelined decoder, rejection sampling |
Tools/convert_draft.py | the ANE deployment recipe, executable |
Tools/plot_results.py | regenerates every figure from Results/ raw data |
FINDINGS.md | the full lab notebook, chronological — including the superseded claims and their revisions |
PLAN.md | the implementation plan + SDK audit (responds to a project spec not included in the repo) |
The measurement binary (zerohop) has zero third-party dependencies; MLX is confined to the separate end-to-end runner (zerohop-m3).
Requirements: Apple Silicon Mac (macOS 15+; developed on M3/26.5.1), Xcode command-line tools, Python 3.12 for model conversion only.
swift build -c release # measurement harness (no deps)
python3.12 -m venv Tools/.venv && Tools/.venv/bin/pip install coremltools numpy matplotlib
Tools/.venv/bin/python Tools/make_models.py Models
xcrun coremlcompiler compile Models/m1_tiny.mlpackage Models/
.build/release/zerohop m0 # noise floor
.build/release/zerohop m1 --e3 sync --e4 rt --e5 none --gpu-warm saturated # handoff
.build/release/zerohop m2 --e1 A --gpu-warm saturated # transport + canary
# end-to-end (downloads models; xcodebuild required — SwiftPM can't compile MLX's Metal shaders)
Tools/.venv/bin/pip install "torch==2.7.0" "transformers==4.53.3"
Tools/.venv/bin/python Tools/convert_draft.py Models --nbits 4
xcrun coremlcompiler compile Models/draft_llama32_1b.mlpackage Models/
xcodebuild build -scheme zerohop-m3 -configuration Release -destination 'platform=macOS' \
-derivedDataPath .build/xcode -skipPackagePluginValidation
.build/xcode/Build/Products/Release/zerohop-m3 --k 6 --n 200 \
--target mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
--coreml-draft Models/draft_llama32_1b.mlmodelc --overlap on
Protocol: ≥500 warmup + ≥10,000 measured iterations per cell, release builds only (debug builds inflate the dispatch segment ~3×), plugged in, thermal-gated. Every run exports meta.json (chip, OS build, power source, thermal timeline) + per-iteration samples.csv to Results/.
One chip (findings are expected to vary by ANE generation — raw data ships for replication); greedy is the optimized regime; the ANE draft is per-call latency-bound (~14–20 ms/token; a trained Medusa-class multi-token head would cut this, and its gains convert to speculation-window headroom rather than device contention); energy unmeasured (powermetrics needs root); the public-vs-private tax number bounds rather than isolates.
Landscape statements are as of July 2026 (prior-art notes updated August 2026).
25 commits
Swift
86.6%
Python
13.4%
Speculative decoding across the Apple Neural Engine and GPU on one chip, via public APIs — 1.47× over GPU-only, with the ANE draft fully hidden under GPU verify. Measurement harness, handoff characterization, and ANE deployment recipe.
Swift
1
25 commits
updated Sep 1, 2026
Speculative decoding across the Apple Neural Engine and GPU — on one consumer chip, via public APIs only.
A 1B draft model runs on the ANE (Core ML), an 8B target verifies on the GPU (MLX/Metal), and a measurement harness characterizes the seam between them. Apple M3 · 16 GB · macOS 26.5.1 · July 2026.
| configuration (Llama-3.1-8B-4bit target, greedy) | tok/s | speedup |
|---|---|---|
| GPU-only baseline (MLX) | 19.1 | 1.00× |
| + speculative draft on the same GPU (serial, k=4) | 24.8 | 1.28× |
| + speculative draft on the ANE (serial, k=4) | 18.2 | 0.95× |
| + ANE draft, pipelined (k=4) | 26.4 | 1.28× |
| + ANE draft, pipelined, fused head (k=6) | 28.1 | 1.47× |
T_draft(k=4) = 95.6 ms + T_handoff(p99) = 0.15 ms < T_verify = 144.4 ms (34% margin).
The project ran measurement-first: a synchronization harness with an explicit kill criterion (p99 handoff > 2 ms ⇒ stop) ran before any model existed. Everything is reported as p50/p95/p99/p99.9 over ≥10,000 iterations per cell — means are never a success criterion. Highlights:
MTLTexture, and page-aligned makeBuffer(bytesNoCopy:)), verified by per-iteration backing-identity asserts; statistically indistinguishable at 10k. A coherency canary — computed by the ANE's own matmul, validated by the GPU kernel before reading the payload — has zero stale reads in 26,000+ iterations.preferred=ane — a cliff independently reported by Benazir & Lin and the reason ANEMLL chunks models at ~950 MB).
Getting a real stateful-KV-cache Llama-3.2-1B onto the ANE through public API hit four walls; each was isolated with a discriminating experiment (Tools/convert_draft.py is the recipe as executable code):
| # | wall | fix |
|---|---|---|
| 1 | entire program supported=[cpu] | fixed-window attention — any tensor-valued slice bound makes the graph dynamic and the ANE compiler rejects everything |
| 2 | states suspected | toy-model experiment: MLState is ANE-eligible — not the poison |
| 3 | still all-CPU at fp16 | ≤1 GB compiled-weight ceiling → 4/6-bit LUT palettization |
| 4 | acceptance collapsed to 11% | torch.jit.trace bakes slice bounds to constants — every KV write hit cache slot 0; fix = one-hot blend write cache·(1−w)+onehot(pos)ᵀ·kv |
Wall 4 was invisible to output correctness (the target corrects every wrong draft — only the acceptance rate betrayed it); a torch-vs-Core ML greedy A/B is now a mandatory draft-health assertion. A side effect of the fix: speculation rollback is O(1) — move the position pointer, re-mask, no cache surgery.
Prior art: the static-shape rule and the fixed-window approach were published by Panaro (Oct 2024), and stateful pure-ANE inference was already shipping in ANEMLL. What I could not find documented anywhere: wall 1's all-or-nothing granularity (one dynamic slice bound rejects the entire program — the coremltools FAQ implies per-layer fallback), and wall 4's silent failure signature plus the one-hot fix — Apple's own stateful-models guide publishes the vulnerable cache-write pattern unwarned.
No published work I could find holds all four of: (1) two-model speculation, (2) ANE-draft + GPU-verify on one consumer chip, (3) public API only, (4) rigorous handoff characterization. Every neighbor holds at most two:
| project | API | engines | speculation | handoff measured? |
|---|---|---|---|---|
| Mirror-SD (Apple, Dec 2025) | unspecified (server-scale) | GPU + NPU, cross-device | ✔ two-model | not on consumer hardware |
| ANEForge (Jun 2026) | private aned stack | ANE only | ✔ two-model exact (2.28×) | ✗ |
| ane.cpp | private _ANEClient | ANE only | self-speculative — slower than plain decode | ✗ |
| SqueezeBits "Yetter" (Aug 2025) | public Core ML + MLX | ANE prefill + GPU decode (iPhone) | ✗ (disaggregation) | ✗ |
| maderix/ANE | private _ANEClient | GPU↔ANE demos (IOSurface zero-copy) | ✗ | demo-level |
| CoreML-LLM | public Core ML | ANE only | ✗ | ✗ |
| Orion | private | ANE characterization | ✗ | dispatch-level |
| DuoDecoding / Dovetail | CUDA-class PCs | CPU draft + GPU target | ✔ two-model | different platform |
| ZeroHop | public Core ML + Metal | ANE draft + GPU verify, one chip | ✔ two-model exact | ✔ per-stage, 10k protocol |
Notes:
On a single engine: ane.cpp's self-speculation on the ANE is slower than plain decoding (draft and verify serialize on one engine — the same contention as the same-GPU control above); ANEForge's two-model speculation on the ANE alone reaches 2.28×. ZeroHop runs the draft on an engine that is otherwise idle.
| artifact | what it is |
|---|---|
Sources/HarnessCore | measurement primitives: exact-percentile recorders (zero-alloc loops), CPU/GPU clock fusion, real-time thread policies, thermal gating, raw CSV/JSON export |
Sources/HarnessM0–M2 | the handoff harness: noise floor, shared-event round trip, zero-copy transport A/B, coherency canary, memory-pressure cells |
Sources/HarnessM3 | the end-to-end system: Core ML/ANE draft with O(1) rollback, MLX target lane, pipelined decoder, rejection sampling |
Tools/convert_draft.py | the ANE deployment recipe, executable |
Tools/plot_results.py | regenerates every figure from Results/ raw data |
FINDINGS.md | the full lab notebook, chronological — including the superseded claims and their revisions |
PLAN.md | the implementation plan + SDK audit (responds to a project spec not included in the repo) |
The measurement binary (zerohop) has zero third-party dependencies; MLX is confined to the separate end-to-end runner (zerohop-m3).
Requirements: Apple Silicon Mac (macOS 15+; developed on M3/26.5.1), Xcode command-line tools, Python 3.12 for model conversion only.
swift build -c release # measurement harness (no deps)
python3.12 -m venv Tools/.venv && Tools/.venv/bin/pip install coremltools numpy matplotlib
Tools/.venv/bin/python Tools/make_models.py Models
xcrun coremlcompiler compile Models/m1_tiny.mlpackage Models/
.build/release/zerohop m0 # noise floor
.build/release/zerohop m1 --e3 sync --e4 rt --e5 none --gpu-warm saturated # handoff
.build/release/zerohop m2 --e1 A --gpu-warm saturated # transport + canary
# end-to-end (downloads models; xcodebuild required — SwiftPM can't compile MLX's Metal shaders)
Tools/.venv/bin/pip install "torch==2.7.0" "transformers==4.53.3"
Tools/.venv/bin/python Tools/convert_draft.py Models --nbits 4
xcrun coremlcompiler compile Models/draft_llama32_1b.mlpackage Models/
xcodebuild build -scheme zerohop-m3 -configuration Release -destination 'platform=macOS' \
-derivedDataPath .build/xcode -skipPackagePluginValidation
.build/xcode/Build/Products/Release/zerohop-m3 --k 6 --n 200 \
--target mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
--coreml-draft Models/draft_llama32_1b.mlmodelc --overlap on
Protocol: ≥500 warmup + ≥10,000 measured iterations per cell, release builds only (debug builds inflate the dispatch segment ~3×), plugged in, thermal-gated. Every run exports meta.json (chip, OS build, power source, thermal timeline) + per-iteration samples.csv to Results/.
One chip (findings are expected to vary by ANE generation — raw data ships for replication); greedy is the optimized regime; the ANE draft is per-call latency-bound (~14–20 ms/token; a trained Medusa-class multi-token head would cut this, and its gains convert to speculation-window headroom rather than device contention); energy unmeasured (powermetrics needs root); the public-vs-private tax number bounds rather than isolates.
Landscape statements are as of July 2026 (prior-art notes updated August 2026).
25 commits
Swift
86.6%
Python
13.4%