Skip to content

Commit a178120

Browse files
authored
Implement virtual pipeline parallelism support
Added support for virtual pipeline parallelism (VPP) by including vp_stage parameter in multiple functions and adjusting model and batch generation logic accordingly.
1 parent 7b88f7a commit a178120

1 file changed

Lines changed: 160 additions & 9 deletions

File tree

flagscale/train/megatron/train_qwen35.py

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import os
1818
import sys
19+
import time
1920
import logging
2021
from functools import partial
2122
from copy import deepcopy
@@ -33,6 +34,8 @@
3334
from megatron.core.utils import StragglerDetector
3435

3536
from 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
3639
from megatron.training import get_args, get_timers, get_tokenizer, print_rank_0
3740
from megatron.training.arguments import core_transformer_config_from_args
3841
from 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

207228
def 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

Comments
 (0)