Skip to content

fix(training): train the DSpark TV term at 0.5 * L1 as documented - #5241

Closed
khazic wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/fix/dspark-tv-half-factor
Closed

khazic wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/fix/dspark-tv-half-factor

Conversation

@khazic

@khazic khazic commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Stacked on #5233 and #5234: this fixes the DSpark core introduced there, so until those merge this PR also lists their commits. Only the last commit is new here; I will rebase once the base PRs land.

What

The trained TV term summed the raw probability L1 distance, which is twice the total-variation distance. With the default alphas the objective was effectively 0.1 * CE + 1.8 * TV instead of the documented 0.1 * CE + 0.9 * TV, and the l1_loss metric reported twice the documented TV value.

Fix

Move the L1-vs-TV convention into the helper itself: _l1_distance_per_token becomes _tv_distance_per_token and returns 0.5 * ||p_draft - p_target||_1 directly. accept_rate = 1 - tv and l1_num = (tv * weight).sum() then fall out with no inline 0.5 constants, so no future call site can accidentally grab the un-halved distance.

Note: the l1_loss metric key is kept for continuity but now reports the TV value (half the raw L1), matching the module docstring and the design doc. Runs compared across this change will show the term drop by 2x; that is the fix, not a regression.

Tests

New unit test test_l1_loss_is_total_variation_not_full_l1 builds two disjoint saturated distributions (raw L1 = 2.0, TV = 1.0) and asserts both the metric and the loss equal 1.0. The test fails on the parent commit and passes here. All 17 dspark unit tests pass; ruff check and format are clean.

Merge note

This branch and the masked-input-robustness fix PR (same stack) both touch the l1_dist block of dspark_loss, so merging both into the DSpark core branch (#5234) will conflict there. Resolve by scrubbing first, then using the TV helper: tv_dist = _tv_distance_per_token(...), tv_dist = torch.where(eval_bool, tv_dist, 0.0), then accept_rate = (1.0 - tv_dist).clamp_(0.0, 1.0).

@copy-pr-bot

copy-pr-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

khazic added 6 commits August 6, 2026 17:16
Design proposal (RFC) for adding DSpark speculative-decoding draft training to
Megatron-Bridge, tracking NVIDIA-NeMo#5230. It describes the DSpark draft (a
semi-autoregressive parallel backbone plus Markov and confidence heads), its
three-term CE / TV / confidence training objective and acceptance metric, and
how it maps onto the existing ModelOpt distillation subsystem pattern (provider
hook, custom forward step, loss, thin entry, HF weight mapping). It includes a
phased implementation plan and references the NeMo AutoModel reference
implementation.

Signed-off-by: khazic <khazzz1c@gmail.com>
Requested in review: the Megatron-Bridge design doc does not need a breakdown
of the AutoModel implementation; the pointer in the overview is enough.

Signed-off-by: khazic <khazzz1c@gmail.com>
The design page carried a four-phase implementation plan and an open-questions
list. Neither describes what this PR (or the repo) actually contains: they
promise work that is not scheduled and has no owner, so they go stale the moment
priorities move. The sequencing and the open decisions belong in the tracking
issue (NVIDIA-NeMo#5230), which is where they can be updated.

The remaining page is the design itself: the algorithm, where it fits in
Megatron-Bridge, and the proposed file surface. The frozen-target supervision
section keeps both capture options, since the trade-off between them is design
content rather than a schedule.

Signed-off-by: khazic <khazzz1c@gmail.com>
First increment of DSpark speculative-decoding draft training (NVIDIA-NeMo#5230; design in
NVIDIA-NeMo#5233): the architecture-agnostic, pure-torch core.

Adds src/megatron/bridge/training/post_training/dspark/:
* heads.py: the sequential head (VanillaMarkov / GatedMarkovHead / RNNHead) that
  injects intra-block token dependency onto the parallel-backbone logits, plus the
  confidence head.
* loss.py: the three-term CE + total-variation + confidence-BCE objective with
  per-position decay, and the analytical acceptance metrics (accept_rate = 1 - TV,
  tau = sum cumprod(accept) + 1). The data-parallel denominator reduction is an
  injectable dp_group, so the same math wires to a Megatron-Core forward step later.

CPU unit tests cover the head math and the loss / acceptance math. The
parallel-aware draft backbone and the forward-step / provider integration (which
need Megatron-Core and multi-GPU validation) are follow-ups.

Signed-off-by: khazic <khazzz1c@gmail.com>
The DSpark loss denominator reduction now runs only when a dp_group is passed;
dp_group=None is local-only. Previously it fell back to the default process
group whenever torch.distributed was initialized, whose members are not the DP
replicas under Megatron-Core parallelism (TP/PP/CP), so the caller must pass the
data-parallel group explicitly.

Add a 2-GPU functional test
(tests/functional_tests/test_groups/training/test_dspark_loss_dp.py) that spawns
two NCCL ranks and asserts the DP-averaged gradient through a shared head matches
the single-process full-batch gradient. This validates the dp_group reduction,
which cannot be exercised on a single process, and needs a GPU node to run.

Signed-off-by: khazic <khazzz1c@gmail.com>
@khazic

khazic commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Closing this: the change is in the wrong direction and @yaoyu-33 is right on the review in #5234.

I checked it independently. Paper Eq. 10 minimizes the weighted raw L1 distance at coefficient 0.9. The factor 1/2 appears in Eq. 8 only, where the analytical acceptance/confidence target is formed. NeMo AutoModel makes the same split: _compute_local_l1_term accumulates l1_dist_per_token * loss_weight_mask with no 1/2, and the 0.5 shows up only in _compute_accept_rate_3d. DeepSpec does the same.

So the implementation was already correct, and what was actually wrong was the docstring in loss.py and the objective section of the design page, both of which described the training term as 0.5 * ||p_draft - p_target||_1. Landing this PR would have halved the dominant term's gradient while leaving the published coefficient in place.

Both descriptions are corrected in #5233 and #5234 instead, and #5234 adds a regression test that asserts the pair together (disjoint distributions give l1_loss == 2 with accept_rate == 0) so the objective and its documentation cannot drift apart again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:training Training loop, callbacks, and runtime integration bug Something isn't working community-request needs-more-tests Requires additional L0 and L1 test coverage before merge needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants