Skip to content

refactor(speculative): one home each for the DFlash family's shared rules - #2757

Draft
h-guo18 wants to merge 7 commits into
haoguo/dflash-training-perffrom
haoguo/dflash-family-cleanup
Draft

h-guo18 wants to merge 7 commits into
haoguo/dflash-training-perffrom
haoguo/dflash-family-cleanup

Conversation

@h-guo18

@h-guo18 h-guo18 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Refactor, no behavior change

Stacked on #2279 (its branch is the base); kept in draft until #2279 lands. Rules the DFlash family wrote out more than once now live in one place each:

  • Block supervision. Which token each block position is trained on, which positions count, and how they are weighted were written out five times (DFlash, DFlash2's selector, LiLiCorr, DSpark, Domino), plus three hand-written teacher-forced token gathers. They are now _block_targets, _block_tokens and _block_position_weights, driven by one class constant, _label_shift: DFlash, DFlash2 and LiLiCorr train position k on the token at anchor + k, and DSpark and Domino on anchor + k + 1. DSpark and Domino still always use decay weighting, as today: they never read dflash_loss_objective. A follow-up makes them honor it.
  • Teacher shift. The base logits that predict the token at position i sit at i - 1. DFlash KD, DSpark and LiLiCorr each applied that shift before calling _teacher_logits. It now takes label positions and shifts them itself, and every caller passes the positions it supervises. A wrong shift trains silently, just worse.
  • Final norm. _apply_base_final_norm(hidden, prenorm, norm) takes the flag as a bool. _maybe_apply_base_final_norm keeps reading it from the producer's dict for EAGLE, and delegates.
  • DDP zero loss. A step with nothing to supervise returns a zero loss that every trainable draft parameter feeds. Four copies in two forms become _draft_zero_loss, in the cheap form (one element per parameter, not a full sum() of every weight). The zero branches inside the loss keep routing through the draft's output (flat_logits.sum() * 0.0). With DFlashModule as its own FSDP2 unit (the MiniMax-M2.7 launcher layout), reading the resharded parameters directly there skips that unit's backward collectives on the rank and aborts the job.
  • Accuracy. Five copies of the masked top-1 accuracy become _masked_top1.
  • Target layers. hidden_states[lid + 1] for each target layer, in three forwards and both generation paths, becomes _target_hidden.
  • Generation. DFlash and DSpark's pseudo_speculative_generate shared their first half; it is now _generation_base + _draft_block. The block's position ids come from the training _build_position_ids at anchor = ctx_len.
  • Generation mask. _build_generate_swa_mask hand-wrote the training visibility rule a second time. It now evaluates _draft_mask_mod for its one block, anchored at ctx_len, through the dense helper the training mask also uses.
  • Config defaults. Thirteen variant fields were read with getattr(config, name, <default>) next to DFlashConfig's own default. One pair had drifted: dflash_fp32_master_weights defaults to True in the config and False in the fallback. They are now read directly. projector_type dispatch goes through a table instead of an if/elif chain.

Net change to source against #2279: +357 / −426 lines.

Usage

No API change.

Testing

  • tests/unit/torch/speculative (+ tests/unit/torch/export/test_hf_spec_rope_export.py): 411 passed on transformers 5.5.4, 5.12.1 and 5.18.0 (5.5.4 and 5.18.0 fall within CI's tf_min and tf_latest pins). tests/gpu/torch/speculative/plugins: 14 passed, 1 skipped.
  • Bitwise A/B against the previous head of this PR. One training step and one generation step for each of 23 cases, in fp32 and bf16 (46/46 identical): loss, every gradient, the variants' metrics, and the generated draft tokens.
    • DFlash: KD and CE.
    • DFlash2: ce / tv / lambda objectives, with the selector.
    • LiLiCorr: with the penalty and calibration terms.
    • DSpark: vanilla and rnn heads, confidence head on.
    • Domino.
    • Each case runs offline and online, with D-PACE and decay weighting, including steps that take the zero-loss branch.
  • FSDP2 (2 ranks, gloo, the draft as its own fully_shard unit, one rank on the zero branch): both ranks finish, as before.
  • New tests:
    • Label 0 reads row 0 rather than wrapping to the last row.
    • The generation mask equals the old hand-written one (kept verbatim in the test) over 18 combinations of context length, window and block causality.
    • Each variant's labels and supervision on a hand-worked example. This one fails if DSpark's shift is 0 or the anchor slot stops being excluded; no existing test caught either.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A
  • Did you get Claude approval on this PR?: N/A

Additional Information

Follow-up to the design review of #2279; review after it.

🤖 Generated with Claude Code

h-guo18 and others added 3 commits October 12, 2026 07:39
…norm core

The next-token shift -- the base logits that predict the token at position i sit at i - 1 --
was spelled out at each of _teacher_logits' three callers: DFlash KD and DSpark as
(safe_label_indices - 1).clamp(min=0), LiLiCorr as anchor + s for the token at anchor + 1 + s.
A wrong shift trains without an error, just worse, so it now lives once: _teacher_logits
takes the label positions and applies the shift itself, and the callers pass what they
supervise. DFlash2's predecessor gather uses the same offset for a different quantity (the
previous token, not the teacher) and keeps its own.

_maybe_apply_base_final_norm took the producer's whole outputs dict, so _teacher_logits
wrapped its one flag in a single-key dict to call it. The bool now has its own function,
_apply_base_final_norm, which the dict reader delegates to; EAGLE keeps calling the dict
reader, unchanged.

Behavior is unchanged: against this branch's parent, offline DFlash KD, LiLiCorr (penalty and
calibration) and DSpark give bitwise-equal losses and gradients in fp32 and bf16, with pre-
and post-norm captures stored in float64. The teacher tests now pass labels and expect row
label - 1, and fail without the shift; a new test pins that label 0 reads row 0 rather than
wrapping to the last row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
…he DFlash family

