Add Grain support for multimodal SFT data processing pipeline - #4754
Add Grain support for multimodal SFT data processing pipeline#4754snehalv2002 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new multimodal SFT input pipeline using Grain, including the vision_sft_preprocessing_pipeline for handling text and image preprocessing, updated configurations, and unit tests. The reviewer feedback highlights several important improvements for robustness: explicitly raising an error for the unsupported ElasticIterator in multimodal SFT (and simplifying the corresponding batching logic), reordering tokenizer checks in tokenization to prevent HuggingFace tokenizers from bypassing truncation and max length limits, and ensuring robust handling of both list and tuple types when parsing data and image columns.
| assert len(data_columns) == 2, f"Need two data_columns for query and response, received {data_columns=}" | ||
| text_columns = list(data_columns) |
There was a problem hiding this comment.
ElasticIterator is not supported yet for multimodal SFT because post-batch transformations (like folding images) cannot be easily applied to the iterator. We should explicitly raise a ValueError if config.grain_use_elastic_iterator is enabled to prevent silent failures or cryptic runtime errors.
| assert len(data_columns) == 2, f"Need two data_columns for query and response, received {data_columns=}" | |
| text_columns = list(data_columns) | |
| if config.grain_use_elastic_iterator: | |
| raise ValueError( | |
| "ElasticIterator is not supported yet for multimodal SFT because post-batch " | |
| "transformations (like folding images) cannot be easily applied to the iterator." | |
| ) | |
| assert len(data_columns) == 2, f"Need two data_columns for query and response, received {data_columns=}" | |
| text_columns = list(data_columns) |
| if config.grain_use_elastic_iterator: | ||
| pass | ||
| else: | ||
| dataset = dataset.batch(batch_size, drop_remainder=True) | ||
|
|
||
| dataset = dataset.map(input_pipeline_utils.FoldImagesIntoBatch(model_name=config.model_name)) | ||
| dataset = dataset.map(input_pipeline_utils.ShiftData(ignored_ids=[pad_id], axis=1)) |
There was a problem hiding this comment.
Since grain_use_elastic_iterator is not supported for multimodal SFT (and we raise an error at the start of the function), we can simplify this block and remove the dead/broken conditional branch.
dataset = dataset.batch(batch_size, drop_remainder=True)
dataset = dataset.map(input_pipeline_utils.FoldImagesIntoBatch(model_name=config.model_name))
dataset = dataset.map(input_pipeline_utils.ShiftData(ignored_ids=[pad_id], axis=1))| def _encode(text): | ||
| if hasattr(hf_tokenizer, "encode"): | ||
| return hf_tokenizer.encode(text) | ||
| if callable(hf_tokenizer): | ||
| res = hf_tokenizer(text, truncation=truncation, max_length=max_length) | ||
| if isinstance(res, dict) and "input_ids" in res: | ||
| return res["input_ids"] | ||
| return res | ||
| if hasattr(hf_tokenizer, "tokenizer"): | ||
| return hf_tokenizer.tokenizer(text, truncation=truncation, max_length=max_length)["input_ids"] | ||
| raise ValueError(f"Unsupported tokenizer: {hf_tokenizer}") |
There was a problem hiding this comment.
Checking hasattr(hf_tokenizer, "encode") first will always evaluate to True for HuggingFace tokenizers, completely bypassing the callable check. This means HuggingFace tokenizers will be called via encode without truncation and max_length arguments, silently ignoring these limits. We should reorder the checks to prioritize the callable/tokenizer wrapper paths and only fall back to encode if they are not applicable.
def _encode(text):
if hasattr(hf_tokenizer, "tokenizer"):
return hf_tokenizer.tokenizer(text, truncation=truncation, max_length=max_length)["input_ids"]
if callable(hf_tokenizer):
try:
res = hf_tokenizer(text, truncation=truncation, max_length=max_length)
if isinstance(res, dict) and "input_ids" in res:
return res["input_ids"]
return res
except TypeError:
pass
if hasattr(hf_tokenizer, "encode"):
return hf_tokenizer.encode(text)
raise ValueError(f"Unsupported tokenizer: {hf_tokenizer}")| if data_columns == getattr(config, "eval_data_columns", None): | ||
| image_column = getattr(config, "eval_image_column", "image") | ||
| else: | ||
| image_column = getattr(config, "train_image_column", "image") |
There was a problem hiding this comment.
Comparing data_columns directly to config.eval_data_columns can be fragile if one is parsed as a list and the other as a tuple. Converting both to lists before comparison ensures robust matching.
| if data_columns == getattr(config, "eval_data_columns", None): | |
| image_column = getattr(config, "eval_image_column", "image") | |
| else: | |
| image_column = getattr(config, "train_image_column", "image") | |
| if list(data_columns) == list(getattr(config, "eval_data_columns", [])): | |
| image_column = getattr(config, "eval_image_column", "image") | |
| else: | |
| image_column = getattr(config, "train_image_column", "image") |
| if isinstance(image_column, list): | ||
| columns_to_parse = text_columns + image_column | ||
| else: | ||
| columns_to_parse = text_columns + [image_column] |
There was a problem hiding this comment.
If image_column is parsed as a tuple, checking isinstance(image_column, list) will evaluate to False, causing it to be treated as a single string and leading to malformed columns. We should check for both list and tuple types.
| if isinstance(image_column, list): | |
| columns_to_parse = text_columns + image_column | |
| else: | |
| columns_to_parse = text_columns + [image_column] | |
| if isinstance(image_column, (list, tuple)): | |
| columns_to_parse = text_columns + list(image_column) | |
| else: | |
| columns_to_parse = text_columns + [image_column] |
| if isinstance(image_column, list): | ||
| dataset = dataset.map( | ||
| functools.partial( | ||
| input_pipeline_utils.merge_image_columns, | ||
| image_columns=image_column, | ||
| max_num_images_per_example=config.max_num_images_per_example, | ||
| ) | ||
| ) | ||
| image_column = "images" |
There was a problem hiding this comment.
Similarly, we should support tuple types when merging multiple image columns.
| if isinstance(image_column, list): | |
| dataset = dataset.map( | |
| functools.partial( | |
| input_pipeline_utils.merge_image_columns, | |
| image_columns=image_column, | |
| max_num_images_per_example=config.max_num_images_per_example, | |
| ) | |
| ) | |
| image_column = "images" | |
| if isinstance(image_column, (list, tuple)): | |
| dataset = dataset.map( | |
| functools.partial( | |
| input_pipeline_utils.merge_image_columns, | |
| image_columns=list(image_column), | |
| max_num_images_per_example=config.max_num_images_per_example, | |
| ) | |
| ) | |
| image_column = "images" |
4093d64 to
a2e544a
Compare
a2e544a to
003b264
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
003b264 to
b5942f7
Compare
aireenmei
left a comment
There was a problem hiding this comment.
Could you see if we can also migrate https://github.com/AI-Hypercomputer/maxtext/blob/main/src/maxtext/configs/post_train/sft-vision-slidevqa.yml with current change? Feel free to leave it to future PR if additional changes needed
b5942f7 to
94095d2
Compare
Description
This PR migrates the Multimodal Supervised Fine-Tuning (SFT) data pipeline from
dataset_type=hftodataset_type=grain.Summary of Changes:
vision_sft_preprocessing_pipelineinsrc/maxtext/input_pipeline/grain_data_processing.pyto handle prompt formatting, image decoding, tokenization, prompt masking, batching, image-folding, and padding in Grain._get_pipeline_fningrain_data_processing.pyto route tovision_sft_preprocessing_pipelinewhenconfig.use_sft and config.use_multimodal.convert_to_RGBinsrc/maxtext/multimodal/utils.pyto support dictionary-wrapped bytes ({'bytes': ...}as formatted in Parquet/Grain datasets), raw bytes, and file paths.tokenizationinsrc/maxtext/input_pipeline/input_pipeline_utils.pyto support SentencePiece, TikToken, and Hugging Face tokenizers.apply_multiprocessing_and_prefetchinsrc/maxtext/input_pipeline/data_processing_utils.pyto convertMapDatasettoIterDatasetvia.to_iter_dataset()when needed.src/maxtext/configs/post_train/sft-vision-chartqa.ymlto default todataset_type: grainandgrain_file_type: parquet.docs/tutorials/posttraining/multimodal.mdandtests/end_to_end/tpu/gemma3/4b/test_gemma3_multimodal_sft.shto usedataset_type=grain.tests/unit/input_pipeline/multimodal_sft_grain_test.py.Tests
PYTHONPATH=src pytest tests/unit/input_pipeline/multimodal_sft_grain_test.py(Passed 2/2).snehalv-tpu-v4):python -m maxtext.trainers.post_train.sft.train_sft_native \ src/maxtext/configs/post_train/sft-vision-chartqa.yml \ model_name=gemma3-4b per_device_batch_size=1 steps=5 \ max_prefill_predict_length=1024 max_target_length=2048 \ scan_layers=false async_checkpointing=False attention=dot_product \ tokenizer_path=src/maxtext/assets/tokenizers/tokenizer.gemma3 \ base_output_directory=gs://runner-maxtext-logs/gemma3-4b/multimodal/sft \ dataset_type=grain grain_file_type=parquet \ grain_train_files=gs://aireenmei-multipod/dataset/hf/chartqa/train-*pre-commit run --files ...passed (codespell,pylint,pyink,mdformat,yamllint).Checklist
gemini-reviewlabel.