-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Add MIMO dual gradient finalization (colocated + non-colocated) #5286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
19364db
f0cd889
a2e26fc
75872f2
7457d08
cadd331
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. | ||
|
|
||
| """Dual gradient finalization for MIMO training on the stock Megatron loop.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import torch | ||
| import torch.distributed as dist | ||
|
|
||
| from examples.mimo.training.topology import HeteroTopology | ||
| from megatron.core.distributed.finalize_model_grads import finalize_model_grads | ||
| from megatron.core.models.mimo.config.role import MIMO_LANGUAGE_MODULE_KEY | ||
| from megatron.core.models.mimo.model.base import MimoModel | ||
| from megatron.core.pipeline_parallel.utils import is_pp_last_stage | ||
|
|
||
| # Sentinel set per modality submodule when this rank had that modality's input this step. | ||
| _PARTICIPATED_ATTR = "_mimo_rank_processed_input" | ||
|
|
||
|
|
||
| def _has_modality_input(value) -> bool: | ||
| """Whether this rank received this modality's input this step. | ||
|
|
||
| The batch omits a modality's key when absent, so ``value`` is None (not present) or a | ||
| non-empty nested dict (present); an empty tensor also counts as absent. | ||
| """ | ||
| if isinstance(value, torch.Tensor): | ||
| return value.numel() > 0 | ||
| return bool(value) | ||
|
|
||
|
|
||
| def mark_modality_participation(mimo_model: MimoModel, batch) -> None: | ||
| """Tag each modality submodule with whether this rank had that modality's input this step. | ||
|
|
||
| Reads ``batch["modality_inputs"]`` (keyed by modality name) so the flag is per modality | ||
| rather than vision-specific. | ||
| """ | ||
| modality_inputs = batch.get("modality_inputs", {}) if isinstance(batch, dict) else {} | ||
| for name, submodule in mimo_model.modality_submodules.items(): | ||
| if submodule is not None: | ||
| setattr(submodule, _PARTICIPATED_ATTR, _has_modality_input(modality_inputs.get(name))) | ||
|
|
||
|
|
||
| def reset_modality_participation(mimo_model: MimoModel) -> None: | ||
| """Clear per-step participation flags at the top of each train step.""" | ||
| for submodule in mimo_model.modality_submodules.values(): | ||
| if submodule is not None: | ||
| setattr(submodule, _PARTICIPATED_ATTR, False) | ||
|
|
||
|
|
||
| def _vision_participation_count(submodule, vision_dp_group) -> float: | ||
| """Number of vision-DP ranks that processed image input this step.""" | ||
| val = 1.0 if getattr(submodule, _PARTICIPATED_ATTR, False) else 0.0 | ||
| indicator = torch.tensor([val], dtype=torch.float32, device="cuda") | ||
| dist.all_reduce(indicator, op=dist.ReduceOp.SUM, group=vision_dp_group) | ||
| return float(indicator.item()) | ||
|
|
||
|
|
||
| def _is_pg_member(pg) -> bool: | ||
| """Whether the current rank belongs to ``pg`` (defensive; -1 for non-members).""" | ||
| return pg is not None and dist.get_rank(group=pg) >= 0 | ||
|
|
||
|
|
||
| def _is_token_source_rank(language_pg) -> bool: | ||
| """Whether this rank is on the LLM (last PP stage, TP rank 0) coordinate that sums | ||
| the global token count over DP/CP. | ||
|
|
||
| Sourcing from this single coordinate avoids double-counting across TP/PP replicas. | ||
| The _is_pg_member guards short-circuit encoder-grid ranks (non-member pp/tp groups) | ||
| so they never participate. | ||
| """ | ||
| if language_pg is None: | ||
| return False | ||
| pp = getattr(language_pg, "pp", None) | ||
| tp = getattr(language_pg, "tp", None) | ||
| return ( | ||
| _is_pg_member(pp) | ||
| and _is_pg_member(tp) | ||
| and is_pp_last_stage(pp) | ||
| and dist.get_rank(group=tp) == 0 | ||
| ) | ||
|
|
||
|
|
||
| def _token_source_global_rank(language_grid) -> int: | ||
| """Global rank of the single LLM token-source coordinate (tp=0, cp=0, dp=0, pp=last). | ||
|
|
||
| Derived statically from ``get_rank_enum("pp")`` (the grid's authoritative rank | ||
| enumeration, identical on every rank), so encoder-grid ranks in no LLM group can name | ||
| it. The global minimum rank is (tp=0, cp=0, dp=0), so its PP line is the source line | ||
| and that line's last entry is the (pp=last) source rank. | ||
| """ | ||
| pp_lines = language_grid.get_rank_enum("pp") | ||
| min_rank = min(rank for line in pp_lines for rank in line) | ||
| for line in pp_lines: | ||
| if min_rank in line: | ||
| return int(line[-1]) | ||
| raise RuntimeError( | ||
| f"Could not derive token-source global rank from language grid pp_lines={pp_lines}" | ||
| ) | ||
|
|
||
|
|
||
| def _global_token_count(num_tokens, language_pg, src_global_rank) -> float: | ||
| """Total non-padded tokens in the global batch, visible on every rank. | ||
|
|
||
| Only the LLM token-source rank computes the count by summing over the LLM DP/CP | ||
| group; it then broadcasts that N_global from its global rank to every rank in the | ||
| world (including the non-colocated encoder grid, where ``language_pg`` is None) so | ||
| both modules divide by the same per-token mean. | ||
| """ | ||
| global_num_tokens = torch.zeros(1, dtype=torch.float32, device="cuda") | ||
| if _is_token_source_rank(language_pg): | ||
| # Collective over DP/CP: every (pp_last, tp0) rank participates so the all-reduce | ||
| # does not hang; only DP/CP rank 0 keeps the result and is the broadcast root. | ||
| token_count = num_tokens.to(dtype=torch.float32).sum().view(1) | ||
| dist.all_reduce(token_count, group=language_pg.dp_cp, op=dist.ReduceOp.SUM) | ||
| if dist.get_rank(group=language_pg.dp_cp) == 0: | ||
| global_num_tokens.copy_(token_count) | ||
| dist.broadcast(global_num_tokens, src=src_global_rank) | ||
| return float(global_num_tokens.item()) | ||
|
|
||
|
|
||
| def configure_grad_sync(args, mimo_model: MimoModel, topology: HeteroTopology) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where do we call this function? is the intent to be installed by a later PR? Just to understand where this hook will become active
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah all of this will wire up once i raise the subsequent PRs which completes the whole train loop. for now just setting up the building blocks |
||
| """Configure per-module gradient finalization: each module finalizes over its own groups. | ||
|
|
||
| The encoder and LLM have decoupled parallelism (separate grids), so each reduces its | ||
| gradients over its own process-group collection; both then divide by one shared | ||
| per-token mean (N_global). | ||
|
|
||
| MimoModel structure (each a separately DDP-wrapped module on its own grid):: | ||
|
|
||
| MimoModel | ||
| ├─ language_model (LLM) -> own process groups | ||
| └─ modality_submodules[*] (encoders) -> own process groups | ||
| """ | ||
| module_pgs = topology.module_pgs | ||
| language_pg = module_pgs.get(MIMO_LANGUAGE_MODULE_KEY) | ||
| # Broadcast root for N_global; derived statically so encoder-grid ranks (in no LLM | ||
| # group) can still name it. | ||
| src_global_rank = _token_source_global_rank(topology.grids[MIMO_LANGUAGE_MODULE_KEY]) | ||
| correct_vision_grad = bool( | ||
| getattr(args, "correct_encoder_grad_for_partial_participation", False) | ||
| ) | ||
|
|
||
| def finalize_grads_func(_model_list, num_tokens, force_all_reduce=False, **_kwargs): | ||
|
yashaswikarnati marked this conversation as resolved.
|
||
| # calculate_per_token_loss=True => DDP gradient_scaling_factor 1.0 (pure SUM), | ||
| # so the per-token mean is applied here by dividing every shard by N_global. | ||
| assert num_tokens is not None, ( | ||
| "MIMO grad sync expects calculate_per_token_loss=True so the schedule " | ||
| "forwards total_num_tokens; got None." | ||
| ) | ||
|
|
||
| # N_global is the global token count, published to every rank (including the | ||
| # non-colocated encoder grid) so both modules divide by the same per-token mean. | ||
| n_global = _global_token_count(num_tokens, language_pg, src_global_rank) | ||
| inv = 1.0 / n_global if n_global > 0 else 0.0 | ||
|
|
||
| if mimo_model.language_model is not None: | ||
|
yashaswikarnati marked this conversation as resolved.
|
||
| finalize_model_grads( | ||
| [mimo_model.language_model], | ||
| num_tokens=None, | ||
| pg_collection=language_pg, | ||
| force_all_reduce=force_all_reduce, | ||
| ) | ||
| if inv != 0.0: | ||
| mimo_model.language_model.scale_gradients(inv) | ||
|
|
||
| for name, submodule in mimo_model.modality_submodules.items(): | ||
| if submodule is None: | ||
| continue | ||
| vision_pg = module_pgs.get(name) | ||
| finalize_model_grads( | ||
| [submodule], | ||
| num_tokens=None, | ||
| pg_collection=vision_pg, | ||
| force_all_reduce=force_all_reduce, | ||
| ) | ||
|
|
||
| vision_scale = inv | ||
| if correct_vision_grad and vision_pg is not None and vision_pg.dp is not None: | ||
| vision_dp_group = vision_pg.dp | ||
| if _is_pg_member(vision_dp_group): | ||
| vision_dp_size = dist.get_world_size(vision_dp_group) | ||
| if vision_dp_size > 1: | ||
| participation = _vision_participation_count(submodule, vision_dp_group) | ||
| if 0.0 < participation < vision_dp_size: | ||
| vision_scale *= vision_dp_size / participation | ||
|
|
||
| if vision_scale != 0.0: | ||
| submodule.scale_gradients(vision_scale) | ||
|
|
||
| mimo_model.config.finalize_model_grads_func = finalize_grads_func | ||
| # The schedule always calls grad_scale_func with a Tensor loss; the per-token | ||
| # mean is applied in finalize_grads_func, so no extra scaling is needed here. | ||
| mimo_model.config.grad_scale_func = lambda loss: loss | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe "multimodal_inputs" is better?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we do it in a follow up PR. i basically have to update the naming at data loader, here and model fwd pass interface. can stage a different PR for rename across the board