Skip to content

qwen4_exp: DFlash/DFlash2 speculative decoding for Qwen3.8-Flash-Next - #128

Open
JC1DA wants to merge 1 commit into
wtdcode:masterfrom
JC1DA:qwen3.8-flash-next-dflash2
Open

JC1DA wants to merge 1 commit into
wtdcode:masterfrom
JC1DA:qwen3.8-flash-next-dflash2

Conversation

@JC1DA

@JC1DA JC1DA commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Qwen3.8-Flash-Next can now use a DFlash / DFlash2 draft model for speculative decoding. On 2x A100-80GB this lifts single-stream output from 180.1 to 224.9 tok/s (+25%) over the native MTP-3 drafter, with mean acceptance length going from 2.98 to 4.08.

The draft-model side (qwen3_dflash2.py, the DFlash2 speculator, the registry entry) was already in this repo. What was missing was on the target side, so a DFlash draft could never actually run against Qwen4Exp. Three small changes, no new abstractions.

This is opt-in. Nothing changes unless you pass --speculative-config with "method":"dflash".

What was missing

1. The target never produced the features the drafter was trained on. DFlash drafts condition on the target's hidden states at specific layers (dflash_config.target_layer_ids, here [5, 15, 25, 35, 45]), concatenated. The draft's fc.weight is [2560, 12800], i.e. 5 features of width 2560. Qwen4Exp had no capture hooks at all.

Qwen4ExpModel now inherits EagleModelMixin, both entry classes inherit SupportsEagle3, and the layer loop captures the completed output of the requested layers, averaged over the hc_count hyper-connection streams. "Completed" means the pending hyper-connection combine is materialized first, the same way the layer body and the existing pipeline-parallel and deepstack paths materialize it, so the captured tensor is the real layer output rather than a half-mixed stream.

2. The config check rejected it. Qwen4ExpForConditionalGenerationConfig.verify_and_update_config raised NotImplementedError for any speculative method other than mtp, ngram, ngram_gpu. dflash is now allowed.

3. A latent crash in the V2 model runner. Both call sites of get_mtp_target_hidden_states() sliced the return value unconditionally. The hook is declared -> torch.Tensor | None and returns None for every non-MTP drafter, so profile_run died with 'NoneType' object is not subscriptable. Now guarded. MTP behaviour is unchanged.

Benchmarks

Target /data/calclout_Qwen3.8-Flash-Next-Abliterated-AWQ-W4A16 (AWQ W4A16, qwen4_exp), draft incoai/Qwen3.8-Flash-Next-DFlash2. 2x A100-80GB, TP2, --enable-expert-parallel, --mamba-cache-mode=align, VLLM_PLE_CPU_OFFLOAD=1, MTP comparison uses the documented MTP-3 recipe. 10 fixed math/code/chat prompts, greedy, warmed server, reported as output tokens per wall second. Numbers in parentheses are mean acceptance length.

Concurrency MTP-3 (baseline) DFlash block 4 block 5 block 6 block 7 block 8
1 180.1 (2.98) 188.6 (3.09) 207.6 (3.52) 220.5 (3.76) 223.3 (3.91) 224.9 (4.08)
4 461.8 (3.03) 487.6 (3.05) 472.0 (3.41) 548.1 (3.63) 624.5 (4.03) 519 to 634 (4.03)
8 658.1 (3.00) 699.4 (3.04) 656.6 (3.34) 734.5 (3.62) 733.1 (3.80) 745.8 (3.88)

Acceptance detail. Mean acceptance length is accepted tokens per verification step; drafted-token rate is accepted tokens over drafted tokens.

Config num_speculative_tokens AL @ c1 AL @ c4 AL @ c8 drafted-token rate @ c1 / c4 / c8
MTP-3 3 2.98 3.03 3.00 0.660 / 0.675 / 0.665
block 4 3 3.09 3.05 3.04 0.697 / 0.683 / 0.678
block 5 4 3.52 3.41 3.34 0.630 / 0.602 / 0.586
block 6 5 3.76 3.63 3.62 0.552 / 0.526 / 0.524
block 7 6 3.91 4.03 3.80 0.485 / 0.505 / 0.467
block 8 7 4.08 4.03 3.88 0.440 / 0.434 / 0.411

Per-drafted-token acceptance drops as the block grows while accepted tokens per step keep rising, which is where the throughput comes from. Concurrency-4 throughput has roughly 10% run-to-run spread on this stack (block 8 measured 519, 589 and 634 tok/s on three runs of the same bench), so blocks 6 to 8 are tied there and acceptance length is the better signal.

Per-position acceptance at block 8, single stream, 1,592 verification steps:

Position 0 1 2 3 4 5 6
Accepted 1,295 1,006 800 648 532 423 357
Rate 0.813 0.632 0.503 0.407 0.334 0.266 0.224

Smooth decay rather than a collapse, which is what tells you the captured features are the right ones.

KV cache. Available KV tokens: 2,138,244 with the MTP draft, 2,377,232 with DFlash block 4 falling to 2,161,700 at block 8. Every DFlash config leaves more KV cache than the MTP draft.

Functional checks. Chat completion, --reasoning-parser=qwen3, and --tool-call-parser=qwen3_xml (returns finish_reason: tool_calls with valid arguments) all work at every block size tested.

Block size you have to pass

