Skip to content

Commit 437ee45

Browse files
s-chundiclaude
andcommitted
[train] Add VLM SFT support on the Megatron backend (Qwen3-VL)
Adds vision-language-model support to the Megatron SFT path, mirroring the existing FSDP VLM data plumbing. The vision tower runs on the first pipeline stage; image tensors (pixel_values / image_grid_thw) flow through the same TrainingInputBatch -> Experience path the FSDP backend already uses. Changes: - skyrl/utils/tok.py: add check_is_vlm and get_processor helpers. - skyrl/train/sft_trainer.py: detect VLMs in setup(), build an HF processor, thread it through tokenize_chat_example / _tokenize_chat_last_assistant, emit image tensors as a TensorList in collate_sft_batch, force sequential tokenization for VLMs, and disable sequence packing / microbatch padding removal (unsupported for VLMs). - megatron_model_wrapper.py: is_vlm flag, VLM constraint asserts (no packing, CP, or SP), and pixel_values/image_grid_thw passthrough in both forward and forward_backward_mini_batch. - megatron_worker.py: VLM detection off the HF config, plumb is_vlm into the wrapper, and forward image tensors through the micro-batch builders. - tests: GPU init/forward + 5-step train test (test_megatron_vlm_init.py), CPU tokenization/collation cases (test_sft_tokenization.py). Constraints carried over from the FSDP VLM path: no sample/microbatch packing, no context parallelism, no sequence parallelism, and homogeneous (all-image) batches. MoE+VLM is out of scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f65395 commit 437ee45

6 files changed

Lines changed: 543 additions & 27 deletions

File tree

skyrl/backends/skyrl_train/workers/megatron/megatron_model_wrapper.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ def __init__(
4040
actor_module: List[nn.Module],
4141
actor_optimizer: Optional[torch.optim.Optimizer] = None,
4242
policy_loss_fn: Optional[Callable] = None,
43+
is_vlm: bool = False,
4344
):
4445
self.cfg = config
4546
self.actor_module = actor_module
4647
self.actor_optimizer = actor_optimizer
4748
self.policy_loss_fn = policy_loss_fn
4849
self.remove_microbatch_padding = self.cfg.remove_microbatch_padding
50+
self.is_vlm = is_vlm
4951

5052
config = get_model_config(self.actor_module[0])
5153
# This is set to None by default: https://github.com/NVIDIA/Megatron-LM/blob/07b22a05136a3cb08ece05f7de38cf6aeeb165fb/megatron/core/model_parallel_config.py#L95
@@ -66,6 +68,18 @@ def eval(self):
6668
def __call__(self, *args, **kwargs):
6769
return self.forward(*args, **kwargs)
6870

