Core AI model zoo

Spec-decode on GDN hybrids (Qwen3.5/3.6 family) — static-S verify design (2026-07-02)

Stream C, C2 (draft-model) continuation toward the Qwen3.6-27B dense target on CoreAIChatMac. Companion to spec-decode-design.md / spec-decode-c-feasibility-and-plan.md.

⚠️ Premise correction (important)

The C2 target rationale said “Qwen3.6-27B dense = pure attention → rollback is a counter rewind”. That is wrong. The 27B is dense only in the no-MoE sense — it is a GDN hybrid: 64 layers, full_attention_interval 4 → **48 linear (GatedDeltaNet)

  1. No dynamic-query verify graph. The GDN GatedDeltaUpdate while_loop does not lower on the GPU delegate, and JIT dynamic-query dies ANECompile → MTL4 anyway.
  2. conv/rec states do NOT roll back by an integer — the KV-counter rollback that made pure-attention spec-decode trivial does not cover the SSM states.

Both problems are solved below without touching the engine or the model overlay.

Design

Verify graph = STATIC S=K+1 + chunked GDN scan

conversion/export_qwen3_5_verify_pipelined.py: input_ids static [1, S] (same fixed-query class as the shipped decode-only bundles — the class that runs everywhere), position_ids/KV-seq dynamic (unchanged runtime contract), every linear layer routed through use_loopfree_chunk (_gated_delta_chunk). Quantization configs mirror the decode script, so a verify bundle is weight-identical to its shipped decode sibling.

Hybrid state discipline: snapshot + tail + exact-S re-anchor

KV rolls back for free (stale rows are always rewritten by a later forward before any read — position i is only attended after a forward re-writes it). conv/rec cannot. Rule: device state only ever holds a fully-committed prefix.

Prefill uses the same S-window (full chunks commit; the remainder is the initial tail). One extra probe forward bootstraps a0 when the prefill remainder leaves it unknown.

Reference implementation (runs on Mac GPU today): ondevice/_spec_mac_two_model.py (NgramDrafter + ModelDrafter), gate: ondevice/_spec_verify_runtime_gate.py.

Validated so far (2026-07-02, M4 Max, macOS 27)

gate result
GDN chunk vs step, S=2/9/17 (eager fp16) cos 1.000000, no NaN — PASS
0.8B model verify-forward vs sequential (eager) argmax 9/9 + end-state parity — PASS
0.8B verify bundle export (int8lin, S=9) exports/qwen3_5_0_8b_verify_s9_int8lin
Runtime: verify bundle S=9 vs decode bundle S=1, Mac GPU argmax 45/45 — PASS (ANE errors non-fatal, GPU fallback)
Mac GPU c_v(9), 0.8B via python driver 18.7 ms vs 10.6 ms = 1.77 (small-model + driver-overhead confounded; 27B is BW-bound → expect ≈1.0–1.2)
Spec loop E2E, n-gram (0.8B) LOSSLESS 64/64 PASS, coherent code output
Spec loop E2E, draft-model (0.8B draft == 0.8B target, free-form) LOSSLESS 64/64 PASS; accepted/round 3.57 ≈ 100% of proposals; tokens/target-forward 4.00 (vs 0.97 greedy) — limiter is the S=9 window room, not acceptance

Two hard-won harness lessons (python driver, macOS 27)

  1. Respecialization is ~1.5–2 s per NEW position length (with an ANECompile failure + GPU fallback each time), and hundreds of unique shapes also make a MTL3On4CommandBuffer-never-completes hang likely (an S=1-per-token harness hung reproducibly; the asyncio loop parks in kevent — completion never fires). Fix = shape-stable design: drive EVERYTHING (greedy ref, draft proposals, catch-up) through the S-window verify graph with the tail discipline, so a new shape appears only when the anchor m advances (once per S committed tokens). In the Swift engine expectFrequentReshapes=true covers this; in python it is the difference between working and hanging.
  2. Drafters must propose PAST the anchor a0 (the token the fused round commits first). Proposing continuations of C (not C+[a0]) off-by-ones every draft: the drafter re-proposes a0 itself → acceptance 0. With the fix, a perfect drafter accepts ~100%. (State-NDArray replacement between forwards is safe — repro’d clean in ondevice/_spec_state_swap_repro.py.)

Economics (27B target, engine-grade)