Not a code issue, but a real usability trap for anyone trying DFlash2 on this model. QSA ring capacity is compress_ratio * cdiv(compress_ratio + num_speculative_tokens, compress_ratio) (vllm/models/qwen4_exp/common/qsa_cache.py:847) and must divide the attention block size. This checkpoint has indexer_compress_ratio = 4, so capacity jumps to 12 for num_speculative_tokens 5 to 8, and 12 does not divide the block size that --mamba-cache-mode=align requires (1,616 to 1,648). Startup fails with QSA ring capacity 12 must divide the attention block size 1616.

num_speculative_tokens block QSA capacity minimum block size flag needed
3 4 8 1,600 none
4 5 8 1,616 none
5 6 12 1,616 --block-size 1632
6 7 12 <= 1,632 --block-size 1632
7 8 12 1,648 --block-size 1680

Known limitation, and why it is not fixed here

Prefix-cache reuse falls back to whole-block granularity whenever a DFlash draft is loaded, so --prefix-match-unit=16 and --enable-mamba-fine-grained-prefix-cache stop helping:

Config Prompt tokens Cached on repeat
MTP-3, block size 1,600 1,988 1,968
DFlash block 5, block size 1,616 1,988 0
DFlash block 5, block size 1,616 7,748 4,848 (3 whole blocks)

It is the draft group, not the larger block size: block 5 runs at the automatic block size and still reuses only whole blocks, so anything shorter than two blocks gets no hit. The decision lives in resolve_kv_cache_block_sizes (vllm/v1/core/kv_cache_utils.py) and the partial-mamba tail handling in the scheduler, which is adjacent to what #120 changes. I kept it out of this PR rather than destabilise Qwen4Exp's KV grouping; this PR is what makes the drafter run at all, the reuse granularity is the follow-up.

How to test

Start the model with a DFlash draft instead of the MTP config, everything else unchanged:

--speculative-config '{"method":"dflash","model":"incoai/Qwen3.8-Flash-Next-DFlash2","num_speculative_tokens":7}' \
--block-size 1680

Startup must log Using Eagle3 auxiliary layers from config: (6, 16, 26, 36, 46) (the +1 offset
get_eagle3_aux_layers_from_config applies to DFlash layer ids) and then reach
Application startup complete. /metrics then reports vllm:spec_decode_* counters, which is how
the tables above were read. The baseline column is the documented MTP-3 recipe for this model.

Not covered

  • No accuracy eval (GSM8K / AIME25 under tests/evals/qwen4_exp/). Speculative decoding is verified by rejection sampling and output looked correct, but nobody has scored it.
  • No new unit test. A CPU test would need a stubbed hyper-connection combine to be meaningful, which risks testing the mock. Happy to add one on request.
  • Multimodal capture is unexercised: after a deepstack merge the capture takes the already-materialized stream.
  • Pipeline parallel and context parallel untested. Block sizes deeper than 8 untested.

Relation to #120

Same genre, disjoint code. #120 gives GLM-5.3 a KV group for sliding-window draft layers and adds the equivalent EagleModelMixin / SupportsEagle3 plumbing to glm5next. This PR does the Qwen4Exp side (its own hyper-connection capture semantics and the MTP-buffer guard) and touches no file #120 touches.

Notes for reviewers

Qwen4Exp never produced the auxiliary features a DFlash drafter consumes, and
Qwen4ExpForConditionalGenerationConfig rejected every spec method except mtp and
ngram. Add the capture: Qwen4ExpModel inherits EagleModelMixin, both entry
classes inherit SupportsEagle3, and the layer loop materializes the completed
output of the drafted layers (their pending hyper-connection combine), averages
it over the hc_count streams, and returns it with the sampled stream. DFlash2's
fc.weight is [2560, 12800], so the 5 features named by target_layer_ids
[5, 15, 25, 35, 45] are what the checkpoint expects.

Both get_mtp_target_hidden_states() call sites in the V2 model runner sliced the
hook result unconditionally. It returns None whenever the draft is not the native
MTP layer, so a DFlash draft crashed profile_run with 'NoneType' object is not
subscriptable.

Verified on 2x A100 (TP2, expert parallel) against a Qwen3.8-Flash-Next AWQ W4A16
checkpoint with incoai/Qwen3.8-Flash-Next-DFlash2 at its trained block size 8:
mean acceptance length 4.08 and 224.9 output tok/s at concurrency 1, against 2.98
and 180.1 with MTP-3 over the same 10 prompts. Chat, qwen3 reasoning parsing and
qwen3_xml tool calls all work. Known gap: prefix reuse falls back to whole-block
granularity while a DFlash draft is loaded, so the fine-grained
--prefix-match-unit path needs a follow-up.

Co-Authored-By: pi (Qwen3.8-Flash-Next) <noreply@github.com>
@ogidog

ogidog commented Oct 23, 2026

Copy link
Copy Markdown

Heads-up from a production GLM-5.3-Flash deployment: the DFlash2 drafter failure here (KV-cache planning declining the drafter's sliding-window layers) has the same root cause as our #120 — the fix is a pure-Python patch to _get_kv_cache_groups_glm5_next, no kernel changes. It runs in prod on 4x A100 under real agent traffic: 29.2% token-weighted spec acceptance, mean acceptance length 3.5, ~360 tok/s aggregate at 5 concurrent full-262K-context streams.

Patches plus a CPU-only repro (prints BUG REPRODUCED on the unpatched fork base, FIX PRESENT once patched, FIX VERIFIED on post-#61455 trees): https://github.com/ogidog/glm53-dflash-a100. Happy to see this land in the fork — on current upstream main the same behavior is already covered by vllm-project/vllm#61455.

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.

2 participants