1616
1717import os
1818import sys
19+ import time
1920import logging
2021from functools import partial
2122from copy import deepcopy
3334from megatron .core .utils import StragglerDetector
3435
3536from megatron .training .utils import unwrap_model
37+ from megatron .training .utils import is_first_or_last_pipeline_stage
38+ from megatron .core .transformer .multi_token_prediction import mtp_on_this_rank
3639from megatron .training import get_args , get_timers , get_tokenizer , print_rank_0
3740from megatron .training .arguments import core_transformer_config_from_args
3841from megatron .training .yaml_arguments import core_transformer_config_from_yaml
@@ -100,8 +103,25 @@ def model_provider(
100103 args = get_args ()
101104 print_rank_0 ("start building qwen3.5 model ..." )
102105
106+ # NOTE(vpp): When virtual pipeline parallelism is on, Megatron's get_model()
107+ # builds one chunk per virtual stage and passes vp_stage=i via kwargs. We must
108+ # forward it to Qwen35Model so MTP placement (mtp_on_this_rank) and language
109+ # layer counting land on the correct virtual stage. Defaults to None (VPP off).
110+ vp_stage = kwargs .get ("vp_stage" , None )
111+
103112 # Build transformer config with Qwen35 config class
104113 config = core_transformer_config_from_args (args , Qwen35TransformerConfig )
114+ # 20260726: yaml-driven configs go through validate_yaml(), which skips
115+ # Megatron's validate_args() where virtual_pipeline_model_parallel_size is
116+ # derived from --num-layers-per-virtual-pipeline-stage; backfill it here.
117+ if (
118+ getattr (config , "virtual_pipeline_model_parallel_size" , None ) is None
119+ and getattr (args , "num_layers_per_virtual_pipeline_stage" , None ) is not None
120+ ):
121+ num_layers_per_stage = args .num_layers // args .transformer_pipeline_model_parallel_size
122+ config .virtual_pipeline_model_parallel_size = (
123+ num_layers_per_stage // args .num_layers_per_virtual_pipeline_stage
124+ )
105125 # Qwen3.5 uses zero-centered gamma for RMSNorm; override if needed
106126 # (core_transformer_config_from_args may be affected by apply_layernorm_1p)
107127 config .layernorm_zero_centered_gamma = getattr (args , 'layernorm_zero_centered_gamma' , True )
@@ -124,7 +144,7 @@ def model_provider(
124144 print_rank_0 ("building Qwen3.5 model in TE..." )
125145
126146 # Language model spec: hybrid GDN + Attention
127- language_layer_spec = get_qwen35_language_model_spec (config )
147+ language_layer_spec = get_qwen35_language_model_spec (config , vp_stage = vp_stage )
128148
129149 # Vision model spec (identical to Qwen3-VL)
130150 vision_model_spec = get_qwen3vl_vision_model_spec ()
@@ -134,7 +154,7 @@ def model_provider(
134154 config .variable_seq_lengths = True
135155
136156 # MTP (Multi-Token Prediction) spec
137- mtp_block_spec = get_qwen35_mtp_block_spec (args , config )
157+ mtp_block_spec = get_qwen35_mtp_block_spec (args , config , vp_stage = vp_stage )
138158
139159 args .padded_vocab_size = args .vocab_size
140160 model = Qwen35Model (
@@ -162,6 +182,7 @@ def model_provider(
162182 parallel_output = True ,
163183 language_share_embeddings_and_output_weights = not args .untie_embeddings_and_output_weights ,
164184 mtp_block_spec = mtp_block_spec ,
185+ vp_stage = vp_stage ,
165186 )
166187
167188 model .freeze (
@@ -205,7 +226,7 @@ def get_ltor_masks_and_position_ids(
205226
206227
207228def get_batch (
208- data_iterator , model : Qwen35Model = None
229+ data_iterator , model : Qwen35Model = None , vp_stage : int = None
209230) -> Tuple :
210231 """Generate a batch."""
211232 imgs = None
@@ -215,16 +236,52 @@ def get_batch(
215236 attention_mask = None
216237 position_ids = None
217238
239+ # NOTE(vpp): Under virtual pipeline parallelism the interleaved scheduler calls
240+ # forward_step once per local model chunk. Middle chunks (pre_process=False)
241+ # receive their input activations via set_input_tensor, so tokens/labels/vision
242+ # from the batch are unused for them -- but mRoPE position_ids and
243+ # attention_mask are data-dependent, so middle chunks must still pull data
244+ # (each chunk has its own data iterator under VPP, kept in sync by the
245+ # scheduler) and compute them; see the is_middle_vpp_chunk return branch
246+ # below. When vp_stage is None (VPP off) is_first_or_last_pipeline_stage
247+ # checks the physical pipeline stage, so plain-PP middle stages (e.g. pp4)
248+ # take the same middle-chunk path -- unlike train_gpt.py, they still need
249+ # position_ids because this model's rotary embedding is data-dependent.
250+ args = get_args ()
251+ config = core_transformer_config_from_args (args , Qwen35TransformerConfig )
252+ is_middle_vpp_chunk = not is_first_or_last_pipeline_stage (
253+ vp_stage
254+ ) and not mtp_on_this_rank (config , ignore_virtual = False , vp_stage = vp_stage )
255+ # NOTE(mrope-fix): do NOT early-return all-None for middle chunks without a
256+ # data iterator. Data loaders are built on TP rank 0 only, so non-TP0 ranks
257+ # of a middle pipeline stage always have data_iterator=None; they receive the
258+ # batch via broadcast_data() below (data=None path) and must still compute
259+ # the data-dependent mRoPE position_ids -- the decoder of a middle chunk
260+ # needs them for its rotary embedding. Returning all-None fed
261+ # position_ids=None into the model and crashed in rope.py
262+ # ('NoneType' object has no attribute 'ndim'), while rank 0 of the same
263+ # stage blocked in broadcast_data waiting for the TP peers that had
264+ # already returned. (Hit on plain pp4 without VPP, 2026-08-10.)
265+
218266 cur_platform .range_push ("get_data" )
219267 if data_iterator is not None and get_tensor_model_parallel_rank () == 0 :
268+ # 20260804 data-pipeline probe (FLAGS_PROFILE_DATA=1): time the blocking
269+ # next() — queue wait for dataloader workers (decode+I/O aggregated).
270+ # Per-call line lets post-analysis correlate fetch stalls with iteration
271+ # time spikes (data-dependent shard glitches).
272+ _data_prof = os .getenv ("FLAGS_PROFILE_DATA" , "0" ) == "1"
273+ _t0 = time .perf_counter () if _data_prof else 0.0
220274 data = next (data_iterator )
275+ _fetch_ms = (time .perf_counter () - _t0 ) * 1e3 if _data_prof else - 1.0
221276 pad_token_id = IGNORE_IDX
222277 while (data ["target" ] == pad_token_id ).all ():
223278 logging .getLogger (__name__ ).warning (
224279 "The current data is invalid because the target is all pad_token_id! "
225280 "Get next data to avoid fail, but it's better to check the data!"
226281 )
227282 data = next (data_iterator )
283+ if _data_prof :
284+ print (f"[DATA_FETCH] wait_ms={ _fetch_ms :.1f} " , flush = True )
228285 else :
229286 data = None
230287
@@ -266,6 +323,26 @@ def get_batch(
266323 )
267324 cur_platform .range_pop ()
268325
326+ if is_middle_vpp_chunk :
327+ # mRoPE position_ids are data-dependent: every VPP chunk's decoder needs
328+ # them for rotary embeddings, so middle chunks must also pull data (each
329+ # chunk has its own data iterator under VPP, kept in sync by the
330+ # scheduler) and compute position_ids/attention_mask. Vision tensors and
331+ # labels are unused when pre_process is False.
332+ return (
333+ None ,
334+ None ,
335+ None ,
336+ attention_mask ,
337+ position_ids ,
338+ None ,
339+ None ,
340+ None ,
341+ None ,
342+ None ,
343+ None ,
344+ )
345+
269346 return (
270347 tokens ,
271348 labels ,
@@ -335,11 +412,25 @@ def loss_func(
335412 return (loss , num_tokens , {'lm loss' : reporting_loss })
336413
337414
338- def forward_step (data_iterator , model : Qwen35Model ):
339- """Forward training step."""
415+ def forward_step (data_iterator , model : Qwen35Model , return_schedule_plan : bool = False ):
416+ """Forward training step.
417+
418+ Args:
419+ data_iterator: Input data iterator
420+ model (Qwen35Model): The Qwen3.5 multimodal model
421+ return_schedule_plan (bool): Whether to return a schedule plan (for
422+ overlap_moe_expert_parallel_comm / combined_1f1b) instead of the
423+ output tensor. Mirrors train_deepseek_v4.forward_step.
424+ """
340425 args = get_args ()
341426 timers = get_timers ()
342427
428+ # NOTE(vpp): The interleaved (VPP) scheduler invokes forward_step once per local
429+ # model chunk and tags each chunk's model with .vp_stage. Extract it so get_batch
430+ # can decide whether this chunk needs real data. unwrap_model peels DDP/Float16
431+ # wrappers; vp_stage defaults to None when VPP is off.
432+ vp_stage = getattr (unwrap_model (model ), "vp_stage" , None )
433+
343434 timers ('batch-generator' , log_level = 2 ).start ()
344435 global stimer
345436 with stimer (bdata = True ):
@@ -355,12 +446,65 @@ def forward_step(data_iterator, model: Qwen35Model):
355446 video_thw_grids ,
356447 image_input_mask ,
357448 video_input_mask ,
358- ) = get_batch (data_iterator , model = unwrap_model (model ))
449+ ) = get_batch (data_iterator , model = unwrap_model (model ), vp_stage = vp_stage )
359450 timers ('batch-generator' ).stop ()
360451
452+ # Middle VPP chunks (pre_process=False) receive their input activation via
453+ # set_input_tensor and get None tokens/vision from get_batch. Skip vision
454+ # assembly and let Qwen35Model.forward take its non-pre_process branch
455+ # (vision ignored). position_ids/attention_mask are real: the decoder of a
456+ # middle chunk still needs them for mRoPE and variable-length attention.
457+ if tokens is None :
458+ # 20260727: combined_1f1b (EP A2A overlap) requires forward_step to
459+ # return a schedule plan for EVERY chunk, including middle VPP chunks
460+ # (pp2+vpp2 means both ranks have middle chunks). Build the plan with
461+ # input_ids=None; the wrapper delegates to the language model, whose
462+ # chunk takes its input via set_input_tensor.
463+ if return_schedule_plan :
464+ with stimer :
465+ schedule_plan = model .build_schedule_plan (
466+ input_ids = None ,
467+ position_ids = position_ids ,
468+ vision_data = None ,
469+ vision_grid_thw = None ,
470+ attention_mask = attention_mask ,
471+ labels = None ,
472+ loss_mask = loss_mask ,
473+ )
474+ return schedule_plan , partial (loss_func , loss_mask , model = model )
475+ with stimer :
476+ output_tensor = model (
477+ input_ids = None ,
478+ position_ids = position_ids ,
479+ vision_data = None ,
480+ vision_grid_thw = None ,
481+ attention_mask = attention_mask ,
482+ labels = None ,
483+ )
484+ return output_tensor , partial (loss_func , loss_mask , model = model )
485+
361486 vision_data = torch .cat ([imgs , videos ], dim = 0 )
362487 vision_grid = torch .cat ([image_thw_grids , video_thw_grids ], dim = 0 )
363488
489+ if return_schedule_plan :
490+ assert args .overlap_moe_expert_parallel_comm , (
491+ "overlap_moe_expert_parallel_comm must be enabled to return the schedule plan"
492+ )
493+ with stimer :
494+ schedule_plan = model .build_schedule_plan (
495+ input_ids = tokens ,
496+ position_ids = position_ids ,
497+ vision_data = vision_data ,
498+ vision_grid_thw = vision_grid ,
499+ video_start_index = image_input_mask .sum ().cpu ().item (),
500+ image_input_mask = image_input_mask ,
501+ video_input_mask = video_input_mask ,
502+ attention_mask = attention_mask ,
503+ labels = labels ,
504+ loss_mask = loss_mask ,
505+ )
506+ return schedule_plan , partial (loss_func , loss_mask , model = model )
507+
364508 with stimer :
365509 output_tensor = model (
366510 input_ids = tokens ,
@@ -372,7 +516,6 @@ def forward_step(data_iterator, model: Qwen35Model):
372516 video_input_mask = video_input_mask ,
373517 attention_mask = attention_mask ,
374518 labels = labels ,
375- loss_mask = loss_mask ,
376519 )
377520
378521 return output_tensor , partial (loss_func , loss_mask , model = model )
@@ -437,8 +580,16 @@ def is_dataloader_rank(transformer_pipeline_model_parallel_size):
437580 return is_first_rank
438581
439582
440- def train_valid_test_dataloaders_provider (train_val_test_num_samples ):
441- """Build multimodal train, validation and test dataloaders."""
583+ def train_valid_test_dataloaders_provider (train_val_test_num_samples , vp_stage = None ):
584+ """Build multimodal train, validation and test dataloaders.
585+
586+ NOTE(vpp): vp_stage is required as a kwarg when VPP is on -- pretrain() builds
587+ one data iterator per virtual stage via functools.partial(provider, vp_stage=i)
588+ and asserts the signature accepts it. The energon multimodal stream is identical
589+ across virtual stages (all chunks would see the same samples), so we accept the
590+ argument but do not use it for data partitioning. Per-chunk data gating happens
591+ later in get_batch (only first/last/MTP chunks actually pull from the iterator).
592+ """
442593 args = get_args ()
443594 if not is_dataloader_rank (args .transformer_pipeline_model_parallel_size ):
444595 return None , None , None
0 commit comments