diff --git a/megatron/core/datasets/data_schedule.py b/megatron/core/datasets/data_schedule.py index 0f016473b6a..e6ba1abbf69 100644 --- a/megatron/core/datasets/data_schedule.py +++ b/megatron/core/datasets/data_schedule.py @@ -1,12 +1,65 @@ # Copyright (c) 2025 NVIDIA CORPORATION. All rights reserved. -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional, Type import torch from megatron.core import parallel_state +from megatron.core.datasets.data_schedule_utils import ( + broadcast_scalars, + broadcast_tensor, + build_packed_microbatches, + create_data_iterator, + get_batch_and_global_seqlens, + get_cp_slice_for_thd, + reroute_samples_to_dcp_ranks, +) +from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.pipeline_parallel.hybrid_cp_schedule import BalancedCPScheduler from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.transformer.multi_token_prediction import mtp_on_this_rank as mtp_is_on_rank + + +def _build_thd_padding_mask( + cu_seqlens: torch.Tensor, cu_seqlens_padded: torch.Tensor +) -> torch.Tensor: + """Build a 1D THD padding mask from scheduler sequence metadata.""" + assert cu_seqlens.dim() == 1 + assert cu_seqlens_padded.dim() == 1 + assert cu_seqlens.numel() == cu_seqlens_padded.numel() + + total_tokens = int(cu_seqlens_padded[-1].item()) + if total_tokens == 0: + return torch.empty((0,), dtype=torch.bool, device=cu_seqlens.device) + + num_sequences = cu_seqlens.numel() - 1 + if num_sequences <= 0: + return torch.ones((total_tokens,), dtype=torch.bool, device=cu_seqlens.device) + + positions = torch.arange( + total_tokens, dtype=cu_seqlens_padded.dtype, device=cu_seqlens_padded.device + ) + seq_indices = torch.searchsorted(cu_seqlens_padded[1:].contiguous(), positions, right=True) + + valid_lengths = (cu_seqlens[1:] - cu_seqlens[:-1]).clamp(min=0) + valid_ends = cu_seqlens_padded[:-1] + valid_lengths + return positions >= valid_ends[seq_indices] + + +def _sanitize_thd_padding_values(batch: Dict[str, Any], padding_mask: torch.Tensor) -> None: + """Replace padded token-like slots with safe neutral values in-place.""" + assert padding_mask.dim() == 1 + pad_values = {'tokens': 0, 'labels': 0, 'loss_mask': 0.0, 'position_ids': 0} + for key, pad_value in pad_values.items(): + tensor = batch.get(key) + if tensor is None: + continue + assert tensor.dim() == 1, f"{key} must be 1D before CP slicing, got {tensor.dim()}D" + assert tensor.numel() == padding_mask.numel(), ( + f"{key} length ({tensor.numel()}) must match padding_mask length " + f"({padding_mask.numel()}) before CP slicing." + ) + batch[key] = tensor.masked_fill(padding_mask, pad_value) class HybridCPDataLoaderWrapper: @@ -57,7 +110,6 @@ def get_global_seqlens(self, subsample_seqlens: torch.Tensor) -> List[int]: Gathers the sequence lengths of all subsamples from all DP ranks. Each DP rank loads the same number of microbatches but each microbatch may have a different number of subsamples. - We find the number of subsamples each rank holds and then gather the sequence lengths of all subsamples from all ranks. """ @@ -299,3 +351,551 @@ def __next__(self) -> Any: batch, global_ids_this_rank, global_id_seqlens, sample_id_groups, offsets ) return samples_this_rank_with_id, sample_id_groups + + +class BasePackingScheduler: + """Base class for sequence packing schedulers.""" + + def __init__( + self, + max_seqlen_per_dp_cp_rank: int, + cp_size: int, + dp_size: int, + microbatch_group_size_per_vp_stage: Optional[int], + ): + """ + Args: + max_seqlen_per_dp_cp_rank: The maximum sequence length per DPxCP rank. + cp_size: The context parallel size. + dp_size: The data parallel size. + microbatch_group_size_per_vp_stage: The microbatch group size per virtual + pipeline stage, only used when enabling VPP, otherwise None. + """ + self.max_seqlen_per_dp_cp_rank = max_seqlen_per_dp_cp_rank + self.cp_size = cp_size + self.dp_size = dp_size + self.microbatch_group_size_per_vp_stage = microbatch_group_size_per_vp_stage + + def get_required_sample_keys(self): + """Return the required key of each batch.""" + raise NotImplementedError + + def get_groups_and_subsamples(self, sample_id_seqlens): + """schedule the samples into groups""" + raise NotImplementedError + + def run( + self, + data_iterator, + num_microbatches, + dp_group, + tp_group, + pp_group, + dp_cp_group, + dev, + config, + ): + """ + Run the scheduler and return the new data_iterator. + + Args: + data_iterator: The data iterator. + num_microbatches: The number of microbatches to fetch. + dp_group: Data parallel process group. + tp_group: Tensor parallel process group. + pp_group: Pipeline parallel process group. + dp_cp_group: Data parallel + context parallel process group. + dev: CUDA device. + config: Model parallel config. + + Returns: + new_data_iterator: The new data iterator (or list for VPP). + num_micro_batches: Number of micro batches after scheduling. + seqlen_sum_this_global_batch: Total tokens for FLOPs calculation. + seqlen_squared_sum_this_global_batch: Sum of squared seqlens for FLOPs. + """ + raise NotImplementedError + + +class DpBalancedScheduler(BasePackingScheduler): + """Packs sequences in their original order until reaching the max limit of sequence length.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.max_seq_len_all_ranks = self.max_seqlen_per_dp_cp_rank * self.cp_size + + def get_required_sample_keys(self): + """Return the required key of each batch.""" + return [ + "tokens", + "labels", + "loss_mask", + "position_ids", + "original_seq_len", # Length of the original sequence length, should be a gpu tensor. + "padded_seq_len", # Length of the padded sequence length, should be a gpu tensor. + ] + + def get_groups_and_subsamples(self, sample_id_seqlens): + """ + Packs sequences in their original order until reaching the max limit of sequence length. + """ + sample_id_groups = [] + packed_id_groups = [] + sum_seqlen = 0 + single_microbatch = [] + + for i in range(len(sample_id_seqlens)): + if sum_seqlen + sample_id_seqlens[i][1] <= self.max_seq_len_all_ranks: + single_microbatch.append(i) + sum_seqlen += sample_id_seqlens[i][1] + else: + packed_id_groups.append(single_microbatch) + single_microbatch = [i] + sum_seqlen = sample_id_seqlens[i][1] + if len(single_microbatch) > 0: + packed_id_groups.append(single_microbatch) + + # we want the number of packed sequences to be multiple of dp_size + # so we move few samples from previous microbatch + # to the end of the microbatches if needed + num_packed_sequence = len(packed_id_groups) + + # when enabling vpp, we want the number of packed sequences to be + # multiple of dp_size * microbatch_group_size_per_vp_stage + multiple = self.dp_size * ( + self.microbatch_group_size_per_vp_stage + if self.microbatch_group_size_per_vp_stage is not None + else 1 + ) + if num_packed_sequence % multiple != 0: + remainder = num_packed_sequence % multiple + num_to_move = multiple - remainder + i = num_packed_sequence - 1 + while num_to_move > 0: + assert i >= 0, "Not enough samples to move" + if len(packed_id_groups[i]) > 1: + seq_id = packed_id_groups[i].pop() + packed_id_groups.append([seq_id]) + num_to_move -= 1 + else: + i -= 1 + + num_micro_batches = int(len(packed_id_groups) / self.dp_size) + for i in range(num_micro_batches): + sample_id_groups.append([]) + for j in range(self.cp_size * self.dp_size): + seq_id = int(i * self.dp_size + j / self.cp_size) + sample_id_groups[i].append(packed_id_groups[seq_id]) + return sample_id_groups + + def run( + self, + data_iterator, + num_microbatches: int, + dp_group, + tp_group, + pp_group, + dp_cp_group, + dev: torch.device, + config, + ): + """ + Run the complete scheduling pipeline. + + Packed-sequence datasets are built on TP rank 0 of every PP stage. Each + stage therefore runs the same schedule locally, retaining only the data + fields required by that stage before the all-to-all transfer. + + Args: + data_iterator: The data iterator. + num_microbatches: The number of microbatches to fetch. + dp_group: Data parallel process group. + tp_group: Tensor parallel process group. + pp_group: Pipeline parallel process group. + dp_cp_group: Data parallel + context parallel process group. + dev: CUDA device. + config: Model parallel config. + + Returns: + new_data_iterator: The new data iterator (or list for VPP). + num_micro_batches: Number of micro batches after scheduling. + seqlen_sum_this_global_batch: Total tokens for FLOPs calculation. + seqlen_squared_sum_this_global_batch: Sum of squared seqlens for FLOPs. + """ + + total_dcp_gpus = dp_cp_group.size() + is_first_pp = pp_group.rank() == 0 + is_last_pp = pp_group.rank() == pp_group.size() - 1 + mtp_on_this_pp = mtp_is_on_rank( + layout=config.pipeline_model_parallel_layout, + mtp_num_layers=config.mtp_num_layers, + ignore_virtual=True, + ) + + vpp_size = config.virtual_pipeline_model_parallel_size or 1 + vpp_needs_data = None + if vpp_size > 1: + assert len(data_iterator) == vpp_size + data_iterator = next( + (iterator for iterator in data_iterator if iterator is not None), None + ) + + vpp_needs_data = [False] * vpp_size + if is_first_pp: + vpp_needs_data[0] = True + if is_last_pp: + vpp_needs_data[-1] = True + if mtp_on_this_pp: + for vp_stage in range(vpp_size): + if mtp_is_on_rank( + layout=config.pipeline_model_parallel_layout, + mtp_num_layers=config.mtp_num_layers, + ignore_virtual=False, + vp_stage=vp_stage, + ): + vpp_needs_data[vp_stage] = True + + if data_iterator is not None: + assert tp_group.rank() == 0, "Only TP rank 0 should have data_iterator" + + # Step 1: Fetch batches and gather global sequence lengths + ( + batch, + global_id_seqlens, + global_ids_this_rank, + offsets, + _padded_seqlens_gathered, + original_seqlens_gathered, + ) = get_batch_and_global_seqlens(data_iterator, num_microbatches, dp_group) + + # Step 2: Check required sample keys + for key in self.get_required_sample_keys(): + assert ( + key in batch[0] + ), f"Batch missing required key {key}, provided keys: {batch[0].keys()}" + + # Avoid transferring fields that this pipeline stage never consumes. + keys_to_keep = {'original_seq_len', 'padded_seq_len'} + if is_first_pp or mtp_on_this_pp: + keys_to_keep.update(['tokens', 'position_ids']) + if is_last_pp or mtp_on_this_pp: + keys_to_keep.update(['labels', 'loss_mask']) + for sample in batch: + for key in list(sample): + if key not in keys_to_keep: + del sample[key] + + # Step 3: Schedule samples into groups + sample_id_groups = self.get_groups_and_subsamples(global_id_seqlens) + + # Validate scheduling result + set_gbs = set() + for group in sample_id_groups: + for sub in group: + set_gbs.update(sub) + assert len(set_gbs) == len(global_id_seqlens), ( + f"set_gbs length: {len(set_gbs)} != " + f"global_id_seqlens length: {len(global_id_seqlens)}" + ) + + # Step 4: Reroute samples to DCP ranks + samples_this_rank_with_id = reroute_samples_to_dcp_ranks( + batch, + global_ids_this_rank, + global_id_seqlens, + sample_id_groups, + offsets, + dp_group, + dp_cp_group, + total_dcp_gpus, + ) + + dcp_rank = dp_cp_group.rank() + num_micro_batches = len(sample_id_groups) + + grouped_samples = [ + [ + samples_this_rank_with_id[sub_sample_id] + for sub_sample_id in sample_id_groups[i][dcp_rank] + ] + for i in range(num_micro_batches) + ] + + # Step 5: Build packed microbatches + new_samples = build_packed_microbatches(grouped_samples, dev) + + # Step 6: Calculate FLOPs info + seqlen_sum_this_global_batch = float(sum(original_seqlens_gathered)) + seqlen_squared_sum_this_global_batch = float( + sum(seqlen**2 for seqlen in original_seqlens_gathered) + ) + else: + ( + new_samples, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) = (None, None, None, None) + + # Broadcast scalar schedule results to the remaining TP ranks. + (num_micro_batches, seqlen_sum_this_global_batch, seqlen_squared_sum_this_global_batch) = ( + broadcast_scalars( + [ + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ], + tp_group, + dev, + ) + ) + num_micro_batches = int(num_micro_batches) + + new_data_iterator = create_data_iterator(new_samples, tp_group, config, vpp_needs_data) + + return ( + new_data_iterator, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) + + +scheduler_map: Dict[str, Type[BasePackingScheduler]] = {'dp_balanced': DpBalancedScheduler} + + +def wrap_data_iterator( + data_iterator, config, num_microbatches, pg_collection: ProcessGroupCollection +): + """ + A wrapper function that wraps around an existing data_iterator + and return the num_micro_batches for sequence packing. + + Args: + data_iterator: The original data_iterator to wrap around + config: The config object containing the max_seqlen_per_dp_cp_rank + pg_collection: The process group collection. + """ + dp_cp_group = pg_collection.dp_cp + dp_group = pg_collection.dp + tp_group = pg_collection.tp + pp_group = pg_collection.pp + assert ( + dp_cp_group is not None + and dp_group is not None + and tp_group is not None + and pp_group is not None + ), "dp_cp_group, dp_group, tp_group must not be None when using sequence packing" + + dev = torch.cuda.current_device() + dp_size = dp_group.size() + cp_size = dp_cp_group.size() // dp_size + + scheduler_type = config.sequence_packing_scheduler + scheduler = scheduler_map[scheduler_type]( + config.max_seqlen_per_dp_cp_rank, + cp_size, + dp_size, + # When VPP is enabled, align num_micro_batches to this multiple. + ( + None + if config.virtual_pipeline_model_parallel_size is None + else config.microbatch_group_size_per_vp_stage + ), + ) + + ( + new_data_iterator, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) = scheduler.run( + data_iterator, num_microbatches, dp_group, tp_group, pp_group, dp_cp_group, dev, config + ) + + return ( + new_data_iterator, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) + + +def get_batch_on_this_rank_for_sequence_packing( + data_iterator, + pg_collection: ProcessGroupCollection, + vpp_size: Optional[int] = None, + mtp_on_this_rank: bool = False, + vp_stage: Optional[int] = None, +): + """ + Get a batch of data for sequence packing. + Args: + data_iterator (Iterator): The data iterator to get the batch from. + mtp_on_this_rank (bool): Whether to use multi-token prediction. + vp_stage (Optional[int]): The stage of the pipeline. + pg_collection: The process group collection. + Returns: + tuple of (tokens, labels, loss_mask, attention_mask, position_ids, + packed_seq_params, padding_mask) + """ + + tp_group = pg_collection.tp + pp_group = pg_collection.pp + cp_group = pg_collection.cp + + tp_src_rank = torch.distributed.get_process_group_ranks(tp_group)[0] + + is_tp_rank_0 = tp_group.rank() == 0 + is_first_stage = pp_group.rank() == 0 and (vp_stage is None or vp_stage == 0) + is_last_stage = pp_group.rank() == pp_group.size() - 1 and ( + vp_stage is None or vp_stage == vpp_size - 1 + ) + + dev = torch.cuda.current_device() + + # data_iterator should return a batch including the following keys. + batch_keys = ['cu_seqlens', 'cu_seqlens_padded', 'max_seqlen'] + if is_first_stage or mtp_on_this_rank: + batch_keys.append('tokens') + batch_keys.append('position_ids') + if is_last_stage or mtp_on_this_rank: + batch_keys.append('labels') + batch_keys.append('loss_mask') + + # Get a batch from data_iterator or create an emtpy batch. + if is_tp_rank_0: + assert data_iterator is not None + batch = next(data_iterator) + for key in batch_keys: + assert key in batch, f"{key} is missing in current batch." + else: + assert data_iterator is None, "Non TP 0 rank should not have data_iterator" + batch = {} + + # Build padding_mask before CP slicing while tensors still have the full + # packed length represented by cu_seqlens_padded[-1]. + if is_tp_rank_0: + batch['padding_mask'] = _build_thd_padding_mask( + batch['cu_seqlens'], batch['cu_seqlens_padded'] + ) + _sanitize_thd_padding_values(batch, batch['padding_mask']) + + # Partition padding_mask for context parallel on every PP stage. Partition + # token-like tensors only on stages that own them. + if is_tp_rank_0: + cp_slice_keys = ['padding_mask'] + if is_first_stage or mtp_on_this_rank: + cp_slice_keys.extend(['tokens', 'position_ids']) + if is_last_stage or mtp_on_this_rank: + cp_slice_keys.extend(['labels', 'loss_mask']) + get_cp_slice_for_thd(batch, cp_group, keys=cp_slice_keys) + + # Broadcast cu_seqlens_size because we need it to create placeholder for cu_seqlens and + # cu_seqlens_padded for non TP 0 ranks. + if is_tp_rank_0: + cu_seqlen_size = torch.tensor(batch['cu_seqlens'].size(0), dtype=torch.int32, device=dev) + else: + cu_seqlen_size = torch.empty(1, dtype=torch.int32, device=dev) + broadcast_tensor(cu_seqlen_size, tp_src_rank, tp_group) + cu_seqlen_size = cu_seqlen_size.item() + + # Broadcast total_tokens because padding_mask is prepared on every PP stage. + # Tokens/labels/loss_mask/position_ids use the same length on stages that own them. + if is_tp_rank_0: + total_tokens = torch.tensor(batch['padding_mask'].size(0), dtype=torch.int32, device=dev) + else: + total_tokens = torch.empty(1, dtype=torch.int32, device=dev) + broadcast_tensor(total_tokens, tp_src_rank, tp_group) + total_tokens = total_tokens.item() + + # Step1: Prepare "tokens", "position_ids" on all ranks. + if is_first_stage or mtp_on_this_rank: + if is_tp_rank_0: + assert batch['tokens'].dtype == torch.int64 + assert batch['position_ids'].dtype == torch.int64 + batch['tokens'] = batch['tokens'].view(1, total_tokens) + batch['position_ids'] = batch['position_ids'].view(1, total_tokens) + else: + batch['tokens'] = torch.empty([1, total_tokens], dtype=torch.int64, device=dev) + batch['position_ids'] = torch.empty([1, total_tokens], dtype=torch.int64, device=dev) + else: + # Non first stage rank doesn't need tokens and position_ids. + batch['tokens'] = None + batch['position_ids'] = None + + # Step2: Prepare "labels", "loss_mask" on all ranks. + if is_last_stage or mtp_on_this_rank: + if is_tp_rank_0: + assert batch['labels'].dtype == torch.int64 + assert batch['loss_mask'].dtype == torch.float32 + batch['labels'] = batch['labels'].view(1, total_tokens) + batch['loss_mask'] = batch['loss_mask'].view(1, total_tokens) + else: + batch['labels'] = torch.empty([1, total_tokens], dtype=torch.int64, device=dev) + batch['loss_mask'] = torch.empty([1, total_tokens], dtype=torch.float32, device=dev) + else: + # Non last stage rank doesn't need labels and loss_mask. + batch['labels'] = None + batch['loss_mask'] = None + + # Step3: Prepare "padding_mask" on all TP ranks. + if is_tp_rank_0: + assert batch['padding_mask'].dtype == torch.bool + batch['padding_mask'] = batch['padding_mask'].view(1, total_tokens) + else: + batch['padding_mask'] = torch.empty([1, total_tokens], dtype=torch.bool, device=dev) + + # Step4: Prepare "cu_seqlens", "cu_seqlens_padded", "max_seqlen" on all ranks. + if is_tp_rank_0: + assert batch['cu_seqlens'].dtype == torch.int32 + assert batch['cu_seqlens_padded'].dtype == torch.int32 + assert batch['cu_seqlens'].dim() == 1 + assert batch['cu_seqlens_padded'].dim() == 1 + if type(batch['max_seqlen']) == int: + batch['max_seqlen'] = torch.tensor(batch['max_seqlen'], dtype=torch.int32, device=dev) + else: + assert batch['max_seqlen'].dtype == torch.int32 + assert batch['max_seqlen'].numel() == 1 + else: + batch['cu_seqlens'] = torch.empty([cu_seqlen_size], dtype=torch.int32, device=dev) + batch['cu_seqlens_padded'] = torch.empty([cu_seqlen_size], dtype=torch.int32, device=dev) + batch['max_seqlen'] = torch.empty(1, dtype=torch.int32, device=dev) + + # Broadcast batch inside TP group. + broadcast_tensor(batch['tokens'], tp_src_rank, tp_group) + broadcast_tensor(batch['position_ids'], tp_src_rank, tp_group) + broadcast_tensor(batch['labels'], tp_src_rank, tp_group) + broadcast_tensor(batch['loss_mask'], tp_src_rank, tp_group) + broadcast_tensor(batch['padding_mask'], tp_src_rank, tp_group) + broadcast_tensor(batch['cu_seqlens'], tp_src_rank, tp_group) + broadcast_tensor(batch['cu_seqlens_padded'], tp_src_rank, tp_group) + broadcast_tensor(batch['max_seqlen'], tp_src_rank, tp_group) + + # Extract the data from batch after broadcasting. + tokens = batch['tokens'] + position_ids = batch['position_ids'] + labels = batch['labels'] + loss_mask = batch['loss_mask'] + padding_mask = batch['padding_mask'] + cu_seqlens = batch['cu_seqlens'] + cu_seqlens_padded = batch['cu_seqlens_padded'] + max_seqlen = batch['max_seqlen'].item() + + # Keep original boundaries for loss paths that must identify padding rows. + # Attention kernels and THD partitioning consume the padded boundaries. + packed_seq_params = PackedSeqParams( + qkv_format="thd", + cu_seqlens_q=cu_seqlens, + cu_seqlens_kv=cu_seqlens, + cu_seqlens_q_padded=cu_seqlens_padded, + cu_seqlens_kv_padded=cu_seqlens_padded, + max_seqlen_q=max_seqlen, + max_seqlen_kv=max_seqlen, + local_cp_size=None, + cp_group=None, + pad_between_seqs=False, + ) + + # "attention_mask" is not valid for sequence packing, so set it to None. + return tokens, labels, loss_mask, None, position_ids, packed_seq_params, padding_mask diff --git a/megatron/core/datasets/data_schedule_utils.py b/megatron/core/datasets/data_schedule_utils.py new file mode 100644 index 00000000000..64f2afbe1d2 --- /dev/null +++ b/megatron/core/datasets/data_schedule_utils.py @@ -0,0 +1,423 @@ +# Copyright (c) 2025 NVIDIA CORPORATION. All rights reserved. + +from typing import Dict, List, Optional, Sequence + +import torch + +from megatron.core.extensions.transformer_engine import get_thd_partitioned_indices +from megatron.core.rerun_state_machine import RerunDataIterator + + +def get_cp_slice_for_thd(batch, cp_group, keys: Optional[Sequence[str]] = None): + """Partition sequence data for context parallelism in THD format. + + Args: + batch: Dict with packed sequence data. + cp_group: Context parallel process group. + keys: Sequence data keys to slice. Defaults to the original THD data tensors. + """ + cp_size = cp_group.size() + if cp_size <= 1: + return + cp_rank = cp_group.rank() + # Partition with padded cumulative lengths so CP slices match the THD + # sequence boundaries consumed by attention kernels. + cu_seqlens = batch["cu_seqlens_padded"] + # Use cu_seqlens_padded[-1] for total_tokens instead of batch['tokens'].size(0): + # under VPP, the last PP stage has labels/loss_mask but no tokens, so + # batch['tokens'] is None on that stage. cu_seqlens_padded is always populated. + total_tokens = int(cu_seqlens[-1].item()) + if keys is None: + keys = ('tokens', 'position_ids', 'labels', 'loss_mask') + + index = get_thd_partitioned_indices(cu_seqlens, total_tokens, cp_size, cp_rank) + for key in keys: + if key in batch and batch[key] is not None: + batch[key] = batch[key].index_select(0, index) + + +def _unpack_batch(batch: List[Dict[str, torch.Tensor]]) -> List[Dict[str, torch.Tensor]]: + """Normalize samples that are already unpacked by the varlen dataset.""" + for sample in batch: + if "padded_seq_len" not in sample: + raise KeyError("sequence packing samples must provide 'padded_seq_len'") + for key, value in sample.items(): + if value.ndim == 2 and value.shape[0] == 1: + sample[key] = value.squeeze(0) + if "original_seq_len" not in sample: + sample["original_seq_len"] = sample["padded_seq_len"].clone() + return batch + + +def _get_global_seqlens_and_ids( + padded_subsample_seqlens: torch.Tensor, original_subsample_seqlens: torch.Tensor, dp_group +): + """ + Gathers the sequence lengths of all subsamples from all DP ranks and calculates global IDs. + """ + # Collect the number of subsamples from all ranks + assert padded_subsample_seqlens.shape == original_subsample_seqlens.shape + num_local_subsamples = padded_subsample_seqlens.shape[0] + local_len = torch.tensor( + [num_local_subsamples], dtype=torch.int32, device=padded_subsample_seqlens.device + ) + dp_subsample_count = [torch.zeros_like(local_len) for _ in range(dp_group.size())] + torch.distributed.all_gather(dp_subsample_count, local_len, group=dp_group) + + # Gather padded and original lengths together so scheduling and FLOPs use + # the same global sample ordering without an extra collective. + dp_subsample_counts = torch.stack(dp_subsample_count, dim=0).cpu().view(-1) + max_sub_samples = int(dp_subsample_counts.max().item()) + local_seqlens = torch.stack([padded_subsample_seqlens, original_subsample_seqlens], dim=1) + + if num_local_subsamples < max_sub_samples: + local_seqlens = torch.cat( + [ + local_seqlens, + torch.zeros( + (max_sub_samples - num_local_subsamples, 2), + dtype=torch.int32, + device=local_seqlens.device, + ), + ], + dim=0, + ) + + seqlens_gathered = [torch.empty_like(local_seqlens) for _ in range(dp_group.size())] + torch.distributed.all_gather(seqlens_gathered, local_seqlens, group=dp_group) + + # Trim each seqlens_gathered to the length of the correct sample + for dp_rank, seqlen in enumerate(seqlens_gathered): + seqlens_gathered[dp_rank] = seqlen[: dp_subsample_counts[dp_rank]] + + seqlens_gathered = torch.cat(seqlens_gathered, dim=0).cpu() + padded_seqlens_gathered = seqlens_gathered[:, 0].tolist() + original_seqlens_gathered = seqlens_gathered[:, 1].tolist() + + # Calculate the offsets to assign unique global ID to each subsample. + csum = torch.cumsum(dp_subsample_counts, dim=0, dtype=torch.int32) + offsets = torch.cat([torch.zeros(1, dtype=torch.int32), csum], dim=0) + + # Calculate global ID for each subsample + dp_rank = dp_group.rank() + global_ids = torch.arange( + len(padded_seqlens_gathered), dtype=torch.int32, device=padded_subsample_seqlens.device + ) + + # Create a list of (global_id, seqlen) tuples for scheduling + global_id_seqlens = [(i, padded_seqlens_gathered[i]) for i in range(len(global_ids))] + + # Get the global IDs locally present on this rank + start_idx = offsets[dp_rank] + end_idx = offsets[dp_rank + 1] + + global_ids_this_rank = global_ids[start_idx:end_idx] + + return ( + global_id_seqlens, + global_ids_this_rank, + offsets, + padded_seqlens_gathered, + original_seqlens_gathered, + ) + + +def _pack_sequences( + samples: List, padded_lengths: torch.Tensor, original_lengths: torch.Tensor, dev: torch.device +) -> Dict[str, torch.Tensor]: + """Pack multiple samples into a single packed sample.""" + + def _pack_tensors(tensors): + return torch.cat([t.reshape(-1) for t in tensors], dim=0) + + new_sample = {} + for key in ['tokens', 'labels', 'loss_mask', 'position_ids']: + if key in samples[0]: + new_sample[key] = _pack_tensors([sample[key] for sample in samples]) + + padded_lengths = padded_lengths.to(device=dev, dtype=torch.int32, non_blocking=True).reshape(-1) + cu_seqlens_padded = torch.empty(padded_lengths.numel() + 1, device=dev, dtype=torch.int32) + cu_seqlens_padded[0] = 0 + cu_seqlens_padded[1:] = torch.cumsum(padded_lengths, dim=0) + max_seqlen = torch.max(padded_lengths).to(dtype=torch.int32) + + new_sample["cu_seqlens_padded"] = cu_seqlens_padded + new_sample["max_seqlen"] = max_seqlen + + original_lengths = original_lengths.to( + device=dev, dtype=torch.int32, non_blocking=True + ).reshape(-1) + cu_seqlens = torch.empty(original_lengths.numel() + 1, device=dev, dtype=torch.int32) + cu_seqlens[0] = 0 + cu_seqlens[1:] = torch.cumsum(original_lengths, dim=0).reshape(-1) + new_sample["cu_seqlens"] = cu_seqlens + + return new_sample + + +def broadcast_tensor(item, src_rank, group) -> None: + """Broadcast a tensor from src_rank to all ranks in the group.""" + if item is not None: + torch.distributed.broadcast(item, src_rank, group=group) + + +def broadcast_scalars(values: List, group, dev, dtype=torch.float32) -> List: + """ + Broadcast scalar values from rank 0 to all ranks in the group. + + Args: + values: List of scalar values to broadcast (only used on rank 0). + group: The process group to broadcast within. + dev: The device to use for the tensor. + dtype: The data type for the tensor. + + Returns: + List of broadcasted values. + """ + if group.size() <= 1: + return values + + src_rank = torch.distributed.get_process_group_ranks(group)[0] + num_values = len(values) + + if group.rank() == 0: + info_to_broadcast = torch.tensor(values, dtype=dtype, device=dev) + else: + info_to_broadcast = torch.zeros(num_values, dtype=dtype, device=dev) + + broadcast_tensor(info_to_broadcast, src_rank, group) + + if group.rank() != 0: + values = info_to_broadcast.cpu().tolist() + + return values + + +def create_data_iterator(new_samples, tp_group, config, vpp_needs_data=None): + """Create independent iterators for the virtual pipeline stages.""" + if ( + config.virtual_pipeline_model_parallel_size is not None + and config.virtual_pipeline_model_parallel_size > 1 + ): + vpp_size = config.virtual_pipeline_model_parallel_size + if tp_group.rank() == 0: + new_data_iterator = [] + for vp_stage in range(vpp_size): + if vpp_needs_data is not None and vpp_needs_data[vp_stage]: + samples = [dict(sample) for sample in new_samples] + new_data_iterator.append(RerunDataIterator(iter(samples))) + else: + metadata_keys = ['max_seqlen', 'cu_seqlens', 'cu_seqlens_padded'] + metadata = [ + {key: sample[key] for key in metadata_keys if key in sample} + for sample in new_samples + ] + new_data_iterator.append(RerunDataIterator(iter(metadata))) + else: + new_data_iterator = [None for _ in range(vpp_size)] + else: + new_data_iterator = RerunDataIterator(iter(new_samples)) if tp_group.rank() == 0 else None + + return new_data_iterator + + +def reroute_samples_to_dcp_ranks( + batch, + global_ids_this_rank, + global_id_seqlens, + sample_id_groups, + offsets, + dp_group, + dp_cp_group, + total_dcp_gpus, +): + """ + Reroutes the sub-samples to the correct rank after scheduling. + + For each key in the batch dict, we perform an all-to-all communication + to transfer the data to the correct ranks. + """ + + dp_global_ranks = torch.distributed.get_process_group_ranks(dp_group) + dp_cp_global_ranks = torch.distributed.get_process_group_ranks(dp_cp_group) + global_to_dcp_rank = {global_rank: rank for rank, global_rank in enumerate(dp_cp_global_ranks)} + + def _gid_to_src_rank(gid: int) -> int: + dp_src_rank = torch.bucketize(gid, offsets[1:] - 1) + return global_to_dcp_rank[dp_global_ranks[int(dp_src_rank)]] + + gid2local_id = {int(gid): i for i, gid in enumerate(global_ids_this_rank)} + dcp_rank = dp_cp_group.rank() + dp_ranks = [global_to_dcp_rank[global_rank] for global_rank in dp_global_ranks] + + data_keys = batch[0].keys() + + # Create the send plan + combined_sample_id_groups: List[List[int]] = [[] for _ in range(total_dcp_gpus)] + for d in range(total_dcp_gpus): + for sample_id_group in sample_id_groups: + combined_sample_id_groups[d].extend(sample_id_group[d]) + for dest_rank in range(total_dcp_gpus): + combined_sample_id_groups[dest_rank].sort() + + send_ids_sorted = [ + gid for d in dp_ranks for gid in combined_sample_id_groups[d] if gid in global_ids_this_rank + ] + + send_num_split = [0] * total_dcp_gpus + send_lens_split = [0] * total_dcp_gpus + for dest_rank in range(total_dcp_gpus): + if dest_rank in dp_ranks: + send_seq_lens = [ + global_id_seqlens[gid][1] + for gid in combined_sample_id_groups[dest_rank] + if gid in global_ids_this_rank + ] + send_num_split[dest_rank] = len(send_seq_lens) + send_lens_split[dest_rank] = sum(send_seq_lens) + else: + send_lens_split[dest_rank] = 0 + + # Create the recv plan + recv_sample_id_groups = [[] for _ in range(total_dcp_gpus)] + for gid in combined_sample_id_groups[dcp_rank]: + src_rank = _gid_to_src_rank(gid) + recv_sample_id_groups[src_rank].append(gid) + + recv_lens_split = [0] * total_dcp_gpus + for src_rank in range(total_dcp_gpus): + recv_lens_split[src_rank] = sum( + [global_id_seqlens[gid][1] for gid in recv_sample_id_groups[src_rank]] + ) + + recv_ids_sorted = [gid for d in range(total_dcp_gpus) for gid in recv_sample_id_groups[d]] + recv_counts = [len(recv_sample_id_groups[d]) for d in range(total_dcp_gpus)] + + recv_samples = [{k: None for k in data_keys} for _ in range(sum(recv_counts))] + + def _pack_sample_by_key(key: str) -> torch.Tensor: + flattened_tensors = [] + for gid in send_ids_sorted: + t = batch[gid2local_id[gid]][key].to(torch.cuda.current_device(), non_blocking=True) + flattened_tensors.append(t.reshape(-1)) + return ( + torch.cat(flattened_tensors, dim=0) + if flattened_tensors + else torch.empty(0, device=torch.cuda.current_device(), dtype=batch[0][key].dtype) + ) + + def _unpack_sample_by_key(key: str, recv_tensor: torch.Tensor): + cursor = 0 + for i, gid in enumerate(recv_ids_sorted): + sample_len = ( + 1 if key in ["original_seq_len", "padded_seq_len"] else global_id_seqlens[gid][1] + ) + recv_samples[i][key] = recv_tensor[cursor : cursor + sample_len] + cursor += sample_len + + for key in data_keys: + output_split_sizes, input_split_sizes = ( + (recv_counts, send_num_split) + if key in ["original_seq_len", "padded_seq_len"] + else (recv_lens_split, send_lens_split) + ) + send_tensor = _pack_sample_by_key(key) + recv_tensor_size = sum(output_split_sizes) + recv_tensor = torch.empty( + recv_tensor_size, device=torch.cuda.current_device(), dtype=send_tensor.dtype + ) + torch.distributed.all_to_all_single( + output=recv_tensor, + input=send_tensor, + output_split_sizes=output_split_sizes, + input_split_sizes=input_split_sizes, + group=dp_cp_group, + ) + _unpack_sample_by_key(key, recv_tensor) + + recv_sample_with_id = {recv_id: recv_samples[i] for i, recv_id in enumerate(recv_ids_sorted)} + return recv_sample_with_id + + +def build_packed_microbatches( + grouped_samples: List[List[Dict[str, torch.Tensor]]], dev: torch.device +) -> List[Dict[str, torch.Tensor]]: + """Build packed samples for each microbatch.""" + num_micro_batches = len(grouped_samples) + seg_starts: List[int] = [0] + original_lens_tensors = [] + padded_lens_tensors = [] + + for i in range(num_micro_batches): + samples = grouped_samples[i] + seg_starts.append(seg_starts[-1] + len(samples)) + original_lens_tensors.extend([s["original_seq_len"].reshape(-1) for s in samples]) + padded_lens_tensors.extend([s["padded_seq_len"].reshape(-1) for s in samples]) + + padded_lens_all_gpu = torch.cat(padded_lens_tensors, dim=0).to(dtype=torch.int32) + original_lens_all_gpu = torch.cat(original_lens_tensors, dim=0).to(dtype=torch.int32) + + new_samples: List[Dict[str, torch.Tensor]] = [] + for i in range(num_micro_batches): + samples = grouped_samples[i] + lens_padded = padded_lens_all_gpu[seg_starts[i] : seg_starts[i + 1]] + lens_original = original_lens_all_gpu[seg_starts[i] : seg_starts[i + 1]] + new_sample = _pack_sequences(samples, lens_padded, lens_original, dev) + new_samples.append(new_sample) + + return new_samples + + +def get_batch_and_global_seqlens(data_iterator, num_microbatches, dp_group): + """ + Get the batch and global sequence lengths. + Each DP rank loads the same number of sequences, so we need to gather the sequence + lengths from all ranks then we can schedule the sequences into groups. + Args: + data_iterator: The data iterator. + num_microbatches: The number of microbatches. + dp_group: The data parallel group. + + Returns: + batch: The batch. + global_id_seqlens: The global sequence lengths. + global_ids_this_rank: The global IDs locally present on this rank. + """ + + batch_list = [next(data_iterator) for _ in range(num_microbatches)] + + batch = [] + for item in batch_list: + if isinstance(item, dict): + batch.append(item) + elif isinstance(item, list): + batch.extend(item) + else: + raise ValueError(f"Invalid item type: {type(item)}") + + # Normalize the optional leading batch dimension before scheduling. + batch = _unpack_batch(batch) + + padded_subsample_seqlens = torch.cat([sample["padded_seq_len"] for sample in batch]).to( + dtype=torch.int32, device=torch.cuda.current_device() + ) + original_subsample_seqlens = torch.cat([sample["original_seq_len"] for sample in batch]).to( + dtype=torch.int32, device=torch.cuda.current_device() + ) + + ( + global_id_seqlens, + global_ids_this_rank, + offsets, + padded_seqlens_gathered, + original_seqlens_gathered, + ) = _get_global_seqlens_and_ids(padded_subsample_seqlens, original_subsample_seqlens, dp_group) + + return ( + batch, + global_id_seqlens, + global_ids_this_rank, + offsets, + padded_seqlens_gathered, + original_seqlens_gathered, + ) diff --git a/megatron/core/datasets/readme.md b/megatron/core/datasets/readme.md index 58721b7471b..40cdc111329 100644 --- a/megatron/core/datasets/readme.md +++ b/megatron/core/datasets/readme.md @@ -204,6 +204,26 @@ If the later training job does not specify `--global-batch-size` (which is neede `tools/prepare_cache.py` does not support `--mock-data`, `--sft`, `--fim-data`, or `--step-batch-size-schedule`. +## Packing Scheduler + +The packing scheduler reschedules variable-length sequences across DPxCP ranks to improve GPU utilization. It is built around the following modules: + +### `data_schedule` + +This module contains the high-level scheduling logic and entry points: + +- **`BasePackingScheduler`**: Abstract base class for packing schedulers. Defines the interface for `get_groups_and_subsamples()` (scheduling algorithm) and `run()` (full scheduling pipeline including fetch, schedule, reroute, pack, TP synchronization, and VPP handling). + +- **`DpBalancedScheduler`**: A concrete scheduler that packs sequences in their original order until reaching the max sequence length limit per DPxCP rank. Supports aligning the number of microbatches to DP size and VPP stage multiples. + +- **`wrap_data_iterator()`**: Top-level entry point that wraps an existing `data_iterator`. Every TP-rank-0 PP stage schedules its local iterator, while scalar schedule results are synchronized inside each TP group. It returns the packed iterator, updated number of microbatches, and FLOPs statistics. + +- **`get_batch_on_this_rank_for_sequence_packing()`**: Fetches a packed microbatch on TP rank 0, broadcasts it within the TP group, constructs `PackedSeqParams` (with `cu_seqlens`, `max_seqlen`, `qkv_format=thd`), and optionally partitions sequences across CP ranks using Transformer Engine's `thd_get_partitioned_indices`. + +### `data_schedule_utils.py` + +This module contains the utility functions used by the schedulers. + ## Fast DataLoader initialization Especially for large-scale runs, DataLoader initialization can take several minutes, since it involves opening and memory-mapping multiple files and can significantly stress the filesystem. To speed up this process, we have developed the following three optimizations, controlled by configuration flags: diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 3a8d7f23d09..6376c718efa 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -3396,3 +3396,16 @@ def set_save_original_input(module): from transformer_engine.pytorch.float8_tensor import Float8Tensor except ImportError: Float8Tensor = None + + +def get_thd_partitioned_indices( + cu_seqlens: torch.Tensor, total_tokens: int, cp_size: int, cp_rank: int +) -> torch.Tensor: + """Get partitioned indices for THD data in context parallelism.""" + assert is_te_min_version("1.10.0"), ( + "Please update Transformer Engine to >= 1.10 to use " + "Context Parallel with THD format data" + ) + import transformer_engine_torch as tex + + return tex.thd_get_partitioned_indices(cu_seqlens, total_tokens, cp_size, cp_rank) diff --git a/megatron/core/model_parallel_config.py b/megatron/core/model_parallel_config.py index 88bb070e105..f923c293237 100644 --- a/megatron/core/model_parallel_config.py +++ b/megatron/core/model_parallel_config.py @@ -59,7 +59,7 @@ class ModelParallelConfig: can handle without overflowing the memory. Typically, a good starting point is to set this to maximum sequence length / context parallel size. This is used to calculate the number and length of sub-samples assigned to - each rank when using hybrid_context_parallel. + each rank when hybrid_context_parallel or sequence_packing_scheduler is enabled. """ hybrid_context_parallel: bool = False @@ -69,6 +69,12 @@ class ModelParallelConfig: Please set max_seqlen_per_dp_cp_rank when using hybrid_context_parallel. """ + sequence_packing_scheduler: Optional[Literal['dp_balanced']] = None + """ + Scheduler for packing variable-length THD batches. + dp_balanced: DP-balanced scheduler for sequence packing. + """ + expert_model_parallel_size: int = 1 """Distributes Moe Experts across sub data parallel dimension.""" diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index 0c9ce022db7..b02ab4c6921 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -2829,6 +2829,32 @@ def _scope_to_str(s): self.attention_backend == AttnBackend.flash ), "Batch invariant mode only supports FlashAttention" + if self.sequence_packing_scheduler is not None: + if not HAVE_PACKAGING: + raise ImportError( + "packaging is not installed. Please install it with `pip install packaging`." + ) + if not ( + is_te_min_version("2.9.0") or get_te_version() == PkgVersion("2.9.0.dev0+5b3092a") + ): + raise ValueError( + "THD sequence packing requires Transformer Engine >= 2.9.0 " + f"but got {get_te_version()} (TE < 2.9.0 may have convergence issues)." + ) + + self.variable_seq_lengths = True + assert self.num_moe_experts is None or self.moe_token_dispatcher_type == "alltoall", ( + "sequence_packing only supports moe_token_dispatcher_type='alltoall', " + f"got '{self.moe_token_dispatcher_type}'" + ) + + supported_schedulers = ['dp_balanced'] + if self.sequence_packing_scheduler not in supported_schedulers: + raise ValueError( + f"Unsupported scheduler: {self.sequence_packing_scheduler}. " + f"Available schedulers: {supported_schedulers}" + ) + @dataclass class MLATransformerConfig(TransformerConfig): diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 7f84a30bae0..19c8d2290ca 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1490,6 +1490,25 @@ def validate_args(args, defaults={}): if args.ckpt_format == "fsdp_dtensor": assert args.use_megatron_fsdp, "--ckpt-format fsdp_dtensor is only tested with Megatron FSDP." + if args.sequence_packing_scheduler is not None: + assert not args.hybrid_context_parallel, ( + "--sequence-packing-scheduler and --hybrid-context-parallel are " + "separate scheduling paths and cannot be enabled together" + ) + assert args.calculate_per_token_loss, ( + "Sequence packing requires --calculate-per-token-loss so gradients " + "do not depend on packing boundaries" + ) + args.variable_seq_lengths = True + assert args.max_seqlen_per_dp_cp_rank is not None, ( + "--max-seqlen-per-dp-cp-rank must be set when using sequence packing" + ) + packed_capacity = args.context_parallel_size * args.max_seqlen_per_dp_cp_rank + assert packed_capacity >= args.seq_length, ( + f"Packed sequence capacity ({packed_capacity}) must be at least " + f"--seq-length ({args.seq_length})" + ) + # Data blend checks assert args.mock_data + \ bool(args.data_path) + \ @@ -2148,6 +2167,7 @@ def _add_network_size_args(parser): "bias_dropout_fusion", "apply_rope_fusion", "mamba_training_ssm_states_dtype", + "sequence_packing_scheduler", ] transformer_factory = ArgumentGroupFactory(TransformerConfig, exclude=exclude) transformer_group = transformer_factory.build_group(parser, "transformer configuration") @@ -2919,6 +2939,9 @@ def _add_distributed_args(parser): 'all layers will share the same communication type. Users can also ' 'specify separated types for each layer like ' '--cp-comm-type p2p p2p a2a a2a a2a+p2p a2a+p2p') + group.add_argument('--sequence-packing-scheduler', type=str, default=None, + choices=['dp_balanced'], + help='Pack variable-length sequences across DP x CP ranks.') group.add_argument('--fake-process-group', action='store_true', default=False, help='If set, initialize with fake distributed process group and all distributed communication operations will be skipped. \ This is quite useful for profiling memory usage of distributed training with just one GPU. \ diff --git a/megatron/training/datasets/data_samplers.py b/megatron/training/datasets/data_samplers.py index 296acc97941..80fc8327af9 100644 --- a/megatron/training/datasets/data_samplers.py +++ b/megatron/training/datasets/data_samplers.py @@ -11,7 +11,6 @@ from megatron.core import mpu from megatron.core.datasets.utils import Split - from megatron.training import get_args from megatron.training.dist_signal_handler import DistributedSignalHandler @@ -98,8 +97,11 @@ def close_nvidia_fds(): worker_init_fn if args.num_workers > 0 else None ) # Torch dataloader. - if args.hybrid_context_parallel: - extra_kwargs = {"collate_fn": lambda x: x,} + if ( + args.hybrid_context_parallel + or getattr(args, "sequence_packing_scheduler", None) is not None + ): + extra_kwargs = {"collate_fn": lambda x: x} else: extra_kwargs = {} return torch.utils.data.DataLoader( diff --git a/megatron/training/training.py b/megatron/training/training.py index 0e4c1ea6a23..f9098ed3eaa 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -42,7 +42,7 @@ # First-party. from megatron.core import mpu, nccl_allocator, tensor_parallel -from megatron.core.datasets.data_schedule import HybridCPDataLoaderWrapper +from megatron.core.datasets.data_schedule import HybridCPDataLoaderWrapper, wrap_data_iterator from megatron.core.distributed import DistributedDataParallel as DDP from megatron.core.distributed import ( DistributedDataParallelConfig, @@ -2297,6 +2297,7 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch """ args = get_args() timers = get_timers() + scheduled_num_microbatches = get_num_microbatches() rerun_state_machine = get_rerun_state_machine() save_params_in_this_iteration = (args.save_params_interval is not None and @@ -2309,7 +2310,8 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch (iteration + 1) % args.save_wgrads_interval == 0) save_dgrads_in_this_iteration = (args.save_dgrads_interval is not None and (iteration + 1) % args.save_dgrads_interval == 0) - while rerun_state_machine.should_run_forward_backward(data_iterator): + source_data_iterator = data_iterator + while rerun_state_machine.should_run_forward_backward(source_data_iterator): # Set grad to zero. for model_chunk in model: model_chunk.zero_grad_buffer() @@ -2351,6 +2353,26 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch if isinstance(optim_instance, DistributedOptimizer): optim_instance._copy_main_params_to_param_buffer() + if getattr(config, "sequence_packing_scheduler", None) is not None: + scheduler_pg_collection = get_attr_wrapped_model(model[0], "pg_collection") + assert isinstance(scheduler_pg_collection, ProcessGroupCollection), ( + "sequence packing requires the model to expose a ProcessGroupCollection" + ) + ( + scheduled_data_iterator, + scheduled_num_microbatches, + _total_real_tokens_in_batch, + _seqlen_squared_sum_in_batch, + ) = wrap_data_iterator( + source_data_iterator, + config, + get_num_microbatches(), + pg_collection=scheduler_pg_collection, + ) + else: + scheduled_data_iterator = source_data_iterator + scheduled_num_microbatches = get_num_microbatches() + # Forward pass. if save_activations_in_this_iteration: enable_activation_logging(model, args.save) @@ -2360,9 +2382,9 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch enable_dgrad_logging(model, args.save) losses_reduced = forward_backward_func( forward_step_func=forward_step_func, - data_iterator=data_iterator, + data_iterator=scheduled_data_iterator, model=model, - num_microbatches=get_num_microbatches(), + num_microbatches=scheduled_num_microbatches, seq_length=args.seq_length, micro_batch_size=args.micro_batch_size, decoder_seq_length=args.decoder_seq_length, @@ -2411,7 +2433,17 @@ def _save_state_dict(attr_name, label): should_checkpoint, should_exit, exit_code = rerun_state_machine.should_checkpoint_and_exit() if should_exit: - return {}, True, should_checkpoint, should_exit, exit_code, None, None, 0 + return ( + {}, + True, + should_checkpoint, + should_exit, + exit_code, + None, + None, + 0, + scheduled_num_microbatches, + ) # Empty unused memory. if args.empty_unused_memory_level >= 1: @@ -2505,8 +2537,19 @@ def _save_state_dict(attr_name, label): grad_norm, num_zeros_in_grad, log_max_attention_logit, + scheduled_num_microbatches, ) - return {}, skipped_iter, should_checkpoint, should_exit, exit_code, grad_norm, num_zeros_in_grad, log_max_attention_logit + return ( + {}, + skipped_iter, + should_checkpoint, + should_exit, + exit_code, + grad_norm, + num_zeros_in_grad, + log_max_attention_logit, + scheduled_num_microbatches, + ) def training_log( @@ -2525,6 +2568,7 @@ def training_log( is_first_iteration=False, seqlen_squared_sum_in_batch: float | None = None, total_real_tokens_in_batch: float | None = None, + num_microbatches: int | None = None, ): """Log training information such as losses, timing, ....""" args = get_args() @@ -2694,7 +2738,7 @@ def training_log( # Log MoE metrics. moe_log_string = "" if args.num_experts is not None: - moe_loss_scale = 1 / get_num_microbatches() + moe_loss_scale = 1 / (num_microbatches or get_num_microbatches()) track_names = [] if "aux_loss" in args.moe_router_load_balancing_type: track_names.append("load_balancing_loss") @@ -2733,7 +2777,7 @@ def training_log( # Log MTP metrics. if args.mtp_num_layers is not None: - mtp_loss_scale = 1 / get_num_microbatches() + mtp_loss_scale = 1 / (num_microbatches or get_num_microbatches()) MTPLossLoggingHelper.track_mtp_metrics( mtp_loss_scale, iteration, writer, wandb_writer, total_loss_dict ) @@ -3671,7 +3715,7 @@ def trace_handler(p): # Skip automatic checkpoint on microbatch changes when sequence packing is active # as it intentionally reconfigures microbatches if get_num_microbatches() != num_microbatches and iteration != 0: - if args.rl_use_sequence_packing: + if args.rl_use_sequence_packing or args.sequence_packing_scheduler is not None: print_rank_0( f"[Sequence Packing] Skipping automatic checkpoint at iteration {iteration} " f"(microbatch change: {num_microbatches} -> {get_num_microbatches()})" @@ -3709,6 +3753,9 @@ def trace_handler(p): # Completely skip iteration if needed. if (iteration + 1) in args.iterations_to_skip: + assert ( + getattr(config, "sequence_packing_scheduler", None) is None + ), "Sequence packing scheduler is not supported in skip iteration mode" # Dummy train_step to fast forward train_data_iterator. dummy_train_step(train_data_iterator) if iteration == start_iteration: @@ -3755,6 +3802,7 @@ def trace_handler(p): grad_norm = 0.0 num_zeros_in_grad = 0 max_attention_logit = None + num_microbatches = get_num_microbatches() else: ft_integration.on_training_step_start() ( @@ -3766,6 +3814,7 @@ def trace_handler(p): grad_norm, num_zeros_in_grad, max_attention_logit, + num_microbatches, ) = train_step( forward_step_func, train_data_iterator, model, optimizer, opt_param_scheduler, config, forward_backward_func, iteration=iteration, pg_collection=pg_collection, @@ -3911,6 +3960,7 @@ def trace_handler(p): is_first_iteration=is_first_iteration, seqlen_squared_sum_in_batch=seqlen_squared_sum_in_batch, total_real_tokens_in_batch=total_real_tokens_in_batch, + num_microbatches=num_microbatches, ) is_first_iteration = False @@ -4151,11 +4201,31 @@ def evaluate( # Don't care about timing during evaluation config.timers = None ft_integration.on_eval_step_start() + if getattr(config, "sequence_packing_scheduler", None) is not None: + assert isinstance( + eval_pgc, ProcessGroupCollection + ), "sequence packing requires the model to expose a ProcessGroupCollection" + try: + (packed_data_iterator, scheduled_eval_num_microbatches, _, _) = ( + wrap_data_iterator( + data_iterator, + config, + eval_num_microbatches, + pg_collection=eval_pgc, + ) + ) + except StopIteration: + ft_integration.on_eval_step_end() + config.timers = get_timers() + break + else: + packed_data_iterator = data_iterator + scheduled_eval_num_microbatches = eval_num_microbatches loss_dicts = forward_backward_func( forward_step_func=forward_step_func, - data_iterator=data_iterator, + data_iterator=packed_data_iterator, model=model, - num_microbatches=eval_num_microbatches, + num_microbatches=scheduled_eval_num_microbatches, seq_length=args.seq_length, micro_batch_size=eval_micro_batch_size, decoder_seq_length=args.decoder_seq_length, diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index f7dc78ce9a2..e2de25a0aa8 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -337,6 +337,7 @@ "use_transformer_engine_op_fuser": False, "moe_single_grouped_weight": False, "moe_single_grouped_bias": False, + "sequence_packing_scheduler": None, } # Fields to ignore entirely (ephemeral, environment-specific, very large). SKIP_FIELDS = set() diff --git a/tests/unit_tests/test_sequence_packing.py b/tests/unit_tests/test_sequence_packing.py new file mode 100644 index 00000000000..8ea436bb134 --- /dev/null +++ b/tests/unit_tests/test_sequence_packing.py @@ -0,0 +1,673 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +import random +import sys +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from megatron.core import parallel_state +from megatron.core.datasets.data_schedule import ( + DpBalancedScheduler, + _build_thd_padding_mask, + _sanitize_thd_padding_values, + get_batch_on_this_rank_for_sequence_packing, + wrap_data_iterator, +) +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.rerun_state_machine import RerunDataIterator +from megatron.training.global_vars import unset_global_variables +from tests.unit_tests.test_utilities import Utils + + +def _scheduler_pg_collection(): + """Build the process groups consumed by the packing scheduler.""" + return ProcessGroupCollection.use_mpu_process_groups( + required_pgs=['tp', 'pp', 'cp', 'dp', 'dp_cp'] + ) + + +def test_te_thd_partition_helper_delegates_to_transformer_engine(monkeypatch): + from megatron.core.extensions import transformer_engine as te_extension + + calls = [] + expected = torch.tensor([3, 1], dtype=torch.int64) + + def _partition(cu_seqlens, total_tokens, cp_size, cp_rank): + calls.append((cu_seqlens, total_tokens, cp_size, cp_rank)) + return expected + + monkeypatch.setattr(te_extension, "is_te_min_version", lambda _version: True) + monkeypatch.setitem( + sys.modules, + "transformer_engine_torch", + SimpleNamespace(thd_get_partitioned_indices=_partition), + ) + cu_seqlens = torch.tensor([0, 2, 6], dtype=torch.int32) + + actual = te_extension.get_thd_partitioned_indices(cu_seqlens, 6, 2, 1) + + assert actual is expected + assert calls == [(cu_seqlens, 6, 2, 1)] + + +def test_scheduler_thd_padding_mask_from_cu_seqlens(): + cu_seqlens = torch.tensor([0, 3, 5], dtype=torch.int32) + cu_seqlens_padded = torch.tensor([0, 4, 8], dtype=torch.int32) + + padding_mask = _build_thd_padding_mask(cu_seqlens, cu_seqlens_padded) + + assert torch.equal( + padding_mask, torch.tensor([False, False, False, True, False, False, True, True]) + ) + + +def test_scheduler_sanitizes_thd_padding_values(): + padding_mask = torch.tensor([False, False, True, False, True]) + batch = { + 'tokens': torch.tensor([11, 12, -1, 21, -1], dtype=torch.int64), + 'labels': torch.tensor([12, 13, -1, 22, -1], dtype=torch.int64), + 'loss_mask': torch.ones(5, dtype=torch.float32), + 'position_ids': torch.tensor([0, 1, 2, 0, 1], dtype=torch.int64), + } + + _sanitize_thd_padding_values(batch, padding_mask) + + assert torch.equal(batch['tokens'], torch.tensor([11, 12, 0, 21, 0])) + assert torch.equal(batch['labels'], torch.tensor([12, 13, 0, 22, 0])) + assert torch.equal(batch['loss_mask'], torch.tensor([1.0, 1.0, 0.0, 1.0, 0.0])) + assert torch.equal(batch['position_ids'], torch.tensor([0, 1, 0, 0, 0])) + + +def test_packed_batch_preserves_original_and_padded_cu_seqlens(): + Utils.initialize_model_parallel(1, 1) + + try: + device = torch.device("cuda", torch.cuda.current_device()) + tokens = torch.arange(8, dtype=torch.int64, device=device) + batch = { + 'tokens': tokens, + 'labels': tokens + 1, + 'loss_mask': torch.ones(8, dtype=torch.float32, device=device), + 'position_ids': torch.arange(8, dtype=torch.int64, device=device), + 'cu_seqlens': torch.tensor([0, 3, 5], dtype=torch.int32, device=device), + 'cu_seqlens_padded': torch.tensor([0, 4, 8], dtype=torch.int32, device=device), + 'max_seqlen': torch.tensor([4], dtype=torch.int32, device=device), + } + + *_, packed_seq_params, padding_mask = get_batch_on_this_rank_for_sequence_packing( + iter([batch]), pg_collection=_scheduler_pg_collection() + ) + + torch.testing.assert_close(packed_seq_params.cu_seqlens_q, batch['cu_seqlens']) + torch.testing.assert_close( + packed_seq_params.cu_seqlens_q_padded, batch['cu_seqlens_padded'] + ) + assert torch.equal( + padding_mask, + torch.tensor([[False, False, False, True, False, False, True, True]], device=device), + ) + finally: + Utils.destroy_model_parallel() + + +def test_dp_balanced_scheduler_can_split_group_zero(): + scheduler = DpBalancedScheduler( + max_seqlen_per_dp_cp_rank=8, cp_size=1, dp_size=2, microbatch_group_size_per_vp_stage=None + ) + + assert scheduler.get_groups_and_subsamples([(0, 2), (1, 2)]) == [[[0], [1]]] + + +class MockVariableLengthSequencePackingDataIterator: + """ + Mock data iterator for testing get_batch_on_this_rank_for_sequence_packing. + + Generates variable-length (THD format) packed sequences with deterministic + data for verification across parallel ranks. + """ + + def __init__( + self, + total_seq_length: int, + sequence_lengths: list, + local_cp_size: int = None, + device: str = "cuda", + seed: int = 42, + ): + """ + Args: + total_seq_length: Total length of packed sequences + sequence_lengths: List of individual sequence lengths (variable-length). + If None, generates random variable lengths. + device: Device to create tensors on + seed: Random seed for reproducibility + """ + self.total_seq_length = total_seq_length + self.sequence_lengths = sequence_lengths + self.local_cp_size = local_cp_size + self.device = device + self.seed = seed + assert ( + sum(self.sequence_lengths) == total_seq_length + ), f"Sequence lengths sum {sum(self.sequence_lengths)} != total {total_seq_length}" + + def __iter__(self): + """Interface for the data iterator.""" + return self + + def __next__(self): + """Generate a mock batch with variable-length THD format.""" + dev = self.device + torch.manual_seed(self.seed) + torch.cuda.manual_seed(self.seed) + + tokens = torch.randint(0, 16384, (self.total_seq_length,), dtype=torch.int64, device=dev) + + # Create position_ids that reset for each sequence (THD format) + position_ids = [] + for seq_len in self.sequence_lengths: + position_ids.extend(range(seq_len)) + position_ids = torch.tensor(position_ids, dtype=torch.int64, device=dev) + + # Labels are tokens shifted by 1 for easy verification + labels = tokens + 1 + + # Loss mask: 1.0 for all positions except padding (none here) + loss_mask = torch.ones(self.total_seq_length, dtype=torch.float32, device=dev) + + # Create cu_seqlens for variable-length packed sequences + cu_seqlens = [0] + for seq_len in self.sequence_lengths: + cu_seqlens.append(cu_seqlens[-1] + seq_len) + cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32, device=dev) + cu_seqlens_padded = cu_seqlens.clone() + + max_seqlen = torch.tensor([max(self.sequence_lengths)], dtype=torch.int32, device=dev) + + batch = { + "tokens": tokens, + "position_ids": position_ids, + "labels": labels, + "loss_mask": loss_mask, + "cu_seqlens": cu_seqlens, + "cu_seqlens_padded": cu_seqlens_padded, + "max_seqlen": max_seqlen, + } + + if not ( + parallel_state.is_pipeline_first_stage(ignore_virtual=True) + or parallel_state.is_pipeline_last_stage(ignore_virtual=True) + ): + batch["tokens"] = None + batch["position_ids"] = None + batch["labels"] = None + batch["loss_mask"] = None + + if self.local_cp_size is not None: + batch["local_cp_size"] = torch.tensor( + [self.local_cp_size], dtype=torch.int32, device=dev + ) + + return batch + + +def _gather_tensor_from_tp_group(tensor): + """Gather tensors from all TP ranks for comparison.""" + assert tensor is not None, "Tensor should not be None" + tp_size = parallel_state.get_tensor_model_parallel_world_size() + gathered = [torch.zeros_like(tensor) for _ in range(tp_size)] + torch.distributed.all_gather( + gathered, tensor, group=parallel_state.get_tensor_model_parallel_group() + ) + return gathered + + +def _gather_tensor_from_all_ranks(tensor): + """Gather tensors from all PP ranks for comparison.""" + assert tensor is not None, "Tensor should not be None" + if type(tensor) is int: + tensor = torch.tensor(tensor, dtype=torch.int32, device=torch.cuda.current_device()) + gathered = [torch.zeros_like(tensor) for _ in range(torch.distributed.get_world_size())] + torch.distributed.all_gather(gathered, tensor) + return gathered + + +@pytest.mark.parametrize( + ("tp", "pp", "cp"), + [ + (1, 1, 1), # Basic case: no parallelism + (2, 1, 1), # Tensor parallel only + (1, 2, 1), # Pipeline parallel only + (2, 2, 1), # TP + PP + (1, 1, 2), # CP only + (2, 1, 2), # TP + CP + (1, 2, 2), # PP + CP + (1, 4, 1), # Has middle pp stage + ], +) +def test_get_batch_on_this_rank_for_sequence_packing(tp, pp, cp): + """ + Test get_batch_on_this_rank_for_sequence_packing function with variable-length THD format. + + This test verifies: + 1. TP ranks: All ranks within a TP group receive identical data after broadcast + 2. PP ranks: Middle PP ranks have the same packed_seq_params as first/last stages + 3. CP ranks: Data is correctly partitioned with proper shape and values + 4. Variable-length (THD) format: Different sequence lengths are handled correctly + """ + args = SimpleNamespace() + args.tensor_model_parallel_size = tp + args.pipeline_model_parallel_size = pp + args.context_parallel_size = cp + args.virtual_pipeline_model_parallel_size = None + args.data_parallel_size = 8 // (tp * pp * cp) + args.seq_length = 8192 + + # Skip invalid configurations + if args.data_parallel_size < 1: + raise ValueError(f"Invalid config: tp={tp}, pp={pp}, cp={cp} exceeds world size 8") + + # Initialize model parallel + Utils.initialize_model_parallel(tp, pp, None, context_parallel_size=cp) + + try: + # Create mock data iterator with variable-length sequences + # Only TP rank 0 needs the iterator; other TP ranks pass None + tp_rank = parallel_state.get_tensor_model_parallel_rank() + if tp_rank == 0: + # Use deterministic seed based on DP rank so same data within TP/PP/CP group + dp_rank = parallel_state.get_data_parallel_rank() + sequence_lengths = [1024, 2048, 512, 1536, 3072] + assert ( + sum(sequence_lengths) == args.seq_length + ), f"Sequence lengths sum {sum(sequence_lengths)} != total {args.seq_length}" + data_iterator = iter( + MockVariableLengthSequencePackingDataIterator( + total_seq_length=args.seq_length, + sequence_lengths=sequence_lengths, # Variable lengths, sum=8192 + seed=42 + dp_rank, # Same seed within PP/CP group + ) + ) + else: + # Non-TP-rank-0 ranks don't need the iterator + data_iterator = None + + # Call the function under test + result = get_batch_on_this_rank_for_sequence_packing( + data_iterator=data_iterator, + pg_collection=_scheduler_pg_collection(), + mtp_on_this_rank=False, + vp_stage=None, + ) + + # Unpack the result. Scheduler THD always returns padding_mask. + tokens, labels, loss_mask, attention_mask, position_ids, packed_seq_params, padding_mask = ( + result + ) + + # Get parallel state info + tp_rank = parallel_state.get_tensor_model_parallel_rank() + pp_rank = parallel_state.get_pipeline_model_parallel_rank() + cp_rank = parallel_state.get_context_parallel_rank() + is_first_stage = parallel_state.is_pipeline_first_stage(ignore_virtual=True) + is_last_stage = parallel_state.is_pipeline_last_stage(ignore_virtual=True) + is_first_or_last = is_first_stage or is_last_stage + + assert padding_mask is not None + assert padding_mask.dtype == torch.bool + assert padding_mask.dim() == 2 + assert padding_mask.size(0) == 1 + assert not padding_mask.any(), "Mock data has no per-sequence padding." + + # ===================================================================== + # TEST 1: Verify data based on pipeline stage + # ===================================================================== + if is_first_stage: + assert tokens is not None, "First stage should have tokens" + assert position_ids is not None, "First stage should have position_ids" + assert tokens.dim() == 2, "Tokens should be 2D (batch, seq)" + assert position_ids.dim() == 2, "Position IDs should be 2D (batch, seq)" + assert tokens.size(0) == 1, "batch should be 1 in THD format" + assert position_ids.size(0) == 1, "batch should be 1 in THD format" + else: + assert tokens is None, "Non-first stage should not have tokens" + assert position_ids is None, "Non-first stage should not have position_ids" + + if is_last_stage: + assert labels is not None, "Last stage should have labels" + assert loss_mask is not None, "Last stage should have loss_mask" + assert labels.dim() == 2, "Labels should be 2D (batch, seq)" + assert loss_mask.dim() == 2, "Loss mask should be 2D (batch, seq)" + assert labels.size(0) == 1, "batch should be 1 in THD format" + assert loss_mask.size(0) == 1, "batch should be 1 in THD format" + else: + assert labels is None, "Non-last stage should not have labels" + assert loss_mask is None, "Non-last stage should not have loss_mask" + + # ===================================================================== + # TEST 2: Verify all ranks have consistent packed_seq_params + # ===================================================================== + assert packed_seq_params is not None + assert packed_seq_params.qkv_format == "thd" + + test_keys = [ + "cu_seqlens_q", + "cu_seqlens_q_padded", + "max_seqlen_q", + "cu_seqlens_kv", + "cu_seqlens_kv_padded", + "max_seqlen_kv", + ] + for key in test_keys: + tensor = getattr(packed_seq_params, key) + assert tensor is not None + gathered_tensor = _gather_tensor_from_all_ranks(tensor) + for i in range(1, len(gathered_tensor)): + assert torch.equal( + gathered_tensor[0], gathered_tensor[i] + ), f"Rank 0 and rank {i} have different {key}" + + # ===================================================================== + # TEST 3: Verify TP ranks receive identical data after broadcast + # ===================================================================== + if tp > 1: + test_tensors = [padding_mask] + if is_first_stage: + test_tensors.extend([tokens, position_ids]) + if is_last_stage: + test_tensors.extend([labels, loss_mask]) + + for tensor in test_tensors: + gathered_tensors = _gather_tensor_from_tp_group(tensor) + for i in range(1, tp): + assert torch.equal( + gathered_tensors[0], gathered_tensors[i] + ), f"TP rank 0 and rank {i} have different data" + + # ===================================================================== + # TEST 4: Verify CP partitioning + # ===================================================================== + if cp > 1: + # With CP, the sequence should be partitioned + expected_seq_len = args.seq_length // cp + + if is_first_stage: + actual_seq_len = tokens.shape[1] + assert ( + actual_seq_len == expected_seq_len + ), f"CP partitioned tokens have wrong shape: {actual_seq_len} != {expected_seq_len}" + + # Verify labels only if all CP ranks are at last stage + if is_last_stage: + actual_seq_len = labels.shape[1] + assert ( + actual_seq_len == expected_seq_len + ), f"CP partitioned labels have wrong shape: {actual_seq_len} != {expected_seq_len}" + + actual_seq_len = padding_mask.shape[1] + assert ( + actual_seq_len == expected_seq_len + ), f"CP partitioned padding_mask has wrong shape: {actual_seq_len} != {expected_seq_len}" + + finally: + Utils.destroy_model_parallel() + unset_global_variables() + + +@pytest.mark.parametrize( + ("tp", "pp", "cp", "vpp", "scheduler_type", "mtp_vpp"), + [ + (1, 1, 8, None, "dp_balanced", False), + (2, 1, 4, None, "dp_balanced", False), + (2, 4, 1, None, "dp_balanced", False), + (2, 2, 1, None, "dp_balanced", False), + (1, 4, 1, 4, "dp_balanced", False), + (1, 4, 1, 4, "dp_balanced", True), + ], +) +def test_wrap_dataloader(tp, pp, cp, vpp, scheduler_type, mtp_vpp, monkeypatch): + ''' + Test wrap_dataloader function with different scheduler types. + ''' + args = SimpleNamespace() + args.tensor_model_parallel_size = tp + args.pipeline_model_parallel_size = pp + args.context_parallel_size = cp + args.virtual_pipeline_model_parallel_size = None + args.data_parallel_size = 8 // (tp * pp * cp) + args.seq_length = 8192 + args.max_seqlen_per_dp_cp_rank = 8192 + + # Skip invalid configurations + if args.data_parallel_size < 1: + raise ValueError(f"Invalid config: tp={tp}, pp={pp}, cp={cp} exceeds world size 8") + + def _create_single_sample(seq_len): + # hard code the padding size to 16 + pad_size = 16 + seq_len_padded = ((seq_len + pad_size - 1) // pad_size) * pad_size + device = torch.device("cuda", torch.cuda.current_device()) + tokens = torch.randint(0, 128, (seq_len_padded,), dtype=torch.int64, device=device) + labels = tokens + 1 + position_ids = torch.arange(seq_len_padded, dtype=torch.int64, device=device) + loss_mask = torch.ones(seq_len_padded, dtype=torch.float32, device=device) + loss_mask[0:seq_len] = 1 + loss_mask[seq_len:] = 0 + cu_seqlens = torch.tensor([0, seq_len_padded], dtype=torch.int32, device=device) + + return { + 'tokens': tokens, + 'labels': labels, + 'loss_mask': loss_mask, + 'position_ids': position_ids, + 'cu_seqlens': cu_seqlens, + 'original_seq_len': torch.tensor([seq_len], dtype=torch.int32, device=device), + 'padded_seq_len': torch.tensor([seq_len_padded], dtype=torch.int32, device=device), + } + + # Initialize model parallel + Utils.initialize_model_parallel(tp, pp, vpp, context_parallel_size=cp) + + global_batch_size = 64 + micro_batch_size = 1 + nums = [random.randint(2048, args.seq_length) for _ in range(global_batch_size)] # 64 sequences + + config = SimpleNamespace() + config.max_seqlen_per_dp_cp_rank = args.max_seqlen_per_dp_cp_rank + config.microbatch_group_size_per_vp_stage = pp + config.virtual_pipeline_model_parallel_size = vpp + config.sequence_packing_scheduler = scheduler_type + config.pipeline_model_parallel_layout = object() if mtp_vpp else None + config.mtp_num_layers = 1 if mtp_vpp else None + + dp_rank = parallel_state.get_data_parallel_rank() + dp_size = parallel_state.get_data_parallel_world_size() + + pp_rank = parallel_state.get_pipeline_model_parallel_rank() + tp_rank = parallel_state.get_tensor_model_parallel_rank() + + is_pp_first = pp_rank == 0 + is_pp_last = pp_rank == pp - 1 + is_tp_first = tp_rank == 0 + + if mtp_vpp: + mtp_pp_rank = 1 + mtp_vp_stage = 2 + + def _mock_mtp_on_this_rank(*, ignore_virtual, vp_stage=None, **_kwargs): + return pp_rank == mtp_pp_rank and (ignore_virtual or vp_stage == mtp_vp_stage) + + monkeypatch.setattr( + "megatron.core.datasets.data_schedule.mtp_is_on_rank", _mock_mtp_on_this_rank + ) + + num_micro_batches_old = global_batch_size // micro_batch_size // dp_size + + if is_tp_first: + samples = [ + _create_single_sample(num) + for num in nums[dp_rank * num_micro_batches_old : (dp_rank + 1) * num_micro_batches_old] + ] + data_iterator = RerunDataIterator(iter(samples)) + else: + data_iterator = None + + if is_tp_first: + if vpp is not None and vpp > 1: + data_iterator = [data_iterator] + [None for _ in range(vpp - 1)] + try: + # Call the function under test + ( + new_data_iterator, + num_micro_batches, + num_total_tokens_this_global_batch, + sequence_square_sum_this_global_batch, + ) = wrap_data_iterator( + data_iterator, config, num_micro_batches_old, _scheduler_pg_collection() + ) + + # check the result + assert type(num_micro_batches) is int + assert ( + type(num_total_tokens_this_global_batch) is float + or type(num_total_tokens_this_global_batch) is np.float32 + ) + assert ( + type(sequence_square_sum_this_global_batch) is float + or type(sequence_square_sum_this_global_batch) is np.float32 + ) + + def _check_batch(batch_all, batch_keys): + for batch in batch_all: + assert set(batch) == set( + batch_keys + ), f"batch keys: {set(batch)} != expected keys: {set(batch_keys)}" + for key in batch_keys: + assert batch[key] is not None + + if is_tp_first: + metadata_keys = ["cu_seqlens", "max_seqlen", "cu_seqlens_padded"] + + def _expected_keys(vp_stage=None): + keys = list(metadata_keys) + is_mtp_stage = mtp_vpp and pp_rank == mtp_pp_rank and vp_stage == mtp_vp_stage + if (is_pp_first and (vp_stage is None or vp_stage == 0)) or is_mtp_stage: + keys.extend(["tokens", "position_ids"]) + if (is_pp_last and (vp_stage is None or vp_stage == vpp - 1)) or is_mtp_stage: + keys.extend(["labels", "loss_mask"]) + return keys + + token_batches = None + if vpp is not None and vpp > 1: + # check metadata for all stages (save batches to avoid re-consuming iterators) + all_stage_batches = [] + for vp_stage, temp_data_iterator in enumerate(new_data_iterator): + stage_batch = [next(temp_data_iterator) for _ in range(num_micro_batches)] + all_stage_batches.append(stage_batch) + _check_batch(stage_batch, _expected_keys(vp_stage)) + if is_pp_first: + token_batches = all_stage_batches[0] + else: + # non-VPP: single iterator + batch_all = [next(new_data_iterator) for _ in range(num_micro_batches)] + _check_batch(batch_all, _expected_keys()) + if is_pp_first: + token_batches = batch_all + + # CHECK TOKEN SUM ON FIRST PP RANK + # Note: data_iterator is consumed by wrap_data_iterator, new_data_iterator is consumed above. + # Use `samples` for before-wrap and the first data-carrying iterator after wrapping. + if is_pp_first: + # Compute token sum before wrap + token_sum_before = torch.tensor(0, dtype=torch.int64, device='cuda') + for sample in samples: + token_sum_before += sample['tokens'].long().sum() + + # Compute token sum after wrap. + token_sum_after = torch.tensor(0, dtype=torch.int64, device='cuda') + for batch in token_batches: + token_sum_after += batch['tokens'].long().sum() + + # Reduce sum across dp_cp group and verify equality + dp_cp_group = parallel_state.get_data_parallel_group(with_context_parallel=False) + torch.distributed.all_reduce( + token_sum_before, op=torch.distributed.ReduceOp.SUM, group=dp_cp_group + ) + torch.distributed.all_reduce( + token_sum_after, op=torch.distributed.ReduceOp.SUM, group=dp_cp_group + ) + + assert ( + token_sum_before == token_sum_after + ), f"Token sum mismatch: before={token_sum_before.item()}, after={token_sum_after.item()}" + + else: + if vpp is not None and vpp > 1: + assert type(new_data_iterator) is list and len(new_data_iterator) == vpp + for data_iterator in new_data_iterator: + assert data_iterator is None + else: + assert new_data_iterator is None + + finally: + Utils.destroy_model_parallel() + unset_global_variables() + + +def test_wrapped_batch_with_pipeline_and_context_parallel(): + """Exercise scheduler field filtering followed by PP-aware CP slicing.""" + Utils.initialize_model_parallel(1, 2, None, context_parallel_size=2) + + try: + device = torch.device("cuda", torch.cuda.current_device()) + dp_rank = parallel_state.get_data_parallel_rank() + tokens = torch.arange(16, dtype=torch.int64, device=device) + dp_rank * 100 + sample = { + 'tokens': tokens, + 'labels': tokens + 1, + 'loss_mask': torch.ones(16, dtype=torch.float32, device=device), + 'position_ids': torch.arange(16, dtype=torch.int64, device=device), + 'original_seq_len': torch.tensor([12], dtype=torch.int32, device=device), + 'padded_seq_len': torch.tensor([16], dtype=torch.int32, device=device), + } + config = SimpleNamespace( + max_seqlen_per_dp_cp_rank=8, + microbatch_group_size_per_vp_stage=None, + virtual_pipeline_model_parallel_size=None, + sequence_packing_scheduler="dp_balanced", + pipeline_model_parallel_layout=None, + mtp_num_layers=None, + ) + + packed_iterator, num_microbatches, token_sum, squared_sum = wrap_data_iterator( + RerunDataIterator(iter([sample])), config, 1, _scheduler_pg_collection() + ) + assert num_microbatches == 1 + assert token_sum == 24.0 + assert squared_sum == 288.0 + + tokens, labels, loss_mask, _, position_ids, packed_seq_params, padding_mask = ( + get_batch_on_this_rank_for_sequence_packing( + packed_iterator, pg_collection=_scheduler_pg_collection() + ) + ) + is_first = parallel_state.is_pipeline_first_stage() + assert (tokens is not None) == is_first + assert (position_ids is not None) == is_first + assert (labels is None) == is_first + assert (loss_mask is None) == is_first + assert packed_seq_params.qkv_format == "thd" + torch.testing.assert_close( + packed_seq_params.cu_seqlens_q, torch.tensor([0, 12], dtype=torch.int32, device=device) + ) + torch.testing.assert_close( + packed_seq_params.cu_seqlens_q_padded, + torch.tensor([0, 16], dtype=torch.int32, device=device), + ) + assert padding_mask.shape == (1, 8) + finally: + Utils.destroy_model_parallel() + unset_global_variables() diff --git a/tests/unit_tests/training/test_train_step_schedule_plumbing.py b/tests/unit_tests/training/test_train_step_schedule_plumbing.py index 6dcb407f920..ab67b312556 100644 --- a/tests/unit_tests/training/test_train_step_schedule_plumbing.py +++ b/tests/unit_tests/training/test_train_step_schedule_plumbing.py @@ -67,3 +67,52 @@ def test_train_step_forwards_schedule_plumbing(): def test_train_step_defaults_to_none(): captured = _run() assert captured["p2p_communicator"] is None and captured["pg_collection"] is None + + +def test_training_log_uses_scheduled_microbatch_count_for_mtp(): + args = SimpleNamespace( + timing_log_level=0, + perform_rl_step=False, + micro_batch_size=1, + data_parallel_size=1, + world_size=1, + seq_length=8, + freeze_all_layers=False, + num_experts=None, + mtp_num_layers=1, + dsa_indexer_loss_coeff=None, + log_interval=100, + ) + + with ( + mock.patch.object(training_mod, "get_args", return_value=args), + mock.patch.object(training_mod, "get_timers", return_value=mock.MagicMock()), + mock.patch.object(training_mod, "get_tensorboard_writer", return_value=None), + mock.patch.object(training_mod, "get_wandb_writer", return_value=None), + mock.patch.object(training_mod, "get_one_logger", return_value=None), + mock.patch.object(training_mod, "get_energy_monitor", return_value=None), + mock.patch.object(training_mod, "get_num_microbatches", return_value=8), + mock.patch.object( + training_mod, "reduce_max_stat_across_model_parallel_group", return_value=None + ), + mock.patch.object(training_mod.one_logger_utils, "track_app_tag"), + mock.patch.object( + training_mod.MTPLossLoggingHelper, "track_mtp_metrics" + ) as track_mtp_metrics, + ): + training_mod.training_log( + loss_dict={}, + total_loss_dict={}, + learning_rate=None, + iteration=1, + loss_scale=1.0, + report_memory_flag=False, + skipped_iter=0, + grad_norm=None, + params_norm=None, + num_zeros_in_grad=None, + max_attention_logit=None, + num_microbatches=3, + ) + + assert track_mtp_metrics.call_args.args[0] == 1 / 3