feat(mdp): run per-layer CUDA graphs on the MDP decoder - #49
Open
BestJuly wants to merge 5 commits into
Open
Conversation
Give MDP's decoder data path two independent, opt-in properties. --mdp-greedy-packing fills a fixed num_microbatches bins to a token budget of max_seqlen_per_dp_cp_rank x cp_size, consuming as many samples as that takes, instead of a fixed --micro-batch-size count. This decouples padding waste from MBS while keeping num_microbatches static, which the PP schedule, the VPP replay cursors, and per-layer CUDA-graph slot sizing all depend on. The grouping rule is DpBalancedScheduler's in-order greedy fill; MDP cannot use that scheduler itself, because it asserts on GPT-only sample keys, drops pixel_values / image_grid_thw, and reroutes samples across DP without pixel awareness, so that combination is now rejected with that reason. --micro-batch-size and --global-batch-size become pure bin-count knobs, and consumed_train_samples is reported from a real all-reduced count. --thd-static-packing makes the collator emit exactly max_seqlen_per_dp_cp_rank x cp_size rows with cu_seqlens* padded to thd_max_packed_sequences + 1 entries -- the same shape contract --sequence-packing-scheduler gives the decoder-only path. The tail is an append_dummy_seq sequence; the pre-tail cu_seqlens is emitted alongside so the FLOPs accumulator keeps excluding pad tokens. --mdp-mock-dataset-config-json gives the mock dataset a controllable sequence-length distribution, reusing --varlen-mock-dataset-config-json's schema and MockSFTLowLevelDataset, seeded so every rank rebuilds an identical scenario pool. The default pool is byte-identical. Verified on 4xGB300: with a degenerate min=max=mean=1024 distribution and a 4096 budget, greedy and greedy+static both reproduce the fixed-MBS=4 baseline loss and grad-norm trajectory bit-for-bit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Li Tao <lit@nvidia.com>
Strict-review follow-ups on the greedy/static packing branch. CRITICAL: the greedy sequence cap was thd_max_packed_sequences verbatim, but under --thd-static-packing the padding tail is appended to cu_seqlens as an ordinary dummy sequence, so a bin filled to the cap needs cap + 2 entries and overflows the cap + 1 capacity inside _pad_cu_seqlens. dp_balanced already reserves that slot (_get_scheduler_max_real_num_seqs); greedy now does too, via greedy_max_real_sequences(), and thd_max_packed_sequences < 2 is rejected under static packing. Latent until a bin happened to hit the cap. IMPORTANT: consumed_samples() summed every greedy stream, so an evaluation pass leaked into the next iteration's consumed_train_samples delta. Streams are now tagged train/eval at creation and eval is excluded. The one-iteration shift --mdp-overlap-window-capture introduces is documented. Also: drop greedy_bin_sizes (no runtime use path; its assertions moved onto GreedySampleStream), and build the mock scenario pool once per provider call instead of four times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Li Tao <lit@nvidia.com>
… True The static-shape test still encoded the first implementation's pad_between_seqs=True. The shipped value is derived from the collator's row alignment, so at TP=CP=1 it is False -- and provably so: cu_seqlens and cu_seqlens_padded coincide, which the test now also asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Li Tao <lit@nvidia.com>
The static tail policy is append_dummy_seq, so the pad does land in
cu_seqlens_q and the collator emits the pre-tail vector as flops_cu_seqlens.
The test still asserted the earlier extend_last behavior ('no override
emitted'). Assert the property that actually matters instead: the override sums
to the real token count, using cu_seqlens_q would count the whole static pad,
and accumulate_flops_stats consumes the override.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Li Tao <lit@nvidia.com>
Three changes make per-layer ('partial') CUDA graphs actually replay on MDP's
decoder, and one makes them worth enabling.
1. Widen the predicate. GraphableMegatronModule._is_thd_cuda_graph asked 'is
MCore's packing scheduler configured' when it needs to know 'does the
incoming THD batch have fixed shapes'. Those coincided only while the
scheduler was the sole fixed-shape producer; --thd-static-packing is a third.
One shared packed_seq_params.thd_shapes_are_static(config) now backs the four
conflated call sites (module.py, transformer_config.py's THD CUDA-graph gate,
the same gate in arguments.py, and the MoE dispatcher restriction).
_get_thd_varlen_max_num_microbatches deliberately stays dp_balanced-specific.
2. Fix layer discovery. TECudaGraphHelper._discover_layers resolves layers with
get_attr_wrapped_model(chunk, 'decoder'), which unwraps only through
'.module'; MDP's decoder lives at MultimodalModel.language_model, so the
lookup raised, the helper swallowed its own error at DEBUG level, and it
captured ZERO layers while reporting success. MultimodalModel now forwards
decoder / mtp / rotary_pos_emb / position_embedding_type, and core raises
when every chunk fails that lookup.
3. Rewrite the MDP gate. full_iteration stays rejected (it would capture P4
itself); per-layer graphs are accepted only under --thd-static-packing, and
still rejected together with --mdp-overlap-window-capture.
4. Stop forcing pad_between_seqs=True in the graph path. It cannot be a graph
input and must not be inferred from CUDA tensors during capture, but it does
not have to be guessed: under --thd-static-packing it is a function of the
collator's row alignment alone. Forcing True is not free -- TE disables
FlashAttention for THD whenever padding may exist between sequences, and on
this configuration cuDNN fused attention reports 'no backend supports the
provided input', so TE fell through to its unfused O(T^2) backend and OOM'd
at 32 GiB. With the value derived, attn graphs replay.
Measured on 4xGB300, 20 iterations, lognormal lengths, budget 8192:
attn graphs 194.6 ms/iter and 320.0 TFLOP/s/GPU vs 215.7 ms and 288.6 for eager
with the same static pad -- ~10% faster, loss and grad norm matching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Li Tao <lit@nvidia.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Unlocks per-layer ("partial") CUDA graphs on the MDP decoder. Per-layer
graphs were not structurally incompatible with MDP's replay-iterator
mechanism — P4 runs the unmodified decoder schedule and the bridge only
touches embedding leaves — but capture had never actually reached a working
replay before this branch.
Built on top of
--thd-static-packing/--mdp-greedy-packing(thepreceding PR): per-layer graph capture needs a batch-independent shape
contract, which is exactly what that PR provides.
One commit,
3c8e72cd5.What shipped
thd_shapes_are_static(config)predicate replacing four conflatedcall sites (
transformer/module.py,transformer_config.pyx2,training/arguments.py).full_iterationgraphs stay rejected,per-layer graphs are permitted under
thd_static_packing(
megatron/core/mdp/config.py:_validate_cuda_graph_options)._discover_layersfixed to forward through model-wrapper properties and tofail loudly (not silently) when every chunk's decoder is unreachable
(
examples/multimodal_dev/models/base.py,megatron/core/transformer/cuda_graphs.py).pad_between_seqsderived from the collator's row alignment instead offorced
True— the change that actually makesattngraphs replay (seebelow).
The bug: v1 silently captured 0 layers
The original attempt at this (an earlier, now-superseded branch) never
reached replay because
_discover_layersonly unwrapped.moduleto findthe decoder, while under MDP the decoder lives at
self.language_model— a different wrapper path. Capture therefore silentlyfound 0 graphable layers on every rank, and CUDA graphs were a no-op.
_discover_layersnow forwards through the actual wrapper chain, and a newloud failure (
test_unreachable_decoder_fails_loudly) makes a futurezero-layer regression a hard error instead of a silent no-op. On this branch:
INFO:...cuda_graphs:Rank 0: 4 graphable layers.— all 4 decoder layers, onevery rank.
The other bug:
pad_between_seqs=Truewas the real blocker, not a second-order cost_reconstruct_packed_seq_params_from_kwargshardcodedpad_between_seqs=True, reasoning that the value can't be a graph input andinferring it from CUDA tensors would synchronize during capture. With that,
attngraphs OOM'd at 32 GiB (T=8192): forcingTruemakes FlashAttentionineligible, and on this configuration (head_dim 256, TP=CP=1) cuDNN fused
attention declines too, so TE fell through to
UnfusedDotProductAttention's O(T²)torch.baddbmm. The value doesn't haveto be guessed: under
--thd-static-packingit's a function of thecollator's row alignment alone (
thd_collate_row_alignment), which isbatch-independent by construction — exactly what a graph needs. At
TP=CP=1 the alignment is 1, no sample is ever padded,
cu_seqlensandcu_seqlens_paddedcoincide, and the honest answer isFalse. With itderived, FlashAttention is eligible and
attngraphs replay. The conservativeTrueis retained for every configuration that doesn't setthd_static_packing.moe_routerfusion: originally blocked, now confirmed workingAt review time,
--cuda-graph-modules moe_routerwith--moe-router-fusion(default on) failed capture insidefused_moe_aux_loss_fwdwithcudaErrorStreamCaptureInvalidated— confirmedunrelated to MDP (a plain
pretrain_gpt.pydecoder-only THD run reproducedthe identical failure), and attributed to a TE-side dynamic-shape gap in the
fused aux-loss kernel under stream capture.
Retested 2026-08-21 on an image with TE
2.18.0+27486e0(up from2.16.0.dev0+cf64ef56at review time), attributed toMegatron-LM PR #4359
adding dynamic-shape support to the fused router:
moe_routernow capturescleanly with the real fused
aux_lossrouter (no--moe-router-load-balancing-type noneworkaround needed), and so does thefull
attn moe_router moe_preprocessscope — MDP enabled throughout(
--mdp-enable).Performance (4x GB300, MDP enabled, lognormal lengths, budget 8192, cap 16, TP=PP=CP=1, DP=4, 4 decoder layers / 8 experts top-2, 20 iterations, mean over iterations 6-20)
attnmoe_router(fused)attn+moe_routerattn+moe_router+moe_preprocess(max scope)moe_routeralone reproduces the eager baseline's loss/grad-norm bit forbit; every graph combination stays within ~3.5e-4 relative loss of eager over
20 iterations.
moe_preprocessonly adds ~0.6% beyondattn+moe_routeralone on this proxy (its own op is cheap here); a production model may see a
larger contribution.
mlpgraphs are inapplicable to this proxy (everylayer is MoE —
--num-experts 8, no--moe-layer-freq).Recommendation: enable the maximal scope,
--cuda-graph-modules attn moe_router moe_preprocess, together with--thd-static-packing, for MDP at TP=CP=1.Strict review
strict_review_v2.md(base5200c89a3, head reviewed = this PR's tip3c8e72cd5, i.e. reviewed at the exact commit in this PR): 0 CRITICAL,1 IMPORTANT (an all-chunks-zero guard in
_discover_layersthat would haveturned legitimate zero-graphable-layer models into hard failures — fixed to
key off lookup failures instead), 2 SUGGESTION (a row-alignment rule
restated in a third place; an unreachable-today
pad_to_multipledesyncrisk, now an explicit assert) — all addressed before this commit.
Tests
4x GB300, TE
2.18.0+27486e0:Not covered
Production Qwen3.5-VL shape — this proxy is launch-bound, so the
static-pad tax is unmeasurable here (
eager_staticvseager_nopad: +1.4%step time) and will show up more at production layer weight, shrinking the
graph win shown above. CP>1, PP>1, SP+PP>1 (
_needs_full_local_padding_maskstarts applying to MDP and is unverified), and
seq_aux_loss(only thedefault
aux_lossrouter was retested with the newer TE).No push to NVIDIA/Megatron-LM and no upstream PR is created by this update.