Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a392b42
Add VLM support to the dynamic-batching inference server
RPrenger Aug 4, 2026
2402b1d
NeMo-RL working branch.
cspades Aug 5, 2026
23fad30
Match vLLM multimodal API.
cspades Aug 10, 2026
5080610
Contain SUBMIT_REQUEST errors so one bad payload doesn't kill the engine
RPrenger Aug 13, 2026
e43f374
Clean up VLM request data if _add_request rejects the request
RPrenger Aug 13, 2026
c373111
Reject incomplete static-tiling image payloads at the wire boundary
RPrenger Aug 13, 2026
c7833b4
Preserve VLM subtype and image data across suspend/resume
RPrenger Aug 13, 2026
0299f03
Fix placeholder token bug with RL + Gym.
cspades Aug 13, 2026
5ee057b
Address PR comments for cspades' changes.
cspades Aug 13, 2026
25dccea
Apply black/isort autoformatter to the current changeset
RPrenger Aug 13, 2026
bdf13de
Guard the truncation branch for final_position_ids being None
RPrenger Aug 13, 2026
592fd9c
Stop threading a non-image-aware arange into the language model as po…
RPrenger Aug 13, 2026
d513661
Restore h/w kwargs on pixel_shuffle to keep existing callers working
RPrenger Aug 13, 2026
3a39431
Fail loudly for audio / video kwargs on LLaVAModel.forward instead of…
RPrenger Aug 13, 2026
4418ab6
Raise instead of assert on image-embedding count mismatch
RPrenger Aug 13, 2026
bd99491
Fix RADIO class_token_len override chain and self._class_token_len
RPrenger Aug 13, 2026
591f560
Fold two D2H syncs in packed_seq_params rebuild into one
RPrenger Aug 13, 2026
151c8ce
Default inference_wrapper_cls to GPTInferenceWrapper on MegatronLLM /…
RPrenger Aug 13, 2026
76a89ef
Make _generate_impl.multi_modal_data_list optional (backward-compat)
RPrenger Aug 13, 2026
2aba70e
Match the decode-branch rank in current_image_token_mask to the prefi…
RPrenger Aug 13, 2026
fdff099
Short-circuit current_image_embeddings on decode steps
RPrenger Aug 13, 2026
3ed1164
Skip VLM context helpers entirely on text-only workloads
RPrenger Aug 13, 2026
78adb52
Collapse per-image .item() syncs to a single tolist() in expand_image…
RPrenger Aug 13, 2026
7909b4f
Skip num_tiles.sum() D2H sync on dynamic-resolution admissions
RPrenger Aug 13, 2026
643a615
Reject HTTP redirects in the image_url fetch path
RPrenger Aug 13, 2026
084c7be
Note the n>1 image re-preprocessing cost as a known limitation
RPrenger Aug 13, 2026
67c7239
Drop the wire-side static-tiling helper to remove megatron/core → exa…
RPrenger Aug 13, 2026
5e61e13
Stop mutating sys.path inside megatron/core
RPrenger Aug 14, 2026
d96819c
Assert MoE hybrid config has num_moe_experts set in get_hybrid_layer_…
RPrenger Aug 14, 2026
ebc35b7
Give the CLS token a position embedding in the learned_absolute ViT path
RPrenger Aug 14, 2026
a972aa2
Move KimiLearned2DPosEmbed weight to device on both forward paths
RPrenger Aug 14, 2026
b20d25e
Pin apply_chat_template return_dict=False to keep the tensor/list ret…
RPrenger Aug 14, 2026
4eb95a4
Declare Pillow as a megatron-core dev dep for VLM image preprocessing
RPrenger Aug 14, 2026
4915df7
Guard the torchvision import in image_preprocessing with an install-m…
RPrenger Aug 14, 2026
1af7efc
Re-run isort/black on files touched in this round of review fixes
RPrenger Aug 14, 2026
a9464c7
Refresh uv.lock so it matches the Pillow entry added to pyproject.toml
RPrenger Aug 15, 2026
f1180d4
Merge branch 'main' into vlm-inference
RPrenger Aug 17, 2026
43a3650
Lazy-import add_inference_args in vlm_dynamic_inference
RPrenger Aug 17, 2026
9e56ee8
Add docstring to _NoRedirectHandler.http_error_301 for pylint C0116
RPrenger Aug 17, 2026
54e2c47
Address unit-test regressions from run 31997499116
RPrenger Aug 17, 2026
93f19e7
Reformat test_llava_sound.py after skip-decorator addition
RPrenger Aug 17, 2026
17bd43a
Also gate packed_seq_params on the vision-model call
RPrenger Aug 17, 2026
7d2cb12
Guard third VLM dict + skip config lookup for text-only requests
RPrenger Aug 17, 2026
11abf32
Stop passing dead mtp_source_loss_mask kwarg to language model
RPrenger Aug 17, 2026
cffcdbc
Merge branch 'main' into vlm-inference
RPrenger Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/multimodal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ def get_language_model_config(config, enable_fusions=False, apply_rope_fusion=No
)
config.attention_softmax_in_fp32 = True
config.ffn_hidden_size = 8192
elif config.language_model_type == "nemotron6-moe":
config.bias_activation_fusion = False
config.bias_dropout_fusion = False
elif config.language_model_type.startswith("hf://"):
# Loaded from HuggingFace config file.
import transformers
Expand Down Expand Up @@ -392,6 +395,10 @@ def get_vision_projection_config(config, hidden_size, enable_fusions=False):
config.ffn_hidden_size = 2048
config.activation_func = torch.nn.functional.gelu
config.normalization = "LayerNorm"
elif config.language_model_type == "nemotron6-moe":
config.ffn_hidden_size = 20480
config.bias_activation_fusion = False
config.bias_dropout_fusion = False
elif config.language_model_type.startswith("hf://"):
config.activation_func = torch.nn.functional.gelu
config.ffn_hidden_size = 4096
Expand Down
42 changes: 39 additions & 3 deletions examples/multimodal/layer_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import torch

