NVIDIA-NeMo / NVIDIA-NeMo/Automodel
refactor(distributed): unify multimodal module discovery and policy configuration
@yuhezhang-ai is already working on this.
Since Jul 25, 2026.
- Dominant language
- Python
- Stars
- 963
- Forks
- 318
- Avg merge
- 3d 20h
- Merged PRs (30d)
- 141
Description
Motivation
#2763 exposed a recurring problem: several features independently decide which model submodules
are multimodal, and those private taxonomies drift into distributed-correctness bugs. The next
rank-asymmetric mixed-modality safety feature also needs richer shared metadata: modality, role,
and whether a path may be skipped.
This issue proposes:
- one resolver for multimodal module identity;
- one
distributed.multimodalnamespace for policies applied to those modules; and - a later, separately tested migration of
freeze_configto the same addressing vocabulary.
Problem
“Which submodule is the vision/audio tower?” is currently answered independently:
| Owner | Match rule |
|---|---|
_get_model_layer_group_specs() |
per-class FQN paths in language / vision / audio groups |
shared/multimodal_fsdp.py |
exact leaf-name taxonomy for dense and MoE FSDP |
apply_parameter_freezing() |
attributes plus substring matching on lowercased FQNs |
Before #2763, FSDP creation and enumeration also used separate lists. That caused real bugs:
- BAGEL's
vit_modelexisted in the MoE tuple but not the shared FSDP list. - Gemma4's sibling
embed_visionandembed_audiomodules—instances of the same class—could
receive different sharding treatment because only one name was listed. _iter_fsdp_modules()could miss FSDP units created byapply_fsdp(), excluding them from the
MoE gradient-accumulation synchronization state machine.
#2763 fixes those immediate FSDP inconsistencies by sharing one taxonomy. The architectural
duplication remains: activation checkpointing still uses the layer-group table, freezing uses
substring matching, and future conditional-execution safety needs more than a leaf name.
Three planned PRs need to add policy for the same multimodal paths:
- #2763 initially adds
distributed.frozen_multimodal_shardingto choose frozen-module FSDP ownership. - #2990 adds vision frame sharding over CP as
distributed.cp_vision_frame_sharding. - A safety follow-up will add a dummy-forward policy for rank-asymmetric mixed-modality batches, as
run_dummy_forward.
Rather than let each PR introduce an independent distributed config path, all three should extend
distributed.multimodal. They govern different behaviors, but each controls how resolved
multimodal modules interact with the existing parallel axes of the text model.
Finally, freeze_config is unvalidated. In the current VLM/audio/long-context example scope, 65
configs set freeze_config.freeze_embeddings, but the VLM freezing path never reads that key.
Proposal
1. One multimodal module resolver
@dataclass(frozen=True)
class MultimodalModuleSpec:
path: str # FQN relative to model root
modality: str = "vision" # conventional but open
role: str | None = None # conventional but open
conditionally_executed: bool = True # forward may be skipped
modality should remain an open string: a structure or graph encoder should be addressable without
being mislabeled as vision. The label is mainly a config key; frozen state comes from
requires_grad, and conditional execution comes from the declaration.
role should land with the resolver even though role-based freezing lands later. Existing and
planned consumers already distinguish towers, projectors, embedders, and resamplers. Keep it open
rather than using a closed enum, and use None when a compatibility fallback cannot identify the
role safely instead of silently defaulting every module to tower.
Keep generic config decoding outside the resolver and use a typed contract:
def resolve_multimodal_modules(
model: nn.Module,
*,
declared_specs: Sequence[MultimodalModuleSpec] = (),
override_specs: Sequence[MultimodalModuleSpec] = (),
) -> tuple[ResolvedMultimodalModule, ...]:
...
declared_specs is the canonical inventory chosen from, in order:
- a model-class declaration (the normal path for models we own);
- a central per-architecture table for plain Transformers classes;
- the current leaf-name heuristic as a compatibility fallback, with a warning.
Optional override_specs take precedence for the same canonical path. A
ResolvedMultimodalModule pairs the stable spec with its live module object. This keeps the
resolver independent of an ambiguous generic config dependency while making declarations and
overrides explicit.
The table is still required because models such as Gemma3, Qwen3-VL, Llava, and SmolVLM are plain
Transformers classes and cannot declare AutoModel-specific metadata. The heuristic keeps unknown
third-party VLMs working without making the fallback silent.
Separate canonical declarations from live module binding. The model declaration, architecture
table, and optional config overrides produce stable specs; binding those paths to module objects
happens after structural transformations such as PEFT for distributed consumers. If freezing must
bind earlier, rebind after PEFT rather than retaining stale module objects. Evaluate requires_grad
from the live parameters after freezing/PEFT and immediately before sharding; never cache frozen
state in the declaration.
Resolver contracts should include canonical unwrapped FQNs, identity deduplication for aliases,
and the ability for explicit overrides to correct metadata. Parent/child normalization must retain
a child with a distinct policy-relevant role rather than discarding it merely because its parent
was selected.
Activation-checkpointing scope filtering, both FSDP parallelizers, FSDP unit enumeration, and
future conditional-path safety should consume this resolver. activation_checkpointing_scope: multimodal should then mean every resolved non-language group rather than explicitly
“vision + audio.”
2. Group multimodal distributed policies
distributed.multimodal should hold policies for applying the text model's existing parallel axes
to resolved multimodal modules. It should not create a separate vision/audio mesh; that is topology
and belongs beside pipeline_config or moe_parallel_config.
| knob | scope | axis borrowed | what is distributed |
|---|---|---|---|
frozen_sharding |
fully frozen resolved modules | DP / FSDP | parameters |
frame_sharding |
per modality | CP, or CP × TP | encoder compute |
run_dummy_forward |
per modality | DP | collective alignment |
Target config shape:
# Optional override for third-party models or incorrect declarations.
# Normally absent because an AutoModel-owned model declares these specs itself.
multimodal:
modules:
- path: perceiver_resampler
modality: vision
role: resampler
conditionally_executed: true
distributed:
multimodal:
frozen_sharding: root # root | per_layer | replicate; default for ALL
validate_rank_uniform_execution: false # false | <n diagnostic steps> | always
vision:
frame_sharding: # was distributed.cp_vision_frame_sharding
enabled: true # default false
mesh_dims: [cp] # only supported value initially
min_tokens: 2048 # below this, stay replicated
cost_alpha: auto # int | auto | null
run_dummy_forward: auto # auto | true | false
audio:
frozen_sharding: per_layer # per-modality override of the top-level default
run_dummy_forward: auto
frozen_sharding is a top-level default because “fully frozen” is not modality-specific. Its
scope is nevertheless limited to modules returned by the resolver, so it cannot silently capture
a frozen language model. Per-modality sections override that default.
run_dummy_forward is per modality because only the model knows how to construct a valid dummy
input and execute that modality-specific path. The optional top-level multimodal.modules list is
an identity-metadata escape hatch; model-class declaration is the normal mechanism and travels with
the model for pip users. It does not belong under distributed because activation checkpointing
and freezing also consume the same resolved inventory and must not read another component's policy
config.
frame_sharding.mesh_dims is a serialized selection so the configuration remains self-describing
now that the policy name does not encode CP. It defaults to [cp], and [cp] is the only
supported value initially. Runtime code must resolve the selected dimensions from MeshContext
and reject dimensions that are absent or unsupported; it must not accept and ignore the field.
Extend the accepted values to [cp, tp] only when the caller, ownership and gradient constraints,
and the corresponding test matrix are implemented. The model capability remains separate: it
declares whether the encoder supports independent per-frame computation, while mesh_dims
declares where that computation is sharded.
These knobs need shared validation. Frame sharding can create rank asymmetry when a rank receives
no frames; frozen sharding and frame sharding partition the same module; and a conditionally
executed per-layer FSDP unit is unsafe unless execution is rank-uniform.
Why run_dummy_forward is needed
A trainable tower or projector may be skipped on a text-only batch. If it owns an FSDP unit, ranks
that skip it do not enter its collectives and the group can hang. Keeping it in the root avoids
that unit-level mismatch, but leaves no gradient for its parameters on the skipping ranks.
Root ownership plus set_reduce_scatter_unused_params(True) was prototyped and deadlocks with
nested FSDP groups: text-only ranks schedule the conditional root reduce-scatter first, while image
ranks schedule a nested language-layer reduce-scatter first on the same communicator.
The working remedy is to run the conditional path on a dummy input and add a differentiable
zero-weighted result. Every rank then traverses the same trainable modules without changing the
loss. This pattern already exists in llama_nemotron_vl; retrieval forwards a per-call
run_dummy_vision signal only to models that accept it.
The mechanism must remain model-owned: only the model knows its cheapest valid dummy input and how
to connect the tower plus any trainable projector/embedder/resampler to the graph. What this
proposal adds is reusable policy, so safety is not hardcoded in individual recipes.
run_dummy_forward: auto derives whether dummy execution is required from the distributed
strategy, final post-freezing/PEFT requires_grad, conditionally_executed metadata, and the
rank-uniform modality-execution declaration from the batch planner. The model-owned dummy-forward
capability determines whether the required safety action can be performed.
If those facts require dummy execution and the model supports it, enable it. If dummy execution
is required but the model lacks that capability, fail setup rather than silently proceeding
unsafely. When the planner guarantees rank-uniform modality execution, auto may leave the dummy
path disabled. Explicit overrides remain subject to the same correctness validation.
3. Validate component contracts; do not derive configs from each other
Do not make frozen_sharding: auto read dataset config. Identical distributed config should not
change meaning depending on the paired dataset.
Instead, each component declares its own fact:
- the resolver identifies conditionally executed modules;
- the data/batch planner declares whether modality execution may differ across ranks;
- distributed config selects sharding and dummy-forward policy.
Setup validation can then reject per_layer with potentially rank-asymmetric execution, a
trainable conditional FSDP unit with dummy safety disabled, or unsupported frame-sharding axes.
An optional runtime validator can check planned modality execution for the first N microbatches or
continuously. A bounded check is diagnostic only: uniformity during warmup cannot guarantee a later
batch is uniform and therefore cannot make an otherwise unsafe per_layer policy correct.
Correctness must come from a rank-uniform batching contract, dummy-forward safety, or an always-on
precheck. The collective must run at an always-executed model/recipe boundary before any
conditional tower; placing it inside a tower hook would deadlock because skipping ranks never enter
the hook.
4. Migrate freeze_config last, in a separate PR
Freezing currently uses substrings, so intent depends on naming: multi_modal_projector remains
trainable while Gemma4's embed_vision is frozen. Reuse the resolver's (modality, role)
vocabulary:
freeze_config:
vision: {tower: true, projector: false}
audio: {tower: true, projector: true}
language_model: false
modules: [patch_generator.video_embedder] # escape hatch
This is behavior-changing because today's substring overreach is load-bearing for some configs.
First snapshot the frozen-parameter FQN set for every tracked example, then preserve or explicitly
document each change.
Strict validation should reject the ignored VLM freeze_embeddings key rather than begin honoring
it: those 65 configs have historically trained with the key ignored.
Suggested sequencing
- Decide whether #2990 moves into the nested namespace before merge or gets an explicit migration.
- Keep #2763 focused on the immediate FSDP policy and shared FSDP taxonomy.
- Land the resolver, including open
rolemetadata, and migrate discovery consumers without
changing freezing behavior. - Add the model-owned dummy-forward protocol, policy, and setup/runtime validation.
- Migrate
freeze_configseparately, gated by frozen-parameter snapshots.
The YAML above is the target shape; the fields do not need to land together.
Open questions
- Should the collator/batch planner own the planned modality-execution vector, with the dataset
declaration used as a conservative setup-time fact? - Should
conditionally_executedbe model-declared or inferred from the forward contract? - Should diagnostic runtime uniformity validation default to a bounded warmup window or remain
opt-in? - What is the smallest model-owned dummy protocol covering the encoder and all trainable
projector/embedder/resampler modules? - Should dummy input use the full image size or a model-derived minimum?
- Should config policy and per-call signal share a name (
run_dummy_forwardvs.
run_dummy_vision)?
Already resolved in #2763
FSDP unit creation and enumeration now consume the same shared taxonomy. Narrowing creation to the
old two-name enumeration was rejected: with wrap_outer_model=False, a trainable multimodal module
on the outer model could otherwise belong to no FSDP unit and lose gradient reduction across DP
ranks.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.