Skip to content

ENH(qwen35): MTP speculative decode on dense TP through the shared draft and accept path - #769

Open
alpineQ wants to merge 1 commit into
warpfront:masterfrom
alpineQ:feature/dense-tp-mtp-spec
Open

alpineQ wants to merge 1 commit into
warpfront:masterfrom
alpineQ:feature/dense-tp-mtp-spec

Conversation

@alpineQ

@alpineQ alpineQ commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Dense-TP Qwen3.5/3.6 (tp>1) now serves MTP speculative decode instead of the plain EP AR loop: the MTP head is resolved on rank 0 under the request's typed speculation.mtp/mtp_k policy, the K-step draft is the shared mtp_draft_chain, verify runs across ranks through the dense-TP capture entry point the DFlash mesh already uses, and accept/rollback is the shared mtp_shared_verify_accept_rollback made generic over an MtpTrunkBackend (slot or mesh), so there is no forked accept path. Two prefill-side changes ride along and apply to tp=1 as well: the dense-TP batched prefill now applies the same Q8 multi-row attend admission as the single-GPU chunk path (it was hard-coded off), and that admission accepts 2..=3 rows by running the ROWS=4 body as one rows_valid-masked group.

Measured on Qwen3.8-27B .mq4 + .mtp sidecar, 2x RX 7900 XTX (gfx1100), KV q8/vmm, greedy, thinking off, 256 max tokens, fresh process per cell, two byte-identical passes per cell:

cell short prompt decode 33k prompt decode (md5 4da0398c)
tp2 AR 53.2 46.7
tp2 MTP 72.3 48.5
tp1 MTP 81.4 52.0
tp1 AR 46.3 39.5

Output identity (md5 of content): short prompt, AR tp1 / AR tp2 / MTP tp1 / MTP tp2 all 5c7632f2e098 — greedy MTP is lossless on both topologies. At 33k, AR tp1 == AR tp2 (fe957aac1cbe) but MTP tp1 (9aded82f057b) and MTP tp2 (f745696e0a69) differ from AR and from each other: the batched-verify numerics flip near-ties at that depth on either topology. The AR path across TP is exact.

Scope of the measurements:

  • All numbers are gfx1100. The 2..=3-row admission also applies to gfx1201, where it is unmeasured; bench_flash_rows --check --rows 2 / --rows 3 on an R9700 is the check.
  • flash_rows_per_block previously documented 0.85x at 2 rows against the batched tile. bench_flash_rows at seq 33014 on gfx1100 now gives 1.26x at 2 rows, 1.59x at 3, 1.94x at 4, 2.40x at 8, and 0.83x at 1 (a single row stays on the batched kernel); parity vs the batched kernel is rel 2.5e-7..4.2e-7 at every row count. If 2 rows measures below 1.0x on gfx1201, the floor goes back to 4; nothing else in the PR depends on it.

The loader contract is also repaired: the EP load API did not carry SpecLoadCfg, so a load request's mtp_mode=off / mtp_k was ignored at tp>1 (only the process-global mtp_mode was read). spec is now threaded load_model_ep_admitted -> load_model_ep_qwen35 -> load_model_tp_qwen35_dense; block-verify drafters (n-gram, DFlash, DSpark) are refused on the mesh at build time, not silently held inert.

Which surface(s) does this touch?

  • kernelcrates/rdna-compute/src/attention.rs (flash_rows_per_block: masked ROWS=4 group for 2..=3 rows; no .hip change)
  • loadcrates/hipfire-loader (rank-0 MTP head under typed SpecLoadCfg, Qwen35DenseTpGuard, free_multi on unload), crates/hipfire-daemon/src/main.rs (one argument forwarded)
  • servecrates/hipfire-generate (route selector: dense-TP + MTP takes the spec route), crates/hipfire-runtime/src/spec.rs (free_multi hooks)
  • arch crate(s): hipfire-arch-qwen35 (mtp_dense_tp.rs new; mtp_spec.rs MtpTrunkBackend split; dense-TP prefill multi-row admission; q8_multirow_attn_admitted floor 4 -> 2)
  • crates/hipfire-quantize / quant formats
  • control plane
  • docs / CI / scripts only
  • policy filesscripts/leanup-thresholds.txt: daemon_lines 4863 -> 4864 (the daemon load arm forwards spec_cfg to the EP loader; one line). Commit message carries RATCHET-RAISE: daemon_lines 4863 -> 4864, traded for the EP load arm honoring the request's typed mtp policy; please add the ratchet-raise label.

