Skip to content

Commit 23743e2

Browse files
authored
fix(data): exclude packed padding from MoE router statistics (#5470)
Signed-off-by: Yu Yao <yaoyu.094@gmail.com> Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
1 parent 3206435 commit 23743e2

6 files changed

Lines changed: 546 additions & 10 deletions

File tree

‎src/megatron/bridge/data/collators/sft.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def _build_text_sft_batch(
133133
pad_token_id=0 if pad_token_id is None else int(pad_token_id),
134134
ignore_index=ignore_index,
135135
pad_to_multiple_of=in_batch_packing_pad_to_multiple_of,
136+
emit_padding_mask=in_batch_packing_pad_to_multiple_of > 1,
136137
)
137138
batch["tokens"] = batch["input_ids"]
138139
batch["metadata"] = metadata

‎src/megatron/bridge/data/packing/gpt_sft.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ def collate_fn(self, batch):
203203
# contains physical offsets including per-sequence alignment padding.
204204
cu_seqlens: list[list[int]] = []
205205
cu_seqlens_padded: list[list[int]] | None = [] if self._pad_seq_to_mult > 1 else None
206-
for item in batch:
206+
padding_masks: list[list[bool]] = []
207+
for row_idx, item in enumerate(batch):
207208
position_ids.append([])
208209
cu_seqlens.append([0])
209210
if cu_seqlens_padded is not None:
@@ -235,11 +236,27 @@ def collate_fn(self, batch):
235236
# actual tokens for training
236237
if len(cu_seqlens_padded[-1]) > len(cu_seqlens[-1]):
237238
cu_seqlens[-1].append(cu_seqlens[-1][-1])
239+
240+
# The packed token tensor follows the physical offsets. Mark the
241+
# tail between each logical sequence and its physical boundary so
242+
# MCore's MoE router can exclude those alignment-only tokens.
243+
padding_mask = [False] * max_length
244+
for logical_start, logical_end, padded_start, padded_end in zip(
245+
cu_seqlens[-1][:-1],
246+
cu_seqlens[-1][1:],
247+
cu_seqlens_padded[-1][:-1],
248+
cu_seqlens_padded[-1][1:],
249+
):
250+
padding_start = padded_start + logical_end - logical_start
251+
assert padding_start <= padded_end
252+
padding_mask[padding_start:padded_end] = [True] * (padded_end - padding_start)
238253
else:
239254
assert cu_seqlens[-1][-1] <= max_length
240255
# Prepadded data may leave padding at the end of the packed sequence.
241256
if cu_seqlens[-1][-1] != max_length:
242257
cu_seqlens[-1].append(max_length)
258+
padding_mask = [False] * len(input_ids[row_idx]) + [True] * (max_length - len(input_ids[row_idx]))
259+
padding_masks.append(padding_mask)
243260

244261
if self.pad_cu_seqlens:
245262
# Pad both boundary representations to the same static shape with zero-length
@@ -273,6 +290,10 @@ def collate_fn(self, batch):
273290
"position_ids": torch.LongTensor(position_ids),
274291
"token_count": token_count,
275292
}
293+
# Keep the key present for every offline-packed batch so mask handling
294+
# never depends on local padding contents. For fixed-width packing, this
295+
# also keeps the input structure stable for full-iteration CUDA graphs.
296+
processed_batch["padding_mask"] = torch.BoolTensor(padding_masks)
276297

277298
if self.return_cu_seqlen:
278299
# The DataLoader collates every row assigned to this data-parallel rank before

‎src/megatron/bridge/training/gpt_step.py‎

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
# limitations under the License.
1414

1515
import logging
16-
from functools import partial
16+
from functools import partial, wraps
1717
from typing import Iterable
1818

1919
import modelopt.torch.distill as mtd
2020
import torch
21-
from megatron.core import parallel_state
21+
from megatron.core import parallel_state, tensor_parallel
2222
from megatron.core.models.gpt import GPTModel
2323
from megatron.core.pipeline_parallel.utils import (
2424
is_pp_first_stage,
@@ -27,6 +27,7 @@
2727
is_vp_last_stage,
2828
)
2929
from megatron.core.transformer.enums import LayerType
30+
from megatron.core.transformer.moe.router import TopKRouter
3031
from megatron.core.transformer.pipeline_parallel_layer_layout import PipelineParallelLayerLayout
3132
from megatron.core.utils import (
3233
get_batch_on_this_cp_rank,
@@ -43,6 +44,7 @@
4344
from megatron.bridge.training.utils.flop_utils import accumulate_flops_metadata, get_model_chunk_vp_stage
4445
from megatron.bridge.training.utils.packed_seq_utils import get_packed_seq_params, get_thd_cp_partition_indices
4546
from megatron.bridge.training.utils.pg_utils import get_pg_collection
47+
from megatron.bridge.utils.cuda_graph import cuda_graph_module_names
4648

4749

4850
logger = logging.getLogger(__name__)
@@ -55,6 +57,8 @@
5557
_LEGACY_PACKED_SEQ_HOST_KEYS = ("cu_seqlens_argmin", "max_seqlen", "cu_seqlens_unpadded_argmin")
5658
_LEGACY_PACKED_SEQ_PARAM_KEYS = (*_LEGACY_PACKED_SEQ_DEVICE_KEYS, *_LEGACY_PACKED_SEQ_HOST_KEYS, "total_tokens")
5759
_PackedMetadataValue = torch.Tensor | int | None
60+
_MCORE_EXPERT_BIAS_PADDING_MASK_PATCHED = "_mbridge_expert_bias_padding_mask_compatible"
61+
_MCORE_SCHEDULE_PADDING_MASK_PATCHED = "_mbridge_schedule_padding_mask_compatible"
5862

5963

6064
def _trim_padded_cu_seqlens_for_cp(cu_seqlens: torch.Tensor, cu_seqlens_argmin: torch.Tensor | None) -> torch.Tensor:
@@ -81,14 +85,144 @@ def _has_packed_sequence_metadata(batch: dict[str, torch.Tensor]) -> bool:
8185

8286

8387
def _packed_metadata_for_forward(batch: dict[str, torch.Tensor]) -> dict[str, _PackedMetadataValue] | None:
84-
"""Extract packed-sequence metadata accepted by ``get_packed_seq_params``."""
88+
"""Extract packed-sequence metadata needed by the forward step."""
8589
if batch.get("cu_seqlens_q") is not None:
86-
return {key: batch[key] for key in _CURRENT_PACKED_SEQ_PARAM_KEYS if batch.get(key) is not None}
90+
metadata: dict[str, _PackedMetadataValue] = {
91+
key: batch[key] for key in _CURRENT_PACKED_SEQ_PARAM_KEYS if batch.get(key) is not None
92+
}
93+
if batch.get("padding_mask") is not None:
94+
metadata["padding_mask"] = batch["padding_mask"]
95+
return metadata
8796
if batch.get("cu_seqlens") is not None:
8897
return {key: batch[key] for key in _LEGACY_PACKED_SEQ_PARAM_KEYS if batch.get(key) is not None}
8998
return None
9099

91100

101+
def _patch_mcore_expert_bias_padding_mask() -> None:
102+
"""Adapt MCore expert-bias routing to accept a flat padding mask.
103+
104+
TODO(https://github.com/NVIDIA/Megatron-LM/issues/6111): Remove this
105+
compatibility patch after the MCore dev fix reaches the pinned main commit.
106+
"""
107+
current_apply_expert_bias = TopKRouter._apply_expert_bias
108+
if getattr(current_apply_expert_bias, _MCORE_EXPERT_BIAS_PADDING_MASK_PATCHED, False):
109+
return
110+
111+
def _apply_expert_bias(
112+
self: TopKRouter,
113+
routing_map: torch.Tensor,
114+
padding_mask: torch.Tensor | None = None,
115+
) -> None:
116+
if padding_mask is not None:
117+
flat_mask = padding_mask.reshape(-1)
118+
assert flat_mask.shape[0] == routing_map.shape[0], (
119+
f"padding_mask flat {flat_mask.shape} vs routing_map {routing_map.shape}"
120+
)
121+
padding_mask = flat_mask.unsqueeze(-1)
122+
current_apply_expert_bias(self, routing_map, padding_mask=padding_mask)
123+
124+
setattr(_apply_expert_bias, _MCORE_EXPERT_BIAS_PADDING_MASK_PATCHED, True)
125+
TopKRouter._apply_expert_bias = _apply_expert_bias
126+
127+
128+
def _patch_mcore_schedule_plan_padding_mask() -> None:
129+
"""Forward the schedule-plan chunk mask into the pinned MCore MoE router.
130+
131+
TODO: Remove this compatibility patch after MCore dev's schedule-plan
132+
padding-mask forwarding reaches the pinned main commit.
133+
"""
134+
from megatron.core.models.common import fine_grained_callables
135+
136+
current_builder = fine_grained_callables.build_transformer_layer_callables
137+
if getattr(current_builder, _MCORE_SCHEDULE_PADDING_MASK_PATCHED, False):
138+
return
139+
140+
@wraps(current_builder)
141+
def build_transformer_layer_callables(layer):
142+
forward_funcs, backward_dw = current_builder(layer)
143+
if not hasattr(layer.mlp, "route"):
144+
return forward_funcs, backward_dw
145+
146+
forward_funcs = list(forward_funcs)
147+
current_pre_dispatch = forward_funcs[0]
148+
149+
@wraps(current_pre_dispatch)
150+
def pre_dispatch_with_padding_mask(node, *args, **kwargs):
151+
chunk_padding_mask = getattr(node.chunk_state, "padding_mask", None)
152+
if chunk_padding_mask is None:
153+
return current_pre_dispatch(node, *args, **kwargs)
154+
155+
had_instance_route = "route" in layer.mlp.__dict__
156+
instance_route = layer.mlp.__dict__.get("route")
157+
current_route = layer.mlp.route
158+
159+
@wraps(current_route)
160+
def route_with_padding_mask(hidden_states, padding_mask=None, *args, **kwargs):
161+
if padding_mask is None:
162+
padding_mask = chunk_padding_mask
163+
return current_route(hidden_states, padding_mask, *args, **kwargs)
164+
165+
layer.mlp.route = route_with_padding_mask
166+
try:
167+
return current_pre_dispatch(node, *args, **kwargs)
168+
finally:
169+
if had_instance_route:
170+
layer.mlp.route = instance_route
171+
else:
172+
delattr(layer.mlp, "route")
173+
174+
forward_funcs[0] = pre_dispatch_with_padding_mask
175+
return forward_funcs, backward_dw
176+
177+
setattr(build_transformer_layer_callables, _MCORE_SCHEDULE_PADDING_MASK_PATCHED, True)
178+
fine_grained_callables.build_transformer_layer_callables = build_transformer_layer_callables
179+
180+
181+
def _validate_packed_moe_cuda_graph(config) -> None:
182+
"""Reject router-scoped TE graphs that cannot consume packed padding masks."""
183+
if (
184+
not getattr(config, "num_moe_experts", None)
185+
or getattr(config, "cuda_graph_impl", "none") != "transformer_engine"
186+
):
187+
return
188+
189+
graph_modules = set(cuda_graph_module_names(config))
190+
captures_router = not graph_modules or bool(graph_modules & {"moe", "moe_router", "moe_preprocess"})
191+
if captures_router:
192+
raise ValueError(
193+
"Packed MoE padding masks do not support router-scoped Transformer Engine CUDA graphs in the pinned "
194+
"MCore. Use an attention-only CUDA graph scope or disable scoped CUDA graphs."
195+
)
196+
197+
198+
def _prepare_packed_padding_mask(
199+
padding_mask: torch.Tensor | None,
200+
*,
201+
config,
202+
model: GPTModel,
203+
pg_collection,
204+
) -> torch.Tensor | None:
205+
"""Prepare an alignment-padding mask for the current model stage."""
206+
if padding_mask is None:
207+
return None
208+
if getattr(config, "moe_router_enable_expert_bias", False):
209+
_patch_mcore_expert_bias_padding_mask()
210+
211+
# A pre-process GPT stage scatters this mask alongside its embeddings. HybridModel
212+
# scatters only its embeddings in the pinned MCore, while later PP stages already
213+
# receive SP-local activations, so both paths need the matching local mask here.
214+
needs_sp_scatter = not unwrap_model(model).pre_process or getattr(config, "is_hybrid_model", False)
215+
if getattr(config, "sequence_parallel", False) and needs_sp_scatter:
216+
padding_mask = (
217+
tensor_parallel.scatter_to_sequence_parallel_region(
218+
padding_mask.transpose(0, 1).contiguous(), group=pg_collection.tp
219+
)
220+
.transpose(0, 1)
221+
.contiguous()
222+
)
223+
return padding_mask
224+
225+
92226
def _cu_seqlens_for_cp_partition(batch: dict[str, torch.Tensor]) -> torch.Tensor:
93227
"""Return the cu-seqlens tensor TE should use to partition packed THD tokens."""
94228
if batch.get("cu_seqlens_q") is not None:
@@ -250,6 +384,8 @@ def get_batch_from_iterator(
250384
if "cu_seqlens_q" in batch:
251385
required_device_keys.update(key for key in _CURRENT_PACKED_SEQ_DEVICE_KEYS if key in batch)
252386
required_host_keys.update(key for key in _CURRENT_PACKED_SEQ_HOST_KEYS if key in batch)
387+
if batch.get("padding_mask") is not None:
388+
required_device_keys.add("padding_mask")
253389
elif "cu_seqlens" in batch:
254390
required_device_keys.update(key for key in _LEGACY_PACKED_SEQ_DEVICE_KEYS if key in batch)
255391
required_host_keys.update(key for key in _LEGACY_PACKED_SEQ_HOST_KEYS if key in batch)
@@ -438,6 +574,19 @@ def _forward_step_common(
438574

439575
# Add packed sequence support
440576
if packed_seq_metadata is not None:
577+
padding_mask = packed_seq_metadata.get("padding_mask")
578+
# Supported producers make mask presence configuration-driven and stable
579+
# across DP ranks. Never branch on mask contents here: an all-false local
580+
# batch must follow the same path as a padded batch on another rank.
581+
if padding_mask is not None:
582+
_validate_packed_moe_cuda_graph(config)
583+
padding_mask = _prepare_packed_padding_mask(
584+
padding_mask,
585+
config=config,
586+
model=model,
587+
pg_collection=pg_collection,
588+
)
589+
packed_seq_metadata = {key: value for key, value in packed_seq_metadata.items() if key != "padding_mask"}
441590
# total_tokens drives seq_idx computation in PackedSeqParams.__post_init__,
442591
# which is only needed for Mamba/hybrid SSM layers. Skip it for pure
443592
# transformer models to avoid per-step CUDA overhead.
@@ -449,14 +598,24 @@ def _forward_step_common(
449598
else:
450599
packed_seq_metadata["total_tokens"] = getattr(config, "seq_length", None)
451600
forward_args["packed_seq_params"] = get_packed_seq_params(packed_seq_metadata)
601+
if padding_mask is not None:
602+
forward_args["padding_mask"] = padding_mask
452603

453604
with straggler_timer:
454605
if return_schedule_plan:
455606
assert config.overlap_moe_expert_parallel_comm, (
456607
"overlap_moe_expert_parallel_comm must be enabled to return the schedule plan"
457608
)
609+
if forward_args.get("padding_mask") is not None:
610+
_patch_mcore_schedule_plan_padding_mask()
458611
schedule_plan = model.build_schedule_plan(
459-
tokens, position_ids, attention_mask, labels=labels, loss_mask=loss_mask
612+
tokens,
613+
position_ids,
614+
attention_mask,
615+
labels=labels,
616+
loss_mask=loss_mask,
617+
packed_seq_params=forward_args.get("packed_seq_params"),
618+
padding_mask=forward_args.get("padding_mask"),
460619
)
461620
return schedule_plan, loss_mask
462621
else:

‎tests/unit_tests/data/collators/test_sft.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ def test_text_chat_collate_fn_pads_packed_sequences_to_multiple():
348348
assert batch["cu_seqlens_kv"].tolist() == [0, 3, 7]
349349
assert batch["cu_seqlens_q_padded"].tolist() == [0, 4, 8]
350350
assert batch["cu_seqlens_kv_padded"].tolist() == [0, 4, 8]
351+
assert batch["padding_mask"].tolist() == [[False, False, False, True, False, False, False, False]]
351352
assert batch["max_seqlen_q"].item() == 4
352353
assert batch["max_seqlen_kv"].item() == 4
353354
assert "cu_seqlens" not in batch

‎tests/unit_tests/data/datasets/test_gpt_sft.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import numpy as np
2424
import pytest
25+
import torch
2526

2627
from megatron.bridge.data.datasets import gpt_sft as gpt_sft_module
2728
from megatron.bridge.data.datasets.gpt_sft import (
@@ -381,6 +382,81 @@ def test_collate_fn_supports_different_sequence_counts(self, tmp_path):
381382
assert processed["cu_seqlens_kv"].tolist() == processed["cu_seqlens_q"].tolist()
382383
assert processed["max_seqlen_q"].tolist() == [[3], [7]]
383384
assert processed["max_seqlen_kv"].tolist() == [[3], [7]]
385+
assert processed["padding_mask"].dtype == torch.bool
386+
assert processed["padding_mask"].tolist() == [
387+
[False, False, False, False, False, False, True],
388+
[False, False, False, False, False, False, False],
389+
]
390+
391+
def test_collate_fn_marks_offline_alignment_padding(self, tmp_path):
392+
"""Offline-packed rows expose physical alignment gaps to the MoE router."""
393+
dataset, _ = get_gpt_sft(tmp_path, dataset_type="packed")
394+
dataset.max_seq_length = 8
395+
dataset._pad_seq_to_mult = 4
396+
batch = [
397+
{
398+
"input_ids": np.array([10, 11, 12, 2, 2, 20, 21, 2, 2, 2]),
399+
"seq_boundaries": [0, 5, 10],
400+
"loss_mask": np.ones(10, dtype=np.int64),
401+
}
402+
]
403+
404+
processed = dataset.collate_fn(batch)
405+
406+
assert processed["tokens"].tolist() == [[10, 11, 12, 2, 20, 21, 2, 2]]
407+
assert processed["cu_seqlens_q"].tolist() == [[0, 3, 5]]
408+
assert processed["cu_seqlens_q_padded"].tolist() == [[0, 4, 8]]
409+
assert processed["padding_mask"].dtype == torch.bool
410+
assert processed["padding_mask"].tolist() == [[False, False, False, True, False, False, True, True]]
411+
412+
def test_collate_fn_marks_offline_trailing_padding_without_alignment(self, tmp_path):
413+
"""Default offline packing masks padding added to reach the batch width."""
414+
dataset, _ = get_gpt_sft(tmp_path, dataset_type="packed")
415+
dataset.max_seq_length = 8
416+
dataset.pad_to_max_length = True
417+
batch = [
418+
{
419+
"input_ids": np.array([10, 11, 12, 2]),
420+
"seq_boundaries": [0, 4],
421+
"loss_mask": np.ones(4, dtype=np.int64),
422+
}
423+
]
424+
425+
processed = dataset.collate_fn(batch)
426+
427+
assert processed["tokens"].tolist() == [[10, 11, 12, 2, 2, 2, 2, 2]]
428+
assert processed["cu_seqlens_q"].tolist() == [[0, 3, 8]]
429+
assert "cu_seqlens_q_padded" not in processed
430+
assert processed["padding_mask"].tolist() == [[False, False, False, True, True, True, True, True]]
431+
432+
def test_collate_fn_keeps_padding_mask_key_stable_across_batches(self, tmp_path):
433+
"""Full-iteration graphs require an invariant input dictionary and tensor shape."""
434+
dataset, _ = get_gpt_sft(tmp_path, dataset_type="packed")
435+
dataset.max_seq_length = 8
436+
dataset.pad_to_max_length = True
437+
438+
padded = dataset.collate_fn(
439+
[
440+
{
441+
"input_ids": np.array([10, 11, 12, 2]),
442+
"seq_boundaries": [0, 4],
443+
"loss_mask": np.ones(4, dtype=np.int64),
444+
}
445+
]
446+
)
447+
full = dataset.collate_fn(
448+
[
449+
{
450+
"input_ids": np.array([20, 21, 22, 23, 24, 25, 26, 27, 2]),
451+
"seq_boundaries": [0, 9],
452+
"loss_mask": np.ones(9, dtype=np.int64),
453+
}
454+
]
455+
)
456+
457+
assert padded["padding_mask"].shape == full["padding_mask"].shape == (1, 8)
458+
assert padded["padding_mask"].any()
459+
assert not full["padding_mask"].any()
384460

385461
def test_utils_func_packed(self, tmp_path):
386462
dataset, _ = get_gpt_sft(tmp_path, dataset_type="packed")

0 commit comments

Comments
 (0)