perf(speculative): speed up DFlash/DSpark drafter training 3.12x - #2279
Draft
h-guo18 wants to merge 6 commits into
Draft
perf(speculative): speed up DFlash/DSpark drafter training 3.12x#2279h-guo18 wants to merge 6 commits into
h-guo18 wants to merge 6 commits into
Conversation
…-sparse mask
The DFlash draft handed SDPA a materialized [B, 1, Q, KV] additive float mask.
That mask disqualifies every fused backend -- FlashAttention rejects arbitrary
masks and caps head_dim at 256 -- so PyTorch fell through to the cutlass
memory-efficient backend, whose only kernel is **sm80**. Every DFlash-family
drafter was therefore running an Ampere attention kernel on Blackwell, at roughly
2.5% of peak. Kernel attribution put that one backward kernel at 59% of the whole
training step.
The predicate is block-sparse by construction -- each query block sees a context
PREFIX up to its anchor plus its own block on the diagonal, about 33% dense -- so
it expresses exactly as a FlexAttention BlockMask, and the generated Triton kernel
skips the ~67% of tiles that are entirely masked.
Measured on PDX B300, 3 nodes (1 vLLM serve + 2 trainer = 16 DP ranks), Gemma-4-E4B
DSpark, bs 4 x seq 4096, median over 16 ranks x 20 steady-state steps:
step 1.690 -> 0.711 s (2.38x)
attention bwd 0.997 -> 0.094 s
attention fwd 0.124 -> 0.011 s
Two portability constraints are baked into dflash_flex_attention.py because both
were found the hard way on torch 2.11 / sm103:
* head_dim > 256 must pin 32x32 kernel tiles. 64x32 and 64x64 give
"misaligned address"; 128x32 gives "unspecified launch failure".
* The BlockMask block size must divide the kernel's BLOCK_M/BLOCK_N. head_dim > 256
pins 32x32 so 64 is safe, but head_dim <= 256 lets Inductor autotune up to
BLOCK_M=128, where a 64-wide mask block raises "Q and KV block size must be
divisible by BLOCK_M and BLOCK_N". Gemma-4 dodged this only because head_dim 512
pins the tiles; every head_dim <= 256 drafter (Qwen3, Kimi) would have hit it.
Also note enable_gqa=True is deliberately NOT used: forward matches, but backward
took 568 ms -- 10x the pre-repeated path and 2.5x worse than the SDPA baseline it
replaces. K/V are always pre-repeated instead.
The branch sits inside _build_draft_attention_mask rather than at its three call
sites, so DFlash, DSpark and Domino all benefit and the existing monkeypatch-spy
tests keep observing a single mask build per forward. Off by default
(dflash_use_flex_attention).
OPERATIONAL PREREQUISITE: TRITON_CACHE_DIR must be node-local. FlexAttention
compiles Triton templates on the first backward, and pointing that cache at a
shared filesystem makes concurrent ranks race -- 16 ranks on lustre died with
"OSError: [Errno 14] Bad address" reading a .json mid-write.
Signed-off-by: Hao Guo <haoguo@nvidia.com>
…park metric syncs Three independent inefficiencies in the DSpark training step, all of which grew out of materializing or synchronizing more than the loss actually consumes. * The offline/streaming path built full-sequence [B, seq, vocab] base logits and then gathered a few thousand rows out of them. DFlashBaseModelOutput now carries the post-final-norm base hidden and takes defer_lm_head, so the loss projects only the rows it reads. At the Gemma-4-E4B shape that removes roughly 17 GB/step of memory traffic. * The Markov bias was added out-of-place, allocating a third [B, N, bs, vocab] tensor (8.6 GB at this shape). It now folds in place when the uncorrected logits are dead, which is the case whenever dflash_report_acc is off -- their only consumer is the base_accuracy diagnostic. Numerically identical: a.add_(b) and a + b compute the same elementwise rounded sum, and neither lm_head nor markov_w2 saves its OUTPUT for backward. * Five per-step .item() calls were collapsed into a single torch.stack([...]).tolist(). Each .item() is a full device synchronize, and five of them chop up the window in which DDP's all-reduce can hide behind backward. This one change was worth 39 ms of NCCL exposure on its own. compute_markov_latent() is split out of compute_markov_bias() so the rank-r argument to markov_w2 is reachable without the vocab-wide projection; _rnn_step returns the latent rather than a bias, and both call sites are updated. Measured on PDX B300, 3 nodes (1 vLLM serve + 2 trainer = 16 DP ranks), Gemma-4-E4B DSpark, bs 4 x seq 4096, median over 16 ranks x 20 steady-state steps: 0.711 -> 0.629 s/step. Signed-off-by: Hao Guo <haoguo@nvidia.com>
…slicing _tvd_per_token chunked its row axis with final_logits[i : i + chunk_size]. Slicing and splitting agree on every value, so no numerical test can tell them apart -- only the backward graph can, and there the difference is asymptotic. A slice per chunk creates one SliceBackward0 each, and every one of those allocates a zero tensor of the FULL [N, vocab] shape and scatters its own chunk's gradient into it: O(n_chunks * N * vocab). Tensor.split creates a single SplitBackward0 whose backward is one cat: O(N * vocab). At the Gemma-4-E4B shape (N = 4 * 512 * 8 = 16384 rows, vocab = 262144, so 8 GiB per [N, vocab] bf16 tensor, 16 chunks) that 16x amplification was 93.2 ms/step -- 15% of the step and the second-largest item in the profile after the DDP all-reduce. It had been flat at 97 +/- 1 ms across three earlier optimisation rounds, invisible because it was never broken out of the elementwise bucket. Measured on PDX B300, 3 nodes (1 vLLM serve + 2 trainer = 16 DP ranks), bs 4 x seq 4096, median over 16 ranks x 20 steady-state steps: 0.629 -> 0.541 s/step, with -85.7 ms of the -87.9 ms landing in backward and forward unchanged. In isolation at the production shape, backward went 198.0 -> 107.9 ms while peak memory was unchanged (41.9 -> 42.1 GiB). Bitwise identical, verified elementwise at the production shape: the returned per-token TVD, grad(hidden) and grad(markov_w2.weight) all compare exactly equal. End to end over 40 logged steps, max |this - previous| on the loss is 0.018, against a 0.025 spread among three earlier runs -- i.e. inside the pipeline's own run-to-run noise. The new tests do two things the existing suite could not. TestTvdPerTokenChunking parametrises chunk_size over values that divide N and values that leave a ragged tail: every existing DSpark test runs 96 rows against the default chunk_size of 1024, so all of them fit in a single chunk and no chunk-boundary bug could ever fail CI. test_chunks_via_split_not_slice walks the autograd graph and asserts SplitBackward is present and SliceBackward is not -- pinning the optimisation itself, since it has no observable numerical signature. Signed-off-by: Hao Guo <haoguo@nvidia.com>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
…ion has one shape
_sample_anchor_positions sized its output by the batch: min(num_anchors,
valid_counts.max() - 1), i.e. the longest answer in the batch. n_blocks sets the
draft's q_len and kv_len, and both the block-mask builder and flex_attention are
torch.compile(..., dynamic=False), so every distinct value was a distinct shape and
a fresh compile. Each cost 170-300 s, and new ones kept arriving, because any batch
whose answers are all shorter than the cap mints a width never seen before.
That is not a warm-up cost, it is the run. A 3-node Gemma-4-E4B job spent 1360 of
its first 1510 training seconds frozen in recompilation. Reconstructing the
consumption rate from the serve's request log shows the trainer alternating between
0.6 s/step bursts and multi-minute stalls -- seven of them in ~107 steps, the last
still arriving at step 102.
max_n is now min(num_anchors, max_anchor + 1), a function of the config and the
sequence length alone. The bound the old code computed survives as `cap`, a 0-dim
tensor applied before the sort rather than as a slice width, so the sampled anchors
are bitwise what they were. Widening the slice and sorting after would instead pull
anchors from past the bound into the front of the row and silently move which
positions are trained on, while every shape assertion still passed. The surplus
columns carry keep=False, which every consumer already handles because rows shorter
than the batch maximum have always produced them. Dropping the .item() also takes a
host sync out of every step.
Measured on 3 nodes (1 serve, 2 trainer, bs 4 x 4096 over 16 ranks), 40 steps:
before after
compile events 4 1
compile seconds 772.1 173.5
wall (s) 799.1 204.7 3.90x
The 20-step mean loss is unchanged against a production run of the same recipe --
3.211 / 2.660 / 2.621 / 2.614 before, 3.211 / 2.661 / 2.623 / 2.601 after, inside
this pipeline's 0.025 run-to-run spread. Steady-state step time moves 0.534 ->
0.556 s: short batches now pay for 512 blocks they used to skip, which is the trade.
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
h-guo18
marked this pull request as draft
August 29, 2026 05:23
…e reached The RDMA hidden-states pool is a ring: the producer reuses slots, and the consumer detects a mid-read overwrite by asking /done whether the slot's generation still matches the one its bytes were written under. That check is the only guard against a lapped slot -- the token_ids comparison right after it comes from the server's per-request record, not from the bytes just read, so it passes on a mis-slotted read -- and it defaulted to `valid = True` whenever the /done call itself raised. The two mistakes are not symmetric. Discarding a good sample costs one resample. Keeping a lapped one trains the draft on another prompt's activations, silently: nothing downstream can tell, and the loss curve absorbs it. A /done that errors proves nothing about the slot either, so it now takes the same path an explicit lap does. The transport failure gets its own message rather than reusing the lap warning: a wave of unreachable /done is a sidecar problem, a wave of laps is ring pressure, and the two call for different fixes (a sick server vs. more pool_slots). Not theoretical -- a 3-node Gemma-4-E4B run logged 62 lapped-slot resamples over 80 steps at the launch script's default pool_slots=16, and 16 at pool_slots=128, so the guard fires in normal operation. Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
…d parameters
The draft's decoder loop was where the step's small-kernel tail lived: a profiled
Gemma-4-E4B step ran ~1500 pointwise launches, 403 aten::copy_ and 205 device-to-device
memcpys, individually microseconds and collectively about a third of the step. Nothing
but a compiler fixes that shape of cost, and dflash_use_torch_compile -- despite
promising "torch.compile on DFlash forward/loss methods" -- only ever reached the DSpark
TVD chunk. It now also wraps DFlashModule's body.
dynamic=False is affordable only because the block count is pinned; while n_blocks
tracked the batch this would have recompiled per width at minutes each. The lazy rotary
construction stays outside the compiled region (it mutates the module), and the compiled
body is used in training only: generation runs the same module at a varying length, which
under dynamic=False would mint one compile per length.
The recipe's ddp_find_unused_parameters can now be false, which needed one fix first.
Torch reports it "did not find any unused parameters", but that is a statement about the
batches seen: on a batch whose loss terms are all skipped, _compute_dspark_loss returned
`flat_final.sum() * 0`, and the confidence head hangs off its own projection rather than
final_logits, so it received nothing and DDP would abort the run. That branch now walks
dflash_module.parameters() the way forward()'s no-valid-anchor early return already did.
Both paths are pinned by tests, since the failure mode is a crash hours into a run.
Measured on 3 nodes, 80 steps, against the same recipe without the change:
before after
step time (median) 0.5497 s 0.4927 s -10.4%
forward 0.1305 s 0.1005 s -23%
backward 0.3478 s 0.3048 s -12%
one-time compile 173.5 s 201.3 s
The extra 28 s of compile pays for itself after ~490 steps, so an 80-step profile
actually gets slower (228.8 s -> 243.3 s) while the 88k-step run this targets goes 13.5 h
-> 12.1 h. The 20-step mean loss is unchanged: 3.209 / 2.661 / 2.621 / 2.625 against
3.209 / 2.661 / 2.621 / 2.588, the first three windows equal to three decimals.
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
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.
Summary
Three independent optimisations to the DFlash-family training step, found by attributing every GPU kernel to the CPU scope that launched it. 1.690 → 0.541 s/step (3.12×). Each is separately motivated and separately measured, and they are three commits so they can be reviewed and reverted independently.
The three changes
1. FlexAttention over a block-sparse mask (2.38×). The draft handed SDPA a materialized
[B, 1, Q, KV]additive mask. That disqualifies every fused backend — FlashAttention rejects arbitrary masks and capshead_dimat 256 — so PyTorch fell through to the cutlass memory-efficient backend, whose only kernel is sm80. Every DFlash-family drafter was running an Ampere attention kernel on Blackwell at ~2.5% of peak; that one backward kernel was 59% of the step. The predicate is ~33% dense and expresses exactly as aBlockMask.Two portability constraints are baked into
dflash_flex_attention.py, both found the hard way on torch 2.11 / sm103:head_dim > 256must pin 32×32 kernel tiles — 64×32 and 64×64 givemisaligned address, 128×32 givesunspecified launch failure.BlockMaskblock size must divide the kernel'sBLOCK_M/BLOCK_N.head_dim > 256pins 32×32 so 64 is safe, buthead_dim <= 256lets Inductor autotune up toBLOCK_M=128, where a 64-wide mask block raises "Q and KV block size must be divisible by BLOCK_M and BLOCK_N". Gemma-4 dodged this only becausehead_dim512 pins the tiles; everyhead_dim <= 256drafter would have hit it.enable_gqa=Trueis deliberately not used — forward matches, but backward took 568 ms, 10× the pre-repeated path. K/V are pre-repeated instead.2. Teacher rows on demand, one device sync instead of five (1.13×). The offline/streaming path built full-sequence
[B, seq, vocab]base logits and gathered a few thousand rows out of them;DFlashBaseModelOutputnow carries the post-final-norm base hidden and takesdefer_lm_head, so the loss projects only the rows it reads (~17 GB/step of traffic at this shape). The Markov bias now folds in place rather than allocating a third[N, vocab]tensor — safe because neitherlm_headnormarkov_w2saves its output for backward, and the uncorrected logits are dead wheneverdflash_report_accis off. And five per-step.item()calls became onetorch.stack([...]).tolist(): each is a full device synchronize, and five of them chop up the window in which DDP's all-reduce hides behind backward. That sync change alone was worth 39 ms of NCCL exposure.3.
Tensor.splitinstead of slicing (1.16×)._tvd_per_tokenchunked its row axis withfinal_logits[i : i + chunk_size]. Slicing and splitting agree on every value, so no numerical test can tell them apart — only the backward graph can, and there the difference is asymptotic: a slice per chunk creates oneSliceBackward0each, and every one zero-fills a full[N, vocab]tensor, giving O(n_chunks × N × vocab).splitcreates a singleSplitBackward0whose backward is onecat: O(N × vocab). At this shape that 16× amplification was 93.2 ms/step, flat across the two earlier rounds and invisible because it was never broken out of the elementwise bucket.Correctness
grad(hidden)andgrad(markov_w2.weight)all compare exactly equal.max |after − before|on the loss is 0.018, against a 0.025 spread among three earlier runs — inside the pipeline's own run-to-run noise.The new tests close two gaps.
TestTvdPerTokenChunkingparametriseschunk_sizeover values that divide N and values that leave a ragged tail: every existing DSpark test runs 96 rows against the defaultchunk_sizeof 1024, so all of them fit in a single chunk and no chunk-boundary bug could fail CI.test_chunks_via_split_not_slicewalks the autograd graph and assertsSplitBackwardis present andSliceBackwardis not — pinning an optimisation that has no observable numerical signature.Scope
Change 1 benefits DFlash, DSpark and Domino — the branch sits inside the parent's
_build_draft_attention_mask, not at its three call sites, so all three plugins get it and the existing monkeypatch-spy tests keep observing one mask build per forward. The sm80 fallback affects any drafter passing a dense mask; the magnitude is largest athead_dim > 256.Changes 2 and 3 are DSpark-only (the code is in
hf_dspark.py). Nothing in them is Gemma-4-specific; change 3's magnitude scales with rows × vocab, so a 32k-vocab drafter sees proportionally less.All three are off by default or behaviour-preserving:
dflash_use_flex_attentiondefaults toFalse, and changes 2 and 3 alter no config surface.Operational prerequisite
TRITON_CACHE_DIRmust be node-local once FlexAttention is enabled. It compiles Triton templates on the first backward, and pointing that cache at a shared filesystem makes concurrent ranks race — 16 ranks on a lustre-backed cache died withOSError: [Errno 14] Bad addressreading a.jsonmid-write.Limits and follow-up
All numbers come from one model on one cluster at one shape. Not checked here: whether
hf_dflash.py::_compute_losshas the same[N, vocab]slicing pattern that change 3 fixes.Based on #2186; the diff above is only the delta on top of it.