Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions megatron/training/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,17 @@ def validate_args(args, defaults={}):
# 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) \
Expand Down Expand Up @@ -2398,6 +2409,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")
Expand Down Expand Up @@ -3815,6 +3829,21 @@ def _add_sft_args(parser):
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-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):
Expand Down
222 changes: 214 additions & 8 deletions megatron/training/datasets/sft_dataset.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.

import atexit, json
import atexit
import csv
import math
from collections import Counter
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Union

import numpy as np
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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -199,3 +223,185 @@ 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":
# One sequence length per CSV line; non-numeric cells (e.g. a
# header row) are skipped. Stdlib csv keeps pandas out of the
# package's import-time dependencies.
lengths = []
with open(kwargs["path"], newline="") as f:
for row in csv.reader(f):
for cell in row:
cell = cell.strip()
if not cell:
continue
try:
lengths.append(int(float(cell)))
except ValueError:
continue
self.sequence_lengths = np.array(lengths)
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,
}
29 changes: 29 additions & 0 deletions megatron/training/datasets/utils.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading