Core AI model zoo

Riding Apple’s pipelined GPU engine — 3.5× over a hand-rolled loop, zero custom kernels

The single highest-leverage Core AI LLM finding in this project (2026-06-10/11, verified on M4 Max + iPhone 17 Pro): the same .aimodel weights decode 3.5× faster when Apple’s coreai-pipelined engine drives them instead of a hand-rolled per-token fn.run() loop — Qwen3.5-0.8B int8: 210 tok/s vs 58.5 on M4 Max, 69.7–74.0 vs 42.5–45.4 (fused-kernel monolith) on iPhone 17 Pro (ship config incl. the per-block-32 absmax int8 head; fp16-head figures were 204 / 50.3–51.5). This page is how to put a model on that engine, every trap we hit, and what doesn’t fit. Working artifacts: the qwen3.5 fast path in ../conversion/export_qwen3_5_decode_pipelined.py

Why hand-rolled per-token loops lose ~3×

The zoo’s first engines (and most sample code) drive low-level AIModel.load()await fn.run() per token. Three structural costs, none of them kernel-fixable:

  1. Synchronous dispatchawait fn.run() blocks on GPU completion every token; CPU prep for token N+1 never overlaps GPU work for N. The pipelined engine encode()s and returns, with a PipelineGate(capacity: 3) bounding in-flight steps; the sampler’s Metal completion handler yields the token.
  2. CPU sampling = a full-vocab logits readback per token — 250–600 K-vocab fp16 logits cross the GPU→CPU bus, then argmax on CPU. The engine samples on-GPU (MPSGraph argmax/topK); only the sampled int crosses.
  3. Host-cache KV — re-feeding the whole KV per token (the workaround for the in-graph KV-write bug, see coreai-beta-mpsgraph-kvwrite-bug.md) plus multi-dispatch per token. The engine grows KV on-device (buffer-expand + async blit).

Correction this forced: performance-ceiling.md’s “the MLX gap is structural” verdict was measured on the hand-rolled loop — it was that loop’s ceiling, not Core AI’s. The official engine is ~2× faster than MLX on the same machine (qwen3-0.6B-4bit: ~1,150 tok/s vs MLX ~535).

What a model needs to ride the engine

EngineFactory.createEngine auto-selects coreai-pipelined for dynamic-shape bundles (chunkedStatic → the static/ANE engine instead). The checklist:

The export trick: decode-only, loop-free

Recurrent scans (torch.ops.higher_order.while_loop, e.g. Qwen3.5’s GatedDeltaUpdate) do not lower on the MPSGraph GPU delegate ('scf.while' region type mismatch), and on the macOS-27 beta the while_loop bundle fails even cpu_only (Compiler error 2) — so “it verified on CPU earlier” proves nothing about the GPU graph. The escape that made the qwen3.5 ride possible:

Run contract (every one of these bit us)

State & precision traps on the GPU delegate (found by the LFM2.5 port)

Two macOS-27-beta GPU-delegate behaviors that produce silently wrong decode (the bundle loads and runs; only numerics gating catches them). Both bit LFM2.5-1.2B and neither bit qwen3.5 — pattern- and model-dependent, so treat them as authoring rules:

  1. Don’t chain per-slot writes on one fixed-shape state. N per-layer SSMState.update_states calls compile to N read_handle → slice_update → write_handle round trips on the same state handle; with N > 1 the GPU delegate dropped them ALL (state buffer stays zero — the compiled IR is correct and token-chained; 1 slot works, a 3-slot repro fails; qwen’s 18-slot conv/rec pattern happens to survive). Symptom: position 0 is fine (fresh state IS zero), everything after decodes garbage. Rule: collect each layer’s new state slice and issue ONE fused full-state slice_update per step (reads stay per-layer narrows of the input state; slots are disjoint, so semantics are identical). The KV growing pair is unaffected (its written values are re-read in-graph).
  2. fp16 matmuls in the attention prologue lose ~1.3% relative accuracy under a dynamic-shape graph (the same projection in an all-static graph measures 0.07% — the delegate appears to pick an fp16-accumulation kernel when dynamic dims are present). Whether that matters is model-dependent: LFM2.5’s large q/k-norm gains (|k| up to ~14) amplify it and the error compounds across the stack into garbage logits (full-stack cos 0.71 vs eager); qwen3.5’s modest activations shrug it off. Rule: if the oracle gate fails with healthy per-position cosines that decay through the stack, keep the four attention projections (q/k/v/out) in fp32 — weights fp32, cast in/out around the matmul; layer-level error drops to ~1e-5 at +2 bytes/param for those four (LFM2.5-1.2B: +126 MB). Conv-mixer and MLP matmuls measured clean in fp16.

