[RFC] DFlash - #20840
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20840
Note: Links to docs will display an error until the docs builds have been completed. ❌ 3 Pending, 1 Unclassified FailureAs of commit e3156c4 with merge base d827cf7 ( UNCLASSIFIED FAILURE - DrCI could not classify the following job because the workflow did not run on the merge base. The failure may be pre-existing on trunk or introduced by this PR:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
Hi @cthotti! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
|
@claude review this code |
|
Thanks @cthotti! Am I right in reading the PR that you're not seeing speedup from dflash? |
|
Claude finished @metascroy's task in 3m 33s —— View job Code Review — DFlash on Qwen3-4BNice work — the DFlash design is well thought out, the model-agnostic 🔴 Blocking / correctness1. 2. 🟡 Should fix3. Leftover profiling scaffolding in 4. Missing BSD license headers 🟢 Minor / nits
Testing / CI noteNone of the DFlash tests can run in this repo's CI: they require exported Overall the core algorithm and export pipeline look solid and the correctness claim checks out on inspection. The main things to resolve before merge are the dead |
… hazard, trim profiling scaffolding, add license headers, fix typos - Remove qwen3_dflash-mlx Makefile target + .gitignore block: depended on C++ engine files that are gitignored/not yet landed (follow-up PR) - Rename tests/test_dflash_*.py -> check_dflash_*.py so pytest's test_* glob never collects these manual, hardware-gated driver scripts - Trim sub-millisecond profiling scaffolding in run_dflash.py, keep only draft_exec/target_exec timing under --verbose (those dominate wall time) - Add missing BSD license headers to 8 files - Fix no-op --chat-template flag (only --no-chat-template had any effect) - Fix typos: frmo->from, prodcues->produces, an .pte->a .pte, output project->output projection weights - Add one-line comment on first_mismatch's draft/target length asymmetry - Remove unused default_dflash_layer_ids (layer ids come from --dflash-layers / draft config, not this helper) - Document DFlash's check_dflash_*.py as manual/CI-exempt in README
Yes, I didn't see a speedup on my original hardware, although the implementation is functionally correct and produces token for token identical output to baseline decoding. From profiling, the verification stage dominated each speculative round on my Macbook M2 Air (8 core GPU), so the cost of verifying each drafted block outweighed the time saved by drafting. However, since then, I've rerun the implementation on another Apple Silicon environment using a MacBook M4 (10-core GPU), where I observed a 1.4x speedup over a baseline run. Which makes me think that earlier results were more so hardware dependent. Before drawing a conclusion, I'd like to spend more time running experiments and testing out other Apple environments, like a M2 Pro (16 core GPU) and, if possible, M4 Pro (20 core GPU). Would it be alright if I spend a little more time validating the implementation and updating the PR with those results? |
Yes, that would be great! Pybindings do add overhead, and we do usually benchmark with c++ runners. We have a C++ runner for gemma4-31b (this would require an M4 to run). |
|
Hi @metascroy, thanks for the pointers. On the C++ runner, I don't currently have access to hardware that could run it. I've spent the last few days trying to get an M4-M or M4-XL tier (the memory sizes gemma4-31b would need) through Scaleway, but they're either out of stock most of the time or too expensive. If anyone has spare M4-class hardware or access to something in that range, I'd genuinely like to help build this out further. In the meantime, my hardware-side conclusions are written up in DLFASH_EXPERIMENTS.md in this PR, from which we learned that DFlash outperforms with up-to-date GPU architecture and on specific Math/Code prompts (not simple chat). Also worth noting, the PR doesn't need everything I've touched locally to land. The parts that are actually useful here are:
For the next steps, I'm happy to open a separate issue for the gemma4-31b work specifically, if someone else would like to take a look. |
|
@claude review this code |
|
Claude finished @metascroy's task in 4m 18s —— View job Code Review — DFlash on Qwen3-4B (updated)Thanks for the follow-ups since my last review. I re-read the branch against But re-reviewing surfaced one new blocking issue that dwarfs everything else. 🔴 Blocking — ~40,000 lines of generated MLX artifacts got committed by accidentCommit
I confirmed none of these exist in Please 🟡 Should fix
🟢 Minor / nits (carried over — still open)
On the performance question (@metascroy)Your SummaryThe DFlash implementation and its findings are in good shape and the prior review comments are addressed. The one thing blocking a clean merge is the ~40k lines of accidentally-committed generated FlatBuffers artifacts in |
| return x.reshape(b, h * n_rep, s, d) | ||
|
|
||
|
|
||
| def apply_rotary_pos_emb(q, k, cos, sin): |
There was a problem hiding this comment.
Is it possible to use the mlx rope method for this?
There was a problem hiding this comment.
I tried this, but ran into an issue as the existing rope op wants a fixed position offset, and ours needs to shift depending on context length, which broke the export. If you know a clean way to handle a moving offset like this, I'd be happy to implement it.
| self.weight = nn.Parameter(torch.ones(dim)) | ||
| self.eps = eps | ||
|
|
||
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
There was a problem hiding this comment.
I think F.rms_norm will lower more efficiently?
There was a problem hiding this comment.
Yea, nice idea. I swapped it over to F.rms_norm, kept the same order of operations so the math lines up exactly. And ran the lossless check and it's all good.
|
Hi, I accidentally closed this issue by renaming the branch, but its restored now. |
|
Thanks @cthotti! Let me find some time to check out your PR tomorrow or Friday and run some benchmarks myself. |
|
@cthotti I think this needs another pass before landing. The basic approach works, but the PR currently mixes a Qwen bringup, a second Gemma implementation, a generic export API, and a new draft transformer implementation. That makes the change much larger than necessary and leaves important correctness and performance gaps. My recommendation is to narrow this PR to one generic DFlash path built on Requested changes1. Make
|
|
@claude review this code |
|
Claude finished @metascroy's task in 2m 29s —— View job Code Review — DFlash (Qwen3-4B + Gemma4-31B)I re-read the branch at Landing criteria — status1. Dynamic 2. Reuse the HF reference module — ❌ not done.
No eager-parity test against the reference module exists yet. 3. Real draft KV cache — ❌ not done. 4. Narrow / consolidate — ❌ not done. The parallel Gemma implementation is still fully present: 5. Explicit hidden-tapping feature — ❌ not done. Still exposed as 6. Validation scripts — ❌ not done. Both problems you named are still there:
Cleanup — ❌ not done
One correctness nit worth a comment
SummaryThe algorithm and findings are solid, and the write-ups answer the earlier perf question well (base M4 1.4–1.7× on math/code). But against the six landing criteria, all six are still open — the branch head only carries lint fixes since the request. The highest-leverage items to unblock a clean, reviewable merge are: (2) adapt the HF reference draft instead of the hand-rolled 277-LOC module, (3) add the persistent draft KV cache, and (4) drop the Gemma duplication so this is one generic Qwen-first path. (1), (5), and (6) are smaller and mostly mechanical. |
|
@claude review this code |
|
Claude finished @metascroy's task in 4m 0s —— View job Code Review — DFlash (Qwen3-4B), branch
|
| # | Item | Status |
|---|---|---|
| 1 | Dynamic block_len w/ generation + context bounds |
✅ compute_block_len + Dim("block_len", min=2, max=block_size); bs==1 → target-only step |
| 2 | Reuse HF reference module | ✅ DFlashQwen3Attention(Qwen3Attention) reuses Qwen3RMSNorm/MLP/RotaryEmbedding + ALL_ATTENTION_FUNCTIONS dispatch; embed_scale now loaded; eager-parity test added |
| 3 | Persistent draft KV cache | 🟡 implemented (DFlashDraftKVCache, --cached) but opt-in, unvalidated, docs contradict code — see below |
| 4 | One generic Qwen-first path | ✅ Gemma impl removed (7261f77), single path |
| 5 | Explicit hidden tapping | ✅ renamed --tap-layers; sliding-window combo rejected explicitly |
| 6 | Validation scripts | ✅ lossless fails on nonzero exit + mismatch; target check exercises per-token positions across seq lens; synthetic acceptance-loop tests added |
🔴 Blocking
1. check_dflash_draft.py passes 3 inputs to a 2-input exported program. The uncached export (export_dflash_draft.py:169) traces model with inputs (tokens, target_hidden) — positions are computed inside DFlashDraftModel.forward (dflash_draft_model.py:305). But check_dflash_draft.py:29 calls method.execute([tokens, target_hidden, position_ids]) with a third position_ids tensor the forward never accepted. run_dflash.py:248 correctly calls draft.execute([draft_input, hidden]) (2 args), so this check is inconsistent with both the export and the real runner and will fail against a genuinely-exported draft. Drop the position_ids arg. Fix →
2. Committed scratch files are stale and reference a cache API that no longer exists. Both are tracked (git ls-files confirms) and both are self-described as "Not part of the PR — a throwaway diagnostic":
scratch_draft_cache_probe.pycallscache.write_context(),cache.advance_context(),cache.ctx_len,cache.write_block(), andcache.valid_mask(block_len=8)— none of these exist on the shippedDFlashDraftKVCache(which exposeswrite(),valid_mask(valid_len, device),valid_len_after(),reset()).scratch_draft_cache_equiv.py:70-73callsmodel(tokens, ctx, cache=..., cache_position=..., new_ctx_len=new_ctx_len)— butDFlashDraftModel.forwardtakes nonew_ctx_lenkwarg (it derives it fromtarget_hidden.shape[1]). This raisesTypeErroron run.
So the dflash_draft_model.py:210 claim that the .item()+narrow() cache path was "verified directly via a standalone probe before wiring this in" is not backed by any runnable code on this branch — the probe it refers to tests a different, older cache design. Please git rm both scratch files (the reviewer's earlier "remove experiment files not part of the supported workflow" applies here). Fix →
🟡 Should fix
3. The draft KV cache (criterion 3) has no runnable validation and is off by default. --cached is opt-in in both export_dflash_draft.py and run_dflash.py, and check_dflash_lossless.py runs run_dflash.py without --cached — so the only lossless guarantee covers the uncached (full-reprojection) path. The eager-equivalence check that was meant to validate the cached path (scratch_draft_cache_equiv.py) is broken (#2). Since the persistent cache is the actual point-3 deliverable and the perf argument for it, it needs a real, committed test: either a check_dflash_cached_lossless.py that runs run_dflash.py --cached and compares to baseline, or a fixed eager cached-vs-uncached parity test under tests/. Without it, the cache path can silently regress.
4. Contradictory / duplicated comments in dflash_draft_model.py cached path. The block comment at :149-155 says the design attends "over the FULL fixed-size cache buffer with a validity mask -- never narrow() it down," but the code at :217-221 does exactly the opposite — valid_len = ...item() then full_k.narrow(2, 0, valid_len). The comment at :199-201 ("Write new context K/V into the persistent cache…") is also a stale duplicate of :189-191, describing a write that already happened at :191. And DFlashDraftKVCache.valid_mask() / its docstring (dflash_draft_cache.py:62-74) describe the mask-based approach that the model no longer uses — so valid_mask() is now dead code. Reconcile the comments with the narrow-based implementation actually shipped, and either wire up or remove valid_mask(). This directly obscures a correctness-sensitive path.
5. compute_block_len() is tested but not used by the real loop. run_dflash.py:204 reimplements the bound inline (bs = min(block_size, ...)) and :208-209 reimplements the draft_room bound, rather than calling compute_block_len(). So the synthetic tests in check_dflash_acceptance_loop.py exercise a helper the production loop doesn't call — the two can drift. Call compute_block_len(...) from the loop so the tests cover the real path. Fix →
🟢 Minor / nits
dflash_draft_cache.py:59write()docstring says "Caller masks the unwritten tail viavalid_mask()" — stale; the caller narrows, doesn't mask.check_dflash_draft.py:22hardcodeshidden_size=12800(5 tapped layers × 2560). Fine for a smoke check, but a comment tying it to--tap-layers/target_layer_idswould prevent confusion if the tap set changes.export_dflash_draft.py:118-119docstring referencesscratch_draft_cache_export.py, which isn't in the tree — dangling reference; drop it once Re-sync with internal repository #2 is done.run_dflash.py:305/:311the.float()onnew_hiddenand the two context-accumulation branches are correct but subtle; the existing comments cover it well — no change needed.
Summary
Criteria 1, 2, 4, 5, 6 are in good shape. The blockers are small and mechanical: fix the 3-arg draft check (#1) and remove the two stale scratch files (#2). The most substantive remaining gap is that the persistent draft cache (criterion 3) ships without a runnable lossless/parity check and with comments that contradict the shipped narrow() implementation (#3, #4) — worth closing before landing, since that path is the whole performance rationale for the cache.
· branch dflash-qwen3-4b
… generic pieces and adding Qwen3 adapter
# Conflicts: # backends/mlx/examples/llm/export_llm_hf.py
|
Hi, Thanks for the thorough review @metascroy, and sorry for the slow write-up. All six points are addressed below: 1. Dynamic
|
|
@claude review this code |
|
Claude finished @metascroy's task in 7m 55s —— View job Code Review — DFlash (Qwen3-4B), branch
|
| # | Item | Status |
|---|---|---|
| 1 | Dynamic block_len |
✅ Dim("block_len", min=2, max=block_size); compute_block_len now called by the real loop (run.py:231) |
| 2 | Reuse HF reference module | ✅ DFlashAttention(arch.attention_cls) + ALL_ATTENTION_FUNCTIONS["mlx"], embed_scale loaded, unsupported-layer rejection |
| 3 | Persistent draft KV cache | 🟡 implemented and now the only exported path — but uncached default runner broken (#1), no lossless/parity test (#2) |
| 4 | One generic Qwen-first path | ✅ Gemma removed; single dflash/ package with thin adapter |
| 5 | Explicit hidden tapping | ✅ --tap-layers; but tap+sliding is supported, not rejected as claimed (#4) |
| 6 | Reliable validation | ❌ regressed — in-repo checks deleted, CI doesn't assert losslessness (#2) |
Summary
The generic redesign is a clear improvement and criteria 1, 2, 4 are solid. Two things to resolve before landing: the default (uncached) runner path is broken against the only .pte the exporter produces (#1), and the refactor dropped the losslessness/acceptance-loop/parity tests so the PR's core correctness claim is no longer verified anywhere in-repo (#2). Fixing those two makes this landable.
· branch dflash-qwen3-4b
|
Thanks for the PR @cthotti! I just cleaned up the logic a bit to make it more generic, rather than Qwen3 specific. Once CI passes, we can merge. Note I added an n_draft parameter to the dflash run.py command. Setting it to something small like 3 or 4 significantly speeds things up for me compared to using 16. |
Summary
This PR adds support for DFlash speculative decoding to the ExecuTorch MLX delegate using Qwen3-4B as the reference model. It has hidden-state export for the target model, a configurable DFlash draft model, export support for both models, and an end-to-end speculative decoding driver. The implementation has been verified to produce an identical output to standard greedy decoding.
Although the original issue was targeted at Gemma, my implementation was developed and validated on Qwen3 because publicly available DFlash draft weights are available and the model fits within local hardware constraints. The overall implementation is model-agnostic and designed to support additional architectures in follow-up work.
Fixes #20701
DFlash Overview
Unlike conventional speculative decoding, where a small autoregressive draft model predicts one token at a time, DFlash predicts an entire block of tokens in a single forward pass over a masked block. The draft model is conditioned on intermediate hidden states from the target model, which are added into the Key/Value projections of every draft rather than only at the input. This provides richer conditioning throughout the draft network while keeping the draft model relatively small.
The draft model is implemented through a configurable DFlashConfig, making it adaptable to different transformer architectures. It has been numerically verified on Qwen3 and structurally validated against Llama-3.1 and Gemma-style configurations. There are additional Gemma-specific features documented, like partial RoPE and post-layer scaling, but they remain outside the scope of this PR.
Key Implementation Details
Phase 1: Target hidden-state export
TorchExportableModuleWithStaticCacheAndHidden, which extends the existing Hugging Face export wrapper to return both logits and concatenated hidden states from the selected target layers.Phase 2: Draft model and export
Phase 3: Speculative decoding driver
Python Driver
My PR includes the Python implementation of the speculative decoding loop rather than the earlier C++ engine recommended in the issue.
Profiling showed that nearly all execution time is spent inside the exported target and draft model execution, while the Python overhead contributes only a very small fraction of the total runtime. Since both implementations execute the same exported MLX programs, the Python driver provides similar functionality and is simpler to maintain. A C++ implementation can be added later if tighter runtime integration is needed.
Performance
My primary goal of this PR is to add DFlash support and verify that the implementation is correct. In addition to correctness, I benchmarked the implementation on Apple M2 hardware to understand its performance characteristics.
The most significant observation is that the target model's verification pass scales almost linearly with the number of tokens being verified. On this system, verifying a single token (T=1) takes approximately 32 ms, while verifying a block of 16 tokens (T=16) takes approximately 239 ms, roughly 7.5× the latency for a 16× larger verification window.
Since speculative decoding relies on verifying multiple draft tokens in a single target forward pass, this scaling behavior directly impacts the achievable speedup. The measured acceptance rates (chat: 4.17, math: 5.59, code: 6.81) are not sufficient to offset the additional verification cost on this hardware, so the implementation is currently slower than baseline greedy decoding for a single-request inference on an Apple M2.
This appears to be because of the underlying hardware rather than the DFlash implementation itself. The speculative decoding algorithm is functioning correctly and produces token-for-token identical output to baseline decoding. Platforms where verification cost grows more slowly with batch size are expected to benefit more from DFlash, since speculative decoding becomes increasingly effective as the cost of verifying larger blocks approaches the cost of verifying a single token.
Test Plan
All tests were run using exported target and draft models (
qwen3_4b_dflash_target.pteandqwen3_4b_dflash_draft.pte) with a block size of 16 and 4-bit quantization.Lossless speculative decoding:
python3 examples/models/qwen3/tests/test_dflash_lossless.pyVerifies that DFlash produces token-for-token identical output to standard greedy decoding.
Target model export
Draft model export and dynamic context lengths
Final Results
Math prompt
Code prompt
Chat prompt
cc @mergennachin @iseeyuan @lucylq @helunwencser @tarun292 @kimishpatel @jackzhxng