Two pieces of the family's forwards were written out once per model.

- The step with nothing to supervise returns a zero loss that every trainable draft parameter
  feeds, so DDP's reduction still sees each of them. There were four copies in two forms:
  DFlash's indexed one element of each trainable parameter, while Domino's forward, DSpark's
  forward and DSpark's degenerate-loss branch summed every whole parameter, reading all draft
  weights to produce a zero. HFDFlashModel._draft_zero_loss now has the cheap form once.
- The concatenation of the base layers the draft's fc reads, hidden_states[lid + 1] for each
  target layer, appeared in the DFlash, DSpark and Domino forwards. It is now
  HFDFlashModel._target_hidden, which also carries the "+ 1" (hidden_states[0] is the
  embedding output) that modify()'s check on negative ids refers to.

Losses and gradients are unchanged; DSpark's DDP-coverage tests, which require a gradient on
every draft parameter for a batch with no valid anchor and for one with zero total weight,
pass on the shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
_build_generate_swa_mask hand-wrote the draft's visibility rule a second time -- context
before the block, the window counted from each query's real position, block-causal under
dflash_draft_attention="causal" -- and its docstring asked readers to keep it in step with
training. It is the training rule, _draft_mask_mod, for the one block being drafted,
anchored at ctx_len, so it now evaluates that. The grid evaluation and the additive
conversion that the training mask's dense path already did become _dense_additive_mask,
shared by both.

A verbatim copy of the old builder stays in the tests as the reference: over contexts of 1,
5 and 16 tokens, no window, a window of one block and of two, and bidirectional and causal
blocks, the masks are equal element for element (and None where the old one returned None).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.63946% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.18%. Comparing base (38cd3e7) to head (28eb032).

Files with missing lines Patch % Lines
modelopt/torch/speculative/plugins/hf_dflash.py 98.87% 1 Missing ⚠️
modelopt/torch/speculative/plugins/hf_domino.py 92.85% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           haoguo/dflash-training-perf    #2757      +/-   ##
===============================================================
- Coverage                        78.20%   78.18%   -0.03%     
===============================================================
  Files                              647      647              
  Lines                            71795    71714      -81     
===============================================================
- Hits                             56145    56067      -78     
+ Misses                           15650    15647       -3     
Flag Coverage Δ
unit 59.89% <98.63%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