from megatron.core.extensions.transformer_engine import HAVE_TE
from megatron.core.fusions.fused_bias_dropout import get_bias_dropout_add
from megatron.core.models.hybrid.hybrid_block import HybridStack, HybridStackSubmodules
from megatron.core.ssm.mamba_layer import MambaLayer, MambaLayerSubmodules
Expand All @@ -15,9 +14,11 @@
from megatron.core.transformer.enums import AttnMaskType
from megatron.core.transformer.identity_op import IdentityOp
from megatron.core.transformer.mlp import MLP, MLPSubmodules
from megatron.core.models.gpt.moe_module_specs import get_moe_module_spec
from megatron.core.transformer.spec_utils import ModuleSpec
from megatron.core.transformer.transformer_layer import TransformerLayer, TransformerLayerSubmodules
from megatron.core.transformer.transformer_layer import MoETransformerLayer, TransformerLayer, TransformerLayerSubmodules
from megatron.core.typed_torch import not_none
from megatron.core.extensions.transformer_engine import HAVE_TE

if HAVE_TE:
from megatron.core.extensions.transformer_engine import (
Expand Down Expand Up @@ -127,12 +128,34 @@ def get_layer_spec_te(is_vit=False, padding=False) -> ModuleSpec:
)


def get_hybrid_layer_spec_te(padding=False) -> ModuleSpec:
def get_hybrid_layer_spec_te(config=None, padding=False) -> ModuleSpec:

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.

[IMPORTANT Compatibility] config is inserted as the first positional parameter, and the promised assert is missing — so config=None now fails at spec-build time for every hybrid, not just MoE ones.

Two issues:

  1. Positional shift. get_hybrid_layer_spec_te(config=None, padding=False) moves padding from position 0 to position 1. The sole in-repo caller (examples/multimodal/model.py:117) uses keywords, but any external caller doing get_hybrid_layer_spec_te(True) now silently passes True as the config.

  2. Missing guard. The comment at 139-141 says the intent is to "assert only when it would actually be consulted", but no assert exists. And the deferral doesn't hold: the moe_layer ModuleSpec at 198-209 is constructed eagerly, so num_experts=None (from getattr(config, 'num_experts', None) on a None config) flows into get_moe_module_specget_moe_module_spec_for_backend, which does assert num_experts is not None (megatron/core/models/gpt/moe_module_specs.py:72). So a None config raises for dense hybrids too, with a confusing MoE-flavored assertion message.