Draft 0.8B int8 ≈ 0.9 GB/step vs 27B int8 ≈ 28 GB/step → r ≈ 0.03. With c_v ≈ 1.1–1.2, K = 8, free-form accepted/round 1.4–1.9 (measured 1.7B→8B on device; 0.8B→27B TBD): speedup ≈ (1+j̄)/(c_v + K·r + (1+j̄)/S) ≈ 1.6–1.9× free-form; n-gram fuses in for extractive/code (2–4×). Baseline 15.9 tok/s (shipped int8hu) → ~25–30 tok/s target.

MEASURED — 27B × 0.8B two-model run (Mac GPU, 2026-07-02)

Setup: target qwen3_6_27b_verify_s9_int8hu_block32_sym (S=9), draft qwen3_5_0_8b_verify_s9_int8lin, K≤6, gen 48, --reload-every 3 (reload the target between shape buckets to dodge the macOS27 MTL4CommandQueueErrorDomain death; a straight GPU run dies after ~12 tokens, CPU specialization won’t load).

prompt α (accepted/round) tok/target-fwd (greedy) target fwds draft fwds (med ms) LOSSLESS engine-eq speedup
free 1.40 2.18 (0.96) 22 74 (18) 48/48 PASS ~1.56×
code 3.55 2.40 (0.84) 20 61 (23) 48/48 PASS ~1.89×
rag 2.71 2.09 (0.84) 23 76 (20) 48/48 PASS ~1.67×

S/K SWEEP (2026-07-02 late) — window cost curve + optimal configs

15 runs total, S∈{9,13,17} × K∈{6,8,12,16} (S=13 at K=8 only), all LOSSLESS 48/48 PASS — the snapshot/tail/exact-S re-anchor discipline is solid at every window size.

c_v(S) is NOT linear — compute cliff between S=13 and S=17 (27B int8hu, M4Max GPU, greedy-ref medians): 135 ms @ S=9 → 143 ms @ S=13 (+6%) → 197 ms @ S=17 (+38%). Growing the window is nearly free up to S=13, then hits a kernel/tiling cliff.

Acceptance does rise with the window (code acc/round 3.55 @ S9K6 → 6.71 @ S17K8 → 9.33 @ S17K16) but K>8 never pays: α saturates while draft forwards keep costing ~21-24 ms each (r≈0.15 in this harness).

Cost per token = (target_fwds × greedy-med + draft_fwds × draft-med)/48, baseline-free config comparison (lower = better; S=1 greedy decode proxy ≈ 135 ms/tok):

config free code rag
S=9 K=6 89.6 86.3 97.3
S=13 K=8 105.6 72.7 103.3
S=17 K=6 112.4 92.3 116.5
S=17 K=8 111.3 75.9 111.3
S=17 K=12 124.8 80.3 122.3
S=17 K=16 130.3 80.6 130.7

Verdict:

DRAFT SELECTION A/B (2026-07-02 night) — 0.8B-int4 wins; no training needed to wire

Question: is the 0.8B-int8 draft the best off-the-shelf choice? Constraint: the draft must share the target’s tokenizer (vocab 248320) — Qwen3.5-0.8B is the smallest such model; Qwen3.5-2B is the next size. Draft quality only affects α (lossless is verify-guaranteed), so quantizing the draft can only cost acceptance, not correctness. 6 runs (2 drafts × 3 prompts, S=9 K=6, same 27B target), all LOSSLESS 48/48 PASS:

draft free α / draft-med code α / draft-med rag α / draft-med
0.8B int8lin (baseline) 1.40 / 18 ms 3.55 / 23 ms 2.71 / 20 ms
0.8B int4lin 1.53 / 16 ms 4.00 / 18 ms 2.86 / 18 ms
2B int8lin 1.82 / 25 ms 4.00 / 25 ms 3.33 / 25 ms

Ops addendum: the mid-run disk-full class is TRANSIENT — in-run respecialization temp (mpsgraph under /var/folders) that APFS reclaims minutes after process exit; df lags the reclaim, so disk guards must retry (see _spec_draft_ab3.sh wait_disk). Persistent cache = one ~28 GB entry per 27B verify graph in ~/Library/Caches/coreai-cache (S=17’s entry deleted with the config).

Status / next

Conventions: GPU-SOLO _GPU_LOCK; push/HF/card USER-GATED; no “claude” in commits; explicit paths only; coreml bundles never committed.