Skip to content

Add new SFTDataset to megatron.core.datasets - #5017

Open
asolergi-nv wants to merge 7 commits into
NVIDIA:mainfrom
asolergi-nv:pr2-sft-dataset
Open

Add new SFTDataset to megatron.core.datasets#5017
asolergi-nv wants to merge 7 commits into
NVIDIA:mainfrom
asolergi-nv:pr2-sft-dataset

Conversation

@asolergi-nv

@asolergi-nv asolergi-nv commented May 27, 2026

Copy link
Copy Markdown
Contributor
  • I, the PR author, have personally reviewed every line of this PR.

Summary

This PR replaces the JSONL-backed SFTDataset in megatron/training/datasets/sft_dataset.py with a new pre-tokenized, packed implementation under megatron/core/datasets/sft_dataset.py. The new dataset:

  • Reads pre-tokenized chat conversations from Megatron's file prefixes (.bin / .idx) like pretraining. It's up to the user to apply the chat template and later on pretokenize the data with the tools/preprocess_data.py script.
  • Packs variable-length conversations into fixed-capacity samples using the Modified First-Fit Decreasing (MFFD) bin-packing algorithm and caches the resulting indices on disk.
  • Builds per-token loss masks by parsing each conversation against a configurable chat template (ChatTemplateConfig — Nemotron-3 default). Three flags control what counts toward the loss:
    • train_on_assistant_responses_only
    • train_on_thinking_traces (mask <think>…</think> regions)
    • train_on_tool_calls (mask <tool_call>…</tool_call> regions). Tool responses (environment output inside user turns) are always masked.
  • Emits THD-packed outputs (tokens, labels, loss_mask, cu_seqlens) ready for Flash Attention varlen kernels.

A new helper, preprocess_sft_batch, is added to megatron/core/utils.py and called from both pretrain_gpt.py and pretrain_hybrid.py. It handles the runtime steps that used to happen inside the old __getitem__: padding each sub-sequence to a CP-divisible length, padding or truncating the packed sample to seq_length, and producing position_ids, cu_seqlens_padded, and max_seqlen.

A standalone debug script, inspect_sft_file_prefix.py, is added at the repo root for inspecting a .bin/.idx pair against the Nemotron-3 template. Will remove before merge.

User-facing reference docs for the dataset live in megatron/core/datasets/sft.MD.


Changes per file

New: dataset and helpers

File What it does
megatron/core/datasets/sft_dataset.py (new, ~830 lines) SFTDataset, SFTDatasetConfig, ChatTemplateConfig, Nemotron3ChatTemplateConfig, the MFFD packer (pack_samples + _classify_items), and the chat-template segment parser (extract_segments, _split_tool_calls, find_subsequence).
megatron/core/datasets/sft.MD (new) Reference documentation: output schema, index-building, MFFD phases, segment roles, and worked examples for chat-only and tool-use trajectories.
megatron/core/utils.py (+289 lines) Three SFT helpers: pad_thd_sequences_for_cp (round each segment up to a CP-divisible length), pad_or_truncate_thd_tensors (force the packed sample to exactly max_seq_len), and preprocess_sft_batch (TP-rank-0 batch preprocessing wrapper).
inspect_sft_file_prefix.py (new, 332 lines) Standalone debug script that loads a .bin/.idx pair and prints per-segment role assignments using the hard-coded Nemotron-3 chat template. Not imported by training code.

Removed: the old SFTDataset

File Change
megatron/training/datasets/sft_dataset.py Deleted. The JSONL-backed SFTDataset / SFTLowLevelDataset are superseded by the pre-tokenized core implementation.

Training entrypoints wired to the new dataset

File Change
pretrain_gpt.py Import SFTDataset, SFTDatasetConfig, IGNORE_INDEX from megatron.core.datasets.sft_dataset. Call preprocess_sft_batch inside get_batch when args.sft is set. Return SFTDatasetConfig from core_gpt_dataset_config_from_args when in SFT mode.
pretrain_hybrid.py Same wiring as pretrain_gpt.py. Also refactored core_gpt_dataset_config_from_args to build a data_args dict so the SFT and non-SFT configs share construction.
megatron/elastification/pretrain_hybrid_flex.py Update SFTDataset import to point at the new module.

