Skip to content

[megatron][vlm] Context parallelism for Qwen3-VL / Qwen3.5-VL - #2404

Draft
xinyuangui2 wants to merge 8 commits into
NovaSky-AI:mainfrom
xinyuangui2:vlm-megatron-cp
Draft

xinyuangui2 wants to merge 8 commits into
NovaSky-AI:mainfrom
xinyuangui2:vlm-megatron-cp

Conversation

@xinyuangui2

Copy link
Copy Markdown
Collaborator

Stacked on #2357 (only the last commit is new here). Context parallelism (CP > 1) for VLMs on Megatron.

  • For VLMs, 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's Qwen3VLModel computes 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, which GPTModel requires.
  • The bridge's Qwen VL providers force calculate_per_token_loss=True under 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.
  • VLM + CP + MTP raises (TODO).
  • New GPU test 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

xinyuangui2 and others added 5 commits October 1, 2026 01:38
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +762 to +780
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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

xinyuangui2 and others added 3 commits October 4, 2026 21:06
…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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant