feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade - #1216
feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade#1216raullenchai wants to merge 3 commits into
Conversation
PR #1216 validation scorecardTitle: feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade Verdict: MERGE-SAFE
Details
|
e0ffb8c to
ae575a4
Compare
Three improvements to native-MTP speculative decoding on Qwen3.5/3.6 (hybrid GatedDeltaNet MoE) targets: - Multi-slot recurrent-state rollback (cache_patch): the GatedDeltaNet chunk-split now snapshots (conv, ssm) state at EVERY draft boundary (`rollback_states[n_from_end]`) instead of a single offset, so `_rollback_draft(n)` can restore to any accepted depth. This lifts the SSM K=1 clamp in the generator — chain-of-K now works on hybrid targets. (K=1 reduces to the exact prior single-snapshot behavior.) - Drafter-hidden cascade (qwen3_5_inject): `mtp_forward` gains `return_hidden`, returning the head's pre-lm_head hidden. The generator already feeds it back as the next iteration's hidden when available, so each successive draft is now conditioned on the drafter's OWN refined hidden instead of the target's frozen hidden. Multi-token acceptance at K=3: ~45% -> ~71%. - Lean-Python cascade (generator): the draft cascade no longer host-syncs (`mx.eval`) per step; it stays lazy and materializes once at the verify's single eval. Removes 2*K GPU round-trips per round. Measured (Qwen3.6-35B-A3B, M3 Ultra): 1.24x -> 1.36x, ~73-77% acceptance, coherent. Also validated on Qwen3.5-9B (dense hybrid). spec_decode/ is the core-team lane — opening as a draft for coordination/review. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
ae575a4 to
1a358cb
Compare
…→1.57x) The chain-of-K MTP verify split the GatedDeltaNet scan into K+1 segments to snapshot (conv, ssm) state at every draft boundary — K+1 kernel launches per GDN layer per verify round. Profiling on Qwen3.6-35B-A3B Q4 (M3 Ultra) showed this per-round fragmentation, not Python overhead (~3 ms/round), was the dominant MTP cost; ablating it lifted throughput ~30%. Since ~90% of rounds accept every draft and never roll back, replace the per-boundary snapshots with one fused gated_delta_update plus a lazy rollback closure stashed on the cache. The common all-accept round pays a single scan; only the rare rejection recomputes the accepted-prefix (conv, ssm) with one short scan from the pre-window anchor — byte-exact with the old snapshot, so the lossless contract holds. Validated on Studio (Q4, warm, same harness): baseline 95.0 → MTP 149.0 tok/s = 1.57x (was 114/1.18x), 90% accept, output byte-identical to baseline (lossless). ~89% of mlx-vlm's 167 tok/s, up from 68%. Tests: rewrote test_mtp_multislot_rollback to lock the closure contract (recompute == true (S-n)-token state; single-pass out == unpatched forward, byte-exact). 479 MTP/spec tests pass; full unit suite green (3 pre-existing network/HF-offline failures unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Folded in the single-pass GatedDeltaNet rollback perf fix (commit 8dbe4fe): replaces the per-boundary chunk-split with one fused |
…le closure A fast-path GatedDeltaNet forward (no snapshot_offsets, S<2, or TP bail) must NOT stash a rollback_recompute closure. This is the precondition behind _rollback_draft's early-abort guard: a reject/abort that re-enters the rewind path on a never-snapshotted cache hits the None sentinel and raises loudly rather than silently reusing a stale closure and mis-rewinding the recurrent state. Addresses an external contributor's 'spec cache rewind early-abort must not corrupt cache state' point. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Why
Native-MTP speculative decoding on Qwen3.5/3.6 (hybrid GatedDeltaNet MoE) was clamped to K=1, and even K=1 left throughput on the table. This lifts the clamp and closes the gap toward llama.cpp.
What (3 pieces)
cache_patch.py): the GatedDeltaNet chunk-split now snapshots(conv, ssm)at every draft boundary (rollback_states[n_from_end]) instead of one offset, so_rollback_draft(n)restores to any accepted depth. This letsgenerator.pydrop the SSM K=1 clamp — chain-of-K works on hybrid targets. K=1 reduces to the exact prior single-snapshot behavior.qwen3_5_inject.py):mtp_forwardgainsreturn_hidden, returning the head's pre-lm_head hidden. The generator already feeds it back as the next iteration's hidden, so each successive draft conditions on the drafter's own refined hidden rather than the target's frozen hidden. K=3 acceptance ~45% → ~71%.generator.py): the draft cascade no longer host-syncs (mx.eval) per step — it stays lazy and materializes once at the verify's single eval, removing2*KGPU round-trips/round.Tests
0.0). Greedy served output coherent.Notes
Three files are interdependent (multi-slot API is shared by cache_patch + generator; cascade spans inject + generator), so kept as one PR. Depends functionally on the sidecar fix (#1214) to have a head to draft with.
🤖 Prepared with AI assistance (Claude Code);
spec_decode/review requested.