Tokenizer add_special_tokens plumbing

The dataset tokenizes chat-template delimiter strings (<|im_start|>system\n,
<|im_end|>\n, <think>, …) and must do so without the tokenizer
auto-inserting BOS/EOS. A new add_special_tokens: bool = True keyword is
added through the tokenizer stack:

File Change
megatron/core/tokenizers/text/libraries/abstract_tokenizer.py Add add_special_tokens: bool = True to the text_to_ids ABC signature.
megatron/core/tokenizers/text/libraries/bytelevel_tokenizer.py Accept and ignore add_special_tokens.
megatron/core/tokenizers/text/libraries/huggingface_tokenizer.py Accept add_special_tokens (caller-side responsibility — HF path is unchanged when True).
megatron/core/tokenizers/text/libraries/sentencepiece_tokenizer.py Accept and ignore add_special_tokens.
megatron/core/tokenizers/text/libraries/tiktoken_tokenizer.py Accept and ignore add_special_tokens.
megatron/core/tokenizers/text/libraries/sft_tokenizer.py Forward add_special_tokens to self._tokenizer.encode(...); tighten an assert with a message.
megatron/core/tokenizers/text/libraries/null_tokenizer.py Accept add_special_tokens and change text_to_ids to a character-level encoding [ord(c) % vocab_size for c in text]. This makes the null tokenizer usable in the SFT unit tests (it can now tokenize arbitrary template strings reproducibly).
megatron/core/tokenizers/text/text_tokenizer.py Plumb add_special_tokens through tokenize(...).
megatron/core/tokenizers/utils/build_tokenizer.py Tiny refactor: lift the {NullTokenizer, NullMultimodalTokenizer} -> library name mapping into a NULL_TOKENIZERS dict.

Tests included

New tests

  • tests/unit_tests/data/test_sft_dataset.py (399 lines) — end-to-end
    test of SFTDataset via BlendedMegatronDatasetBuilder.

    • Builds synthetic .bin/.idx files containing 4 conversation shapes
      (simple, with_thinking, with_tool_calls, with_thinking_and_tool_calls),
      with and without system turns.
    • Uses a TestChatTemplateConfig whose delimiter strings tokenize cleanly
      under the (now character-level) NullTokenizer.
    • Parametrized across vocab_size ∈ {131072, 20000} and all five
      (train_on_assistant_responses_only, train_on_thinking_traces, train_on_tool_calls)
      combinations allowed by the asserts in SFTDatasetConfig.__post_init__.
    • Verifies dtype, shape invariants, packed-length bounds, monotonic
      cu_seqlens, label-shift correctness, and per-segment loss-mask values
      by re-running extract_segments on each document inside a packed sample.
  • tests/unit_tests/data/test_cp_utils.py (232 lines) — unit tests for
    pad_thd_sequences_for_cp in megatron/core/utils.py.

    • Compares against a slow obviously-correct _reference_pad implementation.
    • Covers: segments shorter than the divisibility factor, mixed lengths,
      already-divisible (no-op), 2-D input squeezing, dtype preservation
      (int32/int64 for cu_seqlens, mixed dtypes per tensor), multi-tensor
      calls, single-tensor calls, divisibility_factor=1, and randomized
      parametric tests checking the post-condition that every padded segment
      length is a multiple of the divisibility factor.

Updated tests

  • tests/unit_tests/data/test_get_batch.py — the mock SFT data iterator
    now matches the new dataset's output shape (un-padded, no position_ids,
    no max_seqlen). Assertions across test_sft_batch and
    test_hybrid_cp_batch updated to expect cu_seqlens_padded to be
    populated when cp_size > 1 (now produced by preprocess_sft_batch).

  • tests/unit_tests/tokenizers/test_tokenizer.pytest_null_tokenizer
    and test_detokenize_skip_special_tokens_unsupported_backend updated to
    reflect the NullTokenizer's new character-level encoding.