Quantization on the GPU delegate (measured, qwen3.5-0.8B, M4 Max p128/g256)

config size decode tok/s verdict
fp16 1.4 GB 175.8 baseline
int8 k-means g32 (256-entry LUT) 1.0 GB 113.2 LUT gather is the bottleneck — slower than fp16
int8 linear per-block-32 (no LUT) 1.0 GB 204.1 ship config; ≡ fp16-GPU token-for-token
+ untied int8 lm_head 1.3 GB 201.4 no win — the head matvec isn’t the critical path; naive bandwidth models can lie
int4 k-means g8 (+int8 rescue variants) 0.75–0.88 GB fails the oracle gate (12–16, 14/16, 12/16); per_tensor int8 rescue is coarser than int4-g8 LUT for SSM in_projs

The gemma4-E2B sweep (all oracle-8/8, engine path with the PLE provider, M4 Max p128/g256) settles the LUT question — same bytes, 2.25× apart:

config bundle prefill decode verdict
int4 k-means g32 (16-entry LUT) 1.9 GB 41.0 31.5 LUT dequant dominates
int8 linear per-block-32 3.1 GB 71.9 57.2 BW-bound (~165 GB/s effective)
int4 linear per-block-32 2.0 GB 85.3 70.9 ship class; +20–25% over the zoo’s kernel CLI
int4lin --tbl (PLE table as static input) 2.0 GB + 2.35 GB table 87.1 77.0 +8.6% decode on Mac (no per-token wait); see the static-inputs bullet for the iPhone buffer-tax economics