71+
def _assert_vlm_supported(self):
72+
"""Guard the VLM parallelism constraints carried over from the FSDP path.
73+
74+
3D RoPE and multimodal token positions make sample/microbatch packing,
75+
context parallelism, and sequence parallelism unsafe for VLMs today.
76+
"""
77+
assert not self.remove_microbatch_padding, "VLM + microbatch padding removal unsupported"
78+
assert mpu.get_context_parallel_world_size() == 1, "VLM + context parallelism unsupported"
79+
assert (
80+
mpu.get_tensor_model_parallel_world_size() == 1 or self.cfg.policy.sequence_parallel_size == 1
81+
), "VLM + sequence parallelism unsupported"
82+
6983
def forward(
7084
self,
7185
micro_batches: List[dict],
@@ -86,6 +100,8 @@ def forward(
86100
Returns:
87101
torch.Tensor of concatenated log-probs across micro-batches (valid on pipeline last stage only).
88102
"""
103+
if self.is_vlm:
104+
self._assert_vlm_supported()
89105
forward_backward_func = get_forward_backward_func()
90106

91107
def collection_func(logits, data):
@@ -124,11 +140,20 @@ def forward_step(batch_iter, model):
124140
attention_mask = batch["attention_mask"].to(bool)
125141
position_ids = batch["position_ids"]
126142

143+
# VLM image tensors (Qwen-style). Only the first pipeline stage runs
144+
# the vision tower; later stages receive hidden states and never see
145+
# these tensors. The TensorList carries one variable-shape tensor per
146+
# sample, concatenated here into the dense layout the model expects.
147+
vlm_inputs = {}
148+
if batch.get("pixel_values") is not None:
149+
vlm_inputs["pixel_values"] = torch.cat(batch["pixel_values"].tensors, dim=0)
150+
vlm_inputs["image_grid_thw"] = torch.cat(batch["image_grid_thw"].tensors, dim=0)
151+
127152
if self.remove_microbatch_padding:
128153
new_sequences, packed_seq_params = preprocess_packed_seqs(
129154
sequences,
130155
attention_mask,
131-
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True),
156+
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True) or self.is_vlm,
132157
)
133158
new_attention_mask = None
134159
new_position_ids = None
@@ -137,7 +162,7 @@ def forward_step(batch_iter, model):
137162
sequences,
138163
attention_mask,
139164
position_ids,
140-
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True),
165+
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True) or self.is_vlm,
141166
)
142167
packed_seq_params = None
143168

@@ -146,6 +171,7 @@ def forward_step(batch_iter, model):
146171
new_position_ids,
147172
new_attention_mask,
148173
packed_seq_params=packed_seq_params,
174+
**vlm_inputs,
149175
)
150176

151177
if self.remove_microbatch_padding:
@@ -224,6 +250,8 @@ def forward_backward_mini_batch(
224250
Returns:
225251
List[dict]: one metrics dict per micro-batch in order.
226252
"""
253+
if self.is_vlm:
254+
self._assert_vlm_supported()
227255
forward_backward_func = get_forward_backward_func()
228256

229257
# Resolve loss function
@@ -434,11 +462,19 @@ def forward_step(batch_iter, model):
434462
sub_seq_lengths_field = batch.get("sub_seq_lengths")
435463
sub_seq_lengths = [t.tolist() for t in sub_seq_lengths_field] if sub_seq_lengths_field is not None else None
436464

465+
# VLM image tensors (Qwen-style). See the note in ``forward``: only
466+
# the first pipeline stage consumes these; the TensorList is one
467+
# variable-shape tensor per sample, concatenated into a dense batch.
468+
vlm_inputs = {}
469+
if batch.get("pixel_values") is not None:
470+
vlm_inputs["pixel_values"] = torch.cat(batch["pixel_values"].tensors, dim=0)
471+
vlm_inputs["image_grid_thw"] = torch.cat(batch["image_grid_thw"].tensors, dim=0)
472+
437473
if self.remove_microbatch_padding:
438474
new_sequences, packed_seq_params = preprocess_packed_seqs(
439475
sequences,
440476
attention_mask,
441-
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True),
477+
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True) or self.is_vlm,
442478
sub_seq_lengths=sub_seq_lengths,
443479
)
444480
new_attention_mask = None
@@ -448,7 +484,7 @@ def forward_step(batch_iter, model):
448484
sequences,
449485
attention_mask,
450486
position_ids,
451-
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True),
487+
pre_process=mpu.is_pipeline_first_stage(ignore_virtual=True) or self.is_vlm,
452488
)
453489
packed_seq_params = None
454490

@@ -457,6 +493,7 @@ def forward_step(batch_iter, model):
457493
new_position_ids,
458494
new_attention_mask,
459495
packed_seq_params=packed_seq_params,
496+
**vlm_inputs,
460497
)
461498

462499
if self.remove_microbatch_padding:

skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,11 @@ def init_configs(
333333
tokenizer = get_tokenizer(model_path, trust_remote_code=True)
334334
hf_config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
335335

336+
# VLM detection mirrors the FSDP path: a non-null ``vision_config`` on the
337+
# HF config means a vision tower is present. Megatron's TransformerConfig
338+
# has no such field, so this must be read off the HF config.
339+
self.is_vlm = hasattr(hf_config, "vision_config") and hf_config.vision_config is not None
340+
336341
override_config_kwargs = {
337342
"bos_token_id": tokenizer.bos_token_id,
338343
"eos_token_id": tokenizer.eos_token_id,
@@ -632,6 +637,7 @@ def init_model(self, model_path, num_training_steps: int = 1e9):
632637
actor_module=self.actor_module,
633638
actor_optimizer=self.optimizer,
634639
policy_loss_fn=self.policy_loss_fn,
640+
is_vlm=self.is_vlm,
635641
)
636642

637643
self.empty_cuda_cache = self.cfg.policy.megatron_config.empty_cuda_cache
@@ -673,13 +679,20 @@ def forward(
673679
rollout_expert_indices = micro.get("rollout_expert_indices")
674680
if rollout_expert_indices is not None:
675681
rollout_expert_indices = rollout_expert_indices.to(torch.int32)
682+
683+
vlm_inputs = {}
684+
if micro.get("pixel_values") is not None:
685+
vlm_inputs["pixel_values"] = micro.get("pixel_values")
686+
vlm_inputs["image_grid_thw"] = micro.get("image_grid_thw")
687+
676688
micro_dicts.append(
677689
{
678690
"sequences": sequences,
679691
"attention_mask": attention_mask,
680692
"position_ids": position_ids,
681693
"num_actions": num_actions,
682694
"rollout_expert_indices": (rollout_expert_indices if self.enable_router_replay else None),
695+
**vlm_inputs,
683696
}
684697
)
685698

@@ -719,6 +732,11 @@ def forward(
719732
if rollout_expert_indices is not None:
720733
rollout_expert_indices = rollout_expert_indices.to(torch.int32)
721734

735+
vlm_inputs = {}
736+
if experience.pixel_values is not None:
737+
vlm_inputs["pixel_values"] = experience.pixel_values
738+
vlm_inputs["image_grid_thw"] = experience.image_grid_thw
739+
722740
micro_buffer.append(
723741
{
724742
"sequences": sequences,
@@ -732,6 +750,7 @@ def forward(
732750
"rollout_action_logprobs": experience.rollout_logprobs,
733751
"action_mask": experience.action_mask,
734752
"rollout_expert_indices": rollout_expert_indices if self.enable_router_replay else None,
753+
**vlm_inputs,
735754
}
736755
)
737756

@@ -823,6 +842,11 @@ def forward_backward(
823842
if rollout_expert_indices is not None:
824843
rollout_expert_indices = rollout_expert_indices.to(torch.int32)
825844

845+
vlm_inputs = {}
846+
if experience.pixel_values is not None:
847+
vlm_inputs["pixel_values"] = experience.pixel_values
848+
vlm_inputs["image_grid_thw"] = experience.image_grid_thw
849+
826850
micro_buffer.append(
827851
{
828852
"sequences": sequences,
@@ -838,6 +862,7 @@ def forward_backward(
838862
"rollout_expert_indices": rollout_expert_indices if self.enable_router_replay else None,
839863
# used with global sequence packing
840864
"sub_seq_lengths": experience.sub_seq_lengths,
865+
**vlm_inputs,
841866
}
842867
)
843868

@@ -1196,13 +1221,20 @@ def forward(self, data: TrainingInputBatch) -> WorkerOutput:
11961221
rollout_expert_indices = micro.get("rollout_expert_indices")
11971222
if rollout_expert_indices is not None:
11981223
rollout_expert_indices = rollout_expert_indices.to(torch.int32)
1224+
1225+
vlm_inputs = {}
1226+
if micro.get("pixel_values") is not None:
1227+
vlm_inputs["pixel_values"] = micro.get("pixel_values")
1228+
vlm_inputs["image_grid_thw"] = micro.get("image_grid_thw")
1229+
11991230
micro_dicts.append(
12001231
{
12011232
"sequences": sequences,
12021233
"attention_mask": attention_mask,
12031234
"position_ids": position_ids,
12041235
"num_actions": num_actions,
12051236
"rollout_expert_indices": (rollout_expert_indices if self.enable_router_replay else None),
1237+
**vlm_inputs,
12061238
}
12071239
)
12081240

@@ -1287,7 +1319,7 @@ def init_model(self, model_path, num_training_steps: int = 1e9):
12871319
print_model_size(self.actor_module[0])
12881320

12891321
# create worker model
1290-
self.model = MegatronModelWrapper(config=self.cfg, actor_module=self.actor_module)
1322+
self.model = MegatronModelWrapper(config=self.cfg, actor_module=self.actor_module, is_vlm=self.is_vlm)
12911323

12921324
def _set_pad_token_id(self, pad_token_id):
12931325
# this already gets set in the init_model method

0 commit comments

Comments
 (0)