Contribution process

Pre-checks

  • I have added relevant unit tests
  • I have added relevant functional tests
  • I have added proper typing to my code Typing guidelines
  • I have added relevant documentation
  • I have run the autoformatter.sh on my PR

Code review

Feel free to message or comment @NVIDIA/mcore-oncall to help accelerate your merge into main. The less complex your PR is, the faster it will be approved and merged!

All PRs start as draft. If you open a non-draft PR, it will be automatically converted to draft.

Step 1: Mark PR as "Ready for Review"

  1. When your PR is ready, click Ready for Review.
  2. An oncall reviewer is auto-assigned and expert reviewers are notified based on your changes.
    • Some PRs may jump straight to step 2. This is determined by .github/CODEOWNERS.

⚠️ Only mark as ready once merge-conflicts are resolved and the CI is passing.
Final Review might get declined if these requirements are not fulfilled.

Step 2: Final Review

For PRs that change megatron/core, once all expert reviewers have approved, the Final Review label is applied automatically and final reviewers are assigned.

For PRs outside megatron/core, this step is skipped.

Step 3: Approved

Once all required reviewers have approved, the Approved label is applied automatically.

Merge

Any member of mcore-engineers will be able to merge your PR.

Signed-off-by: asolergi-nv <asolergibert@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented May 27, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

Signed-off-by: Antoni-Joan Solergibert <asolergibert@nvidia.com>
@asolergi-nv

Copy link
Copy Markdown
Contributor Author

/ok to test b1f4fc2

@asolergi-nv

Copy link
Copy Markdown
Contributor Author

/ok to test efa5da6

Signed-off-by: asolergi-nv <asolergibert@nvidia.com>
@asolergi-nv

Copy link
Copy Markdown
Contributor Author

/claude review

return tokens

def text_to_ids(self, text: str) -> List[int]:
def text_to_ids(self, text: str, add_special_tokens: bool = True) -> List[int]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: add_special_tokens is accepted but never used in the method body. When include_special_tokens=True (the default), self.tokenizer(text).input_ids always includes BOS/EOS tokens regardless of the caller's request.

SFTDatasetConfig.__post_init__ calls self.tokenizer.tokenize(..., add_special_tokens=False) to tokenize chat template delimiters. With a HuggingFace tokenizer, this silently returns token sequences that include BOS/EOS, so extract_segments will fail to match them in pretokenized conversations — producing incorrect (all-zeros) loss masks.

The same issue applies to tiktoken_tokenizer.py, bytelevel_tokenizer.py, and sentencepiece_tokenizer.py — all accept the parameter but ignore it.

Suggested fix for this file:

def text_to_ids(self, text: str, add_special_tokens: bool = True) -> List[int]:
    """Converts text to tokens ids."""
    if self.include_special_tokens and add_special_tokens:
        return self.tokenizer(text).input_ids
    tokens = self.text_to_tokens(text)
    ids = self.tokens_to_ids(tokens)
    return ids

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — light pass

One bug found:

add_special_tokens silently ignored in most tokenizer backends — The abstract text_to_ids signature was updated to accept add_special_tokens, and SFTDatasetConfig.__post_init__ relies on tokenize(..., add_special_tokens=False) to get clean delimiter token sequences. However, only sft_tokenizer.py actually propagates the parameter. The HuggingFace, TikToken, SentencePiece, and ByteLevel tokenizer implementations accept the parameter but ignore it. For HuggingFace in particular (the most likely backend for real SFT workloads), when include_special_tokens=True (the default), BOS/EOS tokens will be included in the delimiter sequences, causing extract_segments to never match them — resulting in all-zeros loss masks and no training signal on assistant responses.

The unit tests pass because they use NullTokenizer, which has no special tokens.

See inline comment on huggingface_tokenizer.py for a suggested fix.

