Skip to content

Commit f23ae97

Browse files
committed
Enable Tunix-based DPO input processing for Grain
1 parent 7c68a9d commit f23ae97

6 files changed

Lines changed: 235 additions & 20 deletions

File tree

src/maxtext/configs/post_train/dpo.yml

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,21 @@ dpo:
88
dpo_beta: 0.1
99
max_prompt_length: null
1010
packing: false
11-
train_data_columns: ['chosen', 'rejected']
12-
eval_data_columns: ['chosen', 'rejected']
13-
base_output_directory: 'gs://maxtext-external/logs'
1411

1512
per_device_batch_size: 2.0
1613
steps: 10
1714
max_target_length: 512
1815
eval_interval: 5 # test eval once, in the middle of 10 training steps
1916
eval_steps: 2
2017

21-
# TFDS Pipeline ----------------------
22-
dataset_type: tfds
23-
dataset_path: 'gs://maxtext-dataset/dpo/anthropic_rlhf'
24-
dataset_name: 'tfds:1.0.0'
25-
eval_dataset_name: 'tfds:1.0.0'
26-
eval_split: 'test'
27-
28-
# HF Pipeline -------------------------
18+
# Some reasonable defaults for running DPO without extra config params.
19+
model_name: "qwen3-0.6b"
20+
tokenizer_path: "src/maxtext/assets/tokenizers/qwen3-tokenizer"
21+
tokenizer_type: "huggingface"
22+
dataset_type: hf
23+
hf_path: 'Anthropic/hh-rlhf'
24+
train_data_columns: ['chosen', 'rejected']
25+
eval_data_columns: ['chosen', 'rejected']
2926
hf_eval_split: 'test'
3027

3128
gradient_clipping_threshold: 10.0

src/maxtext/configs/types.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2935,7 +2935,9 @@ def calculate_global_batch_sizes(per_device_batch_size, expansion_factor, num_de
29352935
if self.use_dpo:
29362936
if self.packing:
29372937
raise ValueError("For DPO/ORPO, `packing` is not supported.")
2938-
if self.dpo.max_prompt_length is not None and self.dpo.max_prompt_length >= self.max_target_length:
2938+
if self.dpo.max_prompt_length is None:
2939+
self.dpo.max_prompt_length = self.max_target_length // 2
2940+
if self.dpo.max_prompt_length >= self.max_target_length:
29392941
raise ValueError(
29402942
f"dpo.max_prompt_length ({self.dpo.max_prompt_length}) must be less than max_target_length"
29412943
f" ({self.max_target_length})."
@@ -3043,6 +3045,12 @@ def calculate_global_batch_sizes(per_device_batch_size, expansion_factor, num_de
30433045
logger.warning(
30443046
"tfds pipeline is deprecated. Use dataset_type=grain, grain_file_type=tfrecord, and provide grain_train_files."
30453047
)
3048+
if self.use_dpo:
3049+
raise ValueError(
3050+
"TFDS dataset_type=tfds is not supported for DPO training"
3051+
" (config.use_dpo=True). Please use dataset_type=grain or"
3052+
" dataset_type=hf instead."
3053+
)
30463054
if not self.dataset_name:
30473055
raise ValueError("dataset_name can't be empty when dataset_type=tfds")
30483056
if self.eval_interval > 0 and not self.eval_split:

src/maxtext/input_pipeline/dpo_utils.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class DPODataFormatting(grain.MapTransform):
3131
data_column_names: tuple[str, ...]
3232
max_prompt_length: int | None = None
3333

34+
def __post_init__(self):
35+
if self.max_prompt_length is None:
36+
self.max_prompt_length = self.max_target_length // 2
37+
3438
def map(self, element):
3539
"Apply the dataset transformations for DPO."
3640
# 1. Reformat/Extract Columns
@@ -64,17 +68,16 @@ def map(self, element):
6468
) from e
6569

6670
# 2. Padding and Masking
67-
max_prompt_length = self.max_prompt_length or (self.max_target_length // 2)
68-
max_response_length = self.max_target_length - max_prompt_length
71+
max_response_length = self.max_target_length - self.max_prompt_length
6972

70-
assert max_prompt_length > 0, (
73+
assert self.max_prompt_length > 0, (
7174
"max_prompt_length must be positive. " "Check the configs for 'max_prompt_length' and 'max_target_length'."
7275
)
7376
assert max_response_length > 0, (
7477
"max_response_length must be positive. " "Check the configs for 'max_prompt_length' and 'max_target_length'."
7578
)
7679

77-
prompt_ids = self._pad(input_ids, max_prompt_length, left=True)
80+
prompt_ids = self._pad(input_ids, self.max_prompt_length, left=True)
7881
chosen_ids = self._pad(chosen_ids, max_response_length, left=False)
7982
rejected_ids = self._pad(rejected_ids, max_response_length, left=False)
8083

src/maxtext/input_pipeline/grain_data_processing.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from maxtext.input_pipeline import data_processing_utils
3030
from maxtext.input_pipeline import input_pipeline_utils
3131
from maxtext.input_pipeline import grain_tokenizer
32+
from maxtext.input_pipeline import dpo_utils
3233
from maxtext.input_pipeline import multihost_dataloading
3334
from maxtext.utils import gcs_utils
3435
from maxtext.utils import max_logging
@@ -263,6 +264,45 @@ def pretrain_preprocessing_pipeline(
263264
return dataset
264265

265266

267+
def dpo_preprocessing_pipeline(
268+
dataset,
269+
config,
270+
data_columns,
271+
tokenize,
272+
grain_worker_count,
273+
grain_per_worker_buffer_size,
274+
):
275+
"""Use grain to pre-process the dataset and return iterators for dpo fine-tuning"""
276+
dataset = data_processing_utils.parse_and_keep_features(dataset, config, data_columns, tokenize)
277+
tokenizer_model, pad_id = data_processing_utils.get_tokenizer_and_pad_id(config)
278+
279+
if tokenize:
280+
dataset = dataset.map(grain_tokenizer.TokenizeAndTrim(data_columns, config.max_target_length, tokenizer_model))
281+
282+
# Renames arbitrary DPO columns and performs DPO-aware padding.
283+
dataset = dataset.map(
284+
dpo_utils.DPODataFormatting(
285+
pad_id=pad_id,
286+
max_target_length=config.max_target_length,
287+
data_column_names=data_columns,
288+
max_prompt_length=config.dpo.max_prompt_length,
289+
)
290+
)
291+
292+
batch_size = data_processing_utils.get_local_batch_size(config)
293+
if config.grain_use_elastic_iterator:
294+
# ElasticIterator batches internally, so return the pre-batch dataset.
295+
pass
296+
else:
297+
batch_fn = functools.partial(grain.experimental.batch_and_pad, batch_size=batch_size, pad_value=pad_id)
298+
dataset = dataset.batch(batch_size, batch_fn=batch_fn)
299+
300+
dataset = data_processing_utils.apply_multiprocessing_and_prefetch(
301+
dataset, config, grain_worker_count, grain_per_worker_buffer_size
302+
)
303+
return dataset
304+
305+
266306
def _format_chat_template_grain(element, data_columns, tokenizer_model):
267307
"""Grain-compatible mapping function to format raw columns into conversational messages."""
268308
# Convert raw columns to conversational messages
@@ -350,6 +390,8 @@ def sft_preprocessing_pipeline(
350390

351391
def _get_pipeline_fn(config):
352392
"""Returns the appropriate preprocessing pipeline function based on config."""
393+
if config.use_dpo:
394+
return dpo_preprocessing_pipeline
353395
if config.use_sft:
354396
return sft_preprocessing_pipeline
355397
return pretrain_preprocessing_pipeline

src/maxtext/trainers/post_train/dpo/train_dpo.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
)
5050
from maxtext.configs import pyconfig
5151
from maxtext.configs.types import MaxTextConfig
52+
from maxtext.input_pipeline import tokenizer
5253
from maxtext.optimizers import optimizers
5354
from maxtext.trainers.post_train.dpo import hooks
5455
from maxtext.utils import max_logging
@@ -90,7 +91,6 @@ def get_tunix_config(mt_config: MaxTextConfig) -> DPOTrainingConfig:
9091
set_profile_options=set_profile_options,
9192
)
9293

93-
max_prompt_length = mt_config.max_target_length // 2
9494
return DPOTrainingConfig(
9595
eval_every_n_steps=mt_config.eval_interval,
9696
max_steps=mt_config.steps,
@@ -103,8 +103,8 @@ def get_tunix_config(mt_config: MaxTextConfig) -> DPOTrainingConfig:
103103
lambda_orpo=mt_config.dpo.orpo_lambda,
104104
beta=mt_config.dpo.dpo_beta,
105105
label_smoothing=mt_config.dpo.dpo_label_smoothing,
106-
max_prompt_length=max_prompt_length,
107-
max_response_length=mt_config.max_target_length - max_prompt_length,
106+
max_prompt_length=mt_config.dpo.max_prompt_length,
107+
max_response_length=mt_config.max_target_length - mt_config.dpo.max_prompt_length,
108108
)
109109

110110

@@ -113,7 +113,21 @@ def setup_trainer_state(mt_config, goodput_recorder=None):
113113
tunix_config = get_tunix_config(mt_config)
114114

115115
with maybe_record_goodput(goodput_recorder, GoodputEvent.TPU_INIT):
116-
model, mesh = model_creation_utils.from_pretrained(mt_config, wrap_with_tunix_adapter=True)
116+
# We need pad_id to correctly initialize the MaxTextTunixAdapter in the from_pretrained call below.
117+
# To do that we instantiate a separate copy of the tokenizer here, which is somewhat redundant.
118+
# However, there is no clean way to get the pad_id from the tokenizer in the datapipiline here.
119+
tok = tokenizer.build_tokenizer(
120+
mt_config.tokenizer_path,
121+
mt_config.tokenizer_type,
122+
False,
123+
False,
124+
mt_config.hf_access_token,
125+
)
126+
model, mesh = model_creation_utils.from_pretrained(
127+
mt_config,
128+
wrap_with_tunix_adapter=True,
129+
tokenizer_pad_id=tok.pad_id,
130+
)
117131

118132
learning_rate_schedule = maxtext_utils.create_learning_rate_schedule(mt_config)
119133
# pass in model for muon

tests/post_training/unit/dpo_data_processing_test.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
import pytest
2424
import transformers
2525

26+
import grain.python as grain
2627
from maxtext.configs import pyconfig
2728
from maxtext.input_pipeline import dpo_utils
2829
from maxtext.input_pipeline import hf_data_processing
30+
from maxtext.input_pipeline import grain_data_processing
2931
from maxtext.input_pipeline import input_pipeline_interface
3032
from maxtext.utils.globals import MAXTEXT_ASSETS_ROOT, MAXTEXT_CONFIGS_DIR, MAXTEXT_PKG_DIR
3133

@@ -389,5 +391,154 @@ def test_dpo_non_positive_max_prompt_length(self):
389391
)
390392

391393

394+
@pytest.mark.external_training
395+
class TestGrainDPOPipelineProcessing(unittest.TestCase):
396+
"""End-to-end Grain DPO pipeline processing tests."""
397+
398+
def setUp(self):
399+
super().setUp()
400+
self.config = pyconfig.initialize_pydantic(
401+
[
402+
os.path.join(MAXTEXT_PKG_DIR, "dpo_trainer"),
403+
os.path.join(MAXTEXT_CONFIGS_DIR, "post_train", "dpo.yml"),
404+
],
405+
per_device_batch_size=2,
406+
run_name="test",
407+
mesh_axes=["data"],
408+
logical_axis_rules=[["batch", "data"]],
409+
data_sharding=["data"],
410+
base_output_directory="gs://max-experiments/",
411+
tokenizer_path=os.path.join(MAXTEXT_ASSETS_ROOT, "tokenizers", "qwen3-tokenizer"),
412+
train_split="train",
413+
enable_checkpointing=False,
414+
use_dpo=True,
415+
enable_data_shuffling=False,
416+
max_target_length=64,
417+
grain_file_type="parquet", # to trigger KeepFeatures in parse_and_keep_features
418+
tokenizer_type="huggingface",
419+
dataset_type="grain",
420+
grain_train_files="dummy",
421+
eval_interval=0,
422+
)
423+
self.mesh_shape_1d = (len(jax.devices()),)
424+
self.mesh = Mesh(mesh_utils.create_device_mesh(self.mesh_shape_1d), self.config.mesh_axes)
425+
self.process_indices = input_pipeline_interface.get_process_loading_real_data(
426+
self.config.data_sharding,
427+
self.config.global_batch_size_to_load,
428+
self.config.global_batch_size_to_train_on,
429+
self.config.max_target_length,
430+
self.mesh,
431+
)
432+
self.tokenizer = transformers.AutoTokenizer.from_pretrained(
433+
self.config.tokenizer_path,
434+
add_bos_token=False,
435+
add_eos_token=False,
436+
legacy=False,
437+
)
438+
self.pad_id = hf_data_processing._get_pad_id(self.tokenizer) # pylint: disable=protected-access
439+
440+
def get_data_iterator(self, list_of_dicts, data_columns):
441+
"""Helper to initialize the Grain preprocessing pipeline."""
442+
dataset = grain.MapDataset.source(list_of_dicts)
443+
dataset = dataset[self.process_indices.index(jax.process_index()) :: len(self.process_indices)]
444+
dataset = dataset.to_iter_dataset()
445+
446+
iter_ds = grain_data_processing.dpo_preprocessing_pipeline(
447+
dataset=dataset,
448+
config=self.config,
449+
data_columns=data_columns,
450+
tokenize=self.config.tokenize_train_data,
451+
grain_worker_count=0,
452+
grain_per_worker_buffer_size=1,
453+
)
454+
return iter(iter_ds)
455+
456+
def test_dpo_format_3_columns(self):
457+
"""Verify that the 3-column explicit DPO dataset is processed correctly."""
458+
prompt_str = "Question: What is 2+2?"
459+
chosen_str = "Answer: 4"
460+
rejected_str = "Answer: 5"
461+
462+
list_of_dicts = [
463+
{
464+
"input": prompt_str,
465+
"chosen": chosen_str,
466+
"rejected": rejected_str,
467+
}
468+
for _ in range(10)
469+
]
470+
471+
data_iter = self.get_data_iterator(list_of_dicts, ["input", "chosen", "rejected"])
472+
batch = next(data_iter)
473+
474+
# Verify expected keys
475+
for key in (
476+
"prompt_ids",
477+
"chosen_ids",
478+
"rejected_ids",
479+
"prompt_mask",
480+
"chosen_mask",
481+
"rejected_mask",
482+
):
483+
self.assertIn(key, batch)
484+
485+
# Verify batch dimensions match global batch size and split max_target_length
486+
max_prompt_len = self.config.max_target_length // 2
487+
max_response_len = self.config.max_target_length - max_prompt_len
488+
self.assertEqual(
489+
batch["prompt_ids"].shape,
490+
(self.config.global_batch_size_to_load, max_prompt_len),
491+
)
492+
self.assertEqual(
493+
batch["chosen_ids"].shape,
494+
(self.config.global_batch_size_to_load, max_response_len),
495+
)
496+
self.assertEqual(
497+
batch["rejected_ids"].shape,
498+
(self.config.global_batch_size_to_load, max_response_len),
499+
)
500+
501+
# Verify decoded content directly
502+
decoded_prompt = self.tokenizer.decode(batch["prompt_ids"][0], skip_special_tokens=True)
503+
decoded_chosen = self.tokenizer.decode(batch["chosen_ids"][0], skip_special_tokens=True)
504+
decoded_rejected = self.tokenizer.decode(batch["rejected_ids"][0], skip_special_tokens=True)
505+
506+
self.assertEqual(decoded_prompt, prompt_str)
507+
self.assertEqual(decoded_chosen, chosen_str)
508+
self.assertEqual(decoded_rejected, rejected_str)
509+
510+
# Verify mask structure (left padding for prompt -> 1s at the end; right padding for responses -> 1s at start)
511+
self.assertEqual(batch["prompt_mask"][0][-1], 1)
512+
self.assertEqual(batch["chosen_mask"][0][0], 1)
513+
self.assertEqual(batch["rejected_mask"][0][0], 1)
514+
515+
def test_dpo_format_2_columns(self):
516+
"""Verify that 2-column DPO datasets correctly extract common prefixes."""
517+
# We use a clear common prefix and different suffixes
518+
prefix = "Common prompt context for DPO:"
519+
chosen_suffix = " the chosen completion"
520+
rejected_suffix = " the rejected completion"
521+
522+
list_of_dicts = [
523+
{
524+
"chosen": prefix + chosen_suffix,
525+
"rejected": prefix + rejected_suffix,
526+
}
527+
for _ in range(10)
528+
]
529+
530+
data_iter = self.get_data_iterator(list_of_dicts, ["chosen", "rejected"])
531+
batch = next(data_iter)
532+
533+
# Verify decoded extracted prefix and completions robustly against BPE token boundary quirks
534+
decoded_prompt = self.tokenizer.decode(batch["prompt_ids"][0], skip_special_tokens=True)
535+
decoded_chosen = self.tokenizer.decode(batch["chosen_ids"][0], skip_special_tokens=True)
536+
decoded_rejected = self.tokenizer.decode(batch["rejected_ids"][0], skip_special_tokens=True)
537+
538+
self.assertIn("Common prompt context", decoded_prompt)
539+
self.assertIn("chosen", decoded_chosen)
540+
self.assertIn("rejected", decoded_rejected)
541+
542+
392543
if __name__ == "__main__":
393544
unittest.main()

0 commit comments

Comments
 (0)