Skip to content

Commit c52ebcc

Browse files
rootcursoragent
authored andcommitted
fix(trainer): always run VLM vision encoder for FSDP+EP collective symmetry
A text-only micro-batch (no images, or all images truncated away) made its rank skip the vision encoder's FSDP all-gather, desyncing collectives across ranks under FSDP + expert parallelism and deadlocking the run (NCCL timeout). Always invoke the frozen vision encoder, feeding a tiny dummy image when none is present, and skip the masked_scatter in that case so LM embeddings are untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8bcc65a commit c52ebcc

1 file changed

Lines changed: 24 additions & 3 deletions

File tree

src/prime_rl/trainer/models/qwen3_5_moe/modeling_qwen3_5_moe.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,16 @@ def get_input_embeddings(self):
822822
def set_input_embeddings(self, value):
823823
self.language_model.embed_tokens = value
824824

825+
def _dummy_vision_inputs(self, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
826+
"""Smallest valid vision input: a single merged token (grid [1, m, m])."""
827+
vcfg = self.config.vision_config
828+
m = vcfg.spatial_merge_size
829+
num_patches = m * m
830+
patch_dim = vcfg.in_channels * vcfg.temporal_patch_size * vcfg.patch_size * vcfg.patch_size
831+
pixel_values = torch.zeros(num_patches, patch_dim, device=device, dtype=self.visual.dtype)
832+
grid_thw = torch.tensor([[1, m, m]], dtype=torch.long, device=device)
833+
return pixel_values, grid_thw
834+
825835
def forward(
826836
self,
827837
input_ids: torch.LongTensor | None = None,
@@ -835,11 +845,22 @@ def forward(
835845
if inputs_embeds is None:
836846
inputs_embeds = self.language_model.embed_tokens(input_ids)
837847

838-
if pixel_values is not None:
848+
# Always run the vision encoder for collective symmetry: under FSDP + EP
849+
# all ranks share one process group, so every rank must issue the vision
850+
# encoder's all-gathers in the same order each step. A text-only micro-batch
851+
# would otherwise skip them and desync the collectives, deadlocking the run.
852+
# The encoder is frozen, so the dummy pass produces no gradients/backward
853+
# collectives; we discard its embeds by skipping the masked_scatter.
854+
has_images = pixel_values is not None
855+
if has_images:
839856
pixel_values = pixel_values.type(self.visual.dtype)
840-
vision_output = self.visual(pixel_values, grid_thw=image_grid_thw, return_dict=True)
841-
image_embeds = vision_output.pooler_output.to(inputs_embeds.device, inputs_embeds.dtype)
857+
else:
858+
pixel_values, image_grid_thw = self._dummy_vision_inputs(inputs_embeds.device)
859+
860+
vision_output = self.visual(pixel_values, grid_thw=image_grid_thw, return_dict=True)
861+
image_embeds = vision_output.pooler_output.to(inputs_embeds.device, inputs_embeds.dtype)
842862

863+
if has_images:
843864
image_mask = input_ids == self.config.image_token_id
844865
image_mask = image_mask.unsqueeze(-1).expand_as(inputs_embeds).to(inputs_embeds.device)
845866
inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)

0 commit comments

Comments
 (0)