Fix: give config a keyword-only position to preserve the old positional order, and add the guard the comment describes:

def get_hybrid_layer_spec_te(padding=False, *, config=None):
    assert config is not None, (
        "get_hybrid_layer_spec_te requires a config to build the MoE layer spec"
    )

Alternatively, build the moe_layer spec lazily so a dense hybrid never consults num_experts — which would make the existing comment accurate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by f294dbe

"""Hybrid (Mamba + attention + MLP [+ MoE]) layer spec.

Args:
config: language-model ``TransformerConfig``. Required for MoE hybrids
(e.g. nemotron6-moe): the moe_layer branch reads
``num_moe_experts`` / ``moe_grouped_gemm`` off it to match the
checkpoint's architecture. Non-MoE hybrids may pass ``None``;
they never traverse the moe_layer branch.
padding: use padding-causal attention mask (needed for context
parallel + sequence parallel).
"""
attn_mask_type = AttnMaskType.causal
# Padding mask is needed for e.g. Context Parallel.
if padding:
attn_mask_type = AttnMaskType.padding_causal

if config is not None:
assert config.num_moe_experts is not None, (
"get_hybrid_layer_spec_te: config.num_moe_experts must be set to "
"build the MoE branch of the hybrid stack."
)
num_experts = config.num_moe_experts
moe_grouped_gemm = config.moe_grouped_gemm
else:
num_experts = None
moe_grouped_gemm = None

return ModuleSpec(
module=HybridStack,
submodules=HybridStackSubmodules(
Expand Down Expand Up @@ -182,6 +205,19 @@ def get_hybrid_layer_spec_te(padding=False) -> ModuleSpec:
mlp_bda=get_bias_dropout_add,
),
),
moe_layer=ModuleSpec(
module=MoETransformerLayer,
submodules=TransformerLayerSubmodules(
pre_mlp_layernorm=TENorm,
mlp=get_moe_module_spec(
use_te=True,
num_experts=num_experts,
moe_grouped_gemm=moe_grouped_gemm,
moe_use_legacy_grouped_gemm=False,
),
mlp_bda=get_bias_dropout_add,
Comment on lines +208 to +218

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.

[CRITICAL Correctness] num_experts=8 and moe_grouped_gemm=True are hardcoded in the layer spec, ignoring config.num_moe_experts / config.moe_grouped_gemm. get_hybrid_layer_spec_te(padding=False) takes no config, so there is no way for a caller to get anything other than 8 experts.

Why it matters: the expert count is an architecture parameter, so a mismatch against the checkpoint is a load failure or, worse, a silent partial load — the router's output dimension and the expert weight shapes are both derived from it. A checkpoint trained with 16 or 64 experts cannot be loaded through this spec at all. It also silently disagrees with --num-experts on the command line, so a user who sets that flag gets a model that ignores it with no warning. Hardcoding moe_grouped_gemm=True has the same problem in a smaller way: it forces the grouped-GEMM path regardless of --moe-grouped-gemm, which matters for both numerics-sensitive debugging and for environments where grouped GEMM isn't available.

Fix: thread the config through, matching how get_gpt_decoder_block_spec and the other spec builders in mcore take a TransformerConfig:

def get_hybrid_layer_spec_te(config, padding=False) -> ModuleSpec:
    ...
            moe_layer=ModuleSpec(
                module=MoETransformerLayer,
                submodules=TransformerLayerSubmodules(
                    pre_mlp_layernorm=TENorm,
                    mlp=get_moe_module_spec(
                        use_te=True,
                        num_experts=config.num_moe_experts,
                        moe_grouped_gemm=config.moe_grouped_gemm,
                        moe_use_legacy_grouped_gemm=False,
                    ),
                    mlp_bda=get_bias_dropout_add,
                ),
            ),

and update the call site in examples/multimodal/model.py accordingly. If a fixed 8 is genuinely correct for the one model this spec targets, please name that model in a comment and assert config.num_moe_experts == 8 so a mismatched checkpoint fails with a clear message instead of a shape error.

Also flagging for the linting gate: the import reordering in this file's header hunk leaves HAVE_TE and get_moe_module_spec out of isort order. Per the repo guidance, run uv run isort examples/multimodal/layer_specs.py examples/multimodal/model.py before merge.

),
),
),
)

