Skip to content

feat(mdp): run per-layer CUDA graphs on the MDP decoder - #49

Open
BestJuly wants to merge 5 commits into
dev_mdpfrom
lit/mdp-partial-cudagraph-v2
Open

feat(mdp): run per-layer CUDA graphs on the MDP decoder#49
BestJuly wants to merge 5 commits into
dev_mdpfrom
lit/mdp-partial-cudagraph-v2

Conversation

@BestJuly

Copy link
Copy Markdown
Owner

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 (the
preceding PR): per-layer graph capture needs a batch-independent shape
contract, which is exactly what that PR provides.

One commit, 3c8e72cd5.

What shipped

  • Shared thd_shapes_are_static(config) predicate replacing four conflated
    call sites (transformer/module.py, transformer_config.py x2,
    training/arguments.py).
  • MDP's CUDA-graph gate rewritten: full_iteration graphs stay rejected,
    per-layer graphs are permitted under thd_static_packing
    (megatron/core/mdp/config.py:_validate_cuda_graph_options).
  • _discover_layers fixed to forward through model-wrapper properties and to
    fail loudly (not silently) when every chunk's decoder is unreachable
    (examples/multimodal_dev/models/base.py,
    megatron/core/transformer/cuda_graphs.py).
  • pad_between_seqs derived from the collator's row alignment instead of
    forced True — the change that actually makes attn graphs replay (see
    below).

The bug: v1 silently captured 0 layers

The original attempt at this (an earlier, now-superseded branch) never
reached replay because _discover_layers only unwrapped .module to find
the decoder, while under MDP the decoder lives at
self.language_model — a different wrapper path. Capture therefore silently
found 0 graphable layers on every rank, and CUDA graphs were a no-op.
_discover_layers now forwards through the actual wrapper chain, and a new
loud failure (test_unreachable_decoder_fails_loudly) makes a future
zero-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, on
every rank.

The other bug: pad_between_seqs=True was the real blocker, not a second-order cost

_reconstruct_packed_seq_params_from_kwargs hardcoded
pad_between_seqs=True, reasoning that the value can't be a graph input and
inferring it from CUDA tensors would synchronize during capture. With that,
attn graphs OOM'd at 32 GiB (T=8192): forcing True makes FlashAttention
ineligible, 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 have
to be guessed: under --thd-static-packing it's a function of the
collator's row alignment alone (thd_collate_row_alignment), which is
batch-independent by construction — exactly what a graph needs. At
TP=CP=1 the alignment is 1, no sample is ever padded, cu_seqlens and
cu_seqlens_padded coincide, and the honest answer is False. With it
derived, FlashAttention is eligible and attn graphs replay. The conservative
True is retained for every configuration that doesn't set
thd_static_packing.

moe_router fusion: originally blocked, now confirmed working

At review time, --cuda-graph-modules moe_router with
--moe-router-fusion (default on) failed capture inside
fused_moe_aux_loss_fwd with cudaErrorStreamCaptureInvalidated — confirmed
unrelated to MDP (a plain pretrain_gpt.py decoder-only THD run reproduced
the 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 from
2.16.0.dev0+cf64ef56 at review time), attributed to
Megatron-LM PR #4359
adding dynamic-shape support to the fused router: moe_router now captures
cleanly with the real fused aux_loss router (no
--moe-router-load-balancing-type none workaround needed), and so does the
full attn moe_router moe_preprocess scope — 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)

mean ms/iter TFLOP/s/GPU vs eager (static pad)
eager, no static pad 217.6 290.4
eager, static pad (baseline) 214.5 294.6
graph attn 193.9 325.5 -9.6% / +10.5%
graph moe_router (fused) 192.6 329.2 -10.2% / +11.8%
graph attn + moe_router 172.5 367.3 -19.6% / +24.7%
graph attn + moe_router + moe_preprocess (max scope) 171.4 369.4 -20.1% / +25.4%

moe_router alone reproduces the eager baseline's loss/grad-norm bit for
bit; every graph combination stays within ~3.5e-4 relative loss of eager over
20 iterations. moe_preprocess only adds ~0.6% beyond attn+moe_router
alone on this proxy (its own op is cheap here); a production model may see a
larger contribution. mlp graphs are inapplicable to this proxy (every
layer 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 (base 5200c89a3, head reviewed = this PR's tip
3c8e72cd5, i.e. reviewed at the exact commit in this PR): 0 CRITICAL,
1 IMPORTANT (an all-chunks-zero guard in _discover_layers that would have
turned 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_multiple desync
risk, now an explicit assert) — all addressed before this commit.

Tests

4x GB300, TE 2.18.0+27486e0:

torchrun --nproc-per-node 1 -m pytest -q tests/unit_tests/transformer/test_thd_static_shape_predicate.py -> 7 passed
torchrun --nproc-per-node 1 -m pytest -q examples/multimodal_dev/tests/test_mdp_cuda_graph.py             -> 4 passed
torchrun --nproc-per-node 4 -m pytest -q tests/unit_tests/mdp/                                            -> 162 passed

Not covered

Production Qwen3.5-VL shape — this proxy is launch-bound, so the
static-pad tax is unmeasurable here (eager_static vs eager_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_mask
starts applying to MDP and is unverified), and seq_aux_loss (only the
default aux_loss router was retested with the newer TE).

No push to NVIDIA/Megatron-LM and no upstream PR is created by this update.

BestJuly and others added 5 commits August 20, 2026 08:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant