diff --git a/megatron/core/datasets/data_schedule.py b/megatron/core/datasets/data_schedule.py index fa0026f3937..818555c0edd 100644 --- a/megatron/core/datasets/data_schedule.py +++ b/megatron/core/datasets/data_schedule.py @@ -1,6 +1,7 @@ # Copyright (c) 2025 NVIDIA CORPORATION. All rights reserved. -from typing import Any, Dict, List, Optional +import enum +from typing import Any, Dict, List, Optional, Type import torch @@ -9,6 +10,11 @@ _get_global_seqlens_and_ids, broadcast_scalars, broadcast_tensor, + broadcast_to_pp_group, + build_packed_microbatches, + create_data_iterator, + get_batch_and_global_seqlens, + reroute_samples_to_dcp_ranks, ) from megatron.core.packed_seq_params import PackedSeqParams from megatron.core.pipeline_parallel.hybrid_cp_schedule import BalancedCPScheduler @@ -346,6 +352,393 @@ def __next__(self) -> Any: 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. + + Steps: + 1. Fetch batches and gather global sequence lengths + 2. Check required sample keys + 3. Schedule samples into groups + 4. Reroute samples to DCP ranks + 5. Build packed microbatches + 6. Calculate FLOPs info + 7. Broadcast to PP group (for middle PP stages) + 8. Broadcast to TP group (for non-TP-0 ranks) + 9. Handle VPP if enabled + + 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() + + # Handle VPP: extract the correct data_iterator for this PP stage + if ( + config.virtual_pipeline_model_parallel_size is not None + and config.virtual_pipeline_model_parallel_size > 1 + ): + # if enable VPP, data_iterator is a list of data_iterators for each VPP stage, + # and only the first and last stage rank will have data_iterator, + # other stages will have None. + assert len(data_iterator) == config.virtual_pipeline_model_parallel_size + if pp_group.rank() == 0: + # the first stage + data_iterator = data_iterator[0] + elif pp_group.rank() == pp_group.size() - 1: + # the last stage + data_iterator = data_iterator[-1] + else: + data_iterator = None + + # data_iterator is not None when TP rank 0, with PP stage 0 or -1. + if data_iterator is not None: + assert tp_group.rank() == 0 and ( + pp_group.rank() == 0 or pp_group.rank() == pp_group.size() - 1 + ), f"Only TP rank 0 and PP stage 0 or -1 should have data_iterator" + + # Step 1: Fetch batches and gather global sequence lengths + batch, global_id_seqlens, global_ids_this_rank, offsets, 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()}" + + # 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, + tp_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(seqlens_gathered)) + seqlen_squared_sum_this_global_batch = float( + sum(seqlen**2 for seqlen in seqlens_gathered) + ) + else: + ( + new_samples, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) = (None, None, None, None) + + # Step 7: Broadcast to PP group (for middle PP stages) + if tp_group.rank() == 0: + ( + new_samples, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) = broadcast_to_pp_group( + new_samples, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + pp_group, + dev, + ) + + # Step 8: Broadcast to TP group (for non-TP-0 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) + + # Step 9: create data_iterator and handle VPP if enabled + new_data_iterator = create_data_iterator(new_samples, pp_group, tp_group, config) + + return ( + new_data_iterator, + num_micro_batches, + seqlen_sum_this_global_batch, + seqlen_squared_sum_this_global_batch, + ) + + +class PackingSchedulerEnum(enum.Enum): + """Enum for supported sequence packing algorithms.""" + + DP_BALANCED = "dp_balanced" + + +scheduler_map: Dict[PackingSchedulerEnum, Type[BasePackingScheduler]] = { + PackingSchedulerEnum.DP_BALANCED: DpBalancedScheduler +} + + +def wrap_data_iterator( + data_iterator, config, num_microbatches, pg_collection: Optional[ProcessGroupCollection] = None +): + """ + 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 + dp_cp_group: Data parallel context parallel group. + pg_collection: The process group collection. + """ + + if pg_collection is None: + dp_cp_group = parallel_state.get_data_parallel_group(with_context_parallel=True) + dp_group = parallel_state.get_data_parallel_group() + tp_group = parallel_state.get_tensor_model_parallel_group() + pp_group = parallel_state.get_pipeline_model_parallel_group() + else: + 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 + + # Convert string to enum + scheduler_type = config.sequence_packing_scheduler + scheduler_type = PackingSchedulerEnum[scheduler_type.upper()] + + 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, vpp_size: Optional[int] = None, diff --git a/megatron/core/datasets/readme.md b/megatron/core/datasets/readme.md index 58721b7471b..40019642469 100644 --- a/megatron/core/datasets/readme.md +++ b/megatron/core/datasets/readme.md @@ -204,6 +204,28 @@ 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 re-schedules variable-length sequences across DP×CP 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: + +- **`HybridCPDataLoaderWrapper`**: A wrapper class for hybrid context parallel (CP) scheduling. For every `__next__` call, it: (1) pulls a batch of packed samples from each DP rank, (2) gathers sequence lengths across the DP group, (3) schedules sub-samples using the `BalancedCPScheduler`, (4) reroutes sub-samples to the correct DPxCP ranks via all-to-all communication. + +- **`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, broadcast, 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`. It creates the appropriate scheduler, runs the scheduling pipeline, broadcast metadata and new num_microbatches, returns a new data iterator along with the updated number of microbatches and FLOPs statistics. + +- **`get_batch_on_this_rank_for_sequence_packing()`**: Fetches and broadcasts a single packed microbatch for the current rank. Handles TP/PP broadcasting, 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/model_parallel_config.py b/megatron/core/model_parallel_config.py index 157ae1437f5..87c7f768182 100644 --- a/megatron/core/model_parallel_config.py +++ b/megatron/core/model_parallel_config.py @@ -114,7 +114,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 sequence_packing_scheduler is not None. """ hybrid_context_parallel: bool = False @@ -124,6 +124,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 sequence packing and hybrid context parallel. + 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 e783a017056..9d1fa920289 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -3200,6 +3200,40 @@ def _scope_to_str(s): "Disable MoE capacity/expert padding." ) + if self.sequence_packing_scheduler is not None: + # Check TE version. + if not HAVE_PACKAGING: + raise ImportError( + "packaging is not installed. Please install it with `pip install packaging`." + ) + # TODO: remove this after we fix the convergence issue with TE < 2.9. + if not ( + is_te_min_version("2.9.0") or get_te_version() == PkgVersion("2.9.0.dev0+5b3092a") + ): + raise ValueError( + "SFT sequence packing requires Transformer Engine >= 2.9.0 " + f"but got {get_te_version()} (TE < 2.9.0 may have convergence issues)." + ) + + # Needed for passing variable sequences between pp stages. + self.variable_seq_lengths = True + + # TODO(tailaim): add support for other dispatcher types + assert self.moe_token_dispatcher_type == "alltoall", ( + f"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 is not None + and 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 2f619468b86..330bf021871 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1246,13 +1246,6 @@ def validate_args(args, defaults={}): if args.rl_use_sequence_packing: args.consumed_train_bins = 0 - # Support for variable sequence lengths across batches/microbatches. - # set it if the dataloader supports generation of variable sequence lengths - # across batches/microbatches. Due to additional communication overhead - # during pipeline parallelism, it should not be set if sequence length - # is constant during training. - args.variable_seq_lengths = False - # Iteration-based training. # Skip these checks when skip_train is set: LR config is irrelevant. if args.train_iters and not args.skip_train: @@ -1430,6 +1423,23 @@ def validate_args(args, defaults={}): assert args.dataloader_type == 'single', 'Hybrid context parallelism only supported with single dataloader type' assert args.calculate_per_token_loss, 'Hybrid context parallelism must be used with --calculate-per-token-loss' + # Support for variable sequence lengths across batches/microbatches. + # set it if the dataloader supports generation of variable sequence lengths + # across batches/microbatches. Due to additional communication overhead + # during pipeline parallelism, it should not be set if sequence length + # is constant during training. + args.variable_seq_lengths = False + if args.mock_data and args.sft and args.sft_mock_dataset_config_json is None: + args.sft_mock_dataset_config_json = json.dumps( + { + "mode": "distribution", + "type": "lognormal", + "min_seq_len": args.seq_length // 2, + "max_seq_len": args.seq_length, + "mean_seq_len": args.seq_length // 4 * 3, + "lognormal_sigma": 1.1, + } + ) # disable async_tensor_model_parallel_allreduce when # model parallel memory optimization is enabled if (args.tensor_model_parallel_size > 1 or args.context_parallel_size > 1) \ @@ -1642,6 +1652,19 @@ 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." + # Packed-sequence buffer-size check. Placed after varlen scheduler + # auto-select so it validates the final resolved scheduler. + if args.sequence_packing_scheduler is not None: + 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" + ) + total_cp_ranks = args.context_parallel_size + assert total_cp_ranks * args.max_seqlen_per_dp_cp_rank >= args.seq_length, ( + f'Packed sequence buffer size ({total_cp_ranks * args.max_seqlen_per_dp_cp_rank}) ' + f'must be >= single sequence max length ({args.seq_length})' + ) + # Data blend checks assert args.mock_data + \ bool(args.data_path) + \ @@ -2336,6 +2359,9 @@ def _add_network_size_args(parser): "gtp_weight_remat_size", # internal/derived: controlled only via --expert-tensor-parallel-num-weight-shards "expert_gtp_weight_remat_size", + "max_seqlen_per_dp_cp_rank", + "hybrid_context_parallel", + "sequence_packing_scheduler", ] transformer_factory = ArgumentGroupFactory(TransformerConfig, exclude=exclude) transformer_group = transformer_factory.build_group(parser, "transformer configuration") @@ -3172,6 +3198,14 @@ 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('--max-seqlen-per-dp-cp-rank', type=int, default=None, + help='Maximum sequence length per CP rank. This is used to calculate the ' + 'number of sub-samples assigned to each CP rank when using heterogeneous context parallel.') + group.add_argument('--hybrid-context-parallel', action='store_true', default=False, + help='Enables hybrid context parallel. This is used to balance the workload ' + 'of each CP rank when we use packed samples with variable sequence lengths. ' + 'Requires --max-seqlen-per-dp-cp-rank to be set.') + group.add_argument('--sequence-packing-scheduler', type=str, default=None, choices=['dp_balanced']) 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. \ @@ -3719,8 +3753,28 @@ def _add_kitchen_quantization_arguments(parser: argparse.ArgumentParser): def _add_sft_args(parser): group = parser.add_argument_group(title='sft') group.add_argument('--sft', action="store_true", help='Megatron SFT training') - group.add_argument('--sft-tokenizer-prompt-format', type=str, default="nemotron-h-aligned", - help='SFT prompt format.') + group.add_argument( + '--sft-tokenizer-prompt-format', + type=str, + default="nemotron-h-aligned", + help='SFT prompt format.', + ) + group.add_argument( + '--sft-mock-dataset-config-json', + type=str, + default=None, + help='This config provides the necessary information for the mock dataset. ' + 'Accepts either an inline JSON literal or a path to a JSON file containing ' + 'the same schema. You can either specify a CSV file that contains sequence lengths, ' + 'where each line stores the length of a sequence, for example: ' + '{"mode":"file","path":"/path/to/file"}. Alternatively, you can specify a distribution ' + '(currently only supporting lognormal distribution) along with the required parameters, ' + 'for example, {"mode":"distribution","type":"lognormal","min_seq_len":1024,' + '"max_seq_len":2048,"mean_seq_len":1536,"lognormal_sigma":1.1}, where sigma controls ' + 'the variability of the lognormal distribution. ' + 'If not specified and --mock-data is set, defaults to a lognormal distribution with ' + 'min_seq_len=seq_length//2, max_seq_len=seq_length, mean_seq_len=seq_length*3//4, lognormal_sigma=1.1.', + ) return parser def _add_logits_distillation_args(parser): diff --git a/megatron/training/datasets/data_samplers.py b/megatron/training/datasets/data_samplers.py index d51d9c6c8a2..edc72e91d9e 100644 --- a/megatron/training/datasets/data_samplers.py +++ b/megatron/training/datasets/data_samplers.py @@ -45,14 +45,16 @@ def build_pretraining_data_loader(dataset, consumed_samples): is_eval = split in (Split.valid, Split.test) micro_batch_size = getattr(args, 'eval_micro_batch_size', args.micro_batch_size) if is_eval else args.micro_batch_size global_batch_size = getattr(args, 'eval_global_batch_size', args.global_batch_size) if is_eval else args.global_batch_size - if split == Split.valid and args.full_validation: batch_sampler = MegatronFullValidationSampler( total_samples=len(dataset), data_parallel_rank=mpu.get_data_parallel_rank(), data_parallel_size=mpu.get_data_parallel_world_size()) elif args.dataloader_type == 'single': - if args.hybrid_context_parallel: + if ( + getattr(args, "hybrid_context_parallel", False) + and getattr(args, "sequence_packing_scheduler", None) is None + ): batch_sampler = HybridCPMegatronPretrainingSampler( total_samples=len(dataset), consumed_samples=consumed_samples, @@ -61,7 +63,8 @@ def build_pretraining_data_loader(dataset, consumed_samples): data_parallel_rank=mpu.get_data_parallel_rank(), data_parallel_size=mpu.get_data_parallel_world_size()) else: - # Megatron sampler + # Megatron sampler. Packing schedulers consume one microbatch at a + # time and form packed global batches themselves. batch_sampler = MegatronPretrainingSampler( total_samples=len(dataset), consumed_samples=consumed_samples, @@ -103,9 +106,21 @@ def close_nvidia_fds(): maybe_worker_init_fn = ( worker_init_fn if args.num_workers > 0 else None ) - # Torch dataloader. - if args.hybrid_context_parallel: - extra_kwargs = {"collate_fn": lambda x: x,} + # Identity collate for VarlenDataset and packing-scheduler paths; + # they emit one variable-length dict per sample, not stack-able by + # the default collate. --varlen-sbhd-validation is excluded: it bypasses + # packing and emits fixed-length [seq_length] samples that the default + # collate stacks normally. + if ( + ( + getattr(args, "use_varlen_dataset", False) + and not getattr(args, "varlen_sbhd_validation", False) + ) + or getattr(args, "hybrid_context_parallel", False) + or getattr(args, "sequence_packing_scheduler", None) is not None + or getattr(args, "use_vanilla_collate_fn", False) + ): + extra_kwargs = {"collate_fn": lambda x: x} else: extra_kwargs = {} return torch.utils.data.DataLoader( @@ -231,7 +246,6 @@ def __iter__(self): global_batch_idx.extend(batch[start_idx[i]:end_idx[i]]) yield global_batch_idx - class MegatronFullValidationSampler: """Sampler for full validation that handles small datasets gracefully. diff --git a/megatron/training/datasets/sft_dataset.py b/megatron/training/datasets/sft_dataset.py index 3f93927387d..80c625d665c 100644 --- a/megatron/training/datasets/sft_dataset.py +++ b/megatron/training/datasets/sft_dataset.py @@ -1,15 +1,18 @@ # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. -import atexit, json +import atexit from collections import Counter -from typing import Any, Dict, Optional +import math +from typing import Any, Dict, List, Optional, Union import numpy as np +import pandas as pd import torch from megatron.core.datasets.gpt_dataset import GPTDatasetConfig from megatron.core.datasets.megatron_dataset import LowLevelDataset, MegatronDataset from megatron.core.datasets.utils import Split +from megatron.training.datasets.utils import load_json_arg IGNORE_INDEX = -100 @@ -61,6 +64,8 @@ def __init__( config: GPTDatasetConfig, ) -> None: super().__init__(dataset, dataset_path, indices, num_samples, index_split, config) + # Pre-calculate padding divisor to avoid redundant computation in get_item + self.padding_divisor = self._calculate_padding_divisor() @staticmethod def numel_low_level_dataset(low_level_dataset: LowLevelDataset) -> int: @@ -88,6 +93,26 @@ def _split_conversations(self, merged_conversations): split_conversations.append(current) return split_conversations + def _calculate_padding_divisor(self) -> int: + """ + Calculate the divisor used for sequence padding. + tp_pad = tp_size * 2 if tp_size > 1 else 1 + cp_pad = cp_size * 2 if cp_size > 1 else 1 + cp_pad = cp_pad * dp_size if hybrid_cp else cp_pad + divisor = cp_pad * tp_pad + """ + if self.config.hybrid_context_parallel: + # Hybrid CP: consider both CP and DP + cp_pad = self.config.data_parallel_size * self.config.context_parallel_size * 2 + else: + # Standard CP: only consider CP + cp_pad = self.config.context_parallel_size * 2 if self.config.context_parallel_size > 1 else 1 + tp_pad = self.config.sequence_parallel_size if self.config.sequence_parallel_size > 0 else 1 + divisor = cp_pad * tp_pad + # TODO(tailaim): do we need to pad for FP8 execution? + # divisor = ((divisor + 15) // 16) * 16 + return divisor + def __getitem__(self, idx: int) -> Dict[str, Any]: tokenizer = self.config.tokenizer @@ -124,12 +149,11 @@ def extend_with_padding(tokens, targets, positions, pad_len): assert not self.config.reset_position_ids pack_positions.extend(range(len(tokens_list))) - if self.config.context_parallel_size > 1: - pad_granularity = self.config.context_parallel_size * 2 - mod_token_count = len(pack_tokens) % pad_granularity - if mod_token_count != 0: - pad_len = pad_granularity - mod_token_count - extend_with_padding(pack_tokens, pack_targets, pack_positions, pad_len) + pad_granularity = self.padding_divisor + mod_token_count = len(pack_tokens) % pad_granularity + if mod_token_count != 0: + pad_len = pad_granularity - mod_token_count + extend_with_padding(pack_tokens, pack_targets, pack_positions, pad_len) # TODO(duncan): Consider also padding to multiple of number of tokens here. This might # be needed for efficiency (and potentially set via command-line argument). @@ -199,3 +223,171 @@ def extend_with_padding(tokens, targets, positions, pad_len): 'cu_seqlens': padded_cu_seqlens, 'max_seqlen': max_seqlen, } + + +class MockSFTLowLevelDataset: + """The low-level mock dataset for SFT + + Args: + mode (str): Either 'file' or 'distribution'. + **kwargs: Additional arguments depending on mode. + For mode='file': path (str) - path to a CSV file with sequence lengths. + For mode='distribution': type (str), min_seq_len (int), max_seq_len (int), + mean_seq_len (int), and distribution-specific params (e.g. lognormal_sigma). + """ + + seed: int = 0 + """The hard-coded random seed to use to set the NumPy RNG""" + + size: int = 1000000 + """The hard-coded number of sequence to generate""" + + def __init__(self, mode: str, **kwargs) -> None: + np.random.seed(self.seed) + + if mode == "file": + self.sequence_lengths = np.array(pd.read_csv(kwargs["path"])).flatten() + self.size = len(self.sequence_lengths) + elif mode == "distribution": + min_seq_len = kwargs["min_seq_len"] + max_seq_len = kwargs["max_seq_len"] + mean_seq_len = kwargs["mean_seq_len"] + if kwargs["type"] == "lognormal": + lognormal_sigma = kwargs["lognormal_sigma"] + self.sequence_lengths = self.generate_lognormal_samples( + self.size, mean_seq_len, lognormal_sigma, min_seq_len, max_seq_len + ) + else: + raise ValueError(f"Unsupported distribution type {kwargs['type']}") + else: + raise ValueError(f"Unsupported mode '{mode}', must be 'file' or 'distribution'") + + def generate_lognormal_samples(self, size, mean, sigma, min_seq_len, max_seq_len): + mu = np.log(mean) - sigma**2 / 2 + samples = np.random.lognormal(mu, sigma, size) + samples = np.clip(samples, min_seq_len, max_seq_len) + return samples.astype(int) + + def __len__(self) -> int: + return self.size + + def __getitem__(self, idx: int) -> List[np.ndarray]: + # the length of sample is 'length', but only length-1 elements are generated here, + # because an eod token will be appended at the end later in SFTDataset + + length = self.sequence_lengths[idx % self.size] + sample = np.arange(1, length, dtype=np.int64) + return sample + + +class MockSFTDataset(SFTDataset): + """The mock dataset used during SFT""" + + def __init__( + self, + dataset: LowLevelDataset, + dataset_path: Optional[str], + indices: np.ndarray, + num_samples: Optional[int], + index_split: Split, + config: GPTDatasetConfig, + ) -> None: + super().__init__(dataset, dataset_path, indices, num_samples, index_split, config) + + @staticmethod + def build_low_level_dataset(dataset_path: str, config: GPTDatasetConfig) -> LowLevelDataset: + if config.sft_mock_dataset_config_json is None: + mock_config = { + "mode": "distribution", + "type": "lognormal", + "min_seq_len": config.sequence_length // 2, + "max_seq_len": config.sequence_length, + "mean_seq_len": config.sequence_length // 4 * 3, + "lognormal_sigma": 1.1, + } + else: + mock_config = load_json_arg(config.sft_mock_dataset_config_json) + return MockSFTLowLevelDataset(**mock_config) + + def __len__(self) -> int: + return self.num_samples + + def __getitem__(self, idx: int) -> Dict[str, Any]: + + tokenizer = self.config.tokenizer + pack_length = self.config.sequence_length + eod = tokenizer.eod + pad = tokenizer.pad + + tokens = self.dataset[int(self.indices[idx % len(self.indices)])] + + def extend_with_padding(tokens, targets, positions, pad_len): + tokens.extend([pad] * pad_len) + targets.extend([pad] * pad_len) + positions.extend(range(positions[-1] + 1, positions[-1] + 1 + pad_len)) + + # Convert tokens to list and add EOD + tokens_list = tokens.tolist() + if tokens_list[-1] != eod: + tokens_list.append(eod) + targets_list = list(tokens_list) + + pack_tokens = list(tokens_list) + pack_targets = list(targets_list) + pack_positions = list(range(len(tokens_list))) + cu_seqlens = [0] + + # Pad to padding_divisor alignment + if self.padding_divisor > 1: + mod_token_count = len(pack_tokens) % self.padding_divisor + if mod_token_count != 0: + pad_len = self.padding_divisor - mod_token_count + extend_with_padding(pack_tokens, pack_targets, pack_positions, pad_len) + + # Record padded boundary after padding + cu_seqlens.append(len(pack_tokens)) + + # Handle any necessary truncation + if len(pack_tokens) >= pack_length + 1: # +1 here to account for later alignment + max_body = pack_length - 1 + pack_tokens = pack_tokens[:max_body] + pack_targets = pack_targets[:max_body] + pack_tokens.extend([eod, pad]) + pack_targets.extend([eod, pad]) + pack_positions = pack_positions[:pack_length + 1] + cu_seqlens[-1] = len(pack_tokens) - 1 + + # Handle any necessary padding + if len(pack_tokens) < pack_length + 1: # +1 here to account for later alignment + pad_len = pack_length + 1 - len(pack_tokens) + extend_with_padding(pack_tokens, pack_targets, pack_positions, pad_len) + cu_seqlens[-1] = len(pack_tokens) - 1 + + assert len(pack_tokens) == pack_length + 1 + assert len(pack_targets) == pack_length + 1 + assert len(pack_positions) == pack_length + 1 + + # Align and convert to tensors + input_ids = torch.tensor(pack_tokens[:-1], dtype=torch.int64) + labels = torch.tensor(pack_targets[1:], dtype=torch.int64) + position_ids = torch.tensor(pack_positions[:-1], dtype=torch.int64) + + # Loss mask + loss_mask = torch.ones(pack_length, dtype=torch.float32) + loss_mask[labels == pad] = 0.0 + loss_mask[labels == IGNORE_INDEX] = 0.0 + + assert len(cu_seqlens) >= 2 + cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int32) + # Calculating max_seqlen here because of possible effects of truncation and padding + adjacent_diffs = cu_seqlens[1:] - cu_seqlens[:-1] + max_seqlen = adjacent_diffs.max() # max_seqlen is a 0-D tensor + + return { + 'tokens': input_ids, + 'labels': labels, + 'loss_mask': loss_mask, + 'position_ids': position_ids, + 'cu_seqlens': cu_seqlens, + 'max_seqlen': max_seqlen, + } diff --git a/megatron/training/datasets/utils.py b/megatron/training/datasets/utils.py new file mode 100644 index 00000000000..1fe6d7ef83e --- /dev/null +++ b/megatron/training/datasets/utils.py @@ -0,0 +1,29 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + +"""Shared utilities for training-side dataset helpers.""" + +import json +import os +from typing import Any, Optional + + +def load_json_arg(spec: Optional[str]) -> Optional[Any]: + """Parse a CLI JSON argument that may be either a JSON literal or a path + to a JSON file. + + The argument is interpreted as a file path when ``spec`` points to an + existing regular file on the local filesystem; otherwise it is parsed as + a JSON literal string. Returns ``None`` when ``spec`` itself is ``None``, + so callers can use it transparently for optional CLI flags. + + Used by the ``--sft-mock-dataset-config-json`` and + ``--varlen-mock-dataset-config-json`` flags, which both accept either an + inline JSON snippet or the path to a file containing the same JSON + document. + """ + if spec is None: + return None + if os.path.isfile(spec): + with open(spec, "r") as f: + return json.load(f) + return json.loads(spec) diff --git a/megatron/training/training.py b/megatron/training/training.py index 37ee411e019..3c6fa7aa4ad 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -43,7 +43,7 @@ # First-party. from megatron.core._rank_utils import safe_get_rank 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, @@ -245,22 +245,6 @@ stimer = StragglerDetector() -# Per-iteration packed-sequence (THD) accumulator. The tensor holds TWO stats, -# both computed from the REAL ``cu_seqlens`` (i.e. unpadded sub-sequence lengths -# -- ``cu_seqlens_padded`` is intentionally ignored so that neither the -# per-chunk CP alignment padding nor any end-of-sequence padding is counted as -# useful work): -# index 0 -> ``sum_i(L_i)`` (total real tokens; used for token-linear FLOPs: -# projections, MLP, MoE, MTP, logits) -# index 1 -> ``sum_i(L_i**2)`` (used for core-attention FLOPs) -# Lives on GPU as fp64 so per-micro-batch updates run as fused kernels with -# no host sync; the only host sync happens once at ``consume`` time after a -# single 2-element all-reduce. ``_seqlen_stats_active`` flips to ``True`` the -# first time an update lands this iteration and is the gate that decides -# whether ``consume_*`` issues a collective at all -- unpacked BSHD runs -# never call ``update_*`` so the flag stays ``False`` and no collective fires. -_seqlen_stats_in_iteration: Optional[torch.Tensor] = None -_seqlen_stats_active: bool = False # Only report memory for first 3 checkpoint saves. num_checkpoints_memory_reported = 0 @@ -710,6 +694,24 @@ def print_datetime(string, override_timestamp=None): time_str = datetime.fromtimestamp(override_timestamp).strftime('%Y-%m-%d %H:%M:%S.%f') print_rank_0(f'[{string}] datetime: {time_str} ') +# Per-iteration packed-sequence (THD) accumulator. The tensor holds TWO stats, +# both computed from the REAL ``cu_seqlens`` (i.e. unpadded sub-sequence lengths +# -- ``cu_seqlens_padded`` is intentionally ignored so that neither the +# per-chunk CP alignment padding nor any end-of-sequence padding is counted as +# useful work): +# index 0 -> ``sum_i(L_i)`` (total real tokens; used for token-linear FLOPs: +# projections, MLP, MoE, MTP, logits) +# index 1 -> ``sum_i(L_i**2)`` (used for core-attention FLOPs) +# Lives on GPU as fp64 so per-micro-batch updates run as fused kernels with +# no host sync; the only host sync happens once at ``consume`` time after a +# single 2-element all-reduce. ``_seqlen_stats_active`` flips to ``True`` the +# first time an update lands this iteration and is the gate that decides +# whether ``consume_*`` issues a collective at all -- unpacked BSHD runs +# never call ``update_*`` so the flag stays ``False`` and no collective fires. +_seqlen_stats_in_iteration: Optional[torch.Tensor] = None +_seqlen_stats_active: bool = False +_seqlen_stats_are_global: bool = False + def update_seqlen_stats_from_cu_seqlens(cu_seqlens): """Add ``sum(L_i)`` and ``sum(L_i ** 2)`` from one micro-batch's REAL ``cu_seqlens``. @@ -728,7 +730,7 @@ def update_seqlen_stats_from_cu_seqlens(cu_seqlens): the all-reduce; BSHD callers that never invoke this function leave the flag at ``False`` and pay zero collective cost. """ - global _seqlen_stats_in_iteration, _seqlen_stats_active + global _seqlen_stats_in_iteration, _seqlen_stats_active, _seqlen_stats_are_global if cu_seqlens is None or cu_seqlens.numel() < 2: return # Pin the accumulator to the current CUDA device when available so the @@ -747,6 +749,21 @@ def update_seqlen_stats_from_cu_seqlens(cu_seqlens): _seqlen_stats_in_iteration[0] += seqlens.sum() _seqlen_stats_in_iteration[1] += (seqlens * seqlens).sum() _seqlen_stats_active = True + _seqlen_stats_are_global = False + + +def set_seqlen_stats_in_iteration(total_real_tokens, seqlen_squared_sum): + """Seed per-iteration THD FLOPs stats that were already computed globally.""" + global _seqlen_stats_in_iteration, _seqlen_stats_active, _seqlen_stats_are_global + if total_real_tokens is None or seqlen_squared_sum is None: + return + if _seqlen_stats_in_iteration is None: + device = torch.device(f'cuda:{torch.cuda.current_device()}') if torch.cuda.is_available() else 'cpu' + _seqlen_stats_in_iteration = torch.zeros(2, dtype=torch.float64, device=device) + _seqlen_stats_in_iteration[0] = float(total_real_tokens) + _seqlen_stats_in_iteration[1] = float(seqlen_squared_sum) + _seqlen_stats_active = True + _seqlen_stats_are_global = True def consume_seqlen_stats_in_iteration() -> Tuple[Optional[float], Optional[float]]: @@ -774,13 +791,15 @@ def consume_seqlen_stats_in_iteration() -> Tuple[Optional[float], Optional[float replicated across TP/CP/PP); the world all-reduce therefore overcounts by a factor of ``TP * CP * PP``, which we divide out. """ - global _seqlen_stats_in_iteration, _seqlen_stats_active + global _seqlen_stats_in_iteration, _seqlen_stats_active, _seqlen_stats_are_global if not _seqlen_stats_active: # BSHD path: never allocated the tensor; tell the caller to use the # closed-form defaults. return None, None t = _seqlen_stats_in_iteration - if torch.distributed.is_initialized() and mpu.model_parallel_is_initialized(): + if _seqlen_stats_are_global: + dedup = 1 + elif torch.distributed.is_initialized() and mpu.model_parallel_is_initialized(): torch.distributed.all_reduce(t) tp_size = max(mpu.get_tensor_model_parallel_world_size(), 1) cp_size = max(mpu.get_context_parallel_world_size(), 1) @@ -796,6 +815,7 @@ def consume_seqlen_stats_in_iteration() -> Tuple[Optional[float], Optional[float # iterations reuse it without reallocating. t.zero_() _seqlen_stats_active = False + _seqlen_stats_are_global = False return total_real_tokens / dedup, seqlen_squared_sum / dedup @@ -3064,6 +3084,20 @@ 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: + ( + data_iterator, + scheduled_num_microbatches, + total_real_tokens_in_batch, + seqlen_squared_sum_in_batch, + ) = wrap_data_iterator(data_iterator, config, get_num_microbatches()) + set_seqlen_stats_in_iteration( + total_real_tokens_in_batch, + seqlen_squared_sum_in_batch, + ) + else: + scheduled_num_microbatches = get_num_microbatches() + # Forward pass. if save_activations_in_this_iteration: enable_activation_logging(model, args.save) @@ -3073,7 +3107,7 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch enable_dgrad_logging(model, args.save) grad_context, forward_only = _forward_backward_grad_context(args) _fb_cm = ( - span_cm("megatron.train.iteration.forward_backward", tracer=_otel_step_tracer, num_microbatches=get_num_microbatches()) + span_cm("megatron.train.iteration.forward_backward", tracer=_otel_step_tracer, num_microbatches=scheduled_num_microbatches) if _otel_sg_enabled('forward_backward') and _otel_step_tracer is not None else nullcontext() ) with grad_context, _fb_cm: @@ -3081,7 +3115,7 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch forward_step_func=forward_step_func, data_iterator=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, @@ -4605,6 +4639,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: @@ -5131,13 +5168,23 @@ 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: + try: + (packed_data_iterator, scheduled_eval_num_microbatches, _, _) = ( + wrap_data_iterator(data_iterator, config, eval_num_microbatches) + ) + except StopIteration: + break + else: + packed_data_iterator = data_iterator + scheduled_eval_num_microbatches = eval_num_microbatches with _otel_managed_span('evaluate', 'megatron.evaluate.step', **{'megatron.eval_iteration': iteration}): 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/pretrain_gpt.py b/pretrain_gpt.py index 8b979d16d4a..a750f459568 100644 --- a/pretrain_gpt.py +++ b/pretrain_gpt.py @@ -39,6 +39,7 @@ def _rank0_only_showwarning(message, category, filename, lineno, file=None, line from gpt_builders import gpt_builder from megatron.core import mpu from megatron.core.datasets.blended_megatron_dataset_builder import BlendedMegatronDatasetBuilder +from megatron.core.datasets.data_schedule import get_batch_on_this_rank_for_sequence_packing from megatron.core.datasets.gpt_dataset import GPTDataset, GPTDatasetConfig, MockGPTDataset from megatron.core.enums import ModelType from megatron.core.package_info import __version__ as mcore_version @@ -74,7 +75,7 @@ def _rank0_only_showwarning(message, category, filename, lineno, file=None, line from megatron.training.argument_utils import gpt_config_from_args, pretrain_cfg_container_from_args from megatron.training.arguments import core_transformer_config_from_args, parse_and_validate_args from megatron.training.datasets.fim_dataset import GPTFIMDataset, GPTFIMDatasetConfig -from megatron.training.datasets.sft_dataset import SFTDataset +from megatron.training.datasets.sft_dataset import MockSFTDataset, SFTDataset from megatron.training.training import update_seqlen_stats_from_cu_seqlens from megatron.training.utils import get_blend_and_blend_per_split, is_first_or_last_pipeline_stage from model_provider import model_provider @@ -113,6 +114,19 @@ def get_batch(data_iterator, vp_stage: Optional[int] = None): args = get_args() config = core_transformer_config_from_args(args) + if args.sequence_packing_scheduler is not None: + return get_batch_on_this_rank_for_sequence_packing( + data_iterator, + vpp_size=config.virtual_pipeline_model_parallel_size, + mtp_on_this_rank=mtp_on_this_rank_func( + layout=config.pipeline_model_parallel_layout, + mtp_num_layers=config.mtp_num_layers, + ignore_virtual=False, + vp_stage=vp_stage, + ), + vp_stage=vp_stage, + ) + cp_size = args.context_parallel_size tp_rank = mpu.get_tensor_model_parallel_rank() is_sft = args.sft @@ -307,44 +321,61 @@ def forward_step(data_iterator, model: GPTModel, return_schedule_plan: bool = Fa timers('batch-generator', log_level=2).start() with stimer(bdata=True): vp_stage = get_attr_wrapped_model(model, "vp_stage") - ( - attention_mask, - cu_seqlens, - cu_seqlens_padded, - hybrid_cp_group, - labels, - local_cp_size, - loss_mask, - max_seqlen, - position_ids, - tokens, - ) = get_batch(data_iterator, vp_stage) - - packed_seq_params = None - if cu_seqlens is not None: - # Squeeze the batch dim: the batch dict keeps cu_seqlens as (1, N) - # for consistency, but PackedSeqParams and TE expect 1-D. - cu_seqlens = cu_seqlens.squeeze(0) - if cu_seqlens_padded is not None: - cu_seqlens_padded = cu_seqlens_padded.squeeze(0) - # Use real (unpadded) cu_seqlens to feed the FLOPs accounting: varlen - # attention only computes work for real tokens within each chunk. - update_seqlen_stats_from_cu_seqlens(cu_seqlens) - cu_seqlens_for_params = ( - cu_seqlens_padded if cu_seqlens_padded is not None else cu_seqlens - ) # TODO(asolergi-nv): Currently there is a bug forcing cu_seqlens to be cu_seqlens_padded - packed_seq_params = PackedSeqParams( - qkv_format="thd", - cu_seqlens_q=cu_seqlens_for_params, - cu_seqlens_kv=cu_seqlens_for_params, - cu_seqlens_q_padded=cu_seqlens_padded, - cu_seqlens_kv_padded=cu_seqlens_padded, - max_seqlen_q=int(max_seqlen.item()), - max_seqlen_kv=int(max_seqlen.item()), - local_cp_size=int(local_cp_size.item()) if local_cp_size is not None else None, - cp_group=hybrid_cp_group, - tokens_per_sample=args.seq_length, - ) + batch = get_batch(data_iterator, vp_stage) + + if len(batch) == 7: + ( + tokens, + labels, + loss_mask, + attention_mask, + position_ids, + packed_seq_params, + padding_mask, + ) = batch + elif len(batch) == 6: + tokens, labels, loss_mask, attention_mask, position_ids, packed_seq_params = batch + padding_mask = None + else: + ( + attention_mask, + cu_seqlens, + cu_seqlens_padded, + hybrid_cp_group, + labels, + local_cp_size, + loss_mask, + max_seqlen, + position_ids, + tokens, + ) = batch + + padding_mask = None + packed_seq_params = None + if cu_seqlens is not None: + # Squeeze the batch dim: the batch dict keeps cu_seqlens as (1, N) + # for consistency, but PackedSeqParams and TE expect 1-D. + cu_seqlens = cu_seqlens.squeeze(0) + if cu_seqlens_padded is not None: + cu_seqlens_padded = cu_seqlens_padded.squeeze(0) + # Use real (unpadded) cu_seqlens to feed the FLOPs accounting: varlen + # attention only computes work for real tokens within each chunk. + update_seqlen_stats_from_cu_seqlens(cu_seqlens) + cu_seqlens_for_params = ( + cu_seqlens_padded if cu_seqlens_padded is not None else cu_seqlens + ) # TODO(asolergi-nv): Currently there is a bug forcing cu_seqlens to be cu_seqlens_padded + packed_seq_params = PackedSeqParams( + qkv_format="thd", + cu_seqlens_q=cu_seqlens_for_params, + cu_seqlens_kv=cu_seqlens_for_params, + cu_seqlens_q_padded=cu_seqlens_padded, + cu_seqlens_kv_padded=cu_seqlens_padded, + max_seqlen_q=int(max_seqlen.item()), + max_seqlen_kv=int(max_seqlen.item()), + local_cp_size=int(local_cp_size.item()) if local_cp_size is not None else None, + cp_group=hybrid_cp_group, + tokens_per_sample=args.seq_length, + ) timers('batch-generator').stop() @@ -354,7 +385,13 @@ def forward_step(data_iterator, model: GPTModel, return_schedule_plan: bool = Fa args.overlap_moe_expert_parallel_comm ), "overlap_moe_expert_parallel_comm must be enabled to return the schedule plan" schedule_plan = model.build_schedule_plan( - tokens, position_ids, attention_mask, labels=labels, loss_mask=loss_mask + tokens, + position_ids, + attention_mask, + labels=labels, + loss_mask=loss_mask, + packed_seq_params=packed_seq_params, + padding_mask=padding_mask, ) return schedule_plan, partial(loss_func, loss_mask, model=model) else: @@ -365,6 +402,7 @@ def forward_step(data_iterator, model: GPTModel, return_schedule_plan: bool = Fa labels=labels, loss_mask=loss_mask, packed_seq_params=packed_seq_params, + padding_mask=padding_mask, ) # [ModelOpt]: model is needed to access ModelOpt distillation losses @@ -428,6 +466,7 @@ def core_gpt_dataset_config_from_args(args: Any) -> GPTDatasetConfig: "sequence_parallel_size": args.tensor_model_parallel_size * args.sequence_parallel, "hybrid_context_parallel": args.hybrid_context_parallel, "inter_document_masking": args.dataloader_inter_document_masking, + "sft_mock_dataset_config_json": args.sft_mock_dataset_config_json, } # add FIM args to the config @@ -466,7 +505,10 @@ def train_valid_test_datasets_provider(train_val_test_num_samples, vp_stage=None is_packed_sequence = False if args.sft: - dataset_type = SFTDataset + if args.mock_data: + dataset_type = MockSFTDataset + else: + dataset_type = SFTDataset is_packed_sequence = True # SFT always uses packed sequence else: if args.mock_data: diff --git a/tests/unit_tests/models/test_hybrid_moe_model.py b/tests/unit_tests/models/test_hybrid_moe_model.py index 037dbae3ee4..2332e35a82f 100644 --- a/tests/unit_tests/models/test_hybrid_moe_model.py +++ b/tests/unit_tests/models/test_hybrid_moe_model.py @@ -352,6 +352,7 @@ "moe_single_grouped_weight": False, "moe_single_grouped_bias": False, "moe_hybridep_pad_uneven_dispatch_inputs": 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 index 99520e1c4ca..b2fa3d69db9 100644 --- a/tests/unit_tests/test_sequence_packing.py +++ b/tests/unit_tests/test_sequence_packing.py @@ -1,7 +1,9 @@ # Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +import random from types import SimpleNamespace +import numpy as np import pytest import torch @@ -10,7 +12,9 @@ _build_thd_padding_mask, _sanitize_thd_padding_values, get_batch_on_this_rank_for_sequence_packing, + wrap_data_iterator, ) +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 @@ -329,3 +333,185 @@ def test_get_batch_on_this_rank_for_sequence_packing(tp, pp, cp): finally: Utils.destroy_model_parallel() unset_global_variables() + + +@pytest.mark.parametrize( + ("tp", "pp", "cp", "vpp", "scheduler_type"), + [ + (1, 1, 8, None, "dp_balanced"), + (2, 1, 4, None, "dp_balanced"), + (2, 4, 1, None, "dp_balanced"), + (2, 2, 1, None, "dp_balanced"), + (1, 4, 1, 4, "dp_balanced"), + ], +) +def test_wrap_dataloader(tp, pp, cp, vpp, scheduler_type): + ''' + 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, + } + + # 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 + + 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_pp_first_or_last = is_pp_first or is_pp_last + is_tp_first = tp_rank == 0 + + num_micro_batches_old = global_batch_size // micro_batch_size // dp_size + + if is_tp_first and (is_pp_first or is_pp_last): + 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: + if is_pp_first: + data_iterator = [data_iterator] + [None for _ in range(vpp - 1)] + elif is_pp_last: + data_iterator = [None for _ in range(vpp - 1)] + [data_iterator] + else: + data_iterator = [None for _ in range(vpp)] + 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) + + # 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_keys) <= set( + batch.keys() + ), f"batch keys: {set(batch.keys())} missing {set(batch_keys) - set(batch.keys())}" + for key in batch_keys: + assert batch[key] is not None + + if is_tp_first: + # CHECK KEYS + batch_keys = ["cu_seqlens", "max_seqlen", "cu_seqlens_padded"] + if vpp is not None and vpp > 1: + # check metadata for all stages (save batches to avoid re-consuming iterators) + all_stage_batches = [] + for temp_data_iterator in 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, batch_keys) + + # check for first or last stage on first or last pp rank + if is_pp_first_or_last: + batch_all = all_stage_batches[0] if is_pp_first else all_stage_batches[-1] + batch_keys += ["tokens", "position_ids", "labels", "loss_mask"] + _check_batch(batch_all, batch_keys) + else: + # non-VPP: single iterator + batch_all = [next(new_data_iterator) for _ in range(num_micro_batches)] + if is_pp_first_or_last: + batch_keys += ["tokens", "position_ids", "labels", "loss_mask"] + _check_batch(batch_all, batch_keys) + + # CHECK TOKEN SUM ON FIRST OR LAST PP RANK + # Note: data_iterator is consumed by wrap_data_iterator, new_data_iterator is consumed above. + # Use `samples` for before-wrap, reuse `batch_all` from the check above for after-wrap. + if is_pp_first_or_last: + # 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 (batch_all already collected above with tokens) + token_sum_after = torch.tensor(0, dtype=torch.int64, device='cuda') + for batch in batch_all: + 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()