Skip to content

Commit efaad1b

Browse files
committed
Static padding and insert video
1 parent 03cabc3 commit efaad1b

15 files changed

Lines changed: 788 additions & 73 deletions

File tree

src/maxtext/common/common_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class MultimodalInput:
8484
video_masks: Array | None = None
8585
audio_embeddings: Array | None = None
8686
audio_masks: Array | None = None
87+
audio_token_masks: Array | None = None
8788
bidirectional_mask: Array | None = None
8889
bidirectional_mask_video: Array | None = None
8990

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
base_config: "base.yml"
16+
17+
use_sft: true
18+
use_tunix_gradient_accumulation: true
19+
use_multimodal: true
20+
sft_train_on_completion_only: true
21+
packing: false # packing is not supported yet
22+
freeze_vision_encoder_params: true
23+
learning_rate: 2.e-5
24+
25+
# -------------- Model --------------
26+
model_name: "qwen3-omni-30b-a3b"
27+
tokenizer_path: "Qwen/Qwen3-Omni-30B-A3B-Instruct"
28+
29+
# -------------- HF pipeline --------------
30+
dataset_type: "hf"
31+
hf_path: "parquet"
32+
hf_train_files: "gs://YOUR_BUCKET/path/to/parquet/*.parquet"
33+
train_split: "train"
34+
train_data_columns: ["query", "label"]
35+
train_image_column: "video"
36+
37+
# Local SSD path for videos on the TPU VM
38+
video_directory: "/path/to/video_directory"

src/maxtext/configs/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,13 +1872,18 @@ class MultimodalGeneral(BaseModel):
18721872
description="Maximum number of images per example for training with image lists. -1 means no limit.",
18731873
)
18741874
video_path: PathStr = Field("", description="Path to a video for decoding.")
1875+
video_directory: PathStr = Field("", description="Local directory path containing video files for SFT.")
18751876
audio_path: PathStr = Field("", description="Path to an audio file for decoding.")
18761877
video_placeholder: str = Field("<|video|>", description="Placeholder string for video in text prompts.")
18771878
audio_placeholder: str = Field("<|audio|>", description="Placeholder string for audio in text prompts.")
18781879
use_audio_in_video: bool = Field(False, description="Extract and use audio from video files.")
18791880
use_mrope: bool = Field(False, description="Enable Multi-dimensional RoPE for Qwen3-Omni models.")
18801881
mrope_section: list[int] = Field([24, 20, 20], description="Dimensions for temporal, height, width in MRoPE.")
18811882
position_id_per_seconds: int = Field(25, description="Temporal granularity for MRoPE (tokens per second).")
1883+
filter_sft_sequences_by_length: bool = Field(
1884+
False,
1885+
description="Filter out multimodal SFT sequences that exceed max_prefill_predict_length or max_target_length.",
1886+
)
18821887

18831888

18841889
class VisionTower(BaseModel):

