Skip to content

[2/2] refactor(speculative): train the DFlash family through one forward with shared loss terms - #2990

Draft
h-guo18 wants to merge 6 commits into
haoguo/dflash-loss-contextfrom
haoguo/dflash-shared-forward
Draft

h-guo18 wants to merge 6 commits into
haoguo/dflash-loss-contextfrom
haoguo/dflash-shared-forward

Conversation

@h-guo18

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

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Refactor. Training behavior is unchanged except for the two disclosed cases below. One backward-breaking API change: DSpark's and Domino's training metrics move under draft_metrics (see Metrics).

This PR is stacked on #2989, whose branch is its base; that PR is stacked on #2762. It is the second of two PRs: merge #2989 first, then this one. A new DFlash-family variant is now added one way: it reuses DFlash's training forward and changes only its loss, plus its head if it has one.

  • One training forward. DSpark and Domino each carried a copy of DFlash's training forward. Each copy ran the base model, built the loss mask, sampled anchors and built the draft inputs. Family fixes went missing in those copies; the padding-aware loss mask, for example, never reached them. HFDFlashModel.forward is now the only training forward, and no variant overrides forward(). DSpark and Domino change only the hooks [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989 introduced, plus _build_objective:
    • _online_base_outputs: DSpark uses the inner model's final hidden state, which the loss projects only at the rows it reads. Domino takes only the target hidden states.
    • _draft_logits: DSpark's Markov bias or Domino's GRU correction on the backbone's logits. The AR-validation decodes from fix(speculative): DSpark and Domino AR validation and label alignment #2762 are unchanged. Domino's generation test now checks its drafts against _draft_logits.
    • _build_objective: the block objective, ce and tvd for DSpark and ce for Domino.
    • _position_weighting: DSpark and Domino still always decay.
    • _compute_loss: the objective plus the terms the variant owns. DSpark adds its confidence head, whose target reuses the TVD term's per-token values. Domino blends (λ) the objective on the corrected logits with the objective on the backbone logits.
  • Loss terms as components. The divergences join dflash_losses.py, which names no variant.
    • The divergences (ce, kd, tvd, tv and lambda) form a closed registry. Each one reduces under the position weights the way the old code did.
    • Each divergence also declares whether it reads the teacher and whether it backpropagates through the cross-entropy. These declarations decide when offline training reconstructs the teacher's logits and when the cross-entropy keeps its gradient.
    • An objective is an ordered [(name, weight, term)] list, summed left to right. modify() builds it through _build_objective: DFlash and LiLiCorr keep kd or ce, and DFlash2 picks tv or lambda. DFlash2's tv and lambda are now objective terms, so they pass through the zero-loss gate instead of replacing the loss after it (change (b) below).
    • LossContext also computes the per-token TVD lazily, at most once. The TVD chunk compiles under dflash_use_torch_compile for every variant, no longer only in DSpark's modify().
  • Metrics (backward breaking). Every variant now returns its metrics under draft_metrics, as detached 0-dim tensors. Each objective term is reported as loss_<term>, and backbone_accuracy is reported when a head sits on the backbone.
  • Removed: _compute_dspark_loss, _compute_domino_loss, _apply_markov_head and _apply_domino_head. Also removed: DSpark's and Domino's forward() overrides.

The three zero losses stay as they were:

  • Inside the loss, the gate still routes through the draft's output (final.sum() * 0.0), so an FSDP2 unit around the draft still runs its backward. Domino's λ-blend sits behind the same gate.
  • The forward's early return, where no draft has run, keeps _draft_zero_loss.
  • DSpark's degenerate step keeps _draft_zero_loss, which also feeds its confidence head for DDP.

Disclosed changes:

  • (a) DSpark and Domino adopt DFlash's loss mask, which intersects labels, attention_mask and loss_mask. This is the same fix as the separate padding PR, through the same _build_loss_mask helper. Merging the two conflicts only where the padding PR edits the DSpark and Domino forwards that this PR removes; keeping this PR's side resolves it (see Testing). The result is identical whenever attention_mask is all ones where labels are valid. That holds in every shipped recipe and in streaming and offline batches.
  • (b) A DFlash2 step under tv or lambda whose total position weight is <= 1 now takes the zero-loss gate like every other objective. Before, the blend was computed over that tiny weight sum. The selector term is unchanged.
  • Error path only: an offline DSpark batch that has neither base logits nor base_model_hidden_states now raises at the start of the forward, not after the draft has run.

Usage

Configs are unchanged. A variant is now written as hooks:

class HFMyDraftModel(HFDFlashModel):
    def _build_objective(self, config):
        return [objective_term("ce", 0.5), objective_term("tvd", 0.5)]

    def _draft_logits(self, batch):
        backbone = super()._draft_logits(batch).backbone
        return DraftLogits(final=backbone + self.my_head(batch), backbone=backbone)

outputs = model(**batch)
outputs.draft_metrics["loss_tvd"]  # detached 0-dim tensor; .item() for a float

Testing

  • tests/unit/torch/speculative plus tests/unit/torch/export/test_hf_spec_rope_export.py: 520 passed on transformers 5.5.4, 5.12.1 and 5.18.0 (513 on [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989; the other 7 are this PR's new cases). 5.5.4 and 5.18.0 fall within CI's tf_min and tf_latest pins. On 4 CPUs, each changed test takes at most 0.7 s and each new case under 0.1 s.
  • tests/gpu/torch/speculative/plugins on one GPU: 20 passed, 1 skipped (it needs 2 GPUs).
  • Bitwise A/B against [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989. It compares loss, every gradient, train_acc, the metrics under the rename above, and the generated draft tokens.
    • One training step and one generation step for each of 23 cases, in fp32 and bf16: 44/46 identical. The cases cover DFlash (KD and CE), DFlash2 (ce, kd, tv and lambda, with the selector), LiLiCorr, DSpark (vanilla and rnn heads, with the confidence head) and Domino. Each runs offline and online, with D-PACE and with decay, including zero-loss steps. Domino's online cases generate with fix(speculative): DSpark and Domino AR validation and label alignment #2762's correction-head decode on both sides.
    • The other 2 runs are change (b), on a step that keeps one label of weight < 1. The loss goes from 0.96958977 to 0 in fp32 (0.968748331 to 0 in bf16). The backbone gradients lose the TV term, and the selector gradients and all metrics are unchanged. The same step under lambda goes from 2.86922026 to 0 in fp32 (2.87524128 to 0 in bf16).
    • Further cases, all identical:
      • the DSpark gated head, and the rnn head online;
      • DSpark and Domino with the KD flag and D-PACE set, both of which they still ignore;
      • DFlash2 ce on a zero-loss step, DFlash2 KD online, and LiLiCorr KD on a zero-loss step;
      • six cases whose labels are -100 on some tokens.
    • Each refactor commit against its parent, on the cases it affects: 30/32, 8/8 and 6/6. The 2 non-identical runs are change (b) in the commit that introduces it.
    • The new loss_<term> metrics equal the loss for DFlash and origin_loss for LiLiCorr, bitwise.
    • Against fix(speculative): DSpark and Domino AR validation and label alignment #2762's head, with [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989, the results are the same: every run identical except the four runs of change (b).
  • FSDP2 test: 2 ranks on gloo, the draft as its own fully_shard unit, and one rank on the in-loss zero branch. Both ranks finish, under CE and under KD, with the same losses as on [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989 and on fix(speculative): DSpark and Domino AR validation and label alignment #2762's head. Domino under the same wrapping stops with the same mixed Tensor/DTensor error on fix(speculative): DSpark and Domino AR validation and label alignment #2762's head, on [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989 and on this one; this PR does not change that.
  • Merged with the separate padding PR's branch: Git reports conflicts only in hf_dspark.py and hf_domino.py, where the padding PR edits the forwards that this PR removes. Keeping this PR's side of both files gives this PR's tree plus the padding PR's regression test, with one _build_loss_mask. On that tree, the padding PR's regression test and the DSpark and Domino test files pass.
  • New tests:
    • A DFlash2 step with nothing to train returns the zero loss under ce, tv and lambda. Before this PR, tv and lambda gave 0.968 and 3.352 on it.
    • Each shared divergence (ce, kd, tvd and tv) runs through the real _loss_context and _compute_loss under non-uniform decay weights. Each result equals, bitwise, a verbatim copy of the reduction it replaced. Before, the lambda test compared DFlash2's own copy of the CE reduction with DFlash's. That check went away when the blend started calling the shared term.
    • Mutation check: normalizing ce, kd, tvd or tv by the supervised count instead of the weight sum, or reducing ce under the mask, passes the rest of the suite (519 tests) and fails exactly one case of this test.
  • The terms' needs_ce_grad declarations now decide whether the cross-entropy keeps its gradient. [1/2] refactor(speculative): hand the DFlash-family loss one context per training step #2989 added the test for that: under D-PACE, the context that a real training forward passes to the loss keeps the gradient for ce, tv and lambda but not for KD. Its tv, lambda and KD cases now also pin the declarations.
    • Mutation check: flipping needs_ce_grad on tv, lambda or kd passes the rest of the suite (519 tests) and fails exactly that case.
    • On a step configured like dflash2.yaml, flipping the lambda declaration leaves the loss bitwise unchanged, but 33 of 36 draft gradients change, because the backbone then trains only through the selector.
  • Updated tests: DSpark's zero-weight DDP test runs the real _loss_context/_compute_loss. Domino's generation test from fix(speculative): DSpark and Domino AR validation and label alignment #2762 teacher-forces the drafts through _draft_logits; it still fails if drafting or training skips the correction head or feeds it the wrong prefix. DSpark's and Domino's metrics are read from draft_metrics.

Before your PR is "Ready for review"

Make sure you read and follow Contributor guidelines and your commits are signed (git commit -s -S).

Make sure you read and follow the Security Best Practices (e.g. avoiding hardcoded trust_remote_code=True, torch.load(..., weights_only=False), pickle, etc.).

Additional Information

Against #2989, the source changes are +297 / −507 lines. Follow-ups build on the hooks: DSpark and Domino honoring dflash_loss_objective, and logging draft_metrics in the trainer.

Merge order: #2989, then this PR.

🤖 Generated with Claude Code

h-guo18 and others added 6 commits October 24, 2026 12:11
…ivergence terms

The block objective was a branch in HFDFlashModel._compute_loss (KD or CE), and DFlash2's
dflash_lk_loss_type replaced it after the fact: _lk_loss recomputed the loss from the
context's per-token cross-entropy and weights, outside the gate that turns a step with
nothing to train into a zero loss.

The divergences are now components in dflash_losses.py that name no variant, a closed
registry of callables (ctx, source) -> 0-dim loss, each reducing under the step's position
weights the way the old code did: ce, kd (soft cross-entropy against the base model), tvd
(L1 to the base model's distribution, DSpark's term), tv (1 - q(gold)) and lambda (the
acceptance-weighted CE/TV blend, still reduced term by term and blended with a detached
scalar). Each term declares whether it reads the teacher and whether it backpropagates
through the per-token cross-entropy.

An objective is an ordered [(name, weight, term)] list that modify() builds through the
_build_objective hook: DFlash and LiLiCorr keep kd or ce, DFlash2 picks tv or lambda.
_compute_loss sums it left to right and reports each term as loss_<name>, plus the
backbone's accuracy when a head sits on it. The declarations replace the per-variant
flags: offline training reconstructs the teacher's logits when a term needs them, and the
context computes the cross-entropy with gradient only when a term backpropagates through
it. LossContext gains the per-token TVD, and the TVD chunk compiles under
dflash_use_torch_compile for every variant, no longer only DSpark's modify().

One disclosed behavior change: a DFlash2 step under tv or lambda whose total position
weight is <= 1 now takes the zero-loss gate like every other objective; before, the blend
was computed over that tiny weight sum. The selector term is unchanged, still computed on
every step.

Verified against the previous commit with one train step and one generation step per case,
in fp32 and bf16: DFlash (KD and CE), DFlash2 (ce/kd/tv/lambda with the selector) and
LiLiCorr, offline and online, D-PACE and decay, including zero-loss steps, give
bitwise-equal loss, gradients, train_acc, metrics and generated tokens (30/32). The other
two runs are the change above on a step that keeps one label of weight < 1: the fp32 loss
goes from 0.96958977 to 0 (bf16: 0.968748331 to 0), the 33 backbone gradients lose the TV
term, and the 3 selector gradients and all metrics are unchanged. The new loss_<name>
metrics equal the loss for DFlash and origin_loss for LiLiCorr, bitwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
HFDSparkModel.forward was a copy of the DFlash training forward (base model, loss mask,
anchors, draft inputs, draft) followed by _apply_markov_head and _compute_dspark_loss,
which rebuilt the labels, weights, gate and accuracy that the shared loss now provides.
Copies of the forward are where the family's fixes went missing: the loss-mask rule DFlash
gained for padding never reached it.

DSpark now changes only its hooks:
- _build_objective: [("ce", dflash_ce_loss_alpha), ("tvd", dflash_l1_loss_alpha)].
- _draft_logits: the Markov transition bias on the backbone's logits (the old
  _apply_markov_head without the confidence head).
- _online_base_outputs: the inner model and its final hidden, which the loss projects
  only at the rows it reads.
- _position_weighting: "decay", as before (dflash_loss_objective is not wired for DSpark).
- _compute_loss: the objective, plus the confidence head's BCE against 1 - 0.5 * TVD,
  sharing the TVD term's per-token values. A step with nothing to train keeps returning
  _draft_zero_loss, which also feeds the confidence head for DDP.

With the shared forward DSpark adopts DFlash's loss mask (labels, attention_mask and
loss_mask intersected), the fix the padding PR makes on main; it is identical whenever
attention_mask is all ones where labels are valid, as in every shipped recipe and in
streaming and offline batches. Offline batches now fail at the entry when the TVD term has
no teacher to read, rather than after the draft has run.

Metrics move to draft_metrics: ce_loss, l1_loss and base_accuracy become loss_ce, loss_tvd
and backbone_accuracy; confidence_loss keeps its name.

Verified against the previous commit with one train step and one generation step per case,
in fp32 and bf16: the vanilla and rnn heads with the confidence head on, offline and
online, and a step that takes the zero-loss branch give bitwise-equal loss, gradients,
train_acc, metrics (under the names above) and generated tokens (8/8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
HFDominoModel.forward was the second copy of the DFlash training forward, followed by
_apply_domino_head and _compute_domino_loss, which again rebuilt the labels, weights, gate
and accuracies. Domino now changes only its hooks:
- _build_objective: [("ce", 1.0)].
- _draft_logits: the GRU correction on the backbone's logits (the old _apply_domino_head).
- _online_base_outputs: the inner model; Domino reads no teacher.
- _position_weighting: "decay", as before.
- _compute_loss: (1 - lambda_base) * objective("final") + lambda_base *
  objective("backbone"), behind the shared zero-loss gate.

Domino no longer overrides forward(): its eval branch already delegated to the shared
forward, so with training on the hooks above nothing is left to override.

Like DSpark, Domino adopts DFlash's loss mask (labels, attention_mask and loss_mask
intersected): identical whenever attention_mask is all ones where labels are valid.

Metrics move to draft_metrics: final_loss, base_loss and base_accuracy become loss_final,
loss_backbone and backbone_accuracy; lambda_base keeps its name.

Verified against the previous commit with one train step and one generation step per case,
in fp32 and bf16: offline and online steps and a step that takes the zero-loss branch give
bitwise-equal loss, gradients, train_acc, metrics (under the names above) and generated
tokens (6/6).

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

Update the call sites of the internals the previous commits removed or renamed, keeping
each test's intent:
- DSpark's zero-weight DDP test runs the real _loss_context/_compute_loss instead of the
  removed _apply_markov_head/_compute_dspark_loss.
- Domino's AR-validation test teacher-forces the drafts through _draft_logits instead of
  the removed _apply_domino_head. It still fails when either the drafting or the training
  side skips the correction head or feeds it the wrong prefix.
- The DSpark and Domino metrics are read from draft_metrics, under the new names where
  they changed, and as tensors.

One new test pins the disclosed behavior change: a DFlash2 step whose position weights
sum to <= 1 takes the zero loss under ce, tv and lambda alike. Before, tv and lambda gave
0.968 and 3.352 on it.

tests/unit/torch/speculative and test_hf_spec_rope_export.py: 516 passed on transformers
5.5.4, 5.12.1 and 5.18.0. The changed tests take at most 0.7 s each on 4 CPUs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
Before the terms were shared, DFlash2's _lk_loss carried its own copy of the cross-entropy
reduction, so test_zero_decay_and_unit_scale_is_exactly_cross_entropy compared two copies.
Now the lambda blend calls the ce term, and that test compares the term with itself. The
kd, tvd and tv reductions were never compared with anything. A parametrized test now runs
ce, kd, tvd and tv through the real _loss_context and _compute_loss under decay weights, and
compares each loss bitwise with a verbatim copy of the old reduction:
HFDFlashModel._compute_loss for ce and kd, DSpark's _compute_dspark_loss for tvd, and
DFlash2's _lk_loss for tv.

The terms' needs_ce_grad declarations need no new test. They now set the per-token
cross-entropy's grad mode, which test_cross_entropy_gradient_follows_the_objective already
pins for ce, tv, lambda and KD. If the tv or lambda declaration is flipped, the loss value
does not change, but the backbone trains through the selector alone; the shipped
dflash2.yaml uses lambda with the selector.

Mutation check. Each source change below passes the rest of tests/unit/torch/speculative
and test_hf_spec_rope_export.py (519 passed), and fails exactly the case named:
- ce, kd, tvd or tv divides by the supervised count instead of the weight sum: the new
  test's [ce], [kd], [tvd] or [tv]. Reducing ce under the mask instead of the weights also
  fails [ce].
- tv or lambda declares needs_ce_grad = False, or kd declares it True:
  test_cross_entropy_gradient_follows_the_objective's [tv], [lambda] or [kd].
Flipping ce's own declaration fails four existing tests and that test's [ce].

Each new case takes under 0.1 s on 4 CPUs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
DSpark's dspark_metrics and Domino's domino_metrics have been in every release since 0.46.0.
The DSpark and Domino commits remove both keys, rename three inner keys of each, and return
tensors where both returned Python floats. Nothing in the repo reads these keys, but user
code that reads them breaks.

The backward-breaking entry for DFlash2's and LiLiCorr's keys now also names these two keys
and the renamed inner keys.

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

copy-pr-bot Bot commented Oct 24, 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 24, 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.

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