Repository navigation
feat(spec): add MTP drafts for the Qwen3.5 family and Qwen3.8-Flash-Next - #844
Merged
Merged
Conversation
6 of 8 tasks
…of the scheduler's ChunkedReq
…d check it after load
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
FlashML-org#850 adopted with fixups, FlashML-org#848/FlashML-org#849 superseded (FlashML-org#790/FlashML-org#788), FlashML-org#845 and FlashML-org#844 deferred (no NVFP4 qwen4exp GGUF; GPU driver down). FlashML-org#316 was closed upstream without merge and is kept for the ROCm branches. Assisted-by: Claude Opus 5.5
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
… Qwen3.5 family and Qwen3.8-Flash-Next Conflicts: shared speculative CLI flags (one --speculative-algorithm with dflash and mtp), kv_quant next to num_speculative_tokens in the KV pool factory, the disk PLE staging (max_eager_tokens keeps next's scale bytes and layer-major staging), and the Flash-Next forward (normed hand-over plus the verify PLE stash and the feature export). The MTP draft layer unpacks next's (hidden, normed) layer output; layer-major prefill is off for any speculative algorithm since it exports no draft features. Assisted-by: Claude Opus 5.5
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
FlashML-org#844 (MTP drafts) adopted with fixups after GPU benchmarks: Qwen3.6-35B MTP matches DFlash speed with less KV, Qwen3.8-27B MTP keeps 5x the context of DFlash2 at ~80% of its speed. Assisted-by: Claude Opus 5.5
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
Brings in upstream PR FlashML-org#844 (MTP drafts) and the DFlash/PLE fill fix. next-speed's Flash-Next DFlash and FlashML-org#844's target verify sit side by side: - kvcache: QSAKVCache.spec_tokens_for sizes the index ring for DFlash blocks and, otherwise, for the verify rows (decode_rows_per_req - 1); the pool takes upstream's num_speculative_tokens name. - qwen4_exp/ple.py: PLEMetadata keeps both `verify` (DFlash graph verify, committed by PLELayer.dflash_commit) and `is_verify` (MTP verify, committed by the SlotVerify handlers); _short_conv routes each. - qwen4_exp/model.py: the hidden-layer taps for DFlash plus FlashML-org#844's ngram stash on verify and the draft feature export. Assisted-by: Claude Opus 5.5
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
Upstream merged FlashML-org#844 (MTP drafts) and FlashML-org#838 (gpt-oss GGUF). next already carries FlashML-org#844 with its fixups; upstream's squash matches the PR head we merged, so every conflict keeps next's adapted side. FlashML-org#838 comes in as is, with its one docs/cli.md line added to next's --moe-cpu-layers row. Assisted-by: Claude Opus 5.5
KarrAcaRn
pushed a commit
to KarrAcaRn/FreeToken-ByAI
that referenced
this pull request
Nov 2, 2026
FlashML-org#843 (completions n) adopted with fixups after a live check on the 4090; FlashML-org#851 (VNNI nvfp4 CPU kernels) deferred: this VM's CPU exposes no AVX-VNNI or AVX-512; FlashML-org#831 deferred (Windows collection); FlashML-org#832 superseded by FlashML-org#844, which upstream merged. Assisted-by: Claude Opus 5.5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #828
Third PR of the speculative decoding plan in #828: the first draft method, MTP, which drafts with the checkpoint's own
mtp.*layer. It covers the Qwen3.5 family (qwen3_5andqwen3_5_moearchitectures, dense and MoE) from HF checkpoints, and Qwen3.8-Flash-Next (qwen4_exp) from its HF checkpoint or from a GGUF target with a Strata MTP pack. It builds on the verify step from #836, the seams from #833, theradix_lookaheadkeys from #834 and the shared GatedDeltaNet layer from #841. The full stack is in the draft PoC #832.Summary
--speculative-algorithm mtp(--spec-algo mtp) turns it on; k defaults to 3 (--speculative-num-steps, up to 8).--speculative-draft-model-path(--spec-draft-model) points at the draft weights when they are not in the target checkpoint, which is the case for Flash-Next GGUF targets: pass a Strata MTP pack, Strata's GGUF that keeps the checkpoint'smtp.*tensors under their HF names. ThenextnMTP blocks llama.cpp writes are not read, for either family. A separate draft checkpoint folder is read with the target's quantization config.docs/cli.mddocuments the three flags.Rejected at config time, before any weights load, so a draft that cannot load does not fail only after the whole target has loaded:
mtp.*.mtp.*tensors, e.g. a Flash-Next GGUF target with no--speculative-draft-model-path; a GGUF draft for a Qwen3.5-family target, whose draft reads safetensors only; a GGUF target with a draft source that is not a GGUF.mtp.*tensors, and an explicit draft path that does not exist, unless it has the form of a Hugging Face repo id (nameororg/name).--dummy-weight("MTP reads its draft from the checkpoint, so it cannot run with --dummy-weight").A checkpoint whose MTP routed experts use a layout the reader does not handle (per-expert NVFP4 such as
nvidia/Qwen3.8-Flash-Next-FP4-Local, or per-tensor FP8), or that stores them stacked without bothgate_up_projanddown_proj, stops at draft load with an error naming the tensors.Commits:
feat(spec): add the MTP proposer for the qwen3_5 familytest(models): read the qwen3_5 MTP head of bf16 and block-fp8 checkpointsfeat(spec): verify qwen4_exp targets on qsa_sparse graphs with PLE verify handlersfeat(spec): add the qwen4_exp MTP draft from the checkpoint or a Strata packdocs(cli): document the speculative decoding flagsfix(spec): refuse MTP draft sources the draft cannot read before the target loadsdocs(qwen4_exp): describe the verify path in the PLE docstringsfix(spec): refuse a missing relative MTP draft path before the target loadsfix(moe): read MTP head experts on the host and require both stacked tensorsrefactor(spec): tell the final prefill chunk from prompt_len instead of the scheduler's ChunkedReqrefactor(spec): let each MTP draft family check its own weight sourcefix(qwen4_exp): size the disk PLE eager staging for decode and verify rowsperf(kernel): read the KV-index gather's chunk count from the grid, not a constexprrefactor(moe): let each family name its MTP expert tensorsrefactor(spec): force the MTP draft's MoE resident in the proposer and check it after loadfix(spec): refuse MTP with dummy weightsrefactor(spec): move HiddenRelay under spec/mtp, its only userrefactor(spec): drop MTPDraft.config, fold its embed hooks into embed_tokensrefactor(models): note FeatureSink's graph binding, drop its runtime_checkablefix(models): read quantized MTP fc projections under the quant configDesign
MTP proposer (
spec/mtp/):ModelSpec.spec_drafts["mtp"]; the interface is theMTPDraftprotocol inspec/mtp/proposer.py:attention_backend,draft_config,relay_width,check_source,load,embed_tokens,forward_hiddenandhead_input. Each family checks its own draft source incheck_source;MTPProposer.adjust_configkeeps the checks that name no family (the registry entry,--dummy-weight, the path, FTW).locatefunction to the shared readermoe/expert_pieces.iter_extra_layer_expert_pieces, which reads them on the host.mtp.fc;mtp.fc_embeddingandmtp.fc_hiddenon Flash-Next) go through the family's dense reader like the target's linears, so a checkpoint that quantizes them is read under its quantization config.with_resident_moe(moe_strategy="fused",decode_target="gpu"), so its experts are resident on the GPU and never enter the target's expert cache or offload, whatever the target's strategy; after load it refuses a draft whose MoE layers are offloaded or shared with the target.radix_lookahead = True: a prefix hit never reuses a draft KV slot written for a different next token.Context.overridewith the target's GDN pool hidden. The draft's per-step state (relayed hidden, first draft token, picked rows) stays insideMTPProposerandspec/mtp/relay.py; nothing method-specific goes on the batch. A prefill chunk is the final one when it ends at the prompt length;spec/imports nothing from the scheduler. Host-to-device index uploads are pinned and non-blocking.FeatureSinkit was captured with, so the MTP verify sink writes into a buffer the proposer owns; theFeatureSinkdocstring now states this for every draft method.gather_kv_indices, which reads its chunk count from the launch grid: over 7 grid shapes a reference check compiled 1 kernel variant instead of 6, with the same output.Flash-Next target verify:
GatedDeltaNetlayer from refactor(layers): share one GatedDeltaNet op in layers/linear_attn #841, whose verify path comes with feat(spec): verify draft tokens on the target side #836; this PR adds nothing GDN-specific.qsa_sparsecaptures multi-row verify graphs (uniform_extend_graph="device"); before this, speculative decoding rejected Flash-Next at startup. The per-request pending ring of index keys is sized with the draft depth (QSAKVCache.ring_capacity_for(index_ratio, k)), so the rows a verify writes and then rejects never overwrite a ring entry an open group still needs. The same applies to the QSA draft pool.ple_conv,ple_ngram_ctx). The pool does not know how to restore them: eachSlotStateSpecnow names aSlotVerifyhandler class (models/config.py). The pool builds one handler per state next to the GDN verify scratch, the owner gets it back withpool.verify_handler(name)at forward time, andcommit_verifyruns the GDN commit and then every handler's commit; the handlers share one pinned, non-blocking upload of the slot and count indices; verify request i's scratch is row i. Handler scratch bytes are counted with the GDN verify scratch. Speculative decoding is rejected at config time, before the weights load, if any slot state has no handler.PLELayer._verify_conv) and stages[live history | verify conv inputs]per row; the n-gram context is not rolled, and the model stages[live context | verify input ids]per row instead. The commit restores both as they stand after the accepted rows.PLEMetadata.is_verifycarries the flag, and a verify takes its PLE slots and indptr frombatch.fla_metadataas decode does.--ple-backend disk(the default): a verify graph replay reads its rows from the graph staging buffer like decode does,rows_per_reqtokens per request read back from the device. The graph buffer holdsmax_running_req * (k + 1)rows, since verify graphs stop atmax_running_reqrequests. The eager staging holdsmax(max_extend_tokens, max_running_req * (k + 1))rows, since an eager verify can stage more rows than a prefill chunk.Flash-Next MTP draft (
models/qwen4_exp/mtp.py):FeatureSink.final_hiddenfrom the model, with the LM head told not to export again), and the draft maps its output through its own mixer before the target's LM head.mtp.*tensors, or from a Strata MTP pack: a GGUF with the same tensor names, ggml-typed linears and stacked ggml experts, passed with--speculative-draft-model-path.Tests
New or extended:
tests/models/test_qwen3_5_moe_weight.py: the MTP reader fills exactly the draft the engine builds, for bf16 (stacked experts) and block-fp8 (per-expert, quantized linears) checkpoints; the draft's MoE stays resident though the target offloads its experts; the routed experts keep the form the checkpoint stores.tests/models/qwen4_exp/test_ple.py::test_verify_commit_matches_stepwise_decode: a PLE verify over k+1 rows leaves the conv history and n-gram context untouched; committing n rows (different n per request, verify rows mapped to other slots) leaves exactly what n decode steps leave, and each row's conv output equals its decode step bitwise.tests/models/qwen4_exp/test_qsa_backend.py::test_decode_graph_replay_matches_eager[verify]: a captured 4-row verify replay equals the eager verify bitwise.tests/models/qwen4_exp/test_qsa_backend.py::test_verify_after_rejected_rows_compresses_like_decode: after a verify whose last rows are rejected, the next verify compresses the open index group as decode steps do.tests/models/qwen4_exp/test_ple_disk.py::test_wait_sync_large_graph[verify]: a verify graph replay on the disk PLE backend reads every row it needs, bitwise against the in-memory table.tests/spec/test_verify_contract.py: a tinyqwen4_expmodel (GDN + QSA, PLE, hyper-connections) joins the contract test: speculative decoding with stub drafts must reproduce plain greedy decoding token for token, with and without graphs. The dummy weights are scaled by fan-in and the PLE table gets seeded rows, so the PLE path carries signal and ulp noise stays small; the tie check is now relative to the logit magnitude (the same 0.125 bound as before for logits in [64, 128)).Each new check, run with only the logic it guards reverted:
test_mtp_head_fills_the_draft_and_its_expertstest_mtp_head_fills_the_draft_and_its_expertswith_resident_moereturns the target's config unchangedOffloadMoELayertest_verify_commit_matches_stepwise_decodetest_verify_commit_matches_stepwise_decodetest_verify_after_rejected_rows_compresses_like_decodetest_wait_sync_large_graph[verify]host_fill_batchtakes the graph path for decode onlytest_verify_steps_reproduce_greedy_decode[qwen4_exp]Reverting the staged
token_to_reqslice in the QSA graph test makes the capture crash on a shape mismatch rather than fail an assertion. The twotest_verify_commit_matches_stepwise_decoderows ran on 4f8f0cda (this PR on an earlier head of #836) and thewith_resident_moerow on e5ac81f; the other rows ran on this branch before its first rebase onto #836.Test environment
kernel/triton/attention.pymoved by 6 lines) besides one sentence indocs/cli.md.tests/spec,tests/layers,tests/models/qwen4_exp,tests/models/test_qwen3_5_moe_weight.py,tests/moeandtests/kernels(758 passed, 244 skipped).tests/spec,tests/models/qwen4_expandtests/models/test_qwen3_5_moe_weight.py(187 passed, 154 skipped). The MTP-on greedy runs of Qwen3.6-35B-A3B and the Flash-Next NVFP4 checkpoint are token for token identical to the ones on e02a5890 in the tables below, and the Flash-Next GGUF with the Strata pack to its run on 8815c0b (846 tokens, answer 70, accept length 3.44). The same greedy run with MTP also completed on the block-fp8Qwen/Qwen3.6-35B-A3B-FP8(16384 tokens, the limit; accept length 3.51), on Qwen3.6-35B-A3B with its experts offloaded (moe_strategy="offload", automatic MoE cache; identical to the fused run), and on the Flash-Next NVFP4 checkpoint without CUDA graphs (identical to the run with graphs).Unit tests
1705 passed, 211 skipped, 1 failed. The failure is
tests/models/test_quant_config.py::test_scheme_for_agrees_with_the_stored_tensors[apodex/Apodex-1.1-mini-NVFP4[index-only]], the same one #836 reports (a local checkpoint withquant_method: modelopt_mixed); this PR does not change the quantization code it checks.Smoke tests, both passed:
FREETOKEN_TEST_MODEL=/path/to/Qwen3.6-35B-A3B FREETOKEN_AIME25_JSONL=/path/to/aime25.jsonl PYTHONPATH=python pytest tests/e2e/test_aime.py FREETOKEN_REBUILD_TEST_MODEL=/path/to/Qwen3.6-35B-A3B FREETOKEN_REBUILD_EXTRA_ARGS="--speculative-algorithm mtp --max-running-requests 4" PYTHONPATH=python pytest tests/e2e/test_cache_rebuild.pyThe second one boots
ft servewith MTP on and grows the KV pool (the draft KV is rebuilt), grows the GDN state pool (the draft KV is kept), flushes the prefix cache and is refused an unfittable size, generating after each step.End-to-end
All runs use AIME25 problem 1 (problems 1-8 for the concurrent run) through
freetoken.llm.LLM(model_path, dtype=torch.bfloat16, max_running_req=4)with the chat template, MTP withspeculative_algorithm="mtp"and the default k = 3. Checkpoints:Qwen/Qwen3.6-35B-A3BandQwen/Qwen3.6-27B(bf16),nvidia/Qwen3.8-Flash-Next-NVFP4(default--ple-backend disk, experts offloaded by the automatic MoE cache), and the Flash-Next GGUFISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF(IQ3_XXS) with the Strata packmtp-q2_0.ggufasspeculative_draft_model_path.Speculative decoding off must match main. Greedy (
temperature=0,max_tokens=16384, natural end) on Qwen3.6-35B-A3B, compared token by token with the same run on main a2ce636 (which #841 and #836 reproduced token for token): both runs hit the 16384-token limit and are identical.MTP off vs on, greedy, same settings:
The accept length is committed tokens per request per verify step (at most k + 1 = 4). The Qwen3.6 divergence points are the ones the PoC run showed.
8 requests at once with MTP (problems 1-8 submitted together, recommended sampling
temperature=1.0, top_p=0.95, top_k=20,max_tokens=4096): every request finished without error and the end of every output reads as coherent reasoning. Where a request finished before the limit, its boxed answer is the reference answer.Decode speed, one request: problem 1, recommended sampling, 1024 decode tokens (
ignore_eos), one warmup then 3 runs, median. The KV column is the pool the engine sized at startup with default memory settings; the draft's weights and KV come out of the same budget.Startup rejections checked on this branch: an FTW checkpoint (
Qwen3.6-27B-NVFP4converted to FTW) and the Flash-Next GGUF without a draft path both stop at config time with the errors above. No Qwen3.5-family GGUF was at hand, so that rejection was checked by callingMTPProposer.adjust_configwith theQwen3_5GGUFForCausalLMregistry entry ("MTP is not supported for Qwen3_5GGUFForCausalLM yet"). The other config-time checks were run through the engine's config resolution on local checkpoints, on CPU: a Strata pack as the draft of Qwen3.6-27B, the Flash-Next GGUF with the Flash-Next HF folder as its draft, Qwen3-30B-A3B (nomtp.*) as the draft of Qwen3.6-27B, and a draft path that does not exist are refused; Qwen3.6-35B-A3B, the Flash-Next NVFP4 checkpoint (alone and with the Strata pack) and the Flash-Next GGUF with the Strata pack pass. The expert-format error was checked against themtp.*expert names of local checkpoints: the bf16 and block-fp8 Qwen3.5-35B-A3B and Qwen3.6-35B-A3B checkpoints, three NVFP4 releases of Qwen3.6-35B-A3B and the Flash-Next NVFP4 checkpoint pass,nvidia/Qwen3.8-Flash-Next-FP4-Local(per-expert NVFP4) is refused. Rerun through the config resolution on e5ac81f, the FTW, Flash-Next GGUF and CPU cases above give the same results,--dummy-weightis refused, and of three draft paths that do not exist,./nonexistent-draftandmodels/x/nonexistent-draftare refused whilenonexistent-org/nonexistent-draftpasses as a repo id.Known gaps
max_running_req=4). The chain graphs' slot-index buffers ((k-1) x max batch x page-table width x 4 B) and the draft extend graph's capture buffers are not budgeted either; all of these come out of thememory_ratioheadroom.MTPProposeritself (the contract test drives a stub proposer): chunked against one-shot prefill, chain-graph against eager drafting, the post-verify relay over padded rows, and detach/attach. A bug there would lower the accept rate, not the output, since the verify is exact.