Test plan

  • cargo build --release --workspace --all-targets --locked clean
  • cargo test --lib --workspace passes (2951 passed, 0 failed)
  • scripts/leanup-ratchets.sh 21/21, check-crate-maps.py --check clean; ratchet-diff.sh passes with the declared raise + label
  • Route selector: generation_route_matrix_tests::dense_tp_qwen_with_mtp_takes_the_spec_route_only_when_it_can_serve; admission tests updated for the 2..=32 floor; flash_rows_per_block unit test added
  • bench_flash_rows --check --rows {1,2,3,4,5,8} on gfx1100, seq 33014: parity rel 2.5e-7..4.2e-7, timings above
  • serve_harness.py --mode battery on hardware: tp2 MTP, tp2 AR, tp1 MTP, and tp2 MTP --mode chain — each 5/5 finish=stop, runaway=0, empty=0, attractor=0 (JSON below)
  • redline_daemon_harness.py on qwen3.5-4b.mq4 (kernel bucket): prefill128/512 and decode stable=True, 19 AQL contracts, shadow exact=True gdn_frame_exact=True
  • speed-gate.sh: not run; the committed gfx1100 baseline was recorded on another host and does not reproduce here, the fresh-process A/B table above is the perf evidence
  • no-gpu-ci.sh: Rust half green. The pytest half fails on this NixOS host for reasons outside the diff (/bin/bash in test_kernel_atlas, mq4c_repack.HfqmError, the MQ4R registry golden hash, a fake-cargo exit 126); none of those files are touched.
  • Pre-existing, identical on ad10b3d97 with the same binary lineage (A/B run): serve-loop-gate.sh --ar on qwen3.5:0.8b scores 3/10 on both paths with open think span at end of generation refusals (the 0.8B does not close <think> in 400 tokens); agentic-gate.sh --fast hard-fails because the gate sends generate without attempt_id, which the daemon protocol now requires.

Artifacts: qwen3.8-27b.mq4 sha256 5bb556a6cc84035234995c017c9791aa3951ad1eae4cf8c8172b0eaef399e507; qwen3.8-27b.mtp sha256 080e93826210f1d3203906da6318f509ec6d5568d98ee3227c0027bec6549c80. Battery daemon md5 db8ffad692c09a9d5deb2d57805c5a59.

