Skip to content

Commit 5412c23

Browse files
committed
feat(data): blend several sources on the direct-HF SFT path
Let DirectHFSFTDatasetConfig.source take a list of sources so a run can draw its training rows from several Hugging Face sources at once, weighted by source_weights. Each weight is an epoch count over its source. A single source keeps deriving its own validation and test splits. A list has no single split to derive from, so those splits need explicit sources or have to be disabled. Signed-off-by: ryan.u <ryumin93@gmail.com>
1 parent e4cb868 commit 5412c23

6 files changed

Lines changed: 520 additions & 12 deletions

File tree

‎docs/training/data-preparation.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,28 @@ dataset.preprocessing = PromptCompletionSFTPreprocessingConfig(
189189

190190
Structured multi-turn rows require chat preprocessing; Bridge does not silently flatten them into prompt-completion text.
191191

192+
To train on several sources at once, pass a list to `source`. Each entry in `source_weights` is an epoch count for its source: `1.0` keeps every row once, `2.5` keeps every row twice plus half of them again, and `0.5` keeps half of them. Fractional passes draw without replacement. Omitting the weights gives every source one pass.
193+
194+
```python
195+
from megatron.bridge.data.builders import DirectHFSFTDatasetConfig, HFDatasetSourceConfig
196+
197+
dataset = DirectHFSFTDatasetConfig(
198+
seq_length=4096,
199+
source=[
200+
HFDatasetSourceConfig(dataset_name="squad"),
201+
HFDatasetSourceConfig(dataset_name="gsm8k"),
202+
],
203+
source_weights=[1.0, 2.0],
204+
blend_seed=1234,
205+
validation_source=HFDatasetSourceConfig(dataset_name="squad", split="validation"),
206+
do_test=False,
207+
)
208+
```
209+
210+
Sources are adapted to canonical SFT rows before mixing, so their raw column layouts do not have to match. A single source still derives its own validation and test splits; a list has no single split to derive from, so set `validation_source` and `test_source` explicitly or disable those splits.
211+
212+
Weights count rows, not tokens. SFT rows vary in length, so sources with equal row counts can still contribute very different token counts.
213+
192214
Known semantic datasets should use their preset name, for example `squad`, `gsm8k`, `openmathinstruct2`, `cord_v2`, `raven`, `rdr`, `medpix`, `cv17`, or `llava_video_178k`. Do not combine `dataset_name` with `path_or_dataset`, `subset`, or `schema_adapter`; a preset owns those coupled fields. `split`, `load_kwargs`, and `adapter_kwargs` remain available for split selection and declarative runtime options such as a video root. Presets validate published split support: `raven`, `rdr`, and `llava_video_178k` are train-only; `medpix` and `squad` have no test split; `gsm8k` has no validation split; and OpenMathInstruct-2 exposes training variants only. Disable unsupported derived validation/test splits or supply explicit compatible sources.
193215

194216
For text chat, `hf_processor_path=None` reuses the training tokenizer only when that tokenizer already defines the intended chat template. Otherwise select a vocabulary-compatible instruction processor explicitly, as above.

‎src/megatron/bridge/data/builders/direct_hf_sft.py‎

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Serializable config and runtime builder for direct Hugging Face SFT."""
1616

1717
import logging
18-
from collections.abc import Callable
18+
from collections.abc import Callable, Mapping
1919
from dataclasses import dataclass, field
2020
from functools import partial
2121
from typing import Any, Literal
@@ -40,7 +40,9 @@
4040
HFDatasetSourceConfig,
4141
hf_dataset_supports_split,
4242
load_and_adapt_hf_dataset,
43+
load_and_blend_hf_datasets,
4344
prepare_hf_dataset_sources,
45+
resolve_blend_weights,
4446
)
4547
from megatron.bridge.models.hf_pretrained.utils import is_safe_repo
4648
from megatron.bridge.training.tokenizers.tokenizer import MegatronTokenizer
@@ -51,17 +53,29 @@
5153
CollateFunction = Callable[..., dict[str, torch.Tensor]]
5254

5355

56+
def _as_source(entry: "HFDatasetSourceConfig | Mapping[str, Any]") -> HFDatasetSourceConfig:
57+
"""Rebuild a source that an override round trip flattened into a mapping."""
58+
if isinstance(entry, HFDatasetSourceConfig):
59+
return entry
60+
return HFDatasetSourceConfig(**dict(entry))
61+
62+
5463
@dataclass(kw_only=True)
5564
class DirectHFSFTDatasetConfig(DataloaderConfig):
5665
"""Serializable configuration for direct Hugging Face SFT datasets.
5766
5867
Chat preprocessing is the compatibility default for multimodal and
5968
conversation sources. New text recipes should select chat or paired-text
6069
preprocessing explicitly.
70+
71+
``source`` takes one source or a list of them. A list blends the training
72+
rows across the sources, weighted by ``source_weights``.
6173
"""
6274

6375
seq_length: int
64-
source: HFDatasetSourceConfig
76+
source: HFDatasetSourceConfig | list[HFDatasetSourceConfig]
77+
source_weights: list[float] | None = None
78+
blend_seed: int = 1234
6579
validation_source: HFDatasetSourceConfig | None = None
6680
test_source: HFDatasetSourceConfig | None = None
6781
preprocessing: SFTPreprocessingConfig = field(default_factory=ChatSFTPreprocessingConfig)
@@ -82,7 +96,7 @@ def validate(self) -> None:
8296
if self.seq_length <= 0:
8397
raise ValueError("seq_length must be greater than 0.")
8498
validate_sft_preprocessing_config(self.preprocessing)
85-
self.source.validate()
99+
self._validate_sources()
86100
if self.do_validation and self.validation_source is not None:
87101
self._inherit_source_adapter_kwargs(self.validation_source)
88102
self.validation_source.validate()
@@ -101,12 +115,50 @@ def validate(self) -> None:
101115
if self.in_batch_packing_pad_to_multiple_of <= 0:
102116
raise ValueError("in_batch_packing_pad_to_multiple_of must be greater than 0.")
103117

118+
@property
119+
def training_sources(self) -> list[HFDatasetSourceConfig]:
120+
"""The sources the training rows are drawn from, blended or not."""
121+
return list(self.source) if isinstance(self.source, list) else [self.source]
122+
123+
@property
124+
def split_source(self) -> HFDatasetSourceConfig | None:
125+
"""The source that derives validation and test splits, if one is unambiguous."""
126+
sources = self.training_sources
127+
return sources[0] if len(sources) == 1 else None
128+
129+
def _validate_sources(self) -> None:
130+
"""Validate the training sources and any blend weights."""
131+
# Any override re-serializes this config through OmegaConf, which returns
132+
# dataclasses as plain mappings even when the override targeted another field.
133+
if isinstance(self.source, list):
134+
self.source = [_as_source(entry) for entry in self.source]
135+
else:
136+
self.source = _as_source(self.source)
137+
138+
sources = self.training_sources
139+
if isinstance(self.source, list) or self.source_weights is not None:
140+
resolve_blend_weights(sources, self.source_weights)
141+
for entry in sources:
142+
entry.validate()
143+
144+
if len(sources) > 1:
145+
for name, split_source in (
146+
("validation_source", self.validation_source),
147+
("test_source", self.test_source),
148+
):
149+
enabled = self.do_validation if name == "validation_source" else self.do_test
150+
if enabled and split_source is None:
151+
raise ValueError(
152+
f"A blended source has no single split to derive from; set {name} or disable that split."
153+
)
154+
104155
def _inherit_source_adapter_kwargs(self, split_source: HFDatasetSourceConfig) -> None:
105156
"""Fill unset adapter arguments on another split of the training source."""
106-
if split_source.dataset_name != self.source.dataset_name or not self.source.adapter_kwargs:
157+
source = self.split_source
158+
if source is None or split_source.dataset_name != source.dataset_name or not source.adapter_kwargs:
107159
return
108160
split_adapter_kwargs = dict(split_source.adapter_kwargs or {})
109-
for key, value in self.source.adapter_kwargs.items():
161+
for key, value in source.adapter_kwargs.items():
110162
if split_adapter_kwargs.get(key) is None:
111163
split_adapter_kwargs[key] = value
112164
split_source.adapter_kwargs = split_adapter_kwargs
@@ -197,20 +249,31 @@ def select_direct_hf_sft_collate(
197249
raise ValueError("Prompt-completion preprocessing supports text-only examples.")
198250

199251

252+
def load_direct_hf_sft_train_examples(config: DirectHFSFTDatasetConfig) -> list[dict[str, Any]]:
253+
"""Load the training rows, blending them when the config names several sources."""
254+
sources = config.training_sources
255+
if len(sources) == 1 and config.source_weights is None:
256+
return load_direct_hf_sft_examples(sources[0], config.preprocessing)
257+
rows = load_and_blend_hf_datasets(sources, config.source_weights, seed=config.blend_seed)
258+
return normalize_sft_examples(rows, config.preprocessing)
259+
260+
200261
def build_direct_hf_sft_split(
201262
config: DirectHFSFTDatasetConfig,
202263
source: HFDatasetSourceConfig,
203264
target_length: int,
204265
processor: Any,
205266
*,
206267
collate_impl: CollateFunction | None = None,
268+
examples: list[dict[str, Any]] | None = None,
207269
) -> DirectSFTDataset | None:
208270
"""Build one requested direct-HF SFT split."""
209271
if target_length <= 0:
210272
return None
211273
from megatron.bridge.data.collators.registry import model_collate_required_for_all_examples
212274

213-
examples = load_direct_hf_sft_examples(source, config.preprocessing)
275+
if examples is None:
276+
examples = load_direct_hf_sft_examples(source, config.preprocessing)
214277
if collate_impl is None and model_collate_required_for_all_examples(type(processor).__name__):
215278
if not isinstance(config.preprocessing, ChatSFTPreprocessingConfig):
216279
raise ValueError(
@@ -256,7 +319,7 @@ def build(
256319
self.config.do_validation
257320
and context.valid_samples > 0
258321
and self.config.validation_source is None
259-
and not hf_dataset_supports_split(self.config.source, "validation")
322+
and not hf_dataset_supports_split(self.config.split_source, "validation")
260323
):
261324
raise ValueError(
262325
"The selected Hugging Face source has no validation split; disable validation or set one."
@@ -265,14 +328,19 @@ def build(
265328
self.config.do_test
266329
and context.test_samples > 0
267330
and self.config.test_source is None
268-
and not hf_dataset_supports_split(self.config.source, "test")
331+
and not hf_dataset_supports_split(self.config.split_source, "test")
269332
):
270333
raise ValueError("The selected Hugging Face source has no test split; disable test or set one.")
271-
validation_source = self.config.validation_source or self.config.source.with_split("validation")
272-
test_source = self.config.test_source or self.config.source.with_split("test")
334+
split_source = self.config.split_source
335+
validation_source = self.config.validation_source or (
336+
split_source.with_split("validation") if split_source is not None else None
337+
)
338+
test_source = self.config.test_source or (
339+
split_source.with_split("test") if split_source is not None else None
340+
)
273341
requested_sources = []
274342
if context.train_samples > 0:
275-
requested_sources.append(self.config.source)
343+
requested_sources.extend(self.config.training_sources)
276344
if self.config.do_validation and context.valid_samples > 0:
277345
requested_sources.append(validation_source)
278346
if self.config.do_test and context.test_samples > 0:
@@ -287,12 +355,14 @@ def build(
287355
and getattr(context.tokenizer, "library", None) in {"sentencepiece", "tiktoken"}
288356
):
289357
processor = normalize_direct_hf_sft_processor(context.tokenizer)
358+
train_examples = load_direct_hf_sft_train_examples(self.config) if context.train_samples > 0 else None
290359
train_dataset = build_direct_hf_sft_split(
291360
self.config,
292-
self.config.source,
361+
self.config.training_sources[0],
293362
context.train_samples,
294363
processor,
295364
collate_impl=self._collate_impl,
365+
examples=train_examples,
296366
)
297367
valid_dataset = (
298368
build_direct_hf_sft_split(

‎src/megatron/bridge/data/sources/hf.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Declarative Hugging Face sources, named presets, and shared normalization."""
1616

17+
import random
1718
from collections.abc import Sequence
1819
from dataclasses import dataclass, replace
1920
from pathlib import Path
@@ -324,3 +325,98 @@ def load_and_adapt_hf_dataset(source: HFDatasetSourceConfig) -> list[dict[str, A
324325
adapter_name=resolved.schema_adapter,
325326
adapter_kwargs=resolved.adapter_kwargs,
326327
)
328+
329+
330+
def resolve_blend_weights(
331+
sources: Sequence[HFDatasetSourceConfig],
332+
weights: Sequence[float] | None,
333+
) -> list[float]:
334+
"""Validate blend weights against their sources and fill in the uniform default.
335+
336+
Args:
337+
sources: The sources taking part in the blend.
338+
weights: One positive weight per source, or None for equal weights.
339+
340+
Returns:
341+
One weight per source.
342+
343+
Raises:
344+
ValueError: If no source is given, if the counts disagree, or if a weight
345+
is not positive.
346+
"""
347+
if not sources:
348+
raise ValueError("A blend must name at least one source.")
349+
if weights is None:
350+
return [1.0] * len(sources)
351+
if len(weights) != len(sources):
352+
raise ValueError(f"Blend sources and weights must be equal in number, got {len(sources)} and {len(weights)}.")
353+
for weight in weights:
354+
if weight <= 0:
355+
raise ValueError(f"Blend weights must be positive, got {weight}.")
356+
return [float(weight) for weight in weights]
357+
358+
359+
def blend_sft_rows(
360+
rows_per_source: Sequence[Sequence[dict[str, Any]]],
361+
weights: Sequence[float],
362+
*,
363+
seed: int,
364+
) -> list[dict[str, Any]]:
365+
"""Draw rows from several sources under per-source epoch counts.
366+
367+
A weight is how many passes the blend takes over a source: ``1.0`` keeps
368+
every row once, ``2.5`` keeps every row twice plus half of them again, and
369+
``0.5`` keeps half of them. Fractional passes draw without replacement.
370+
371+
Weights count rows, not tokens. SFT rows vary in length, so equal row counts
372+
do not make equal token counts.
373+
374+
Args:
375+
rows_per_source: Canonical SFT rows for each source of the blend.
376+
weights: One positive weight per source.
377+
seed: Seeds the fractional draws and the shuffle that interleaves sources.
378+
379+
Returns:
380+
The blended rows, holding references to the rows that were passed in.
381+
382+
Raises:
383+
ValueError: If a source contributed no rows.
384+
"""
385+
rng = random.Random(seed)
386+
blended: list[dict[str, Any]] = []
387+
for index, (rows, weight) in enumerate(zip(rows_per_source, weights)):
388+
if not rows:
389+
raise ValueError(f"Blend source {index} produced no rows.")
390+
full_passes = int(weight)
391+
for _ in range(full_passes):
392+
blended.extend(rows)
393+
remainder = round(len(rows) * (weight - full_passes))
394+
if remainder:
395+
blended.extend(rng.sample(list(rows), remainder))
396+
397+
rng.shuffle(blended)
398+
return blended
399+
400+
401+
def load_and_blend_hf_datasets(
402+
sources: Sequence[HFDatasetSourceConfig],
403+
weights: Sequence[float] | None,
404+
*,
405+
seed: int,
406+
) -> list[dict[str, Any]]:
407+
"""Load several Hugging Face splits and mix them under per-source epoch counts.
408+
409+
Each source is adapted to canonical SFT rows first, so sources with unrelated
410+
column layouts can be blended as long as they share a schema adapter target.
411+
412+
Args:
413+
sources: The sources taking part in the blend.
414+
weights: One positive weight per source, or None for one pass each.
415+
seed: Seeds the fractional draws and the shuffle that interleaves sources.
416+
417+
Returns:
418+
The blended canonical SFT rows.
419+
"""
420+
resolved_weights = resolve_blend_weights(sources, weights)
421+
rows_per_source = [load_and_adapt_hf_dataset(source) for source in sources]
422+
return blend_sft_rows(rows_per_source, resolved_weights, seed=seed)

‎tests/functional_tests/test_groups/training/test_sft.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,67 @@ def test_direct_hf_prompt_completion_training_step(self, tmp_path):
180180
finally:
181181
clear_directories(shared_base_dir)
182182

183+
@pytest.mark.run_only_on("GPU")
184+
def test_direct_hf_blended_sources_training_step(self, tmp_path):
185+
"""Run one GPT training step over a weighted blend of two direct-HF sources."""
186+
initialize_distributed()
187+
shared_base_dir = broadcast_path(tmp_path)
188+
checkpoint_dir, tensorboard_dir, _, _ = self._setup_directories(shared_base_dir)
189+
first_path = os.path.join(shared_base_dir, "blend-a.jsonl")
190+
second_path = os.path.join(shared_base_dir, "blend-b.jsonl")
191+
192+
if torch.distributed.get_rank() == 0:
193+
# NullTokenizer reads whitespace-separated integers, so the sources are
194+
# told apart by disjoint number ranges rather than by a text label.
195+
for path, offset, rows in ((first_path, 0, 8), (second_path, 100, 4)):
196+
with open(path, "w", encoding="utf-8") as output_file:
197+
for index in range(rows):
198+
value = offset + index
199+
record = {"input": f"{value} {value}", "output": str(value + 1)}
200+
output_file.write(json.dumps(record) + "\n")
201+
torch.distributed.barrier()
202+
203+
try:
204+
config = self._create_config(1, checkpoint_dir, tensorboard_dir, seq_length=16)
205+
config.model.vocab_size = 1024
206+
config.train.global_batch_size = 4
207+
config.train.micro_batch_size = 2
208+
config.tokenizer = TokenizerConfig(tokenizer_type="NullTokenizer", vocab_size=1024)
209+
config.dataset = DirectHFSFTDatasetConfig(
210+
seq_length=16,
211+
source=[
212+
HFDatasetSourceConfig(
213+
path_or_dataset="json",
214+
load_kwargs={"data_files": {"train": first_path}},
215+
),
216+
HFDatasetSourceConfig(
217+
path_or_dataset="json",
218+
load_kwargs={"data_files": {"train": second_path}},
219+
),
220+
],
221+
source_weights=[1.0, 3.0],
222+
preprocessing=PromptCompletionSFTPreprocessingConfig(
223+
prompt_column="input",
224+
completion_column="output",
225+
),
226+
do_validation=False,
227+
do_test=False,
228+
enable_in_batch_packing=True,
229+
pad_to_multiple_of=1,
230+
num_workers=0,
231+
persistent_workers=False,
232+
)
233+
234+
pretrain(config, forward_step)
235+
verify_checkpoint_files(
236+
checkpoint_dir,
237+
1,
238+
ckpt_format=config.checkpoint.ckpt_format,
239+
storage_writers_per_rank=config.checkpoint.storage_writers_per_rank,
240+
)
241+
finally:
242+
clear_directories(shared_base_dir)
243+
183244
@pytest.mark.run_only_on("GPU")
184245
def test_direct_hf_non_packed_context_parallel_training_step(self, tmp_path):
185246
"""Run direct-HF dynamic padding through a two-rank context-parallel step."""

0 commit comments

Comments
 (0)