Skip to content

Commit 07147d6

Browse files
authored
Fix global token-weighted SFT evaluation loss (#7207)
Signed-off-by: Tailai Ma <58548582+xiaoyao0115@users.noreply.github.com> Signed-off-by: Tailai Ma <tailaim@nvidia.com>
1 parent 943adaf commit 07147d6

2 files changed

Lines changed: 95 additions & 14 deletions

File tree

‎megatron/training/training.py‎

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5741,19 +5741,11 @@ def evaluate(
57415741
val = [x[key].view(-1) for x in loss_dicts]
57425742

57435743
if val[0].numel() == 2:
5744-
if args.sft:
5745-
# normalize over micro batch instead of global
5746-
val = torch.vstack(val)
5747-
val = val[:, 0] / val[:, 1].clamp(min=1)
5748-
val = val.mean()
5749-
torch.distributed.all_reduce(val, group=eval_dp_cp_group)
5750-
val /= torch.distributed.get_world_size(group=eval_dp_cp_group)
5751-
total_loss_dict[key][0] += val
5752-
total_loss_dict[key][1] += 1
5753-
else :
5754-
val = torch.vstack(val).sum(dim=0)
5755-
torch.distributed.all_reduce(val, group=eval_dp_cp_group)
5756-
total_loss_dict[key] += val
5744+
# Preserve loss sums and valid-token counts across microbatches,
5745+
# DP/CP ranks, and iterations. SFT masks can make counts unequal.
5746+
val = torch.vstack(val).sum(dim=0)
5747+
torch.distributed.all_reduce(val, group=eval_dp_cp_group)
5748+
total_loss_dict[key] += val
57575749
elif val[0].numel() == 1:
57585750
val = torch.cat(val).sum()
57595751
total_loss_dict[key][0] += val
@@ -5799,7 +5791,7 @@ def evaluate(
57995791

58005792
for key in total_loss_dict:
58015793
numerator, denominator = total_loss_dict[key]
5802-
total_loss_dict[key] = numerator / denominator
5794+
total_loss_dict[key] = numerator / denominator.clamp(min=1)
58035795

58045796
timers('evaluate').stop()
58055797
timers.log(['evaluate'])
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
3+
"""Exercise evaluate's global token reduction without a GPU or distributed backend."""
4+
5+
from types import SimpleNamespace
6+
from unittest import mock
7+
8+
import pytest
9+
import torch
10+
11+
from megatron.training import training as training_mod
12+
13+
14+
@pytest.mark.parametrize("sft", [False, True])
15+
@pytest.mark.parametrize("scheduled", [False, True])
16+
@pytest.mark.parametrize("empty", [False, True])
17+
def test_evaluate_weights_valid_tokens_across_batches_ranks_and_iterations(sft, scheduled, empty):
18+
args = SimpleNamespace(
19+
eval_global_batch_size=4,
20+
eval_micro_batch_size=1,
21+
data_parallel_size=2,
22+
gtp_weight_remat_size=1,
23+
cuda_graph_impl="none",
24+
moe_expert_rank_capacity_factor=None,
25+
seq_length=8,
26+
decoder_seq_length=None,
27+
eval_iters=2,
28+
empty_unused_memory_level=0,
29+
consumed_valid_samples=0,
30+
exit_duration_in_mins=None,
31+
sft=sft,
32+
)
33+
config = SimpleNamespace(sequence_packing_scheduler="default_dynamic_cp" if scheduled else None)
34+
groups = SimpleNamespace(pp=object(), dp_cp=object(), dp_cp_gtp_remat=object())
35+
# Local token counts differ between microbatches and evaluation iterations.
36+
local = [[(2, 1), (36, 9)], [(8, 2), (0, 0)]]
37+
if scheduled:
38+
local = [local[0] + [(0, 0)], local[1][:1]]
39+
counts = [len(step) for step in local]
40+
losses = [
41+
[
42+
{"lm loss": torch.tensor([0.0, 0.0] if empty else pair, dtype=torch.float32)}
43+
for pair in step
44+
]
45+
for step in local
46+
]
47+
remote = iter([torch.tensor([0.0, 0.0] if empty else pair) for pair in [(42, 6), (12, 2)]])
48+
tensor = torch.tensor
49+
50+
def cpu_tensor(*args, **kwargs):
51+
kwargs["device"] = "cpu"
52+
return tensor(*args, **kwargs)
53+
54+
def reduce(value, group=None):
55+
assert group is groups.dp_cp_gtp_remat
56+
value.add_(next(remote))
57+
58+
forward_backward = mock.Mock(side_effect=losses)
59+
model = mock.Mock()
60+
with (
61+
mock.patch.object(training_mod, "get_args", return_value=args),
62+
mock.patch.object(training_mod, "get_timers", return_value=mock.MagicMock()),
63+
mock.patch.object(training_mod, "get_rerun_state_machine", return_value=mock.Mock()),
64+
mock.patch.object(training_mod, "get_forward_backward_func", return_value=forward_backward),
65+
mock.patch.object(training_mod, "get_attr_wrapped_model", return_value=groups),
66+
mock.patch.object(training_mod, "is_pp_last_stage", return_value=True),
67+
mock.patch.object(training_mod, "has_nvidia_modelopt", False),
68+
mock.patch.object(training_mod, "ft_integration"),
69+
mock.patch.object(training_mod, "_otel_managed_span", return_value=mock.MagicMock()),
70+
mock.patch.object(
71+
training_mod,
72+
"wrap_data_iterator",
73+
side_effect=[(iter([]), count, 0, 0) for count in counts],
74+
),
75+
mock.patch.object(torch, "tensor", side_effect=cpu_tensor),
76+
mock.patch.object(torch.distributed, "all_reduce", side_effect=reduce) as all_reduce,
77+
):
78+
result, _, exited = training_mod.evaluate(
79+
lambda *a, **kw: None, iter([]), [model], None, config
80+
)
81+
82+
# (2 + 36 + 8 + 42 + 12) / (1 + 9 + 2 + 6 + 2) == 5, not mean of means.
83+
torch.testing.assert_close(result["lm loss"], tensor(0.0 if empty else 5.0))
84+
assert all_reduce.call_count == 2
85+
assert [call.kwargs["num_microbatches"] for call in forward_backward.call_args_list] == counts
86+
assert args.consumed_valid_samples == 8
87+
assert not exited
88+
model.eval.assert_called_once()
89+
model.train.assert_called_once()

0 commit comments

Comments
 (0)