[megatron][vlm] Context parallelism for Qwen3-VL / Qwen3.5-VL - #2404
xinyuangui2 wants to merge 8 commits into
Conversation
Megatron-Bridge's Qwen3-VL model rebuilds 3D mRoPE positions per packed sub-sequence when it receives a THD stream with position_ids=None, so the existing packed path works for VLMs. Context parallelism stays blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuNZVD7sZm2e3tySMqTsCG Signed-off-by: xgui <xgui@anyscale.com>
…dDeltaNet layers The double-packing guard from NovaSky-AI#1769 matched every Qwen3VLModel, so it also blocked dense Qwen3-VL, which takes SkyRL's [1, T] THD stream without re-packing. Found by the 4xH100 A/B run (worker init ValueError). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuNZVD7sZm2e3tySMqTsCG Signed-off-by: xgui <xgui@anyscale.com>
Megatron-Bridge#4532 (in the pinned 8e7077c6) makes Qwen3VLModel treat a caller-packed [1, T] THD stream as authoritative: no second compaction, and the caller's packed_seq_params reach every layer unchanged. The guard from NovaSky-AI#1769 (model_packs_sequences_internally) guarded against the pre-#4532 behaviour, so drop it, its stale GPU-test case, and comments that said the VL path cannot pack. language_model_only stays: it skips the vision tower for text-only Qwen3.5 training. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuNZVD7sZm2e3tySMqTsCG Signed-off-by: xgui <xgui@anyscale.com>
…n3-VL and Qwen3.5-VL Variable-length image samples plus one text-only row, microbatches of 4 and a trailing single-sample microbatch, TP=1 and TP=2+SP. Compares every scored token and splits the stats by slot in the packed microbatch, so a sample-boundary leak (mRoPE restart, or GatedDeltaNet state/conv carried over) shows up as later slots being worse than slot 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZKuFwA4VYK2tfFDVgsWt2 Signed-off-by: xgui <xgui@anyscale.com>
…e model apply the CP split For VLMs, preprocess_packed_seqs(shard_for_cp=False) keeps the full packed stream on every CP rank (sub-sequences still padded to 2*CP alignment) and Megatron-Bridge's Qwen3VLModel computes mRoPE, places image features and deepstack, and applies the CP partition itself (Megatron-Bridge#4532). Text models keep SkyRL's pre-split, which GPTModel requires. The bridge's Qwen VL providers force calculate_per_token_loss under CP and the model asserts it at construction; SkyRL's loss function returns no token count and uses the default scaling, so a pre-DDP-wrap hook clears the flag again. VLM+CP+MTP raises (TODO). New GPU test compares CP=2 with CP=1 on per-token logprobs and grad norm for Qwen3-VL-2B and Qwen3.5-0.8B. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZKuFwA4VYK2tfFDVgsWt2 Signed-off-by: xgui <xgui@anyscale.com>
There was a problem hiding this comment.
Code Review
This pull request enables and tests context parallelism (CP) and sample packing for Vision-Language Models (VLMs) such as Qwen3-VL. It introduces a shard_for_cp option in sequence preprocessing to keep the full packed stream on each CP rank, and adds a keep_skyrl_loss_scaling pre-wrap hook to preserve default loss scaling under CP. New tests are added to verify packed-vs-unpacked parity and CP-vs-no-CP parity. Feedback on the changes suggests making the keep_skyrl_loss_scaling hook more robust by handling both single nn.Module and iterable inputs to prevent potential runtime TypeErrors.
| def keep_skyrl_loss_scaling(model: List[nn.Module]) -> List[nn.Module]: | ||
| """Pre-DDP-wrap hook: undo ``calculate_per_token_loss`` forced on by Megatron-Bridge. | ||
|
|
||
| True for ``Qwen3VLModel`` (e.g. Qwen3.5 via the VL bridge), which would | ||
| double-pack and corrupt the GDN ``cu_seqlens`` under SkyRL sample packing, so | ||
| :class:`MegatronModelWrapper` refuses packing for it. Returns ``False`` when | ||
| mbridge / Qwen3VL is not importable, so other models are unaffected. | ||
| The bridge's Qwen VL providers turn on ``calculate_per_token_loss`` whenever | ||
| CP > 1, and ``Qwen3VLModel.__init__`` asserts it. The flag only changes how | ||
| Megatron normalizes the loss: DDP then skips its 1/(dp*cp) scaling and | ||
| ``finalize_model_grads`` divides by the token count the loss function returns. | ||
| SkyRL's loss function returns ``(loss, metrics)`` without a token count and | ||
| relies on the default scaling, the same as for text models under CP. So clear | ||
| the flag after the model is built and before DDP reads it. | ||
| """ | ||
| try: | ||
| from megatron.bridge.models.qwen_vl.modelling_qwen3_vl.model import ( | ||
| Qwen3VLModel, | ||
| ) | ||
| except ImportError: | ||
| return False | ||
|
|
||
| chunks = model if isinstance(model, (list, tuple)) else [model] | ||
| for chunk in chunks: | ||
| unwrapped = unwrap_model(chunk) | ||
| unwrapped_list = unwrapped if isinstance(unwrapped, (list, tuple)) else [unwrapped] | ||
| if any(isinstance(m, Qwen3VLModel) for m in unwrapped_list): | ||
| return True | ||
| return False | ||
| seen = set() | ||
| for chunk in model: | ||
| for module in chunk.modules(): | ||
| config = getattr(module, "config", None) | ||
| if config is not None and id(config) not in seen and getattr(config, "calculate_per_token_loss", False): | ||
| config.calculate_per_token_loss = False | ||
| seen.add(id(config)) | ||
| return model |
There was a problem hiding this comment.
The keep_skyrl_loss_scaling pre-wrap hook assumes that model is always an iterable (e.g., a list or tuple of modules). However, depending on the provider and configuration, the pre-wrap hook might be invoked with a single nn.Module instead of a list. If that happens, iterating over model will raise a TypeError: '...' object is not iterable at runtime.
To make this hook robust and prevent potential crashes, we should handle both a single nn.Module and a list/tuple of modules, similar to how it was done in the previous model_packs_sequences_internally helper.
| def keep_skyrl_loss_scaling(model: List[nn.Module]) -> List[nn.Module]: | |
| """Pre-DDP-wrap hook: undo ``calculate_per_token_loss`` forced on by Megatron-Bridge. | |
| True for ``Qwen3VLModel`` (e.g. Qwen3.5 via the VL bridge), which would | |
| double-pack and corrupt the GDN ``cu_seqlens`` under SkyRL sample packing, so | |
| :class:`MegatronModelWrapper` refuses packing for it. Returns ``False`` when | |
| mbridge / Qwen3VL is not importable, so other models are unaffected. | |
| The bridge's Qwen VL providers turn on ``calculate_per_token_loss`` whenever | |
| CP > 1, and ``Qwen3VLModel.__init__`` asserts it. The flag only changes how | |
| Megatron normalizes the loss: DDP then skips its 1/(dp*cp) scaling and | |
| ``finalize_model_grads`` divides by the token count the loss function returns. | |
| SkyRL's loss function returns ``(loss, metrics)`` without a token count and | |
| relies on the default scaling, the same as for text models under CP. So clear | |
| the flag after the model is built and before DDP reads it. | |
| """ | |
| try: | |
| from megatron.bridge.models.qwen_vl.modelling_qwen3_vl.model import ( | |
| Qwen3VLModel, | |
| ) | |
| except ImportError: | |
| return False | |
| chunks = model if isinstance(model, (list, tuple)) else [model] | |
| for chunk in chunks: | |
| unwrapped = unwrap_model(chunk) | |
| unwrapped_list = unwrapped if isinstance(unwrapped, (list, tuple)) else [unwrapped] | |
| if any(isinstance(m, Qwen3VLModel) for m in unwrapped_list): | |
| return True | |
| return False | |
| seen = set() | |
| for chunk in model: | |
| for module in chunk.modules(): | |
| config = getattr(module, "config", None) | |
| if config is not None and id(config) not in seen and getattr(config, "calculate_per_token_loss", False): | |
| config.calculate_per_token_loss = False | |
| seen.add(id(config)) | |
| return model | |
| def keep_skyrl_loss_scaling(model: Union[nn.Module, List[nn.Module]]) -> Union[nn.Module, List[nn.Module]]: | |
| """Pre-DDP-wrap hook: undo ``calculate_per_token_loss`` forced on by Megatron-Bridge. | |
| The bridge's Qwen VL providers turn on ``calculate_per_token_loss`` whenever | |
| CP > 1, and ``Qwen3VLModel.__init__`` asserts it. The flag only changes how | |
| Megatron normalizes the loss: DDP then skips its 1/(dp*cp) scaling and | |
| ``finalize_model_grads`` divides by the token count the loss function returns. | |
| SkyRL's loss function returns ``(loss, metrics)`` without a token count and | |
| relies on the default scaling, the same as for text models under CP. So clear | |
| the flag after the model is built and before DDP reads it. | |
| """ | |
| chunks = model if isinstance(model, (list, tuple)) else [model] | |
| seen = set() | |
| for chunk in chunks: | |
| for module in chunk.modules(): | |
| config = getattr(module, "config", None) | |
| if config is not None and id(config) not in seen and getattr(config, "calculate_per_token_loss", False): | |
| config.calculate_per_token_loss = False | |
| seen.add(id(config)) | |
| return model |
…okens only The test read logprobs through the forward(loss_fn=cross_entropy) path, which returns each sample's values compacted to its loss_mask and left-aligned, but indexed them as right-aligned, so it compared the wrong positions (and zeros). Use the inference forward path (the RL old/ref-logprob path, right-aligned [B, response_length]) and score only each row's assistant answer, as RL does: image-placeholder targets of random-noise images have huge, bf16-sensitive logprobs that are never trained on. Found by the 4xH100 run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZKuFwA4VYK2tfFDVgsWt2 Signed-off-by: xgui <xgui@anyscale.com>
…swer-token mask Same alignment fix as the packed-vs-unpacked test: read right-aligned [B, response_length] logprobs from the inference forward and score the batch's loss_mask (assistant answer tokens). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZKuFwA4VYK2tfFDVgsWt2 Signed-off-by: xgui <xgui@anyscale.com>
Stacked on #2357 (only the last commit is new here). Context parallelism (CP > 1) for VLMs on Megatron.
preprocess_packed_seqs(shard_for_cp=False)sends the full packed stream to every CP rank (each sample still padded to 2*CP). Megatron-Bridge'sQwen3VLModelcomputes mRoPE on the full stream, places image/deepstack features, and applies one CP partition to all of it ([training] fix Qwen3-VL packed vlm_step MRoPE NVIDIA-NeMo/Megatron-Bridge#4532). Text models keep SkyRL's pre-split, whichGPTModelrequires.calculate_per_token_loss=Trueunder CP and the model asserts it at construction. SkyRL's loss returns no token count and relies on the default scaling (same as text CP), so a pre-DDP-wrap hook (keep_skyrl_loss_scaling) clears it again.test_megatron_vlm_cp_vs_no_cp(Qwen3-VL-2B, Qwen3.5-0.8B; 2 GPUs): CP=2 vs CP=1 per-token logprobs and grad norm.Not yet run on GPU.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MZKuFwA4VYK2tfFDVgsWt2