spec : add DFlash2 support (local convolution + candidate selector) - #27342
spec : add DFlash2 support (local convolution + candidate selector)#27342SubSir wants to merge 16 commits into
Conversation
|
Hi @SubSir, thanks for your contribution! Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:
Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below. |
|
I've revised the PR description. It's fully hand-written now. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…and DFlash v1 Adds docs/dflash2-strix.md with the four-arm llama-benchy comparison on Qwen3.8-27B UD-Q4_K_XL, and vendors the unedited tool output under benchmarks/results/dflash2-20260819/. Headline, decode t/s at depth 0 / 8k / 32k: base (no spec decode) 11.81 11.44 10.54 DFlash v1 (n=5) 21.09 12.87 10.75 DFlash2 (n=4) 26.39 21.58 21.11 DFlash2 (n=7) 25.18 21.46 16.32 DFlash2 holds about 2x over base at every depth measured, while DFlash v1 decays to 1.02x by 32k, i.e. by then it is worth nothing over plain decode. Draft width matters at depth: at 32k, width 4 beats width 7 by 29 percent, and the two tie at shallow depth. DFlash2 engine support is upstream ggml-org/llama.cpp#27342 by Jian Chen and is not our work; the page says so. What is ours is the gfx1151/Vulkan validation and the draft-width tuning. Drafter weights are inco's published Apache-2.0 GGUFs. Measured with llama-benchy 0.4.0, which draws prompts from a Gutenberg book so acceptance reflects real prose. Content matters more than expected here: a code-corpus prompt set decays about 47 percent from shallow to 32k where this corpus decays about 20 percent, so the page carries that caveat rather than presenting one number as definitive. --runs 2, so the spec arms carry +/- 1.0 to 2.5 and only the 32k width gap is comfortably outside the noise. Prefill was taken at the server default -ub 512; -ub 256 is the measured dense optimum on these weights, so that column is a few percent below the ceiling. Noted in the page, rerun deferred. Assisted-by: Claude Opus 5
This comment has been minimized.
This comment has been minimized.
This comment was marked as off-topic.
This comment was marked as off-topic.
|
@Shamish There is already a fix for the reported problem at z-lab#1 As for the Performance on a single RTX3090, it seems to be not significantly better than MTP: MTP: DFlash2 (using maxn=4 because 7 was slower): |
This comment has been minimized.
This comment has been minimized.
|
Thanks for the PR. It would be great if you could run the benchmark on SpeedBench using the corresponding checkpoints and report the performance numbers. @SubSir |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Assisted-by: Claude Opus 5
| LLAMA_API int32_t llama_model_n_embd_out (const struct llama_model * model); | ||
| LLAMA_API int32_t llama_model_n_layer (const struct llama_model * model); | ||
| LLAMA_API int32_t llama_model_n_layer_nextn(const struct llama_model * model); | ||
| LLAMA_API int32_t llama_model_dflash_selector_top_k(const struct llama_model * model); |
| MODEL_TENSOR.DFLASH_ATTN_CONV_BASE: "blk.{bid}.attn_conv_base", | ||
| MODEL_TENSOR.DFLASH_ATTN_CONV_PROJ: "blk.{bid}.attn_conv_proj", | ||
| MODEL_TENSOR.DFLASH_FFN_CONV_BASE: "blk.{bid}.ffn_conv_base", | ||
| MODEL_TENSOR.DFLASH_FFN_CONV_PROJ: "blk.{bid}.ffn_conv_proj", | ||
| MODEL_TENSOR.DFLASH_SELECTOR_PREV: "selector_predecessor", | ||
| MODEL_TENSOR.DFLASH_SELECTOR_NEXT: "selector_successor", | ||
| MODEL_TENSOR.DFLASH_SELECTOR_HIDDEN: "selector_hidden", |
There was a problem hiding this comment.
| MODEL_TENSOR.DFLASH_ATTN_CONV_BASE: "blk.{bid}.attn_conv_base", | |
| MODEL_TENSOR.DFLASH_ATTN_CONV_PROJ: "blk.{bid}.attn_conv_proj", | |
| MODEL_TENSOR.DFLASH_FFN_CONV_BASE: "blk.{bid}.ffn_conv_base", | |
| MODEL_TENSOR.DFLASH_FFN_CONV_PROJ: "blk.{bid}.ffn_conv_proj", | |
| MODEL_TENSOR.DFLASH_SELECTOR_PREV: "selector_predecessor", | |
| MODEL_TENSOR.DFLASH_SELECTOR_NEXT: "selector_successor", | |
| MODEL_TENSOR.DFLASH_SELECTOR_HIDDEN: "selector_hidden", | |
| MODEL_TENSOR.DFLASH_ATTN_CONV_BASE: "blk.{bid}.attn_conv_base", | |
| MODEL_TENSOR.DFLASH_ATTN_CONV_PROJ: "blk.{bid}.attn_conv_proj", | |
| MODEL_TENSOR.DFLASH_FFN_CONV_BASE: "blk.{bid}.ffn_conv_base", | |
| MODEL_TENSOR.DFLASH_FFN_CONV_PROJ: "blk.{bid}.ffn_conv_proj", | |
| MODEL_TENSOR.DFLASH_SELECTOR_PREV: "selector_predecessor", | |
| MODEL_TENSOR.DFLASH_SELECTOR_NEXT: "selector_successor", | |
| MODEL_TENSOR.DFLASH_SELECTOR_HIDDEN: "selector_hidden", |
| ml.get_key(LLM_KV_LOGIT_SCALE, hparams.f_logit_scale, false); | ||
| hparams.f_final_logit_softcapping = 0.0f; | ||
| ml.get_key(LLM_KV_FINAL_LOGIT_SOFTCAPPING, hparams.f_final_logit_softcapping, false); | ||
|
|
||
| ml.get_key(LLM_KV_DFLASH_BLOCK_SIZE, hparams.dflash_block_size, false); |
There was a problem hiding this comment.
| ml.get_key(LLM_KV_LOGIT_SCALE, hparams.f_logit_scale, false); | |
| hparams.f_final_logit_softcapping = 0.0f; | |
| ml.get_key(LLM_KV_FINAL_LOGIT_SOFTCAPPING, hparams.f_final_logit_softcapping, false); | |
| ml.get_key(LLM_KV_DFLASH_BLOCK_SIZE, hparams.dflash_block_size, false); | |
| ml.get_key(LLM_KV_LOGIT_SCALE, hparams.f_logit_scale, false); | |
| hparams.f_final_logit_softcapping = 0.0f; | |
| ml.get_key(LLM_KV_FINAL_LOGIT_SOFTCAPPING, hparams.f_final_logit_softcapping, false); | |
| ml.get_key(LLM_KV_DFLASH_BLOCK_SIZE, hparams.dflash_block_size, false); |
| { LLM_KV_DFLASH_BLOCK_SIZE, "%s.block_size" }, | ||
| { LLM_KV_DFLASH_CONV_KERNEL_SIZE, "%s.conv_kernel_size" }, | ||
| { LLM_KV_DFLASH_CONV_GROUP_SIZE, "%s.conv_group_size" }, | ||
| { LLM_KV_DFLASH_SELECTOR_RANK, "%s.selector_rank" }, | ||
| { LLM_KV_DFLASH_SELECTOR_TOP_K, "%s.selector_top_k" }, |
There was a problem hiding this comment.
Move this to a separate block - no need to break the indentation here.
Assisted-by: Claude Opus 5
Assisted-by: Claude Opus 5
|
cc: @fairydreaming @ORippler for changes to the top-k kernel |
This comment was marked as spam.
This comment was marked as spam.
Temporary dismiss my review because the m-rope bug is quite déjà-vu, saw smth similar during muse glimmer development. I will have a look
I tested the performance of the included TOP_K kernel on RTX PRO 6000 Max-Q - compared it to the CUB argsort implementation and upcoming DeviceBatchedTopK (we don't use it yet, still waiting for the next CCCL release). Used the following test cases and compared times per one test case run (k=64): But noticed that this implementation runtime increases with k, so I also ran slightly different test set with k=1024: Results (k=64)
As you can see this kernel is generally slower than DeviceBatchedTopK.
But for small k it's faster than the argsort-based implementation. For 200000 cols the implementation falls back to argsort-based implementation. Results (k=1024)
For large k the new kernel is used only for low number of cols but it's clearly much slower. VerdictThis kernel is faster only in some cases (small k values). My opinion is that it's better to wait for DeviceBatchedTopK (CUDA) and rocprim::segmented_topk (ROCm) that should be universally faster instead of adding multiple implementations for specialized cases. |
How do these per-kernel times transfer to E2E times on consumer-grade HW? |
|
@SubSir could you please apply this patch to fix the image input (mrope bug)? https://gist.github.com/ngxson/b5ef18797c1bdcb1814cf09afadfd7eb that should make it aligned with muse glimmer rope handling |
Assisted-by: Claude Opus 5
ngxson
left a comment
There was a problem hiding this comment.
GGUF will need to be reconverted, and vision input should work with new GGUF (tested on my side)
Please also fix the failing CI
This comment was marked as low quality.
This comment was marked as low quality.
|
@ORippler, I tested on RTX PRO 6000 Blackwell using Qwen3.8-27B. It's about 2-5% end to end throughput impact.
(Q4_K_M MMVQ capped means I use GEMM kernel for an 8-token batch. It's another issue, not directly related to Dflash2. I provided the data here because otherwise it's a bit strange that BF16's throughput is nearly the same as Q4_K_M). So if it's not that much to you, I'll follow @fairydreaming's opinion and use the original kernel. But the cost of Dflash2 is a bit higher on llama.cpp than sglang or vllm. @ngxson @sswtodo, the ci fail is fixed in f7aadef, and I just uploaded the converted GGUF to z-lab/Qwen3.8-27B-DFlash2-GGUF. Later we'll update incoai/Qwen3.8-27B-DFlash2-GGUF. Thanks! |
This comment was marked as low quality.
This comment was marked as low quality.
This comment was marked as spam.
This comment was marked as spam.
Replace the headline block with the measured set: the speculative table for the FP4 stack including its draft acceptance, prefill against upstream as a curve over context depth for both a dense hybrid and a MoE, and the per-gate ablations across three quant families. Link bench/ for the structured data, methodology and charts. Three corrections rather than additions: The against-mainline table was stale. It showed the MoE at +3.2% where the current measurement is +12.6% at depth 0; the difference is the tiled concat-transpose kernel. It now carries depth as an axis, because the gain decays with it - +13.0% at depth 0 down to +4.6% at 64k on the dense model. Generation is now stated rather than omitted. It is flat within about 1% on stock K-quants, a small consistent loss on the dense model, and the README says so with the reason it is the wrong test for this fork's generation story. The LDS stride row claimed +14% prefill. That was the whole-fork figure at the time, not the pad's own contribution, which measures +7.3% whole-model. The pad explanation also now documents the coopMatLoad alignment contract and the RADV >= 25.3 gate, because the previous text recommended a pad that collapses pp512 on older drivers. Adds a fork-specific acknowledgements block: ciru-ai/ROCmFPX for the quant formats, Jian Chen for DFlash2 (PR ggml-org#27342), and Nathanw1014/llama.cpp for the Strix Halo Vulkan branch the prefill gates were ported from - noting that every one was re-measured here and two of their defaults are disabled because the gain does not reproduce on this hardware. Assisted-by: Claude Opus 5
For 2-4% E2E I'm fine with taking it, at least until CCCL3.5 has released. This was Linux? Or Windows |
| dflash_selector_prev = create_tensor(tn(LLM_TENSOR_DFLASH_SELECTOR_PREV, "weight"), { rank, n_vocab }, 0); | ||
| dflash_selector_next = create_tensor(tn(LLM_TENSOR_DFLASH_SELECTOR_NEXT, "weight"), { rank, n_vocab }, 0); |
There was a problem hiding this comment.
Quick question: It looks the size of dflash_selector is not small as described in blog +2.0M, e.g. with rank = 256 and n_vocab = 248320(qwen3.8 27B) that comes out to 2 × 256 × 248320 ≈ 127M parameters. Is this expected?
There was a problem hiding this comment.
We didn't explain it clearly in the blog. Actually predecessor_codebook and successor_codebook are projected from the target embedding and for Qwen3-4B, plus the hidden projection is 3 * 2560 * 256 = 1.97M. That's what we said 2M. And for implementation convenience, we materialized the projection into codebooks since 77.8M or 127M is not that much.
| ggml_tensor * blocks = ggml_reshape_3d(ctx0, hidden, hidden_size, block_size, n_blocks); | ||
| ggml_tensor * coeffs = ggml_reshape_4d(ctx0, dynamic, n_groups, kernel_size, 2, n_tokens); | ||
| ggml_tensor * coeffs_side = ggml_view_3d(ctx0, coeffs, n_groups, kernel_size, n_tokens, | ||
| coeffs->nb[1], coeffs->nb[3], side * coeffs->nb[2]); | ||
|
|
||
| ggml_tensor * coeff_all = ggml_cont(ctx0, coeffs_side); | ||
| coeff_all = ggml_reshape_4d(ctx0, coeff_all, 1, n_groups, kernel_size, n_tokens); | ||
| coeff_all = ggml_repeat_4d(ctx0, coeff_all, group_size, n_groups, kernel_size, n_tokens); | ||
|
|
||
| ggml_tensor * base_side = ggml_reshape_4d(ctx0, | ||
| ggml_view_1d(ctx0, base, hidden_size * kernel_size, side * base->nb[2]), | ||
| group_size, n_groups, kernel_size, 1); | ||
|
|
||
| ggml_tensor * weight_all = ggml_add(ctx0, coeff_all, base_side); | ||
|
|
||
| ggml_tensor * result = nullptr; | ||
| for (int64_t tap = 0; tap < kernel_size; ++tap) { | ||
| ggml_tensor * values = blocks; | ||
| if (tap > 0) { | ||
| ggml_tensor * zeros = ggml_fill(ctx0, | ||
| ggml_new_tensor_3d(ctx0, hidden->type, hidden_size, std::min(tap, block_size), n_blocks), 0.0f); | ||
| if (tap < block_size) { | ||
| ggml_tensor * previous = ggml_view_3d(ctx0, blocks, hidden_size, block_size - tap, n_blocks, |
There was a problem hiding this comment.
I feel this part we could optimize with some fusions. Need suggestions from @ggerganov
| ggml_tensor * cand3 = ggml_reshape_3d(ctx0, candidates, top_k, tokens_per_block, n_blocks); | ||
| ggml_tensor * unary3 = ggml_reshape_3d(ctx0, unary, top_k, tokens_per_block, n_blocks); | ||
| ggml_tensor * hid3 = ggml_reshape_3d(ctx0, hidden, rank, tokens_per_block, n_blocks); |
There was a problem hiding this comment.
Nits: the 3/4 suffixes here encode tensor rank rather than meaning, and it gets hard to follow once hid3 / hid / hid4 (and unary / unary3 / un / un4) all coexist across nested scopes. Something like cand_blk / unary_blk / gate_blk would read better.
| // and never more rows than a ubatch holds - so take the tighter bound | ||
| const uint32_t selector_tokens = std::min<uint32_t>( | ||
| n_tokens, model.hparams.dflash_block_size * cparams.n_seq_max); | ||
| res += 32*selector_tokens; |
There was a problem hiding this comment.
why is 32? can you add comment to explain it?
There was a problem hiding this comment.
Sorry, this is from the old version where selector is implemented by for loop. The node number shouldn't be calculated from the num of token in the batch in this version, and I missed it.
I counted on different number of layers and found that nodes per tensor is about 8.8, a bit higher than 8 nodes per tensor in the original code. So I add a special case for Dflash2 to lift the node per tensor to 12. Just in case that when the number of layers is larger than 8, graph nodes is larger than 1024 and it will throw an error.
| layers | tensors | graph nodes | nodes / tensor |
|---|---|---|---|
| 4 | 66 | 582 | 8.8 |
| 6 | 96 | 840 | 8.8 |
| 8 | 126 | 1098 | 8.7 |
| 12 | 186 | 1614 | 8.7 |
| 16 | 246 | 2130 | 8.7 |
On startup with PS: |
Assisted-by: Claude Opus 5
Assisted-by: Claude Opus 5
|
@ORippler It's on Linux. Sorry I don't have resources on Windows. |



Overview
DFlash2 adds two more modules compared to DFlash, grouped dynamic depthwise convolution and candidate selector.
The formula is for reference.
out[i,c] = Σ_t (base[t,c] + δ[i,t,g(c)]) · x[i−t,c].baseis the static kernel,δis the dynamic part predicted from the input.xis divided into groupsg(c)andδis shared across a group. Another one isedge(p→c) = ⟨A[p] ⊙ project(h), B[c]⟩ + unary[c].A/Bare the codebooks for the predecessor and the candidate. This position's hidden is projected byproject(h)to the same rank to multiplyA. Andunary[c]is the draft's own score forc.DFlash2 is enabled when the checkpoint is DFlash2; no need to use extra flag.
We evaled
Qwen3.8-27BatQ4_K_Mon Apple M5 Pro 64 GB. And the test data is first 8 problems of GSM8K.Concurrency is set to 1 and temperature is 1.0, top-p 0.95, top-k 20. We use default
xhighreasoning and set2,048 maximum new tokens.
Requirements