feat: add gemma4 moe ring CP - #1914
Conversation
|
Heads-up on validation status: I'm currently running end-to-end tests on 4 nodes (32x A100-80GB) for both the MoE path ( |
4-node smoke-test results (32x H100-80GB)End-to-end CP verification on 4 nodes (32 x H100-80GB) with Gemma4-E4B MoE (~7B total, 4B activated). Loss parity: CP=1 vs CP=2 (matched per-GPU shard size)To verify the ring-attention + flex_attention path is mathematically equivalent to the non-CP baseline, I ran two matched configurations (same per-GPU
Trajectories overlap within noise. Residual Δ is from different global batch sizes (32 vs 16) sampling different mock examples per step plus reduction-order FP differences. Topology / infra notes
31B dense: not yet validatedI have not yet run the 31B dense config end-to-end. Earlier attempts OOM'd during backward on a 32x 80GB cluster with pure FSDP+CP (weights + Adam state + flex_attention backward workspace together exceed per-GPU budget). 31B long-context training almost certainly needs TP alongside CP to shard the hidden dimension as well; that combination is not covered by this PR and will be a follow-up. The 31B yamls in this PR are provided as configuration templates; they are not expected to run as-is without either TP or a larger cluster. Why all-gather + flex_attention instead of ring-attentionThe all-gather variant here is deliberately simple: each rank gathers full K/V along the sequence dimension then runs a compiled
A proper ring-attention implementation would save the Known limitations (follow-up PR candidates)
|
|
A few more notes to round out the review context. Ring attention is the right long-term directionThe all-gather variant in this PR is a pragmatic starting point, not the ideal design. A proper ring attention implementation (each rank keeps only its local K/V shard and rotates them in a ring) would be strictly better along every axis:
I chose the all-gather + flex_attention path in this PR because it (a) avoids the "mixed torch.Tensor / DTensor" crash in PyTorch's native VLM path: image and no-image both validatedThe recipe-level changes in
Debug decisions worth flagging for the reviewThese are the non-obvious ones we landed on during a multi-day debugging session; a reviewer may want to push back or suggest a better shape.
Happy to split any of the above into their own PRs if the reviewer prefers smaller diffs. |
|
/ok to test 1699fd2 |
|
/claude review |
| # from the propagated hidden-states vs plain K/V after allgather). | ||
| # Ring attention is still performed correctly because attach_cp_sdpa_hooks | ||
| # (registered in infrastructure.py for all CP>1 cases) wraps Q/K/V as | ||
| # sequence-sharded DTensors at the SDPA call site; the DTensor SDPA | ||
| # dispatch then handles the ring communication. All activations outside | ||
| # the SDPA call remain plain tensors. |
There was a problem hiding this comment.
Stale comment: this describes the old DTensor-based approach, but _cp_sdpa now uses manual all-gather of K/V + per-rank causal masking — it no longer wraps Q/K/V as DTensors or relies on DTensor SDPA dispatch.
| # from the propagated hidden-states vs plain K/V after allgather). | |
| # Ring attention is still performed correctly because attach_cp_sdpa_hooks | |
| # (registered in infrastructure.py for all CP>1 cases) wraps Q/K/V as | |
| # sequence-sharded DTensors at the SDPA call site; the DTensor SDPA | |
| # dispatch then handles the ring communication. All activations outside | |
| # the SDPA call remain plain tensors. | |
| # Ring attention is still performed correctly because attach_cp_sdpa_hooks | |
| # (registered in infrastructure.py for all CP>1 cases) replaces SDPA with | |
| # a manual all-gather of K/V followed by per-rank causal masking. All | |
| # activations outside the SDPA call remain plain tensors. |
| batch.pop("image_grid_thw", None) | ||
| batch.pop("video_grid_thw", None) | ||
| batch.pop("n_images_per_sample", None) | ||
| with torch.no_grad(): | ||
| batch["inputs_embeds"] = _model( | ||
| input_ids=_input_ids, | ||
| pixel_values=_pixel_values, |
There was a problem hiding this comment.
Nit/footgun: torch.no_grad() here means the returned inputs_embeds won't carry gradients. This is fine when freeze_embeddings: true (as in all the CP yaml configs), but if a future user runs CP with unfrozen embeddings, gradients won't flow back through the embedding layer and the embeddings will silently stop training.
Consider adding a comment or an assertion to make this constraint explicit, e.g.:
# no_grad is safe here because embeddings and vision tower are frozen under CP;
# if unfreezing, this must be revisited to preserve gradient flow.
ReviewGood work overall — the manual all-gather approach is well-reasoned and the defensive Two inline comments:
Missing test coverage: The PR description mentions unit tests for |
|
…ep in cp_contiguous_shard
Stop splitting make_cp_batch_and_ctx into shared helpers. Its load-balanced
(non-manual) path is now byte-identical to upstream; the only addition is a
single early `if batch.pop("_cp_manual", False)` hop into the model-owned
contiguous-shard path. The gemma4 pre-shard prep (attention_mask -> padding_mask
conversion, position_ids injection, labels resolution) moves into
cp_contiguous_shard.make_contiguous_shard_cp_batch_and_ctx, so the classic CP
path reverts to main exactly and all gemma4-specific logic is contained in
cp_contiguous_shard.
Removed: _prepare_cp_batch_common, _make_context_parallel_cp_batch_and_ctx, and
the module-level _get_submesh/_get_mesh_size (nested back inside the function as
in main).
No behavior change: 26B gemma4 ep8 cp1-vs-cp2 5-step losses are bit-identical to
the pre-refactor cp2 run; parity Δ < 0.02/step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
_run_validation_epoch counted num_label_tokens per-CP-shard and all-reduced both loss and tokens with include_cp=True. That is equivalent but diverges from recipes/llm/train_ft.py (and main), which count the full label tokens *before* CP sharding, reduce the (CP-sharded) loss with include_cp=True, and reduce the token count over DP only -- every CP rank holds the identical full count, so summing it over CP would scale val_loss down by cp_size. Revert to the train_ft.py convention: count (batch["labels"] != -100).sum() pre-shard, reduce total_tokens/total_num_label_tokens without include_cp. Test reverts to test_run_validation_epoch_does_not_sum_tokens_over_cp accordingly. Shrinks the vlm/finetune.py diff vs main to a single line (the pre-embed no_grad removal, which is a real CP fix and is kept). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
…ke TE/DSV4) Move the CP mask handling for Gemma4 into its model-owned ring: the ring pre-hook now nulls the (CP-invalid) full-sequence attention_mask and forces is_causal=True itself, instead of depending on cp_utils' generic attach_context_parallel_hooks mask-strip. attach_context_parallel_hooks now self-skips modules exposing run_cp_manual_attention (mirroring attach_cp_attention_hooks), and the MoE parallelizer only attaches the generic hooks for non-model-owned attention. This makes Gemma4 fully self-contained for CP attention -- transport AND masking -- the same way TE's DotProductAttention is in DSV4. Semantically a no-op: the ring receives identical inputs (attention_mask=None, is_causal=True) either way -- verified by instrumenting the pre-hook. The 26B ep8 cp1-vs-cp2 5-step losses stay within the CP parity band (<0.02/step). Note: cp2 grad_norm/loss shift at the 4th-5th decimal vs the pre-edit cached run (e.g. step0 grad_norm 58.9111 -> 58.8312). This is a torch.compile/inductor recompilation artifact, NOT a masking change: any edit to cp_attention.py -- even a bare `_ = 0` in the pre-hook -- reproduces the identical shift, because editing the source busts the kernel cache and re-autotunes. Both values are valid within fp tolerance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
…d-aid apply_model_infrastructure ran its generic CP attention block (line ~632) for every non-TE model with cp_size>1 -- including MoE models, which already get their full CP setup from the MoE parallelizer's apply_cp (via _shard_ep_fsdp). For Gemma4 this meant the generic hooks were applied twice (the source of the earlier "clobber"), which only worked because cp_utils carried skip-guards. Gate the infra CP block on ep_size <= 1 so MoE models are handled solely by apply_cp -- mirroring how TE/DSV4 is already excluded via _uses_te_attention. With that, Gemma4 (and any model-owned/MoE CP model) no longer reaches attach_context_parallel_hooks at all, so its run_cp_manual_attention skip-guard there was pure dead weight; revert it. cp_utils' attach_context_parallel_hooks is now byte-identical to upstream again. No behavior change: 26B ep8 cp2 5-step losses are bit-identical to the prior commit (the :632 pass was already a no-op for Gemma4 via the skip-guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
…ssic path attach_cp_sdpa_hooks was renamed to attach_cp_attention_hooks and grown a run_cp_manual_attention skip-guard + _cp_uses_attention_hook marker + vision exclusion. But gemma4 is model-owned (its ring is installed by setup_cp_attention) and never reaches this function: apply_cp only calls it when needs_generic_cp_attention (gemma4 takes the model-owned branch), and infra's CP block now skips MoE. DSV4 is TE. So nothing on this branch routes through the classic DTensor-SDPA path's renamed form. Restore the function to upstream verbatim (name + body) and point the dense (infra) and generic-MoE (parallelizer) callers + their tests back at attach_cp_sdpa_hooks. The classic-CP path is now byte-identical to main; gemma4 is unaffected (it does not use it). The vision-exclusion helper / mask-strip marker in attach_context_parallel_hooks are a separate follow-up. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
…ion frozenset gemma4 is model-owned and never reaches attach_context_parallel_hooks (apply_cp calls it only for needs_generic_cp_attention; infra's CP block now skips MoE). So its two divergences from upstream were unneeded here: - the _cp_uses_attention_hook fast-path branch was dead (only gemma4's ring sets the flag, and gemma4 never hits this hook; the flag stays for gemma4's decoder). - the _CP_NON_TEXT_MODULE_PATH_PARTS vision exclusion was protecting gemma4's vision tower at the infra full-model pass, now moot since that pass skips MoE. Restore the function verbatim to main, delete the frozenset + _is_cp_non_text / _is_cp_attention_module_name helpers, and drop the 3 tests that covered the removed behavior. cp_utils.py's only remaining diff vs main is the _cp_manual dispatch in make_cp_batch_and_ctx (gemma4's contiguous-shard batch seam). Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
… + stale comment The CP-attention block carried a stale comment claiming routing happens "by capability at attention time ... in cp_utils" -- that capability skip-guard was removed; routing is decided here in the loop (TE / model-owned setup_cp_attention / generic). Rewrite the comment to match. Also drop the redundant needs_cp_attention_hooks flag: it was just (model_owned or generic) and produced a misleading "Attached CP attention hooks (model-owned)" log even though model-owned attention attaches nothing in this block. Gate the generic-hook attach on needs_generic_cp_attention directly and log model-owned vs generic accurately. No behavior change: gemma4 (model-owned), DSV4 (TE), and generic CP all route exactly as before; 62 parallelizer unit tests pass. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
…in apply_cp Neither flag was needed by the models that exist: gemma4 routes via the model-owned setup_cp_attention branch, DSV4 via the TE DotProductAttention branch. needs_generic_cp_attention only gated a generic plain-SDPA MoE CP fallback that nothing on this branch triggers (main's apply_cp didn't support non-TE attention at all), and model_owned_cp_attention was log-only. Remove both flags and the post-loop generic-hook attach. A full/sliding block that is neither TE nor model-owned now logs a warning (fails loudly, like main) instead of silently relying on a never-exercised path. apply_cp's CP-attention routing is now just: TE -> context_parallel_group; setup_cp_attention -> model-owned; else -> warn. Tests updated to assert no generic hooks are attached and that model-owned routing calls setup_cp_attention. No behavior change for gemma4 (model-owned) or DSV4 (TE); 136 unit tests pass. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
… (infra) The CP PR dropped main's is_compile_enabled gate so attach_cp_sdpa_hooks (the DTensor SDPA re-wrap) ran for all CP>1 -- that was for the original manual-slice gemma4 design (commit abd9d69). gemma4 has since moved to its own ring (setup_cp_attention) and no longer reaches this dense-CP block at all (MoE, skipped by the ep_size<=1 guard). The dense batch path is back to upstream context_parallel, where the SDPA re-wrap is only needed under torch.compile (Dynamo drops DTensor metadata through the compiled graph); without compile, context_parallel dispatches SDPA natively. Restore main's gate: only attach attach_cp_sdpa_hooks when compile is enabled. infra's dense-CP block now matches main except the ep_size<=1 guard (skip MoE, handled by apply_cp) and the _cp_enabled flag. gemma4/DSV4 unaffected (neither uses this block); 131 unit tests pass + 26B cp2 5-step regression matches the validated reference exactly. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
apply_cp no longer routes generic attention through the dense hooks (that path was removed); it warns instead. Fix the ep_size-guard comment accordingly and note it is load-bearing: non-TE MoE (e.g. Gemma4) is not excluded by _uses_te_attention, so without the ep_size gate this pass would mask-strip Gemma4's vision tower and clobber its model-owned ring. Also trim the verbose attach_cp_sdpa_hooks compile-gate comment. Comment-only; no behavior change. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
_make_contiguous_shard_cp_batch enumerated the batch-resident sequence keys three times -- the known_sequence_keys set, the per-key padding block, and the per-key slicing block -- so adding a field meant editing three places. Collapse the batch-resident keys into a single seq_pad_values table (key -> pad sentinel, all seq-dim 1) that drives padding, slicing, and the known-key set. position_ids (special pad), labels and loss_mask (carried as locals) stay handled explicitly. Behavior-preserving: same keys, seq dims, and pad values. 26B ep8 cp2 5-step is bit-identical to the reference (losses and grad_norms); 36 cp_utils unit tests pass. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
… via callable cp_contiguous_shard was used only by Gemma4 (sole _cp_manual setter; DSV4 is TE and never touches it). Move it into the model: components/distributed/ cp_contiguous_shard.py -> components/models/gemma4_moe/cp_batch.py. To keep cp_utils model-agnostic (no distributed->model import) and avoid touching the recipe, dispatch is now a callback: Gemma4's prepare_model_inputs_for_cp attaches make_contiguous_shard_cp_batch_and_ctx to the batch as _cp_make_batch_fn, and make_cp_batch_and_ctx invokes whatever sharding callable the batch carries (replacing the _cp_manual flag + lazy import). vlm/finetune.py untouched. Gemma4 now fully owns its CP transport (ring), masking, and batch sharding; cp_utils carries only the generic callable seam. Behavior-preserving: 26B ep8 cp2 5-step bit-identical to reference; cp_utils unit tests pass. Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
Order the nemo_automodel imports alphabetically (distributed before models) in the cp_utils test files touched by the cp_batch move. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
|
/claude review |
| @@ -0,0 +1,248 @@ | |||
| # Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved. | |||
There was a problem hiding this comment.
Nit: copyright year is 2020 — looks like a copy-paste from an older file. Every other new file in this PR uses 2025.
| # Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved. | |
| # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. |
Addresses Claude review nit: the moved file inherited a 2020 header from the original cp_contiguous_shard.py; bump to 2025 to match the other new files in this PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
|
/claude review |
|
/ok to test f736e09 |
| def _force_repeat_kv_for_sdpa(): | ||
| """Disable native SDPA GQA so the CP hook can own grouped-query handling.""" | ||
| try: | ||
| from transformers.integrations import sdpa_attention | ||
| except ImportError: | ||
| yield | ||
| return | ||
|
|
||
| original_use_gqa = sdpa_attention.use_gqa_in_sdpa | ||
| sdpa_attention.use_gqa_in_sdpa = lambda _attention_mask, _key: False | ||
| try: |
There was a problem hiding this comment.
nit: _force_repeat_kv_for_sdpa is defined and tested but never called in production code — grep shows usage only in test_gemma4_model_cp.py. Is this intended for a follow-up, or was it meant to be wired into the CP forward path? If it's not needed yet, consider removing it to avoid confusion.
Addresses Claude review: this context manager (disable HF sdpa_attention GQA so a hook owns grouped-query handling) was never wired into production. Gemma4's ring CP path owns GQA itself via enable_gqa in its flex/SDPA call (cp_attention.py), so the manager is dead -- only its own swap/restore unit tests referenced it. Remove it, its two tests, and the now-unused contextlib / sys imports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
|
Good catch — /claude review |
|
/ok to test dadd6df |
| @@ -0,0 +1,619 @@ | |||
| # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. | |||
There was a problem hiding this comment.
LGTM — thorough implementation with strong test coverage. The ring attention design (model-owned CP seam, p2p ring FlexAttention, online-softmax merge, custom autograd backward with gradient exchange) is clean and well-isolated from the generic CP infrastructure.
A few observations (all non-blocking):
-
ModelCapabilities: MoE variant already has
supports_cp=True— no staleness issue. The newep8cp2recipe YAML validates it. -
SDPA monkey-patching (lines 586/591): The
_pre_hook/_post_hookpair that swapsF.scaled_dot_product_attentionis safe because layers forward sequentially and the post-hook hasalways_call=True, but it's worth noting that this pattern wouldn't survive concurrent layer execution (e.g. if someone tried to parallelize layers within a pipeline stage). Not a concern for current usage. -
torch.no_grad()removal in the recipe: Intentional and correct — enables gradient flow through trainable multimodal projectors during CP pre-embed. The test correctly verifiesrequires_gradpropagation. -
Double
masked_fillfor empty query rows: Applied both per-chunk (line 297) and after merge (line 368). The post-merge application is the authoritative one; the per-chunk one is defensive. Harmless redundancy.
|
|
||
| model_parts = model.parts if hasattr(model, "parts") else [model] | ||
| for mp in model_parts: | ||
| mp._cp_enabled = True |
There was a problem hiding this comment.
This will set it for all models right?
| # full-sequence embeddings and null out local attention masks. | ||
| # With CP the mask is not sharded along the sequence dim and TE asserts | ||
| # "Padding mask not supported with context parallelism!". | ||
| model._cp_enabled = True |
There was a problem hiding this comment.
this one is used by gemma4. the one below by qwen35. I have another PR on qwen35, will unify the two flags here. They are checks before vlm CP, if the placeholder img tokens are already pre-embd.
The dense/non-EP CP block in apply_model_infrastructure set mp._cp_enabled=True on every model part, but nothing reads it on that path: the only _cp_enabled readers are gemma4_moe and qwen3_5_moe, both MoE models that run ep>1 and get the flag from the MoE parallelizer's apply_cp instead (this block is gated ep_size<=1). No dense model reads it. Remove the dead set; keep the mask-strip / DTensor-SDPA hooks the dense CP path actually needs. Addresses review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
Remove the comment explaining the is_compile_enabled gate; it no longer adds clarity. No behavior change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: HuiyingLi <willwin.lee@gmail.com>
What does this PR do?
Adds a Gemma4-specific Context Parallelism (CP) path for dense and MoE VLM
fine-tuning, without changing the generic CP behavior of any other model.
Gemma4 cannot use the existing causal ring-attention CP path for this workload:
its decoder mask combines image-token bidirectional attention, sliding-window
attention, and packed-sequence document boundaries, and its large head
dimension (256) rules out FlashAttention-2 and the SDPA flash/mem-efficient
kernels. This PR routes only Gemma4 decoder self-attention through a model-owned
p2p ring FlexAttention path, plugged into a small model-agnostic seam in the CP
infrastructure.
At a high level, each CP rank keeps its local query shard. K/V (and the token
metadata needed to build masks) are rotated around the CP ranks via point-to-point
ring exchange — they are never fully all-gathered. For each ring step the active
Gemma4 layer builds a
torch.compile(flex_attention)block mask (full / sliding,packed-document boundaries, image bidirectional groups, padding rows), computes
attention against the current K/V chunk, and the per-chunk outputs are combined with
an online-softmax (logsumexp) merge. Only the local query output shard is returned.
Design
CP attention routing is model-agnostic (
apply_cp/attach_cp_attention_hooks):DotProductAttention→ TE's own context-parallel group (unchanged).run_cp_manual_attention→ the model owns its CPtransport + compute through that seam (Gemma4's flex ring).
cp_utilscontains no model names — only the TE-vs-not split and the seam; theGemma4 ring lives entirely in
components/models/gemma4_moe/cp_attention.pyand isattached via
attach_gemma4_cp_ring_attention. This keeps the path easy to extend toother models that need their own CP attention.
Changelog
components/models/gemma4_moe/cp_attention.py(new)FlexAttention with layer-aware masks (causal, sliding-window, packed-document
boundaries, image-token bidirectional groups, padding-only query rows), online
softmax merge, a custom autograd
Functionwith ring grad exchange, therun_cp_manual_attentionentry, andattach_gemma4_cp_ring_attention.components/models/gemma4_moe/model.pyprepare_model_inputs_for_cp/prepare_inputs_embeds_for_cpto build text+imageinputs_embedson the unsharded sequence before CP sharding.components/distributed/cp_utils.pysharding for the model-owned path; single-document
_packed_seq_idssynthesis;padding / position-id helpers. Generic models keep the DTensor
context_parallelpath.
components/moe/parallelizer.pyapply_cprouting (TE vs hooks; model-owned vs generic DTensor)._transformers/infrastructure.pycp_size > 1.recipes/vlm/finetune.pyinputs_embedsbefore CP sharding for Gemma4 + image inputs.counting), so
val_lossdivides like-for-like.examples/vlm_finetune/gemma4/gemma4_26b_a4b_moe_medpix_ep8cp2_4k.yamlTests
exchange, online-softmax merge, autograd, manual-attention entry), the CP wiring
seam and hooks, the packed/vision mask mapping, parallelizer routing, the
infrastructure CP-hook attach, and the validation token-counting fix —
bringing the PR's changed lines to ~100% unit coverage (CPU-only, no GPU
required; FlexAttention exercised in CPU-eager mode).
Before your PR is "Ready for review"
Pre checks:
Test Results
Targeted behavior
(EP8×CP2, 2k seq, 26B-A4B MoE):
Gemma4 CP using compiled flex_attention p2p ring. Q=(1, 16, 1024, 256) K_local=(1, 8, 1024, 256) head_dim=256->256 cp_rank=0 cp_size=2CP2 forward(A alone) == CP2 forward(packed [A, B])[:, A slice]CP2 forward(B alone) == CP2 forward(packed [A, B])[:, B slice]ring implementation, with layer-specific mask construction.
reference at
cp_size=1, and the online-softmax merge matches a single fullsoftmax.
https://wandb.ai/Nemo-automodel/huiyingl_workspace/runs/plvv3r6b
https://wandb.ai/Nemo-automodel/huiyingl_workspace/runs/itjikprm