src/maxtext/input_pipeline/hf_data_processing.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ def vision_sft_preprocessing_pipeline(
5555
"""pipeline for multimodal SFT with HF dataset"""
5656

5757
assert len(text_columns) == 2, f"Need two text_columns for query and response, received {text_columns=}"
58+
59+
# Format conversations if columns are missing
60+
features_keys = list(dataset.features.keys()) if dataset.features else []
61+
if "conversations" in features_keys and not all(col in features_keys for col in text_columns):
62+
def format_llava_video_dataset(example):
63+
conversations = example["conversations"]
64+
query = ""
65+
label = ""
66+
for turn in conversations:
67+
if turn["from"] == "human" and not query:
68+
query = turn["value"]
69+
elif turn["from"] == "gpt" and not label:
70+
label = turn["value"]
71+
example[text_columns[0]] = query
72+
example[text_columns[1]] = label
73+
return example
74+
75+
dataset = dataset.map(format_llava_video_dataset)
76+
5877
# Tunix GA requires per-micro-batch slicing at the data level,
5978
# whereas Native GA processes the full batch and splits it internally.
6079
if config.elastic_enabled:
@@ -137,6 +156,18 @@ def vision_sft_preprocessing_pipeline(
137156
fn_kwargs={"column_name": text_columns[0], "config": config},
138157
)
139158

159+
# Filter out sequences exceeding max_prefill_predict_length or max_target_length
160+
if getattr(config, "filter_sft_sequences_by_length", False):
161+
max_prefill = getattr(config, "max_prefill_predict_length", 8192)
162+
max_target = getattr(config, "max_target_length", 8192 + 512)
163+
164+
def filter_by_length(example):
165+
prefill_len = len(example[text_columns[0]])
166+
response_len = len(example[text_columns[1]])
167+
return (prefill_len <= max_prefill) and (prefill_len + response_len <= max_target)
168+
169+
dataset = dataset.filter(filter_by_length)
170+
140171
dataset = input_pipeline_utils.HFDataSource(
141172
dataset=dataset,
142173
dataloading_host_index=dataloading_host_index,

src/maxtext/input_pipeline/input_pipeline_utils.py

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,25 @@ def _process_string(string_tensor):
9292

9393
def reformat_prompt(example, column, image_placeholder, model_name):
9494
"""reformat prompt for multimodal SFT"""
95-
if isinstance(example["images"], list):
96-
num_images = len(example["images"])
95+
if isinstance(example["images"], str):
96+
example[column] = mm_processor.reformat_prompt(
97+
example[column], image_placeholder, model_name, num_images=0, video_placeholder=image_placeholder, num_videos=1
98+
)
9799
else:
98-
num_images = 1
99-
example[column] = mm_processor.reformat_prompt(example[column], image_placeholder, model_name, num_images)
100+
if isinstance(example["images"], list):
101+
num_images = len(example["images"])
102+
else:
103+
num_images = 1
104+
example[column] = mm_processor.reformat_prompt(example[column], image_placeholder, model_name, num_images)
100105
return example
101106

102107

103108
def reformat_response(example, column, model_name):
104109
"""reformat response for multimodal SFT"""
105-
example[column] = mm_processor.reformat_response(example[column][0], model_name)
110+
val = example[column]
111+
if isinstance(val, (list, tuple)) and len(val) > 0:
112+
val = val[0]
113+
example[column] = mm_processor.reformat_response(val, model_name)
106114
return example
107115

108116

@@ -120,9 +128,17 @@ def merge_image_columns(example, image_columns, max_num_images_per_example):
120128

121129

122130
def pre_process_image_sft(example, image_column, config):
123-
"""pre-process image for multimodal SFT"""
131+
"""pre-process image or video for multimodal SFT"""
124132

125133
def _process_image_fn(image):
134+
if isinstance(image, str):
135+
import os
136+
137+
video_directory = getattr(config, "video_directory", "")
138+
if video_directory:
139+
image = os.path.join(video_directory, image)
140+
return mm_processor.preprocess_image_for_training(image, config)
141+
126142
if isinstance(image, list):
127143
image = [np.array(mm_utils.convert_to_RGB(img)) for img in image]
128144
else:
@@ -131,7 +147,7 @@ def _process_image_fn(image):
131147
image = mm_processor.preprocess_image_for_training(image, config)
132148
return image
133149

134-
example[image_column] = _process_image_fn(example[image_column])
150+
example[image_column] = _process_image_fn(example[image_column]) if example.get(image_column) is not None else None
135151
return example
136152

137153

@@ -702,12 +718,80 @@ def _pad_image_and_mask(self, preprocessed_image: mm_utils.PreprocessorOutput) -
702718
if not isinstance(preprocessed_image, mm_utils.PreprocessorOutput):
703719
raise TypeError(f"Input must be multimodal_utils.PreprocessorOutput, but got {type(preprocessed_image)}")
704720

705-
if preprocessed_image.pixel_values is None:
706-
raise ValueError("Input preprocessed_image must have pixel_values to pad images.")
707-
708721
if self.config.model_name and self.config.model_name.startswith("qwen3-omni"):
722+
# Pad video_values and audio_values to fixed shapes so grain.Batch can stack them.
723+
video_values = getattr(preprocessed_image, "video_values", None)
724+
video_grid_thw = getattr(preprocessed_image, "video_grid_thw", None)
725+
if video_values is not None:
726+
target_shape = mm_processor.get_dummy_video_shape_for_init(
727+
self.config.model_name, batch_size=1, config=self.config
728+
)
729+
# target_shape = (1, C, max_T_px, max_H_px, max_W_px)
730+
padded = np.zeros(target_shape[1:], dtype=video_values.dtype) # (C, max_T, max_H, max_W)
731+
_, c, t, h, w = video_values.shape
732+
max_t_px, max_h_px, max_w_px = target_shape[2], target_shape[3], target_shape[4]
733+
t_clip = min(t, max_t_px)
734+
h_clip = min(h, max_h_px)
735+
w_clip = min(w, max_w_px)
736+
padded[:, :t_clip, :h_clip, :w_clip] = video_values[0, :, :t_clip, :h_clip, :w_clip]
737+
preprocessed_image.video_values = padded
738+
739+
if video_grid_thw is not None:
740+
from maxtext.multimodal.processor_qwen3_omni import VIDEO_MAX_GRID_T, VIDEO_MAX_GRID_H, VIDEO_MAX_GRID_W
741+
merge_size = getattr(self.config, "spatial_merge_size_for_vit", 2)
742+
max_t = VIDEO_MAX_GRID_T
743+
max_h_merged = VIDEO_MAX_GRID_H // merge_size
744+
max_w_merged = VIDEO_MAX_GRID_W // merge_size
745+
746+
actual_t, actual_h, actual_w = video_grid_thw[0]
747+
actual_t = min(actual_t, VIDEO_MAX_GRID_T)
748+
actual_h = min(actual_h, VIDEO_MAX_GRID_H)
749+
actual_w = min(actual_w, VIDEO_MAX_GRID_W)
750+
751+
actual_h_merged = actual_h // merge_size
752+
actual_w_merged = actual_w // merge_size
753+
754+
mask_3d = np.zeros((max_t, max_h_merged, max_w_merged), dtype=np.int32)
755+
mask_3d[:actual_t, :actual_h_merged, :actual_w_merged] = 1
756+
preprocessed_image.video_mask = mask_3d.flatten()
757+
758+
print(
759+
f"[SFT_DEBUG] Padding Video: Original Pixel Shape: {video_values.shape}, Padded Pixel Shape: {padded.shape}. "
760+
f"Original Grid (THW): {video_grid_thw[0]}, Clipped Grid: [{actual_t}, {actual_h}, {actual_w}]. "
761+
f"Video Mask Shape (flattened): {preprocessed_image.video_mask.shape}, Valid tokens count: {np.sum(preprocessed_image.video_mask)}"
762+
)
763+
764+
audio_values = getattr(preprocessed_image, "audio_values", None)
765+
audio_lengths = getattr(preprocessed_image, "audio_lengths", None)
766+
if audio_values is not None:
767+
target_audio = mm_processor.get_dummy_audio_shape_for_sft(
768+
self.config.model_name, batch_size=1, config=self.config
769+
)
770+
# target_audio = (1, num_mel_bins, AUDIO_MAX_TIME)
771+
_, mel, t_audio = audio_values.shape
772+
padded_audio = np.zeros(target_audio[1:], dtype=audio_values.dtype) # (mel, max_time)
773+
padded_audio[:, :t_audio] = audio_values[0]
774+
preprocessed_image.audio_values = padded_audio
775+
776+
if audio_lengths is not None:
777+
from maxtext.multimodal.processor_qwen3_omni import AUDIO_MAX_TIME, _get_feat_extract_output_lengths
778+
max_audio_tokens = _get_feat_extract_output_lengths(AUDIO_MAX_TIME)
779+
actual_audio_tokens = audio_lengths[0]
780+
781+
audio_token_mask = np.zeros(max_audio_tokens, dtype=np.int32)
782+
audio_token_mask[:actual_audio_tokens] = 1
783+
preprocessed_image.audio_token_mask = audio_token_mask
784+
785+
print(
786+
f"[SFT_DEBUG] Padding Audio: Original Mel Shape: {audio_values.shape}, Padded Mel Shape: {padded_audio.shape}. "
787+
f"Audio Mask Shape: {preprocessed_image.audio_token_mask.shape}, Valid audio tokens count: {np.sum(preprocessed_image.audio_token_mask)}"
788+
)
789+
709790
return preprocessed_image
710791

792+
if preprocessed_image.pixel_values is None:
793+
raise ValueError("Input preprocessed_image must have pixel_values to pad images.")
794+
711795
# Determine the maximum number of images/masks allowed.
712796
image_offsets = mm_processor.get_image_offsets(self.config, preprocessed_image)
713797
single_image_offset = image_offsets // preprocessed_image.pixel_values.shape[0]
@@ -812,6 +896,27 @@ def map(self, element: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
812896
if preprocessed_image.pixel_mask is not None:
813897
output["image_masks"] = preprocessed_image.pixel_mask
814898

899+
# Extract video and audio tensors from Qwen3OmniPreprocessorOutput.
900+
video_values = getattr(preprocessed_image, "video_values", None)
901+
if video_values is not None:
902+
output["videos"] = video_values
903+
video_grid_thw = getattr(preprocessed_image, "video_grid_thw", None)
904+
if video_grid_thw is not None:
905+
output["video_grid_thw"] = video_grid_thw
906+
video_mask = getattr(preprocessed_image, "video_mask", None)
907+
if video_mask is not None:
908+
output["video_masks"] = video_mask
909+
910+
audio_values = getattr(preprocessed_image, "audio_values", None)
911+
if audio_values is not None:
912+
output["audios"] = audio_values
913+
audio_lengths = getattr(preprocessed_image, "audio_lengths", None)
914+
if audio_lengths is not None:
915+
output["audio_lengths"] = audio_lengths
916+
audio_token_mask = getattr(preprocessed_image, "audio_token_mask", None)
917+
if audio_token_mask is not None:
918+
output["audio_token_masks"] = audio_token_mask
919+
815920
return output
816921

817922

src/maxtext/layers/decoders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,7 @@ def _apply_embedding(
752752
text_embeddings=y,
753753
multimodal_embeddings=audio_embeddings,
754754
mask=audio_masks,
755-
token_masks=None,
755+
token_masks=getattr(multimodal_input, "audio_token_masks", None),
756756
)
757757
else:
758758
raise ValueError(f"Unsupported model_name for audio: {cfg.model_name}")

src/maxtext/layers/encoders.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ def __call__(self, input_images, deterministic=False):
9494
else:
9595
embeddings = encoder_output
9696

97+
if self.config.model_name in ["qwen3-omni-30b-a3b"]:
98+
jax.debug.print(
99+
"[SFT_DEBUG] VisionEncoder: Input Shape: {x}, Encoder Output Shape: {y}",
100+
x=input_images.shape,
101+
y=embeddings.shape
102+
)
103+
97104
if self.config.freeze_vision_encoder_params:
98105
embeddings = jax.lax.stop_gradient(embeddings)
99106
if deep_feats is not None:
@@ -103,6 +110,12 @@ def __call__(self, input_images, deterministic=False):
103110
projector = getattr(self, self.projector_name)
104111
embeddings = projector(embeddings)
105112

113+
if self.config.model_name in ["qwen3-omni-30b-a3b"]:
114+
jax.debug.print(
115+
"[SFT_DEBUG] VisionEncoder: Projector Output Shape: {x}",
116+
x=embeddings.shape
117+
)
118+
106119
return embeddings, deep_feats
107120

108121

src/maxtext/models/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def __call__(
130130
encoder_videos: None | jnp.ndarray = None,
131131
encoder_video_masks: None | jnp.ndarray = None,
132132
encoder_audios: None | jnp.ndarray = None,
133+
encoder_audio_token_masks: None | jnp.ndarray = None,
133134
enable_dropout=True,
134135
model_mode=MODEL_MODE_TRAIN,
135136
previous_chunk=None,
@@ -195,6 +196,7 @@ def __call__(
195196
video_masks=encoder_video_masks,
196197
audio_embeddings=audio_embeddings,
197198
audio_masks=audio_masks,
199+
audio_token_masks=encoder_audio_token_masks,
198200
bidirectional_mask=bidirectional_mask_image,
199201
bidirectional_mask_video=bidirectional_mask_video,
200202
)
@@ -443,6 +445,7 @@ def __call__(
443445
encoder_videos: jax.Array | None = None,
444446
encoder_video_masks: jax.Array | None = None,
445447
encoder_audios: jax.Array | None = None,
448+
encoder_audio_token_masks: jax.Array | None = None,
446449
enable_dropout=True,
447450
model_mode=MODEL_MODE_TRAIN,
448451
previous_chunk=None,
@@ -522,6 +525,7 @@ def __call__(
522525
video_masks=encoder_video_masks,
523526
audio_embeddings=audio_embeddings,
524527
audio_masks=audio_masks,
528+
audio_token_masks=encoder_audio_token_masks,
525529
bidirectional_mask=bidirectional_mask_image,
526530
bidirectional_mask_video=bidirectional_mask_video,
527531
)

src/maxtext/models/qwen3.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1906,7 +1906,7 @@ def __init__(self, config: Config, *, mesh=None, rngs: nnx.Rngs = None):
19061906
num_kv_heads=self.config.num_attention_heads_for_vit,
19071907
head_dim=head_dim,
19081908
max_target_length=self.config.num_position_embeddings_for_vit,
1909-
attention_kernel="dot_product",
1909+
attention_kernel="autoselected",
19101910
inputs_q_shape=(1, 1, self.config.hidden_size_for_vit),
19111911
inputs_kv_shape=(1, 1, self.config.hidden_size_for_vit),
19121912
float32_qk_product=self.config.float32_qk_product,

src/maxtext/multimodal/processor.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,13 @@ def preprocess_image_for_training(image, config):
6969

7070
return preprocess_mm_data_llama4(image)
7171
elif config.model_name in ["qwen3-omni-30b-a3b", "qwen3.5-35b-a3b", "qwen3.5-397b-a17b"]:
72-
from maxtext.multimodal.processor_qwen3_omni import preprocess_mm_data_qwen3_omni_for_training # pylint: disable=import-outside-toplevel
72+
from maxtext.multimodal.processor_qwen3_omni import preprocess_mm_data_qwen3_omni_for_training, preprocess_mm_data_qwen3_omni_for_training_video # pylint: disable=import-outside-toplevel
7373

74-
return preprocess_mm_data_qwen3_omni_for_training(image, config)
74+
if isinstance(image, str):
75+
use_audio_in_video = getattr(config, "use_audio_in_video", False)
76+
return preprocess_mm_data_qwen3_omni_for_training_video(image, config)
77+
else:
78+
return preprocess_mm_data_qwen3_omni_for_training(image, config)
7579
else:
7680
raise ValueError(f"Model {config.model_name} not supported for image preprocessing.")
7781

@@ -188,6 +192,24 @@ def get_dummy_image_shape_for_init(model_name, batch_size=1, num_image_per_seque
188192
return image_shape
189193

190194

195+
def get_dummy_video_shape_for_init(model_name, batch_size=1, config=None):
196+
"""Return the fixed padded shape for video batch tensors used in SFT training."""
197+
if model_name in ["qwen3-omni-30b-a3b", "qwen3.5-35b-a3b", "qwen3.5-397b-a17b"]:
198+
from maxtext.multimodal.processor_qwen3_omni import get_dummy_video_shape_for_init_qwen3_omni # pylint: disable=import-outside-toplevel
199+
200+
return get_dummy_video_shape_for_init_qwen3_omni(batch_size, config)
201+
return ()
202+
203+
204+
def get_dummy_audio_shape_for_sft(model_name, batch_size=1, config=None):
205+
"""Return the fixed padded shape for audio batch tensors used in SFT training."""
206+
if model_name in ["qwen3-omni-30b-a3b"]:
207+
from maxtext.multimodal.processor_qwen3_omni import get_dummy_audio_shape_for_init_qwen3_omni_sft # pylint: disable=import-outside-toplevel
208+
209+
return get_dummy_audio_shape_for_init_qwen3_omni_sft(batch_size, config)
210+
return ()
211+
212+
191213
def get_dummy_audio_shape_for_init(config):
192214
"""Return the shape of the dummy audio for specific model's initialization.
193215

0 commit comments

Comments
 (0)