Expand Down
110 changes: 78 additions & 32 deletions examples/multimodal/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
from layer_specs import (get_layer_spec, get_layer_spec_te, get_mlp_module_spec, get_norm_mlp_module_spec_te,
get_hybrid_layer_spec_te)

from megatron.core.models.gpt.gpt_layer_specs import get_gpt_decoder_block_spec
from megatron.core.models.multimodal.llava_model import IMAGE_TOKEN, LLaVAModel
from megatron.core.models.vision.clip_vit_model import get_num_image_embeddings
from megatron.core.transformer.spec_utils import import_module
from megatron.training import get_args, get_tokenizer, print_rank_0
from megatron.training.arguments import core_transformer_config_from_args
from megatron.core.utils import log_single_rank



def model_provider(
pre_process=True, post_process=True, add_encoder=True, add_decoder=True, parallel_output=True,
vp_stage=None, config=None, pg_collection=None
vp_stage=None, config=None, pg_collection=None,
) -> LLaVAModel:
Comment on lines 21 to 24

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.

[CRITICAL Correctness] The explicit vp_stage, config, and pg_collection parameters were replaced by **kwargs, and kwargs is never read anywhere in the function — grep in this file finds it only in the signature (line 23) and in an unrelated comment (line 197). So all three arguments are now silently swallowed.

Why it matters: each of the three has real consequences when dropped, and none of them raises:

  • vp_stage — with virtual pipeline parallelism, get_model() calls the provider once per virtual stage and relies on vp_stage to decide which layers this chunk owns. Dropping it means every virtual chunk builds identically, so VPP silently constructs the wrong model rather than erroring.
  • pg_collection — the caller's process groups are discarded, so the model falls back to parallel_state globals. Any non-default group topology is silently ignored, which is precisely the failure mode the repo's process-group guidance exists to prevent.
  • config — a caller-supplied TransformerConfig is dropped in favour of rebuilding from args, so programmatic construction (tests, NeMo-style callers) gets a different config than it asked for.

The docstring entries were deleted in the same hunk, so there is no longer even a record that these were once accepted. And because **kwargs accepts anything, a caller passing vp_stage=2 gets no TypeError — the argument just evaporates. This is strictly worse than removing the parameters outright, which would at least fail loudly.

Fix: restore the explicit parameters and thread them through to the LLaVAModel constructor (which accepts vp_stage and pg_collection):

Suggested change
def model_provider(
pre_process=True, post_process=True, add_encoder=True, add_decoder=True, parallel_output=True,
vp_stage=None, config=None, pg_collection=None
**kwargs,
) -> LLaVAModel:
def model_provider(
pre_process=True, post_process=True, add_encoder=True, add_decoder=True, parallel_output=True,
vp_stage=None, config=None, pg_collection=None,
) -> LLaVAModel:

and re-add the three docstring lines. If **kwargs was added to tolerate extra arguments from a specific caller, name that caller in a comment and assert the set is empty otherwise (assert not kwargs, f"unexpected kwargs: {sorted(kwargs)}") so silent drops become errors.