local serve_harness battery.json — tp2 MTP (qwen3.8-27b.mq4, --tp 2, mtp auto, kv q8/vmm, greedy)
{"finish":"stop","gen":175,"ctx":84,"cached":0,"tau":1.84,"prefill_tok_s":288.7,"decode_tok_s":57.9,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"expected_substrings":[],"retrieval_missing":[],"prompt_md5":"43ca0d15712d3dfb777b51ae76d8fd5f","request_md5":"ff5e95990908c8a89e813b1bae4f7a4a","assistant_content":"```python\ndef merge_sorted(a, b):\n    \"\"\"Merge two sorted lists into one sorted list.\"\"\"\n    result = []\n    i = j = 0\n\n    while i < len(a) and j < len(b):\n   "}
{"finish":"stop","gen":153,"ctx":95,"cached":0,"tau":2.26,"prefill_tok_s":399.4,"decode_tok_s":66.9,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"expected_substrings":[],"retrieval_missing":[],"prompt_md5":"640e0fd4f55996cb175a422f0a12cef5","request_md5":"6c3ccc42c011f74ccd709726718e70c6","assistant_content":"Step 1: Distance at 60 mph for 2.5 hours  \n\\(60 \\times 2.5 = 150\\) miles\n\nStep 2: Distance at 40 mph for 1.5 hours  \n\\(40 \\times 1.5 = 60\\) miles\n\nStep 3: Total"}
{"finish":"stop","gen":174,"ctx":65,"cached":0,"tau":1.73,"prefill_tok_s":332.2,"decode_tok_s":56.5,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"expected_substrings":[],"retrieval_missing":[],"prompt_md5":"8f66b4c97988825bd8e7840aaf44357e","request_md5":"435da7549755fd77b204691f3a9fbc3e","assistant_content":"Earth's seasons are caused mainly by the tilt of its rotational axis relative to its orbital plane. As Earth orbits the Sun, different hemispheres receive varyi"}
{"finish":"stop","gen":238,"ctx":73,"cached":0,"tau":1.31,"prefill_tok_s":360.0,"decode_tok_s":49.0,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"expected_substrings":[],"retrieval_missing":[],"prompt_md5":"8fe0ad36f61bcf4992cc9df81cdf3817","request_md5":"ed458c086e56dead1a15e8a74a61aa82","assistant_content":"Old Maren found the brass key tangled in kelp where the tide had clawed the rocks clean.  \nIt was warm, though the sea had been freezing all night.  \nWhen she t"}
{"finish":"stop","gen":198,"ctx":71,"cached":0,"tau":1.8,"prefill_tok_s":355.1,"decode_tok_s":58.2,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"expected_substrings":[],"retrieval_missing":[],"prompt_md5":"8bed8e2d056dc1d47dccae9d32dbecf4","request_md5":"274ff1654412336c1f28a529cb07ebb3","assistant_content":"1. Use clear, descriptive names for variables, functions, and modules.\n2. Keep functions small and focused on a single responsibility.\n3. Write meaningful comme"}
tp2 AR control (same prompts, mtp off) and tp1 MTP
{"finish":"stop","gen":175,"tau":null,"decode_tok_s":51.7,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"43ca0d15712d3dfb777b51ae76d8fd5f","assistant_content":"```python\ndef merge_sorted(a, b):\n    \"\"\"Merge two sorted lists into one sorted list.\"\"\"\n    result = []\n    i = j = 0\n\n    while i < len(a) and j < len(b):\n   "}
{"finish":"stop","gen":153,"tau":null,"decode_tok_s":53.1,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"640e0fd4f55996cb175a422f0a12cef5","assistant_content":"Step 1: Distance at 60 mph for 2.5 hours  \n\\(60 \\times 2.5 = 150\\) miles\n\nStep 2: Distance at 40 mph for 1.5 hours  \n\\(40 \\times 1.5 = 60\\) miles\n\nStep 3: Total"}
{"finish":"stop","gen":174,"tau":null,"decode_tok_s":53.2,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8f66b4c97988825bd8e7840aaf44357e","assistant_content":"Earth's seasons are caused mainly by the tilt of its rotational axis relative to its orbital plane. As Earth orbits the Sun, different hemispheres receive varyi"}
{"finish":"stop","gen":238,"tau":null,"decode_tok_s":52.7,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8fe0ad36f61bcf4992cc9df81cdf3817","assistant_content":"Old Maren found the brass key tangled in kelp where the tide had clawed the rocks clean.  \nIt was warm, though the sea had been freezing all night.  \nWhen she t"}
{"finish":"stop","gen":198,"tau":null,"decode_tok_s":53.3,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8bed8e2d056dc1d47dccae9d32dbecf4","assistant_content":"1. Use clear, descriptive names for variables, functions, and modules.\n2. Keep functions small and focused on a single responsibility.\n3. Write meaningful comme"}
{"finish":"stop","gen":175,"tau":1.84,"decode_tok_s":64.5,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"43ca0d15712d3dfb777b51ae76d8fd5f","assistant_content":"```python\ndef merge_sorted(a, b):\n    \"\"\"Merge two sorted lists into one sorted list.\"\"\"\n    result = []\n    i = j = 0\n\n    while i < len(a) and j < len(b):\n   "}
{"finish":"stop","gen":153,"tau":2.26,"decode_tok_s":74.7,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"640e0fd4f55996cb175a422f0a12cef5","assistant_content":"Step 1: Distance at 60 mph for 2.5 hours  \n\\(60 \\times 2.5 = 150\\) miles\n\nStep 2: Distance at 40 mph for 1.5 hours  \n\\(40 \\times 1.5 = 60\\) miles\n\nStep 3: Total"}
{"finish":"stop","gen":174,"tau":1.84,"decode_tok_s":64.9,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8f66b4c97988825bd8e7840aaf44357e","assistant_content":"Earth's seasons are caused mainly by the tilt of its rotational axis relative to its orbital plane. As Earth orbits the Sun, different hemispheres receive varyi"}
{"finish":"stop","gen":238,"tau":1.33,"decode_tok_s":55.0,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8fe0ad36f61bcf4992cc9df81cdf3817","assistant_content":"Old Maren found the brass key tangled in kelp where the tide had clawed the rocks clean.  \nIt was warm, though the sea had been freezing all night.  \nWhen she t"}
{"finish":"stop","gen":198,"tau":1.8,"decode_tok_s":64.7,"attractor":false,"empty":false,"runaway":false,"saw_done":true,"prompt_md5":"8bed8e2d056dc1d47dccae9d32dbecf4","assistant_content":"1. Use clear, descriptive names for variables, functions, and modules.\n2. Keep functions small and focused on a single responsibility.\n3. Write meaningful comme"}

Hardware validation request (optional)

{
  "routes": [
    {"mode": "battery", "tag": "qwen3.8:27b"},
    {"mode": "chain",   "tag": "qwen3.8:27b"}
  ],
  "claim": "dense Qwen3.8-27B at tp=2 with the .mtp sidecar drafts on rank 0 and verifies across ranks; greedy output byte-identical to AR on short prompts; tp=1 MTP and AR unchanged; Q8 multi-row admission at 2..=3 rows does not change AR output"
}

Architecture-trait change?

No. Carrier::spec_target_guard gains an _ep: &mut Option<EpState> parameter (default impls untouched, every carrier updated); Speculator::free_multi / MtpDrafter::mtp_free_multi are new defaulted methods. The Architecture trait is not touched.

…aft and accept path

RATCHET-RAISE: daemon_lines 4863 -> 4864, traded for the EP load arm honoring the request's typed mtp policy
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