Rules of thumb: eager-palettized k-means LUT dequant is the slow class on this delegate at ANY entry count — 256-entry int8 (qwen 113) and 16-entry int4 (gemma 31.5) both lose to per-block LINEAR (scale-multiply) dequant at the same or even 2× the bytes. (Apple’s official int4-km-g8 models are fast, but that path isn’t reproducible via palettize_pytorch_model.) quantize_pytorch_model takes dtype: "int4" with per-block granularity — linear int4 per-block-32 was top-1 EXACT on gemma4 (8/8) but qwen3.5 is int4-NO-GO at every scheme tried: k-means g32 and g8+int8-rescue on the 0.8B, and linear per-block-32 on the 2B (gate 10/16 and it fails even the cache-seeded single step — transformer/SSM-in_proj damage, not head damage; also only 156 tok/s vs int8hu’s 159, int4-linear dequant underuses BW). Quantization sensitivity is a model property — gate it per model. Ornith-1.0-9B is the qwen3.5-family int4 COUNTEREXAMPLE (2026-07-03): the same linear per-block-32 int4 that fails the 0.8B and 2B gates 24/24 exact on this RL-trained 9B (fp32-oracle eager teacher-forced rig, incl. 0.205/0.058-margin knife edges; engine greedy 12/12 ≡ oracle) and measures 58.9 tok/s vs int8hu’s 48.3 on M4 Max (+22%, 7.5 vs 9.8 GB) — this shape sits in the gemma4/LFM int4-linear-wins class, not the qwen-2B flat class. Short-context instruments only (48 gated tokens): per the gemma4-VL 272-token lesson, int8hu keeps the quality claim until a long-context gate clears int4. LFM2.5-1.2B is also int4-NO-GO (3-probe bisect, 2026-06-11): pure int4lin g32 = 14/16; +int8-linear rescue of the conv-mixer projections fixes the mid-position flip (15/16) but a short-context flip at oracle position 1 survives conv rescue, early-layer-MLP rescue AND per-block-16 (cos climbs 0.90→0.95, argmax stays a special token) — recovering it would need ~all-MLP int8, which IS int8lin. The forfeited speed was real: int4lin g32 measured 314 tok/s on M4 Max (+24% over int8lin’s 253) before failing the gate. Two speed rules from the same sweep: per-block-16 scales are a slow class on this delegate (97.6 tok/s vs g32’s 314 — 3.2×; stay at block 32), and int4-linear’s BW win is shape-dependent (LFM +24%, gemma4 +24%, qwen-2B ~flat). The eager quantizer silently skips tied weights — clone the embedding table first if you actually want the head quantized, and measure + gate before believing it helps — on the surface that is actually bandwidth-bound: the untied int8 head looked like a no-win on the 0.8B on the Mac (204→210, +3% — the Mac pipeline hides the head) but the same change is +40% on iPhone (50.3–51.5 → 69.7–74.0) where the fp16 head was 54% of the per-token read; on the 2B it’s +26% on BOTH surfaces (127→161 Mac, 19–21→28–30 iPhone). “No win on the Mac” does NOT mean “no win on the phone” — re-test head quant on every BW-bound surface. Big-vocab heads: quantize with absmax symmetric, never symmetric_with_clipping (RESOLVED 2026-06-11): with the default clipping qscheme the 2B head flips 6/16 oracle top-1s with a tell-tale signature — one sweep position craters to cos 0.62 while neighbors sit at 0.999x = outlier head rows clipped, not uniform noise. Plain symmetric gates 16/16 at identical speed. Ship shape = per-block-32 + symmetric (int8hu --head-sym; block32 is the script default). Measured: qwen-2B 161 tok/s M4 Max, 28–30 tok/s iPhone 17 Pro (≥ the CoreML-2B port’s ~27); qwen-0.8B 210 / 69.7–74.0 — greedy rollouts token-identical to the fp16-head bundles in both cases. The transformer body is fine WITH clipping (int8lin gates 16/16 everywhere) — this rule is specifically about fat-tailed embedding/head tables. QAT checkpoints are the one GUARANTEED int4 route (2026-06-11, gemma4): int4 tolerance is a model lottery (gemma4-PTQ ✓ / qwen3.5 ✗ / LFM2.5 ✗), but Google’s gemma-4-{E2B,E4B}-it-qat-q4_0-unquantized releases are bf16 weights trained for the q4_0 grid — and q4_0 IS per-block-32 absmax symmetric int4, the exact recipe class this delegate runs fast. Re-exported int4lin bundles from both QAT checkpoints gate 8/8 (python + engine + device) at unchanged speed (E2B 74.7–78.9 / 30.7 settled device; E4B-from-QAT is the first E4B port: 53.2–55.8 on M4 Max), so the int4 claim upgrades from “PTQ that happens to gate” to “int4 ≈ bf16 by design” (Google’s wording: “preserving similar quality to bfloat16”). A/B’d symmetric (the literal q4_0 grid) vs the default symmetric_with_clipping on the QAT weights: both 8/8, same speed — qscheme doesn’t matter here; clipping stays the default. Two QAT-checkpoint traps: (1) they prune dead weights — KV-shared layers ship without k_proj/v_proj/k_norm (base checkpoints carry them unused), so a strict loader must tolerate exactly those; (2) the PLE table and the oracle are checkpoint-derived — regenerate BOTH from the QAT weights (the swapped checkpoint is a different oracle; same prompt, healthy margins, near-identical continuations in practice). Per-channel (axis-0) int8 weights are BROKEN on this GPU delegate (found 2026-06-11 replicating the head lever on LFM2.5): the bundle loads and runs but the quantized matmul returns garbage (full-model gate 0/16 with cos=nan; minimal head-only graph reproduces it at multiple vocab shapes, symmetric and clipping alike, while the SAME minigraph with per-block-32 is cos 0.9999x vs torch — torch-level numerics gate 16/16, so it is a delegate lowering bug, not quantization damage). Historical footnote: the qwen A/B granularity bisect never actually exercised per-channel — the export script parsed --head-quant without applying it, so both probes were per-block-32 (byte-identical bundle sizes confirm it); the HF bundles named *_perchan_sym contain per-block-32 heads, and every published number stands. The lesson stacks with the int8-km-LUT one: on this delegate, prefer plain per-block-32 linear for everything until a probe proves otherwise. The head lever replicates across models (2026-06-11, int8hu --head-sym ported to both export scripts, Mac AND iPhone measured): LFM2.5-1.2B 276.5 tok/s decode on M4 Max (+9% over int8lin’s 253.3) and 44.1–46.6 on iPhone 17 Pro (+15–20% over 38.0–39.6, ~94–98% of the ~47 naive ceiling), oracle gate 16/16 + decode PASS, greedy rollouts token-identical to int8lin on both fixed prompts (python runtime and release llm-runner), device numerics 24/24 ≡ Mac-GPU on all 3 runs; bundle 1.62 GB (+0.13 GB for the untied int8 head). Granite-4.0-h-1b: gate 16/16 + decode PASS, Mac-flat (134.2 vs int8lin’s 136.5) but +17–21% on iPhone (30.2–31.3 → 35.4–37.1 typical settled, 24/24 ≡ Mac ×3 runs) — the THIRD “Mac no-win ≠ device no-win” confirmation (after qwen-0.8B and qwen-2B; head ≈ 10% of the per-token read on the BW-saturated surface). Its natural-prompt greedy forks from int8lin at +7 tokens, inside the post-<|end_of_text|> filler — the oracle rollout is token-identical; judge by the gate, not rollout identity.

Numerics gating (how to judge a quantized bundle)

Measured end state (Qwen3.5-0.8B, 2026-06-11)

surface prefill decode
M4 Max, ship (int8 + per-block-32 absmax int8 head) 211.6 210.0
iPhone 17 Pro, ship, one-shot runner 72.0–73.9 69.7–74.0
M4 Max, fp16-head int8lin 198.8 204.1
iPhone 17 Pro, fp16-head int8lin 51.2 50.3–51.5
iPhone 17 Pro, chat app (CoreAIChat Qwen mode = the ship bundle, 200-tok turn) 69.0 62.3–67.0

vs the previous best iPhone config (fused int8 Metal-kernel static monolith, 42.5–45.4): the ship config is ~1.6× with zero custom kernels, and the same bundle runs on macOS at 210. CoreAIChat downloads the ship bundle since 2026-06-11 (chat-surface decode 62.3–67.0 vs the int8lin default’s 47.9, +30–40%).

VLM rider: Qwen3-VL (the id-space trick)

A text-only engine can carry a full VLM without engine changes — proven by Qwen3-VL 2B (models/qwen3-vl/README.md, 187.6 tok/s M4 Max, iPhone numerics 24/24):

Caveats from this port: the engine pads scalar [1] i32 static inputs to a 64-byte minimum buffer; a dynamic-query (input_ids [1,s]) twin exports and gates in torch but crashes the engine at generate on the macOS-27 beta (NSArrayM nil insert) — ship S=1 static and treat chunked prefill as a future lever; the python runtime cannot execute dynamic-shaped-output graphs at all (gate via a static-S=1 twin bundle).

Second rider: Gemma 4 E2B VL (and what it added to the recipe)

Gemma 4 E2B vision (models/gemma4-vl/README.md, 82.4 tok/s M4 Max / 25.5 iPhone) reuses the id-space trick on an ALREADY-SHIPPED text decoder — same checkpoint, same PLE tables — and is simpler than Qwen3-VL (no M-RoPE, no DeepStack, standard positions). New, generalizable findings:

What fits next / what doesn’t

Apple’s coreai-models repo is issues-only (no PRs), so engine capabilities ship from this repo as a patch stack in ../apps/, applied in order on a fresh upstream clone (git -C coreai-models apply <p1> <p2> <p3> <p4> — see ../apps/README.md): coreai-shared-product.patchcoreai-pipelined-extra-states.patchcoreai-pipelined-per-token-inputs.patchcoreai-pipelined-static-inputs.patch (each applies cleanly after the previous and the full stack builds with swift build -c release). If Apple opens the engine, the three capabilities worth upstreaming are: N extra fixed-shape states, the per-token host-input hook, and the static-input buffer hook (the Gemma-4/PLE enablers — EngineOptions.perTokenInputProvider / EngineOptions.staticInputBuffers).