"""Builds the model.

Expand All @@ -28,10 +31,10 @@ def model_provider(
will live on only a subset of the pipeline stages (specifically, only the first stage).
add_decoder (bool): Construct the decoder module (used with pipeline parallelism). Defaults to True. When we use pipelining, the decoder
will live on only a subset of the pipeline stages (specifically, every stage after the first one).
parallel_output (bool): Enable parallel model output.
vp_stage: Optional virtual pipeline stage. Used with virtual pipeline parallelism.
config: Optional transformer config. If None, will be created from args.
pg_collection: Optional process group collection. If None, will use default.
parallel_output (bool): Enable parallel model output.

Returns:
model: A multimodal model.
Expand All @@ -41,28 +44,38 @@ def model_provider(

print_rank_0('building a multimodal model ...')

num_image_embeddings = get_num_image_embeddings(
args.img_h,
args.img_w,
args.patch_dim,
args.vision_model_type,
args.disable_vision_class_token,
1,
args.pixel_shuffle,
args.use_tile_tags,
args.max_num_tiles,
args.tokenizer_prompt_format
)
old_seq_length = args.seq_length
args.seq_length = args.encoder_seq_length = num_image_embeddings
if old_seq_length != args.seq_length:
log_single_rank(
logging.getLogger(__name__),
logging.WARNING,
f"Changed seq_length and encoder_seq_length (vision model sequence length) from {old_seq_length} to num_image_tokens ({num_image_embeddings})"
if getattr(args, 'dynamic_resolution', False):
max_num_image_embeddings = args.seq_length
num_image_embeddings = args.seq_length
if args.pixel_shuffle:
max_num_image_embeddings //= 4
num_image_embeddings //= 4
if getattr(args, 'conv_merging', False):
max_num_image_embeddings //= 4
num_image_embeddings //= 4
else:
num_image_embeddings = get_num_image_embeddings(
args.img_h,
args.img_w,
args.patch_dim,
args.vision_model_type,
args.disable_vision_class_token,
1,
args.pixel_shuffle,
args.use_tile_tags,
args.max_num_tiles,
args.tokenizer_prompt_format
)
old_seq_length = args.seq_length
args.seq_length = args.encoder_seq_length = num_image_embeddings
if old_seq_length != args.seq_length:
log_single_rank(
logging.getLogger(__name__),
logging.WARNING,
f"Changed seq_length and encoder_seq_length (vision model sequence length) from {old_seq_length} to num_image_tokens ({num_image_embeddings})"
)

max_num_image_embeddings = max((args.max_num_tiles + int(args.use_thumbnail)), args.num_frames) * num_image_embeddings
max_num_image_embeddings = max((args.max_num_tiles + int(args.use_thumbnail)), args.num_frames) * num_image_embeddings

assert (
args.decoder_seq_length is not None
Expand All @@ -79,7 +92,7 @@ def model_provider(
language_model_type = args.language_model_type
vision_model_type = args.vision_model_type

base_config = core_transformer_config_from_args(get_args())
base_config = config or core_transformer_config_from_args(get_args())
base_config.language_model_type = args.language_model_type
base_config.vision_model_type = args.vision_model_type
base_config.calculate_per_token_loss = True
Expand All @@ -98,8 +111,19 @@ def model_provider(
elif use_te:
# Padding mask needed for SP/CP.
padding = args.context_parallel_size > 1 and args.sequence_parallel
if args.language_model_type.startswith('nemotron5-hybrid'):
language_transformer_layer_spec = get_hybrid_layer_spec_te(padding=padding)
if args.spec is not None:
language_transformer_layer_spec = import_module(args.spec)
elif args.language_model_type.startswith(('nemotron5-hybrid', 'nemotron6-moe')):
language_transformer_layer_spec = get_hybrid_layer_spec_te(
config=language_config, padding=padding
)
elif getattr(args, 'num_experts', None):
language_transformer_layer_spec = get_gpt_decoder_block_spec(
language_config,
use_transformer_engine=use_te,
normalization=args.normalization,
qk_l2_norm=getattr(args, 'qk_l2_norm', False),
)
else:
language_transformer_layer_spec = get_layer_spec_te(
is_vit=False, padding=padding
Expand All @@ -110,11 +134,12 @@ def model_provider(
)

vision_config = deepcopy(base_config)
vision_config = get_vision_model_config(
vision_config, apply_query_key_layer_scaling=args.apply_query_key_layer_scaling
)
vision_config = get_vision_model_config(vision_config)
# Most ViT checkpoints use bias in linear layers; override --disable-bias-linear.
# Pixtral (both sizes) uses no bias — config.py already sets add_bias_linear=False.
if vision_model_type not in ("pixtral-vit", "pixtral-vit-large"):
vision_config.add_bias_linear = True
if vision_model_type.startswith("hf://"):
assert not args.sequence_parallel, "Huggingface models do not support --sequence-parallel"
assert args.context_parallel_size < 2, "Huggingface models do not support --context-parallel-size > 1"

if vision_model_type in ["clip", "siglip", "radio", "cradio-g"]:
Expand All @@ -141,6 +166,13 @@ def model_provider(
elif vision_model_type == "internvit300M":
from nvlm.internvit import get_internvit300M_layer_spec
vision_transformer_layer_spec = get_internvit300M_layer_spec(use_te=use_te)
elif vision_model_type in ("pixtral-vit", "pixtral-vit-large", "qwen-vl", "kimi-vit"):
if use_te:
vision_transformer_layer_spec = get_layer_spec_te(is_vit=True)
else:
vision_transformer_layer_spec = get_layer_spec(
is_vit=True, normalization=vision_config.normalization
)
elif vision_model_type.startswith("hf://"):
vision_transformer_layer_spec = None
else:
Expand All @@ -159,10 +191,13 @@ def model_provider(
# Make sure the vision model does not inherit first and last pipeline num layers from the language model.
vision_config.first_pipeline_num_layers = vision_config.last_pipeline_num_layers = None

# ``get_*_module_spec_te`` returns ``functools.partial(MLP.as_mlp_submodule,
# submodules=...)`` (see PR #3435). Pull the submodules out of the partial's
# bound kwargs so the vision projection sees an ``MLPSubmodules`` value.
if vision_projection_config.normalization:
vision_projection_layer_spec = get_norm_mlp_module_spec_te().submodules
vision_projection_layer_spec = get_norm_mlp_module_spec_te().keywords["submodules"]
else:
vision_projection_layer_spec = get_mlp_module_spec(use_te=use_te).submodules
vision_projection_layer_spec = get_mlp_module_spec(use_te=use_te).keywords["submodules"]

# Toggle --recompute* for the vision and language model separately.
if args.recompute_vision:
Expand Down Expand Up @@ -202,7 +237,7 @@ def model_provider(
drop_vision_class_token=args.disable_vision_class_token,
vision_projection_config=vision_projection_config,
vision_projection_layer_spec=vision_projection_layer_spec,
vision_projection_type="mlp",
vision_projection_type=args.vision_projection_type,
allow_missing_vision_projection_checkpoint=args.allow_missing_vision_projection_checkpoint,
parallel_output=parallel_output,
share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights,
Expand All @@ -221,9 +256,20 @@ def model_provider(
fp16_lm_cross_entropy=args.fp16_lm_cross_entropy,
image_token_index=image_token_index,
pixel_shuffle=args.pixel_shuffle,
conv_merging=getattr(args, "conv_merging", False),
tile_tags=tile_tags,
max_num_tiles=args.max_num_tiles,
tokenizer_type=args.tokenizer_prompt_format,
use_vision_backbone_fp8_arch=getattr(args, "use_vision_backbone_fp8_arch", False),
dynamic_resolution=getattr(args, "dynamic_resolution", False),
class_token_len=getattr(args, "class_token_len", None),
radio_force_eval_mode=getattr(args, "radio_force_eval_mode", False),
radio_force_cpe_eval_mode=getattr(args, "radio_force_cpe_eval_mode", False),
radio_interpolate_only_cpe=getattr(args, "radio_interpolate_only_cpe", False),
radio_cpe_aspect_ratio_select=getattr(args, "radio_cpe_aspect_ratio_select", False),
radio_disable_cpe=getattr(args, "radio_disable_cpe", False),
vp_stage=vp_stage,
pg_collection=pg_collection,
)

model.freeze(
Expand Down
Loading
Loading