h-guo18 and others added 3 commits October 12, 2026 11:41
Which token each block position is trained on, which positions count, and how they are
weighted were written out five times: DFlash _compute_loss, DFlash2 _compute_loss (for the
selector), LiLiCorr _block_targets, DSpark _compute_dspark_loss and Domino
_compute_domino_loss. The copies differed only in the label offset (DFlash, DFlash2 and
LiLiCorr train position k on the token at anchor + k; DSpark and Domino on anchor + k + 1)
and in whether slot 0 is skipped, so a fix to one never reached the others.

- _block_targets returns the label token, its sequence position and the supervision mask,
  given the class constant _label_shift (0, or 1 for DSpark and Domino). The teacher reads
  its rows from those positions, including LiLiCorr's candidate logits.
- _block_tokens is the teacher-forced token gather that DSpark's and Domino's heads and
  DFlash2's predecessor ids each wrote by hand.
- _block_position_weights holds the D-PACE and decay weighting. DSpark and Domino pass
  "decay" explicitly, which is what they do today: they never read dflash_loss_objective.
- _masked_top1 replaces five copies of the masked top-1 accuracy.

The in-loss zero branches keep routing through the draft's output (flat_logits.sum() * 0.0)
rather than _draft_zero_loss: with DFlashModule as its own FSDP2 unit (the MiniMax-M2.7
launcher layout), reading the resharded parameters directly skips the unit's backward
collectives on that rank and the job aborts with a collective mismatch.

Behavior is unchanged. Against the previous head, a train step of DFlash (KD and CE),
DFlash2 (ce, tv and lambda, with the selector), LiLiCorr, DSpark (vanilla and rnn heads,
confidence head on) and Domino, offline and online, with D-PACE and decay, and steps that
take the zero-loss branch, gives bitwise-equal loss, gradients and metrics in fp32 and bf16.
DFlash2's predecessor ids now come from the shared gather; they differ from the old
formula only at positions whose label lies past the sequence end, which carry no selector
weight.

A new test pins each variant's labels and supervision on a hand-worked example; it fails if
DSpark's shift is set to 0 or if the anchor slot stops being excluded. No existing test did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
…Spark

DFlash's and DSpark's pseudo_speculative_generate repeated the same first half: run the
base model on the prefix, take its greedy next token, gather the target layers onto the
draft's device, build the block (anchor then mask tokens), its position ids and mask, and
run the draft. Only the decoding after that differs (DFlash takes the argmax of positions
1..bs-1; DSpark walks its Markov head over the block).

- _generation_base returns the base model's next token and hidden states, and carries the
  offline check both copies made.
- _draft_block drafts the block that token anchors. Its position ids now come from
  _build_position_ids at anchor = ctx_len -- the training layout -- the way the generation
  mask already comes from the training mask rule. The comment it replaces described a
  uniform-block layout training has not used since the anchors were introduced.
- _target_hidden takes an optional device, so the last two hand-written
  hidden_states[lid + 1] picks go through it.

The docstring's note that generation runs with attention_mask=None was stale since the
generation mask was added and now says what is passed.

Generated tokens are unchanged: DFlash (full attention, sliding window, causal blocks),
DFlash2, LiLiCorr, DSpark and Domino online give bitwise-equal draft tokens in fp32 and bf16
against the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
… a second default

Thirteen variant fields were read as getattr(config, "dflash_...", <default>) although
DFlashConfig declares every one of them with a default, so each default lived twice. One
pair had already drifted: dflash_fp32_master_weights defaults to True in the config and to
False in HFDFlashModel.modify's fallback, which a reader would take for the default. The
fallbacks never run, because modify() always receives a DFlashConfig, so the fields are now
read directly.

convert_to_dflash_model chose the registry for dflash_architecture_config.projector_type
with an if/elif chain and listed the accepted names again by hand in its error. A table
maps each projector type to its registry, and the error lists the table's keys.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2757/

Built to branch gh-pages at 2026-10-12 14:12 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

…e skill tables

The shared generation step raises one message for the whole family, "An offline
DFlash-family model cannot run AR validation / pseudo_speculative_generate". The
known-failure rows in the DFlash and DSpark skill references still quoted the two
per-variant messages it replaced, so a search for the actual error found no row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

1 participant