@asolergi-nv
asolergi-nv marked this pull request as ready for review June 1, 2026 11:00
@asolergi-nv
asolergi-nv requested review from a team as code owners June 1, 2026 11:00
Sync PR 5017 (SFTDataset) with main (225 commits since 873678a).

Conflicts resolved:
- pretrain_hybrid.py (core_gpt_dataset_config_from_args): keep the PR's
  data_args dict + SFTDatasetConfig dispatch, and add main's new
  inter_document_masking key (mirrors pretrain_gpt.py).
- megatron/elastification/pretrain_hybrid_flex.py: import get_batch_on_this_*_rank
  from megatron.core.utils (main relocated them there) and drop the stale
  megatron.training.datasets.sft_dataset import (SFTDataset moved to core.datasets).
- megatron/training/datasets/sft_dataset.py: keep deleted (relocated to
  megatron/core/datasets/sft_dataset.py by this PR).

Auto-merged files reconciled with main's refactors: get_batch_on_this_tp_rank's
is_sft param -> has_cu_seqlens, new flatten_batch_for_packed_sequences step, and
get_batch_on_this_cp_rank's use_per_sequence_balancing kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergi-nv <asolergibert@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Signed-off-by: Antoni-Joan Solergibert <asolergibert@nvidia.com>
@asolergi-nv

Copy link
Copy Markdown
Contributor Author

/ok to test fe2c20e

return tokens

def text_to_ids(self, text: str) -> List[int]:
def text_to_ids(self, text: str, add_special_tokens: bool = True) -> List[int]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to pass special tokens here? Looks like it's not being used by function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it to all tokenizers since we are using this argument in the parent class

return self.ids_to_text(tokens)

def text_to_ids(self, text):
def text_to_ids(self, text, add_special_tokens=True):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same question here

return document_index, sample_index, shuffle_index


def _classify_items(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about moving these extra functions to separate file like sft_utils.py?



@dataclass
class SFTDatasetConfig(GPTDatasetConfig):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think it makes sense to move all the configs to separate sft_configs.py?

@@ -1,201 +0,0 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this okay to remove? or should be put in legacy.

asolergi-nv and others added 2 commits July 22, 2026 11:40
…ator

test_inter_document_masking_batch runs with sft=False, so get_batch does not
call preprocess_sft_batch. After syncing with main it consumed the SFT-path
create_sft_data_iterator, which this PR repurposed to emit *un-padded* data
(padding, position_ids and max_seqlen are produced by preprocess_sft_batch).
With sft=False those fields were absent (position_ids was None), so the test
FAILED and the cp=2 case then hung on a NCCL collective timeout -> SIGABRT,
crashing the whole tests/unit_tests/data bucket.

Add a dedicated create_inter_document_masking_data_iterator that emits data
already padded to seq_length with position_ids / cu_seqlens / max_seqlen (the
contract main's helper provided before this PR), and point the test at it.
Test-only change; production get_batch/utils are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergi-nv <asolergibert@nvidia.com>
Sync PR 5017 (SFTDataset) with upstream main (97 commits, up to a046281).

Conflicts resolved (2, both in the `from megatron.core.utils import (...)` block
of pretrain_gpt.py and pretrain_hybrid.py): keep the PR's `preprocess_sft_batch`
import alongside main's new `get_te_version` / `get_torch_version`.

Also reconciled a silent name collision in tests/unit_tests/data/test_get_batch.py:
main (bf32f44, "Fix inter-document masking crash ... mbs > 1") added its own
`create_inter_document_masking_data_iterator` (mbs>1 variant) with the same name
this branch had introduced, so it shadowed the branch's version. Dropped the
branch's duplicate and pointed `test_inter_document_masking_batch` at main's
canonical iterator with `micro_batch_size=1`.

Verified on 2 GPUs: tests/unit_tests/data (test_get_batch, test_sft_dataset,
test_cp_utils) = 103 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergi-nv <asolergibert@nvidia.com>
@asolergi-nv

Copy link
Copy Markdown
Contributor Author

/ok to test 97b2c55

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants