Skip to content

Commit e49c1aa

Browse files
committed
fix(training): scope DSpark loss DP reduction to the given dp_group
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>
1 parent 4862718 commit e49c1aa

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

‎src/megatron/bridge/training/post_training/dspark/loss.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,14 @@ def _l1_distance_per_token(draft_logits: torch.Tensor, target_logits: torch.Tens
9797

9898

9999
def _reduce_den(value: torch.Tensor, dp_group) -> torch.Tensor:
100-
"""Sum a denominator over the data-parallel group (no-op without a live group)."""
100+
"""Sum a denominator over ``dp_group``. No reduction when ``dp_group`` is ``None``.
101+
102+
The caller must pass the intended data-parallel group explicitly; ``None`` is
103+
local-only. (Do not fall back to the default process group, whose members are
104+
not the DP replicas under Megatron-Core parallelism.)
105+
"""
101106
out = value.detach().clone()
102-
if dp_group is not None or (dist.is_available() and dist.is_initialized()):
107+
if dp_group is not None:
103108
dist.all_reduce(out, op=dist.ReduceOp.SUM, group=dp_group)
104109
return out
105110

@@ -171,7 +176,7 @@ def dspark_loss(
171176

172177
# Divide by the (optionally DP-reduced) denominators; the backward gradient is
173178
# scaled by world_size so the DP-averaged gradient matches the global loss.
174-
world_size = dist.get_world_size(dp_group) if (dist.is_available() and dist.is_initialized()) else 1
179+
world_size = dist.get_world_size(dp_group) if dp_group is not None else 1
175180
ce_g, l1_g, conf_g = (_reduce_den(d, dp_group) for d in (ce_den, l1_den, conf_den))
176181
ce_loss = ce_num / (ce_g + 1e-6)
177182
l1_loss = l1_num / (l1_g + 1e-6) if l1_g.item() > 0 else zero
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Multi-GPU functional test for the DSpark loss data-parallel reduction.
16+
17+
The DSpark loss (:func:`megatron.bridge.training.post_training.dspark.loss.dspark_loss`)
18+
divides each per-token numerator by an all-reduced global denominator and scales
19+
the backward by ``world_size``, so that the data-parallel-averaged gradient equals
20+
the single-process full-batch gradient. That invariant cannot be exercised on a
21+
single process; this test runs two NCCL ranks and asserts the DP-averaged gradient
22+
through a shared head matches the single-process reference over the concatenated
23+
batch. It needs 2 GPUs and is intended for a GPU node / cluster runner (not the
24+
CPU unit-test lane). Run it directly on a 2-GPU node: ``pytest <this file>``.
25+
"""
26+
27+
import os
28+
import socket
29+
30+
import pytest
31+
import torch
32+
import torch.multiprocessing as mp
33+
34+
from megatron.bridge.training.post_training.dspark.heads import VanillaMarkov
35+
from megatron.bridge.training.post_training.dspark.loss import DSparkForwardOutput, dspark_loss
36+
37+
38+
def _free_port() -> int:
39+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
40+
sock.bind(("127.0.0.1", 0))
41+
port = sock.getsockname()[1]
42+
sock.close()
43+
return port
44+
45+
46+
def _grad_over(head, sl, batch, dp_group):
47+
"""Loss backward over the block-slice ``sl``; return the shared head's grad.
48+
49+
Args:
50+
head: Shared ``VanillaMarkov`` whose ``markov_w2.weight`` grad is returned.
51+
sl: Slice into the block axis (dim 0) selecting this rank's blocks.
52+
batch: ``(base_logits, prev_ids, target_ids, aligned_target_logits,
53+
eval_mask, block_keep_mask)`` for the full batch; each is
54+
``[total_blocks, num_blocks, block_size(, vocab)]``.
55+
dp_group: Data-parallel process group for the loss denominator reduction,
56+
or ``None`` for the single-process reference.
57+
58+
Returns:
59+
``markov_w2.weight.grad`` after one backward.
60+
"""
61+
base, prev, target_ids, aligned, eval_mask, keep = batch
62+
head.zero_grad(set_to_none=True)
63+
draft = head.apply_block_logits(base[sl], token_ids=prev[sl], hidden_states=None)
64+
out = DSparkForwardOutput(
65+
draft_logits=draft,
66+
target_ids=target_ids[sl],
67+
eval_mask=eval_mask[sl],
68+
block_keep_mask=keep[sl],
69+
aligned_target_logits=aligned[sl],
70+
)
71+
loss, _ = dspark_loss(out, ce_alpha=0.1, l1_alpha=0.9, confidence_alpha=0.0, dp_group=dp_group)
72+
loss.backward()
73+
return head.markov_w2.weight.grad.detach().clone()
74+
75+
76+
def _worker(rank: int, world_size: int, port: int) -> None:
77+
os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT=str(port), RANK=str(rank), WORLD_SIZE=str(world_size))
78+
torch.cuda.set_device(rank)
79+
torch.distributed.init_process_group("nccl", rank=rank, world_size=world_size)
80+
try:
81+
dev = torch.device("cuda", rank)
82+
blocks_per_rank, num_blocks, block_size, vocab, rank_dim = 3, 2, 4, 32, 5
83+
total = blocks_per_rank * world_size
84+
85+
# Identical head and full batch on every rank (seeded on CPU, then moved), so
86+
# each rank can form the same single-process reference locally.
87+
torch.manual_seed(0)
88+
head = VanillaMarkov(vocab_size=vocab, markov_rank=rank_dim).to(dev)
89+
torch.manual_seed(1234)
90+
shape4 = (total, num_blocks, block_size, vocab)
91+
shape3 = (total, num_blocks, block_size)
92+
batch = (
93+
torch.randn(*shape4).to(dev), # base logits
94+
torch.randint(0, vocab, shape3).to(dev), # prev token ids
95+
torch.randint(0, vocab, shape3).to(dev), # target ids
96+
torch.randn(*shape4).to(dev), # aligned target logits
97+
torch.ones(*shape3, dtype=torch.bool).to(dev), # eval mask
98+
torch.ones(total, num_blocks, dtype=torch.bool).to(dev), # block keep mask
99+
)
100+
101+
grad_ref = _grad_over(head, slice(0, total), batch, dp_group=None)
102+
103+
sl = slice(rank * blocks_per_rank, (rank + 1) * blocks_per_rank)
104+
grad_local = _grad_over(head, sl, batch, dp_group=torch.distributed.group.WORLD)
105+
grad_dist = grad_local.clone()
106+
torch.distributed.all_reduce(grad_dist, op=torch.distributed.ReduceOp.SUM)
107+
grad_dist /= world_size
108+
109+
rel = (grad_dist - grad_ref).abs().max() / grad_ref.abs().max().clamp_min(1e-6)
110+
assert rel < 1e-4, f"[rank {rank}] DP-averaged gradient mismatch rel={rel.item():.3e}"
111+
finally:
112+
if torch.distributed.is_initialized():
113+
torch.distributed.destroy_process_group()
114+
115+
116+
@pytest.mark.skipif(torch.cuda.device_count() < 2, reason="DSpark loss DP-gradient test needs 2 GPUs")
117+
def test_dspark_loss_dp_gradient_matches_single_process():
118+
mp.spawn(_worker, args=(2, _free_port()), nprocs=2, join=True)

0 commit comments

Comments
 (0)