Skip to content

feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade - #1216

Open
raullenchai wants to merge 3 commits into
mainfrom
feat/mtp-hybrid-chain-of-k
Open

feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade#1216
raullenchai wants to merge 3 commits into
mainfrom
feat/mtp-hybrid-chain-of-k

Conversation

@raullenchai

Copy link
Copy Markdown
Owner

Draft — spec_decode/ is the core-team lane. Opening for coordination/review before landing.

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)

  • Multi-slot recurrent rollback (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 lets generator.py drop the SSM K=1 clamp — chain-of-K works on hybrid targets. K=1 reduces to the exact prior single-snapshot behavior.
  • Drafter-hidden cascade (qwen3_5_inject.py): 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, so each successive draft conditions on the drafter's own refined hidden rather than the target's frozen hidden. K=3 acceptance ~45% → ~71%.
  • Lean-Python cascade (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, removing 2*K GPU round-trips/round.

Tests

  • Full MTP unit suite green (200 passed, 2 skipped).
  • Correctness: isolated test shows the chunk-split snapshot/restore is numerically exact (diffs 0.0). Greedy served output coherent.
  • Measured (Qwen3.6-35B-A3B Q4, M3 Ultra): 1.24× → 1.36×, ~73–77% acceptance. Also validated on Qwen3.5-9B (dense hybrid).

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.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

PR #1216 validation scorecard

Title: feat(mtp): chain-of-K on hybrid targets + drafter-hidden cascade
Author: raullenchai
Diff: 4 file(s), +429/-198 LOC, blast radius: medium

Verdict: MERGE-SAFE

step status summary time
fetch PASS 4 files, +429/-198 LOC, blast=medium 1.3s
test_plan_check PASS no checkbox-style test plan found 0.0s
cl_description_quality PASS title OK + body has rationale (1908 chars) 0.0s
supply_chain PASS 15 warning(s) — human review wanted 0.0s
test_env_check PASS installed trusted-pins (2 missing plugin(s) recovered) 4.9s
codex_review skip codex CLI not found on PATH (install: npm i -g @openai/codex) 0.0s
lint PASS clean (4 file(s)) 0.0s
targeted_tests PASS exit 2 (in 2 target file(s)) 0.8s
diff_coverage skip diff-cover not installed — advisory coverage unavailable 0.0s

Details

supply_chain — PASS

Findings:

  • tests/test_mtp_multislot_rollback.py near l45: eval() — usually wrong; never on untrusted data

    # eval() exercises the SAME Metal-kernel segmented path production uses

  • tests/test_mtp_multislot_rollback.py near l47: eval() — usually wrong; never on untrusted data

    layer.eval()

  • tests/test_mtp_multislot_rollback.py near l48: eval() — usually wrong; never on untrusted data

    mx.eval(layer.parameters())

  • tests/test_mtp_multislot_rollback.py near l72: eval() — usually wrong; never on untrusted data

    mx.eval(prefix, verify)

  • tests/test_mtp_multislot_rollback.py near l77: eval() — usually wrong; never on untrusted data

    mx.eval(c[0], c[1])

  • tests/test_mtp_multislot_rollback.py near l84: eval() — usually wrong; never on untrusted data

    mx.eval(c_pat[0], c_pat[1])

  • tests/test_mtp_multislot_rollback.py near l99: eval() — usually wrong; never on untrusted data

    mx.eval(c_ref[0], c_ref[1])

  • tests/test_mtp_multislot_rollback.py near l101: eval() — usually wrong; never on untrusted data

    mx.eval(conv_keep, ssm_keep)

  • tests/test_mtp_multislot_rollback.py near l125: eval() — usually wrong; never on untrusted data

    mx.eval(prefix, verify)

  • tests/test_mtp_multislot_rollback.py near l130: eval() — usually wrong; never on untrusted data

    mx.eval(c[0], c[1])

  • tests/test_mtp_multislot_rollback.py near l135: eval() — usually wrong; never on untrusted data

    mx.eval(out_orig, c_orig[0], c_orig[1])

  • tests/test_mtp_multislot_rollback.py near l140: eval() — usually wrong; never on untrusted data

    mx.eval(out_pat, c_pat[0], c_pat[1])

  • tests/test_mtp_multislot_rollback.py near l176: eval() — usually wrong; never on untrusted data

    mx.eval(prefix, verify)

  • tests/test_mtp_multislot_rollback.py near l180: eval() — usually wrong; never on untrusted data

    mx.eval(c[0], c[1])

  • tests/test_mtp_multislot_rollback.py near l187: eval() — usually wrong; never on untrusted data

    mx.eval(c[0], c[1])

Artifacts:

  • /tmp/pr_validate/pr-1216/run-20260726T171337Z-2143-1785086017733874851/supply-chain-scan.log

@raullenchai
raullenchai force-pushed the feat/mtp-hybrid-chain-of-k branch 5 times, most recently from e0ffb8c to ae575a4 Compare July 25, 2026 04:31
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]>
@raullenchai
raullenchai force-pushed the feat/mtp-hybrid-chain-of-k branch from ae575a4 to 1a358cb Compare July 25, 2026 04:36
…→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]>
@raullenchai

Copy link
Copy Markdown
Owner Author

Folded in the single-pass GatedDeltaNet rollback perf fix (commit 8dbe4fe): replaces the per-boundary chunk-split with one fused gated_delta_update + lazy recompute-on-reject. Validated on M3 Ultra (Qwen3.6-35B-A3B Q4, warm, same OpenAI-server harness): baseline 95 → MTP 149 tok/s = 1.57x (was 114/1.18x), ~90% accept, output byte-identical to baseline (lossless). This is what makes chain-of-K actually pay off — without it the chunk-split ate the K≥2 gain. 479 MTP/spec tests pass; full unit suite green.

@raullenchai
raullenchai marked this pull request as ready for review July 26, 2026 16:05
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant