Generalized Tensor Parallelism (GTP)#
⚠️ Experimental. GTP is an experimental feature and its API, configuration, and behavior may change in future versions without notice.
📦 Requires TransformerEngine >= 2.19 (GTP support is merged into TE main). On an older TE, GTP is disabled at import (
HAVE_GTP = False) and enabling it raises anImportError— please install TransformerEngine >= 2.19.
Generalized Tensor Parallelism (GTP) is a lightweight, high-performance, memory-efficient distributed-training strategy implemented jointly in Megatron-LM and TransformerEngine. It shards weight tensors across a GTP process group and reconstructs them on demand via asynchronous all-gather, so larger models fit in the same memory without sacrificing throughput — the communication is overlapped with computation rather than added to it.
GTP splits the weight-parallel domain into two orthogonal sub-axes — GTP = TP × GTP_remat — so every rank stores 1/(TP × GTP_remat) of each linear weight, together with the matching slice of its gradient and optimizer state.
GTP_remat is an implementation of ZeRO-3, and obeys the same contract: shard the weight (plus grad and optimizer state), all-gather it just before it is needed, use it, free it, reduce-scatter the gradient on the way back. What distinguishes it from the familiar ZeRO-3 / FSDP implementations is where it shards and how finely it materializes:
It shards along a model-parallel axis, not the data-parallel one.
GTP_rematis a sub-axis of the weight-parallel grid that sits on top of TP — theTPslice stays sharded through the GEMM, and only theGTP_rematslice is rebuilt. It therefore composes with TP instead of competing with it for the same weight dimension.It materializes one weight at a time, not a bucket. Each
GTPShardedParamgathers, computes and frees on its own schedule, which is what makes the per-weight prefetch chain (§3.4) and the low-precision gather (§1.3) possible — see the FSDP contrast in §1.1.
slice |
stored |
at GEMM time |
|---|---|---|
|
|
stays sharded — ordinary tensor parallelism; the output is TP-sharded |
|
|
rematerialized: all-gathered across the |
Both GTP_remat collectives are prefetched one step ahead, so they overlap the previous layer’s compute in forward and backward — the gather is off the critical path, not merely asynchronous. Note the two cuts do not always fall on the same axis of the weight (§1.4).
Turning it on. The GTP_remat degree is gtp_weight_remat_size, derived from --tensor-parallel-num-weight-shards (= tensor_model_parallel_size × gtp_weight_remat_size). At gtp_weight_remat_size = 1 GTP is inactive and the path is byte-identical to plain TP + DP, so it is safe to leave in the code path. It composes orthogonally with TP / SP / EP / DDP / CUDA Graphs.
Scope of this document: a high-level summary of GTP_remat — design intent, public CLI surface, and Megatron-LM ↔ TransformerEngine integration touchpoints.
Source: core sharding and collective implementation in megatron/core/tensor_parallel/generalized_tensor_parallelism.py, CUDA-graph lifecycle support in megatron/core/tensor_parallel/gtp_cuda_graphs.py, and the public surface re-exported from megatron/core/tensor_parallel/gtp_api.py. Low-precision tensor primitives (FP8 / MXFP8 / NVFP4) stay in TransformerEngine and are imported by the implementation module.
Outline:
1. Features#
1.1 Fine-grained, per-weight materialization & gradient reduction#
Each weight is sharded 1/N across a GTP_remat group along out_features, stored as a GTPShardedParam subclass of nn.Parameter. Materialization and gradient reduction are both per-weight, per-call — not per-model or per-module:
Independent state per param: each has its own AG state (
state) and RS state (rs_state) machines, both cyclingNONE → ASYNC_WAIT → DATA_READY → NONEand tracked separately so fwd and bwd async ops don’t interfere.Prefetch chain for AG (doubly-linked
prev_w/next_w): during fwd, each weight’sall_gather_and_prefetchissues async AG fornext_w; during bwd,all_gather_and_prefetch_bwdissues async AG forprev_w. Layer i’s AG overlaps with layer i−1’s GEMM. For an L-layer model, L−1 all-gathers are fully hidden behind compute. When activation recompute is enabled, a third chain prefetches the recompute-forward gathers during backward — see §3.1 Recompute-forward prefetch chain. One GEMM of runway covers a gather that stays inside the NVLink domain, but not one that leaves it — the case for MoE routed-expert weights, which also dominate the bytes gathered per block; those get their own one-block-ahead chains — see §3.4 Grouped-expert chains.Deferred RS finalize for wgrad:
wgrad_reduce_scatteron param i launches an async reduce-scatter (handle stashed in_wgrad_rs_handle) and returnsNoneto autograd — the wgrad is NOT finalized intomain_gradyet. Finalization is deferred one step: the next bwd step (param i−1’swgrad_reduce_scatter) callsself.next_w._wait_reduce_scatter()+_finalize_wgrad(), which waits on the stashed handle, accumulates the reduced wgrad intomain_grad, and fires the DDPregister_grad_readyhook. The chain’s head (first-in-fwd, last-in-bwd) uses a synchronous RS since nothing follows it. This one-step deferral is what lets layer i’s RS overlap with layer i−1’s bwd GEMMs.Cold start only: every weight’s very first AG is synchronous (
DATA_READY_SYNC, no prefetch has run yet); the async prefetch chain kicks in from the second forward onward.
Contrast with FSDP: FSDP gathers at module-group granularity in full precision with PyTorch-managed lifecycle. GTP_remat works at individual-weight granularity, in quantized form, with its own explicit ticket-based buffer pool and a one-step-deferred RS finalizer.
FSDP can’t shrink into GTP_remat because FSDP’s overlap is bucket-grained by design — bucket granularity exists to avoid paying NCCL launch latency on tiny params (LayerNorm γ/β, biases, Mamba
dt_bias/D/A_log) and to avoid the per-weight scheduling state that GTP_remat relies on (per-param prefetch chain, ticket-based buffer cache, stream choreography). Removing buckets doesn’t make FSDP faster; it makes FSDP into GTP_remat, with all the engineering that entails — selective wrapping (only large GEMM weights), per-weight prefetch chain, per-param buffer ticket, and explicit AG/RS stream choreography on a side stream so external drains have something meaningful to wait on.
1.2 CUDA graph compatibility#
CG compatibility is designed-in from day one, not retrofitted. The entire sync / buffer / chain architecture is shaped around making captured fwd/bwd replays produce identical bit-for-bit behavior — without the usual capture-vs-eager pitfalls that force other weight-sharding schemes to either disable CG or require special handling.
Chains never cross-link across the capture axis (
GTPChain.GRAPHED/GTPChain.UNGRAPHED, plus the eager-only grouped-expert chains of §3.4).prev_w/next_wonly connect same-chain params, so a captured traversal never reaches into eager Python and vice-versa.torch.cuda.Event(external=True)forag_event/rs_event— the events survive CG capture boundaries and can be waited on from replay-time streams.Idempotent ticket cache:
GTPWeightCache.get(ticket)keepsslot.bufset even afterrelease(), so replays read the same buffer address as capture.clear()drops buffers while keeping tickets valid → supports CG re-capture with lazy re-allocation.Allocate-in-pool at creation (
set_cuda_graph_mempool+cuda_graph_pool_allocation): GRAPHED-chain AG/RS buffers and quantized weight storage are allocated directly into the CG memory pool at first creation (during warmup, before capture), so no CUDA allocations happen inside the captured graph and no post-hoc reallocation/clone is needed. UNGRAPHED buffers stay in regular allocator memory.Lazy, one-shot chain linking:
prefetch_initializedis flipped during the first fwd (warmup), so the chain-construction Python side-effects never execute inside a captured graph. The link table is buffered and flushed atomically at the second forward.DDP hook manual triggering:
register_grad_accum_hookstores the DDP hook on the param;_CudagraphReplayNode.backwardcalls it manually after replay (sinceAccumulateGradhooks are silenced by replay). This is also how theassert self.grad_reduce_handle is not Nonefailure from partial-CG + overlap-grad-reduce is resolved.Warmup is side-effect-free on
main_grad: GTP_remat accumulates wgrad intomain_gradinside the backward (the fusion path returns wgrads as graph outputs instead). Graph capture only records ops; it never runs them. Butcreate_fwd_graphruns an eager warmup fwd+bwd before capturing. That warmup backward executes GTP_remat’smain_grad.add_. Its deferred cascade adds into a cross-graphnext_w(another module) from a stale RS ticket — the prior backward’s wgrad. Andcreate_cudagraphs()runs afterfinalize_model_grads. So this overwrites the finalized (reduced + per-token-scaled) grads and spikes the step’s grad norm. Fix:create_fwd_graphsnapshots the grads its warmup touches — own params + cross-graphnext_w— via_backup_grads_before_capture, then restores them after capture. The bwd graph has no warmup, so it needs none. Bounded to one module’s grads.Graph-owned two-stage backward drain: Stage 1 drains only the all-gathers issued by the current graph and records
bwd_completion_event, allowing the next backward graph to start. Stage 2 drains that graph’s reduce-scatters, accumulates the result intomain_grad, and releases its persistent wgrad-ring slots. See §3.6.Side-stream registration: the
(GRAPHED, gtp_remat_group)ag/rs streams are materialized at runner init (_register_gtp_side_streams) so they are captured before the first forward.
1.3 Low-precision gather (native FP8 / NVFP4 param)#
Wire bandwidth scales with the quantized size, not BF16 size — GTP_remat composes with low-precision training rather than fighting it. The shard is stored as a native MXFP8, native NVFP4, or BF16 weight, gathered with the following mechanics:
Native MXFP8 param —
mxfp8+--fp8-param-gather(always paired, see §2.1). The shard is a nativeMXFP8Tensor(§3.1); the optimizer writes FP32 master → FP8 once per step (off the forward critical path), and the forward all-gathers the FP8 shard directly — no per-microbatch quantize, no cast. The rowwise (fwd) / columnwise (bwd) view comes from a separate gather-quantizer copy (_gtp_gather_quantizer), leaving the param’s own quantizer for the optimizer’s write path.Native NVFP4 param —
--fp4-param-gather(required). Same shape as MXFP8: the shard is a nativeNVFP4Tensor, all-gathered as packed 4-bit (kFloat4E2M1) and optimizer-maintained, no per-microbatch quantize. See the GTP + NVFP4 subsection below.BF16 (no FP8/NVFP4 params). The BF16 shard is all-gathered as-is.
Coalesced NCCL:
grouped_gather_along_first_dimusestorch.distributed._coalescing_managerto batch E experts’ AGs into a single NCCL op.Padding: shards are allocated already padded so each rank’s dim0 stays
pad_for_alignment-divisible (MXFP8: 32). Column-parallel pads the per-TP slice (out_features / tp_size) to a multiple ofpad_for_alignment × gtp_remat_sizeso it survives TE’s TP split aligned; row-parallel / Megatron-local pad the TP-local tensor directly (§3.1). Padding lands contiguous at the tail, so stripping is one trailing slice (tensor[:-pad_length]). For how that tail maps onto individual per-rank shards, and whynum_zerosaccounting has to account for it, see §3.7.
Per-microbatch schedule#
Steady-state fwd (MXFP8 native FP8 param / BF16):
default: ──GEMM(W_0)───────────────────GEMM(W_1)───────────────────GEMM(W_2)──...
ag_str: [AG_issue W_1] [AG_issue W_2]
(no per-microbatch quantize: the FP8 shard is
maintained by the optimizer; BF16 gathers as-is)
Steady-state bwd (MXFP8 / BF16):
default: ──bwd GEMMs(W_i)──...
ag_str: [AG_issue W_{i-1}]
(columnwise view of the same FP8 shard; no quant)
For the native-FP8 (MXFP8), native-NVFP4, and BF16 paths the forward all-gather is a single NCCL op per weight on the GTP_remat ncclStream, with no per-microbatch quantize or GTP_remat-group amax on the critical path (the standard DP-group FP8 amax allreduce in reduce_and_update_fp8_tensors is unchanged by GTP_remat). Only the dist.all_gather issue is wrapped in with torch.cuda.stream(ag_stream); the NCCL kernel runs on c10d’s private ncclStream and overlaps with the next GEMM until it reaches its wait.
Communication volume breakdown#
Per-microbatch per-weight comm budget (assuming bf16 wgrad reduce-scatter):
Format |
Block |
Data B/elem |
Scale_inv B/elem |
Per-elem |
Fwd AR(amax) |
Fwd AG |
Bwd AG |
Wgrad RS (bf16) |
Total B/elem |
vs BF16 |
|---|---|---|---|---|---|---|---|---|---|---|
BF16 |
n/a |
2.0000 |
— |
2.0000 |
— |
2.0000 |
2.0000 |
2.0000 |
6.0000 |
1.00× (baseline) |
MXFP8 |
32 |
1.0000 |
1/32 = 0.0313 |
1.0313 |
— (microscale, no global amax) |
1.0313 |
1.0313 |
2.0000 |
4.0626 |
0.68× (–32%) |
NVFP4 |
16 |
0.5000 |
1/16 = 0.0625 |
0.5625 |
— (scale set at opt-step quantize) |
0.5625 |
0.5625 |
2.0000 |
3.1250 |
0.52× (–48%) |
How to read the columns:
Per-elem=Data B/elem + Scale_inv B/elem— wire cost of one quantized weight buffer (data + scale_inv together).Fwd AGandBwd AGeach carry the quantized buffer once, so they equalPer-elem. Bwd all-gathers the same FP8 shard (columnwise view) — no re-quantize, no AR(amax).Wgrad RS (bf16)= 2.0 B/elem — gradient is reduce-scattered in bf16 regardless of weight precision.Fwd AR(amax)— none per microbatch for either native format: MXFP8 is microscale-only, and native NVFP4 carries its block scales in the gathered buffer with the per-tensor scale set at the optimizer-step quantize (not per forward).Total B/elem=Fwd AG + Bwd AG + Wgrad RS— there is no per-microbatch amax AR to add.
Gathering the pre-quantized weight attacks AG only: the AG portion shrinks ~72% from BF16 → NVFP4, but RS is untouched, so the wgrad RS becomes the dominant comm path in NVFP4 (~64% of the budget at bf16 RS, ~78% at fp32 RS).
GTP + NVFP4 (native NVFP4 param)#
NVFP4 GTP_remat keeps each shard as a native NVFP4Tensor and all-gathers it as packed 4-bit (kFloat4E2M1) — the native-param path, mirroring native MXFP8: the distributed optimizer writes the NVFP4 shard directly once per step and the forward all-gathers it with no per-microbatch quantize.
--fp4-param-gatheris mandatory. Without it NVFP4 GTP falls back to a BF16 all-gather that trips TE’s scaling-mode assert (DELAYEDvsNVFP4);validate_argsenforces it and raises early.Mixed-precision models (per-layer quant config). A model may assign recipes per layer — e.g. NVFP4 default, MXFP8 for
mixer.out_proj, BF16 for attention (linear_qkv/linear_proj) and latent MLPs. NVFP4 params gather natively as above. MXFP8 params cannot be native-param-gathered — the DDP param buffer has no MXFP8 storage remap (replace_raw_datais unimplemented forMXFP8Tensor, unlike NVFP4’s packed-rowwise remap), so they are all-gathered in BF16 and re-quantized with the layer’s own MXFP8 quantizer inside the TE backward dgrad path (not the global delayed recipe). BF16-recipe layers gather BF16 unchanged.
1.4 Composability with TP / SP / EP / DDP#
TP (intra-layer): orthogonal axis — GTP_remat shards
out_featuresregardless of TP’s parallel mode (column or row). 2D grid naturally formed viatp_group × gtp_remat_group.
⚠️ The two cuts are not always on the same axis.
GTP_rematalways slicesout_features(dim 0) of the TP-local weight — independent of TP’spartition_dim:
linear
TP cuts
GTP_rematcutscolumn-parallel (
linear_qkv,linear_fc1)
out_features
out_featuressame axis →
out_features/(TP × GTP_remat)row-parallel (
linear_proj,linear_fc2)
in_features
out_featuresperpendicular →
in_features/TP×out_features/GTP_rematduplicated (
fc1_latent_proj,fc2_latent_proj)none (weight replicated across TP)
out_featuresGTP_remat only →
out_features/GTP_remat; full output reconstructed via AG. Requires--gtp-remat-opt-in-modules moe_latent_proj.
SP (sequence-parallel): transparent — GTP_remat operates at weight dim, SP at sequence dim.
EP (MoE):
GroupedLinearwith GTP_remat → each routed expert sharded acrossEXPERT_GTP_WEIGHT_REMAT_GROUP, independent of EP. MoE AllToAll (HybridEP/NVLink) runs independently of GTP_remat AG/RS (NCCL/IB).DDP: GTP_remat bypasses autograd’s grad accumulator (async RS returns
None;_finalize_wgradaccumulates directly intomain_grad). DDP registers its grad-ready hook on GTP_remat params viaregister_grad_accum_hook(not autograd’sAccumulateGrad); GTP_remat invokes it from_finalize_wgrad(eager path) and_CudagraphReplayNode.backward(captured path) after the wgrad lands inmain_grad, so a bucket’s DDP reduce-scatter runs strictly after every GTP_remat param’s{RS → main_grad add}— never over a stalemain_grad— and DDP↔GTP_remat NIC deadlock at IB scale is avoided. See §3.2.
1.5 Opt-in, minimally invasive integration#
TE is GTP-agnostic. Mcore builds the plain TE linear with an already-sharded
out_featuresand attaches aGTPShardedParamafter construction; TE dispatches through its genericDistributedWeightprotocol (gates onis_distributed_weight) and takes no GTP argument, so there is no framework-level refactor and callers never thread a group (§3.1).Opt-in by linear class; sharding stays per-weight. Which linears participate is decided per TE class at construction — no
gtp_remat_groupis threaded through upper-level modules. Small tensors (LayerNorm γ/β, biases, Mamba SSM params, MoE router) always stay full; MoE latent-proj MLPs default to full but can be opted in via--gtp-remat-opt-in-modules moe_latent_projwhen the projection size is large enough. See §3.1 Class hierarchy for the full per-class breakdown.Off is a byte-for-byte no-op. When the resolved group is
None/size-1,_gtp_pre_initleavesout_featuresunsharded and_gtp_attach_post_initshort-circuits (as doeswrap_module_params_gtpfor Megatron-local linears); whengtp_weight_remat_size == 1thelayers.pyGTP_remat path is skipped entirely.Chain setup is one pass.
classify_gtp_chains(model)walksnamed_parameters()once at init and setschain_idon everyGTPShardedParamfrom the currentcuda_graph_modules(§3.4).Knobs.
GTPRematConfig.{pad_for_alignment, weight_prefetch, check_param_states}, plus the debug-name taggertag_gtp_params_with_namesfor readable link-table output.
1.6 Optimizer-agnostic (Adam + Muon)#
GTP_remat runs under both the standard Adam DistributedOptimizer and Muon (the LayerWiseDistributedOptimizer), DCP save/load included:
Adam shards optimizer state over the gtp_remat/egtp_remat-excluded replicate group, like any GTP_remat run (§3.2).
Muon keeps matrix params whole (Newton–Schulz needs the full 2D weight). A GTP_remat-replicated whole param (e.g. MoE router, latent-proj MLPs by default) then lands on one checkpoint key shared by all GTP_remat peers, so the LayerWise optimizer folds
gtp_rankinto itsreplica_id— exactly one peer writes (the optimizer-state analog of the model-side fold in §3.3).Split-QKV is layout-invariant. Muon orthogonalizes a fused
linear_qkvweight as separate q, k and v blocks, and whether it does must not depend on the parallel layout. Splitting and not splitting are two different update rules, not small perturbations of one: q is typically far larger than k and v, so orthogonalizing the fused matrix lets q dominate the spectrum the k and v rows are scaled by. Newton–Schulz is a nonlinear iteration, so the gap compounds over steps rather than averaging out — the same weight would train differently at TP1 than under GTP_remat.Decide on the across-shards row count —
local × gtp_remat_size − pad— never on this rank’s shard. GTP_remat row-shards dim 0, so a shard-local divisibility test flips toFalsethe moment the GTP_remat degree stops dividing the query-group count: the split then vanishes on every GTP_remat rank while TP1, holding all the rows, keeps it.Split after the all-gather, never before — a row shard cuts q/k/v mid-boundary. The split is therefore reachable on the
duplicatedNewton–Schulz mode only (--muon-tp-mode, defaultduplicated).autois pinned toduplicatedwhenever a split is requested, since its shape-based cost model has no notion ofqkv_split_shapesand could otherwise drop the split on some shapes and keep it on others.blockwiseanddistributed, chosen explicitly, fall back to whole-matrix Newton–Schulz and warn once, since neither keeps a q/k/v boundary.duplicated, the default, is the path that gets the split.
Native-FP8 optimizer-state matching (Muon path). The save-side dequantize (§3.3) hands DCP a fresh BF16 tensor, which breaks the id-based optimizer-param → model-
ShardedTensormatch for every native-FP8 GTP_remat weight. The dequantized copy carries a_gtp_dequant_srcbacklink to the live FP8 param, and_backfill_gtp_sharded_param_mapreuses the model’s own entry (backlink first, tagged-name second) — preserving its full offsets (expert axes included) andreplica_id. Only truly-unmatched params (the SSMin_projweights, gathered+split factories) take the per-shard rebuild, which refuses expert-parallel params rather than emit EP-colliding shards.
Neither path adds a GTP_remat-specific checkpoint format or call site.
1.7 Scaling#
Effective per-GPU weight size = W / (TP × GTP_remat). Example: TP=4 + GTP_remat=8 with NVFP4 → 32× weight-memory reduction and 128× wire-bandwidth reduction vs full BF16 replication, before data parallelism.
Weak scaling. GTP_remat fixes the shard width and grows the job by adding data-parallel replicas (DP = #GPUs / GTP_remat), so per-GPU compute stays constant while only the DP gradient reduction widens with scale.
The best GTP_remat size is model- and cluster-dependent — driven by weight sizes, per-GPU memory headroom, and which collectives can be kept on fast links — so there is no single recommended value. The example below runs on GB200 NVL72 (a 72-GPU NVLink domain) and uses GTP64, which places communication as:
NVLink-local: the dense-layer (Mamba / attention / shared-expert) GTP_remat weight all-gather + wgrad reduce-scatter, and the
EP64all-to-all dispatch/combine — all kept inside one ≤72-GPU NVLink domain (EP64 ≤ NVL72).Inter-node (IB / CX7): the DP gradient reduction plus the
EGTP2expert-weight all-gather / wgrad reduce-scatter, whose 2 shards land on different NVLink domains and so cross nodes.
On an Ultra-proxy hybrid Mamba-MoE model (~280B parameters; GTP64 · EP64 · EGTP2, mb1, MXFP8, BF16 reduce-scatter, no CUDA graph), scaling efficiency holds ≥93 % of the single-domain (128-GPU / DP2) baseline out to 3072 GPUs (DP48), while max reserved memory decreases with scale (137 → 104 GB) as the distributed optimizer shards optimizer/grad state across more DP replicas.
Takeaway: near-flat weak scaling — ≥93 % efficiency from 128 → 3072 GPUs, with per-GPU memory shrinking as DP grows.

1.8 Native distributed checkpointing (DCP)#
GTP_remat + DCP is straightforward:
Reuses the existing checkpoint stack rather than adding a parallel one. GTP_remat-sharded weights and distributed-optimizer state save/load through the standard PyTorch / Mcore
torch_distsharded checkpoint, with no GTP_remat-specific format or call path and a tiny code footprint (one new helper + one helper made GTP_remat-aware).Checkpoints reshard freely across different
(TP, GTP_remat, EGTP_remat, DP, PP)topologies — including a different GTP_remat/EGTP_remat size — with no offline conversion.
See §3.3 Distributed checkpointing (DCP) for details.
2. Usage#
GTP_remat is enabled through two CLI flags on Megatron’s training launcher; everything else (process-group construction, parameter slicing, prefetch chain wiring, optimizer routing) is automatic once the flags are set.
2.1 Knob summary#
The table below covers every GTP-related CLI flag and Python knob. “Required” means GTP either silently breaks or arguments.py asserts without it; “Recommended” means it should almost always be set in a real training run; “Optional” means it is off by default and tunable.
Flag / knob |
Kind |
When to set |
Default |
Details |
|---|---|---|---|---|
|
Required |
Always, to activate dense GTP |
— |
Total TP×GTP_remat shards per dense weight; GTP_remat degree = value ÷ TP. Must be ≥ TP and divisible by it. §2.2 |
|
Required |
MoE models (to shard routed-expert weights) |
— |
Total ETP×EGTP_remat shards per expert weight; EGTP_remat degree = value ÷ ETP. Independent of dense axis. §2.2 |
|
Optional |
BF16 wgrads and GTP_remat axis ≥ 4 |
off |
Replaces the ring RS with an all-to-all + local FP32 sum to eliminate per-hop rounding error. Auto-bypassed at axis size ≤ 2. §2.6 |
|
Optional |
For enabling symmetric-memory NCCL kernels on supported systems |
off |
Enables symmetric memory registration for the dense gtp_remat wgrad reduce-scatter path. Takes precedence over fp32-accum on its group; incompatible with |
|
Optional |
For enabling symmetric-memory NCCL kernels on supported systems |
off |
Enables symmetric memory registration for the routed-expert egtp_remat wgrad reduce-scatter path. §2.7 |
|
Optional |
MoE models with large |
|
Space-separated list of module tokens to opt in to GTP_remat sharding. Currently supported: |
|
Required |
GTP + |
off |
Gathers native MXFP8 shard directly; without it the grad-buffer reuse path is unavailable and |
|
Required |
GTP + |
off |
Reuses the grad buffer for the MXFP8 all-gather (MXFP8 cannot map into the contiguous param buffer). Must accompany |
|
Required |
GTP + |
off |
Gathers native NVFP4 shard directly; without it NVFP4 weights fall back to a BF16 gather that fails the backward GEMM. §1.3 → GTP + NVFP4 |
|
Recommended |
Blackwell (GB200/GB300) and later |
— |
Gives GTP_remat comm streams the SM priority needed for AG/RS overlap with compute. Also export |
Python-only tuning knobs (via update_gtp_config; rarely need changing):
Knob |
Default |
Purpose |
|---|---|---|
|
auto (16 NVFP4, 32 MXFP8, 1 BF16) |
Shard alignment; auto-set by |
|
|
Disable only to debug the synchronous cold-start path. |
|
|
Async wgrad reduce-scatter; disable for easier debugging. |
|
|
Must mirror |
|
|
Persistent wgrad ring slots per scheduling domain (§3.6). Increase if capture rejects same-key writers. |
2.2 Required flags#
# Total number of shards each dense weight (attention, mamba, MLP linears) is split into along
# out_features, across the tensor-parallel + GTP_remat axes. Must be >= --tensor-model-parallel-size and
# divisible by it. The GTP_remat degree is derived as num_weight_shards / tensor_model_parallel_size
# (e.g. TP=1 + num_weight_shards=2 -> GTP_remat=2; TP=2 + num_weight_shards=8 -> GTP_remat=4).
--tensor-parallel-num-weight-shards <num_weight_shards>
# Total number of shards each MoE routed-expert weight is split into along out_features, across the
# expert-tensor-parallel + expert-GTP_remat axes. Must be >= --expert-tensor-parallel-size and divisible
# by it. The expert-GTP_remat degree is derived as num_weight_shards / expert_tensor_parallel_size.
# Independent from --tensor-parallel-num-weight-shards; can be left unset for non-MoE models.
--expert-tensor-parallel-num-weight-shards <num_weight_shards>
The (dense / expert) GTP_remat degree is exposed only through
--tensor-parallel-num-weight-shards/--expert-tensor-parallel-num-weight-shards. The internalgtp_weight_remat_size/expert_gtp_weight_remat_sizeconfig fields are derived from them and have no CLI flag.
Low precision (MXFP8). GTP_remat + --fp8-recipe mxfp8 requires both --fp8-param-gather
and --reuse-grad-buf-for-mxfp8-param-ag (arguments.py asserts this) — the weight is a native FP8
param, and since MXFP8 cannot map into the contiguous param buffer (replace_raw_data unsupported)
the all-gather reuses the grad buffer. Mechanism: §1.3, §3.1.
Low precision (NVFP4). GTP_remat + --fp4-format requires --fp4-param-gather
(arguments.py asserts this) — without it NVFP4 weights fall back to a BF16 gather that fails the
backward GEMM. Mechanism and mixed-recipe (MXFP8-override) handling: §1.3 → GTP + NVFP4.
2.3 High-priority streams (Blackwell and later)#
Required on GB200 / GB300 so the GTP_remat comm streams get the SM priority needed for AG/RS overlap with compute:
--high-priority-stream-groups ep gtp_remat expt_gtp_remat tp
The launcher also exports CUDA_GRAPHS_USE_NODE_PRIORITY=1 so captured CUDA graphs respect the inherited stream priority.
2.4 Minimal end-to-end example#
# 4 ranks, TP=2 + GTP_remat=2 across out_features, BF16 weights.
# TP=2 + num-weight-shards=4 -> GTP_remat = 4 / 2 = 2.
torchrun --nproc-per-node 4 pretrain_gpt.py \
--tensor-model-parallel-size 2 \
--pipeline-model-parallel-size 1 \
--tensor-parallel-num-weight-shards 4 \
--expert-tensor-parallel-num-weight-shards 1 \
--high-priority-stream-groups ep gtp_remat expt_gtp_remat \
--bf16 \
--num-layers 12 --hidden-size 1024 --num-attention-heads 16 \
--seq-length 1024 --max-position-embeddings 1024 \
--micro-batch-size 1 --global-batch-size 4 \
--train-iters 10 \
--use-mcore-models \
--transformer-impl transformer_engine \
--tokenizer-type NullTokenizer --vocab-size 32000 \
--data-path <data> --split 99,1,0
At iter-0 you’ll see one rank-0 log line confirming the active config:
GTP_remat enabled. GTPRematConfig(pad_for_alignment=1, check_param_states=False,
weight_prefetch=True, async_reduction=True, calculate_per_token_loss=False,
reduce_scatter_with_fp32_accumulation=False, graph_wgrad_ring_size=2)
(pad_for_alignment=1 here because this example is BF16 with no quantization-tile
requirement — see the table above.)
2.5 Tuning knobs#
Set via from megatron.core.tensor_parallel.generalized_tensor_parallelism import GTP_CONFIG, update_gtp_config:
update_gtp_config(
pad_for_alignment=1, # NVFP4: 16, MXFP8: 32, BF16: 1 (min for AG/RS); auto-set in training.py
weight_prefetch=True, # Disable to debug the cold-start path
async_reduction=True, # Whether to perform GTP_remat gradient reduction asynchronously
calculate_per_token_loss=False, # Mirror config.calculate_per_token_loss (SUM vs MEAN RS)
reduce_scatter_with_fp32_accumulation=False, # wgrad RS: BF16 all-to-all + FP32 sum (§2.6)
graph_wgrad_ring_size=2, # Persistent wgrad slots per graph scheduling domain
)
training.py auto-tunes pad_for_alignment based on the quantization recipe (--fp4, --fp8-recipe=mxfp8, etc.) before model construction, defaulting to 1 (the minimum needed for equal-sized AG/RS shards) when no low-precision tile size applies. The other knobs are usually left at defaults.
GTP backward reduce-scatter overlap across local CUDA-graph boundaries is enabled automatically. The ownership and ordering protocol is described in §3.6.
CUDA-graph warmup under GTP_remat. When CUDA graphs are enabled, GTP_remat forces a minimum of 2 per-graph warmup steps regardless of
--cuda-graph-warmup-steps(e.g. a user-set0is bumped to2): the first warmup builds the weight-prefetch chain and the second exercises the prefetch path before capture.
2.6 FP32-accumulation wgrad reduce-scatter (optional)#
--gtp-remat-reduce-scatter-with-fp32-accumulation # default: off
A ring reduce-scatter rounds the partial sum at every one of its N-1 hops, so BF16 gradient error compounds with the axis size (≈√N for gradient-like data, worse when contributions share a sign). This flag replaces it with an all-to-all plus one local FP32 sum, eliminating that accumulation error for the same bytes on the wire.
Use when |
wgrads are BF16 (the default) and the gtp_remat axis is ≥ 4 |
Skip when |
|
Gain |
the |
Cost |
one unsharded-wgrad-sized scratch buffer per in-flight reduce-scatter, plus a local FP32 sum and downcast at |
Implemented in megatron/core/distributed/reduce_scatter_with_fp32_accumulation.py. This is the
gtp_remat-axis analogue of --ddp-reduce-scatter-with-fp32-accumulation and independent of
it — a different collective over a different process group, so enable either, both, or neither.
Behaviour notes
The mean stays a pre-scale. Both paths apply
1/gtp_rematto the wgrad before the collective (§3.2 table); undercalculate_per_token_lossthe axis SUMs and no factor applies either way.Auto-bypass at axis size ≤ 2. The gate reads the per-chain group, so each axis decides independently: a
GTP_remat=8 × EGTP_remat=2run gets FP32 accumulation on the dense weights and the plain reduce-scatter on the experts. A group with a registered symmetric pool (§2.7) also bypasses — the pool takes precedence.Scratch lifetime. The buffer comes from GTP’s wgrad pool rather than a fresh
empty_like, and is returned only once the handle is waited — it is the input to the deferred FP32 sum.Batched (grouped / routed-expert) path. The all-to-alls share one
ncclGroupStart/Endvia_coalescing_manager, but the manager cannot serve as the handle: it waits only the NCCL work it collects, while each fp32-accum handle still owes a local FP32 sum. The sums are deferred behind it in one composite handle — which is why the all-to-alls are issued withasync_op=True: for this primitive that flag defers the sum, it does not merely return a handle. (DDP’s own flag sidesteps all this by asserting a single bucket.)
2.7 NCCL symmetric-memory wgrad reduce-scatter (optional)#
--gtp-remat-nccl-ub # dense gtp_remat group default: off
--gtp-expert-remat-nccl-ub # routed-expert egtp_remat group default: off
Allocates the wgrad reduce-scatter send buffers from an NCCL-window-registered memory pool on the gtp_remat / egtp_remat group, so NCCL runs the reduce-scatter as a single symmetric device kernel — NVLS multimem within an NVLink domain, rail kernels when the group spans domains — instead of a ring. Only the send side needs registration; the sharded output lands in the ordinary main_grad.
Use when |
Systems with NCCL symmetric memory support |
Skip when |
|
Gain |
in-switch reduction: fewer SMs and lower latency per reduce-scatter |
Cost |
a persistent registered pool of unsharded-wgrad-sized buffers per group, plus a one-time registration warmup; deregistered at shutdown |
Behaviour notes
Zero-copy producers. Wgrads are written straight into the registered buffer — TE modules via the
DistributedWeight.grad_bufferprotocol, Megatron-native linears via anout=matmul (when the wgrad dtype matchesmain_grad). The untied embedding’s wgrad is materialized byF.embedding’s own backward and pays one copy into the buffer.FP32-accumulation interplay. A registered pool takes precedence over §2.6 on its group: NVLS symmetric reduce-scatters accumulate in fp32 in-switch (NCCL’s
multimem.ld_reduceuses.acc::f32for bf16), so the group keeps the symmetric reduce-scatter and the fp32-accum all-to-all applies only to axes without a pool. E.g.--gtp-remat-nccl-ub+ §2.6 gives a symmetric dense-GTP reduce-scatter and the fp32-accum all-to-all on the EGTP axis.Independent of
--use-nccl-ub. That flag registers DP-group (DDP bucket) buffers; these flags cover the gtp_remat axes only.Pool allocator. The pools are backed by a VMM allocator implementing NCCL’s memory-allocator requirements minimally: unlike
ncclMemAlloc, memory is mapped only on the allocation’s device.ncclMemAllocadditionally maps every allocation on all P2P-visible peer GPUs, and those persistent peer mappings slow CPU-side kernel launching for the whole step (measured −6% end-to-end at 256 GPUs; the VMM allocator recovers it). Window registration accepts this memory and runs the same symmetric kernels.
3. Implementation details#
3.1 GTP_remat architecture (Mcore ↔ TE integration)#

Ownership. TE owns the linear primitives (Linear / LayerNormLinear / LayerNormMLP / GroupedLinear), the low-precision tensor types (FP8 / MXFP8 / NVFP4), and a generic DistributedWeight protocol (transformer_engine/pytorch/distributed_weight.py). Megatron owns all GTP_remat logic — sharding, the prefetch chain, the buffer cache, the AG/RS state machines, and DDP integration. TE never names GTP.
The bridge — three touch points, nothing more:
Construction. Mcore pre-shards
out_features(_gtp_pre_init) so plain TE builds this rank’s shard directly; GTP is attached after build (_gtp_attach_post_init). TE takes no GTP argument.Runtime. TE’s fwd/bwd gate on
is_distributed_weight(weight)and call the generic list-shaped dispatchers (materialize_weight_for_forward/materialize_weight_for_backward,finalize_weight_grads).GTPShardedParamimplements the protocol (materialize_group_for_forward/_backward,finalize_group_grads,grad_buffer); the concrete collectives (all_gather_and_prefetch,wgrad_reduce_scatter) live only in Megatron. A plain tensor is a no-op.Streams.
_register_gtp_side_streams/ drain calls synchronize TE’s GEMMs with the side stream that owns the AG/RS NCCL ops.
One init path, all precisions. Since out_features is pre-sharded, TE builds the shard directly — native MXFP8Tensor (--fp8-param-gather), native NVFP4Tensor (--fp4-param-gather), or BF16 — with no full weight ever materialized. attach_gtp_to_presharded_module then turns it into a GTPShardedParam: a native quantized shard is reclassed in place to GTP_<QuantTensorClass> (stays buffer-resident on the quantized dist-opt path); a BF16 shard is re-registered (no slice — already shard-sized). The optimizer maintains the shard end-to-end, gathered each forward with no per-microbatch re-quantize (§1.3).
Per-GTP-rank init. Each rank draws its own shard, so GTP weights need distinct random values per GTP_remat peer (else the gather would be
gtp_remat_sizeidentical blocks).model_parallel_cuda_manual_seedaddsgtp-remat-rng/egtp-remat-rngtrackers (offset per peer) that_gtp_pre_initroutes init through; replicated params keep the shared trackers. Added only when the axis is active, so non-GTP runs keep a byte-identical tracker set.
Megatron-local linears (
ColumnParallelLinearetc. intensor_parallel/layers.py) still build the full weight and slice post-init viawrap_module_params_gtp— unchanged.
What the flags do under the hood#
The --*-num-weight-shards flags flow through five stages, from process groups to the prefetch chain:
Process groups.
initialize_model_parallel(...)treats GTP_remat/EGTP_remat as first-class orthogonal axes (world = TP·CP·GTP_remat·DP; experts= ETP·EP·EGTP_remat·PP·expert_dp), building_GTP_WEIGHT_REMAT_GROUPand_EXPERT_GTP_WEIGHT_REMAT_GROUP(sizes =num-weight-shards / TPand/ ETP). CP is placed more locally (smaller stride) than GTP_remat on the dense/decoder axis, since CP’s collectives are on the model’s critical path (_inject_gtp_remat_axis(..., after="cp")). DP and gtp_remat stay orthogonal:get_data_parallel_group()is the replicate axis (DDP + optimizer shard over it);with_gtp_remat=Truegives the combined DP × gtp_remat axis for data distribution.Batch-size arithmetic.
args.data_parallel_sizeis the replicate degree only — gtp_remat is divided out of it (folded intototal_model_sizeatarguments.py:446). But data is distributed over the full DP × gtp_remat axis, so each gtp_remat peer consumes a distinct microbatch and the global sample count ismicro_batch_size × data_parallel_size × gtp_weight_remat_size × num_microbatches. The training loop therefore re-appliesgtp_weight_remat_sizeto close the gap: multiplied back in for the LR-schedulerincrementand the loggedbatch_size, divided back out to recovereval_num_microbatches. Without this it would read as a double-count — it is not.Per-class sharding.
extensions/transformer_engine.pydecides per linear class whether to shard, so nogtp_remat_groupis threaded through the module APIs (attention, Mamba, MLP, embedding, MTP). Dense wrappers resolve the group viautils.get_gtp_weight_remat_group(...);TEGroupedLinearusespg_collection.expt_gtp_remat. GroupNone/size-1 → left full; otherwise_gtp_pre_initpre-shardsout_featuresand_gtp_attach_post_initmakes the shard aGTPShardedParam(theDistributedWeightimplementer; native FP8/NVFP4 by reclass, BF16 by re-register). Basete.Linear(MoE latent projections) receives a group only when--gtp-remat-opt-in-modules moe_latent_projis set; otherwise it stays full → see Class hierarchy.Gradients (DDP). GTP_remat shards are ordinary DDP params in the usual dense/expert buffers, reduced over the replicate group. The gtp_remat axis is completed separately: GTP shards by their reduce-scatter, replicated params by an all-reduce in
finalize_model_grads(mean-vs-sum percalculate_per_token_loss) → see §3.2.Optimizer. State is sharded over the same replicate group; global-norm clipping reduces over the dist-opt grad-stats group spanning the full world (incl. gtp_remat/egtp_remat), counting replicated params once per axis to avoid over-counting.
Prefetch chains.
classify_gtp_chains(model)runs once after build (get_model) and wires eachGTPShardedParaminto aGRAPHED/UNGRAPHEDchain fromcuda_graph_modules→ see §3.4 Prefetch-chain construction.
Class hierarchy: which linears shard#
The figure visualizes the per-class split from the list above: green = resolves a GTP_remat group and shards, red = base TELinear (MoE latent projections, full by default; opt-in via --gtp-remat-opt-in-modules moe_latent_proj). Dashed arrows are builds (module → leaf); solid arrows are inherits (leaf → TE primitive).

Buffer / memory management#
Two distinct pools with explicit lifecycle rules:
GTPWeightCache(AG/RS output buffers) — ticket-based, keyed on(shape, dtype, fwd, expert_idx, reduce_scatter), plus a("recompute", parity)suffix for recompute-chain gathers. Same-shape buffers across layers are shared, except between chain neighbours — one-step-ahead keeps the predecessor and the current weight live at once, so_ensure_no_shared_buffer_withfolds a parity bit into the key when the two would collide, at the cost of one extra buffer for the second of the pair. The caller names which chain to guard, because the chains disagree on who a weight’s neighbour is: on the fwd chain the check is normally inert (neighbours are different roles, hence different shapes) and fires only when CG capture leaves two same-shaped weights adjacent — embedding + output_layer alone in theUNGRAPHEDchain — whereas on a recompute chain same-shape adjacency is the norm. Tickets persistent; buffer allocated lazily on firstget(); addresses stable across iterations for CG replay._wgrad_buf_pool(wgrad-GEMM output recycling) — holds the full, unsharded wgrad-GEMM output buffer (shape_unsharded_shape, dtypemain_grad.dtype— fp32 whengrad_reduce_in_fp32, else bf16). The TE backward writes the wgrad into it viamain_grad_func = weight.grad_buffer(aDistributedWeightprotocol method backed byget_wgrad_tensor; it is a scratch, distinct from the shardedparam.main_grad); the protocol’sfinalize_group_grads(backed bywgrad_reduce_scatter) then reduce-scatters it down to the shard and the buffer is returned here. This is a full-weight-shaped fp32/bf16 transient — one of the larger per-weight buffers — and is precision-independent (wgrad is always computed in high precision), so it is identical in BF16 vs MXFP8 runs. Buffers are tagged_from_gtp_wgrad_pool=Trueat_wgrad_pool_get;_wgrad_pool_putno-ops on foreign buffers (fresh allocs from Megatronlayers.pyor aten F.embedding bwd) → caching allocator handles those, so the pool never accumulates untagged buffers.
Overlap design summary#
fwd: AG(W_{i+1}) ∥ GEMM(W_i) ∥ CG replay of captured layers
bwd: AG(W_{i-1}) ∥ dgrad(W_i) → wgrad(W_i) ∥ RS(wgrad_i) ∥ [finalize wgrad_{i+1} + DDP hook]
GTP_remat runs up to three independent prefetch chains, all following one rule — prefetch the weight the next consume will need:
# |
when |
consume |
prefetch (overlap) |
AG direction |
slot |
|---|---|---|---|---|---|
1 |
fwd |
weight |
|
rowwise ( |
|
2 |
bwd dgrad |
weight |
|
columnwise ( |
|
3 |
bwd recompute |
weight |
|
rowwise ( |
|
1b |
fwd (MoE, eager) |
expert weight |
same role in MoE block i+1 ‖ whole block i |
rowwise ( |
|
Row 1b is chain 1 applied to a homogeneous chain: routed-expert fc1/fc2 link across consecutive MoE blocks, so the runway is a full block rather than one GEMM (§3.4 Grouped-expert chains).
Chain 3 exists only when activation recompute is on. It mirrors chain 1 (rowwise, prefetch next) but runs during backward, so it overlaps chain 2 in time on the same weight — hence its own slot. fwd (1) and bwd-dgrad (2) never overlap in time, so they safely share _prefetch_handle. See Recompute-forward prefetch chain below.
At bwd step i the step is launching RS of wgrad_i while finalizing the previous iter’s wgrad (wgrad_{i+1} in bwd order = the next-one-over in fwd order). That one-step deferral is what makes the RS run concurrent with the next layer’s dgrad/wgrad GEMMs instead of blocking after every layer.
Communication never blocks compute except at the very first layer of each direction (cold start) and at enforced serialization points (CG/eager drains, finalize-grads barrier).
wgrad-before-dgrad schedule (deferred to a follow-up MR)#
Current behavior: backward always runs dgrad GEMM, then wgrad GEMM, then issues the GTP_remat wgrad RS — the RS overlaps with the next layer’s bwd GEMMs (the one-step deferral above).
A future MR will add an opt-in wgrad-before-dgrad schedule on _Linear / _LayerNormLinear so the GTP_remat wgrad RS NCCL overlaps with the dgrad GEMM of the same layer (best for the GTP_remat + no-TP case).
Recompute-forward prefetch chain (GTP_remat + activation recompute)#
When a GTP_remat-sharded module is in --recompute-modules (e.g. shared_experts), its forward is re-run during backward to regenerate activations. That recompute-forward must all-gather each weight rowwise again — a third gather lifecycle, concurrent with the in-flight columnwise dgrad gather of the same weight. Since both share one GTPShardedParam, the recompute path gets its own prefetch slot (_recompute_prefetch_handle / _recompute_ag_event) so it never clobbers the dgrad lifecycle’s state / _prefetch_handle / ag_event, and its own buffer ticket (_ag_ticket_recompute) with a parity of its own. Reusing _ag_ticket_fwd is unsafe twice over: a fwd prefetch may still be in flight in that buffer, and the fwd parity is decided against prev_w — a different neighbour. Without its own parity, consecutive recompute nodes share one buffer and the one-ahead prefetch overwrites the weight still being read: silent wrong activations, then NaN.
The recompute weights form a separate linked list (_recompute_next), self-populated on the first backward from the weights actually re-gathered while in_fp8_activation_recompute_phase() is true — membership is observed, not configured (no tagging, so it tracks exactly what each checkpointed module re-gathers). Each recompute-forward consume prefetches the next recompute weight, so every gather except the global-first overlaps preceding recompute / dgrad / wgrad compute:
recompute-fwd of shared_experts (per layer: GEMM fc1 → SReLU → GEMM fc2, then dgrad+wgrad)
Before (on-demand):
default: AG(fc1)─GEMM fc1─SReLU─AG(fc2)─GEMM fc2─dgrad─wgrad─... every AG exposed
After (recompute chain):
default: GEMM fc1─SReLU─GEMM fc2─dgrad─wgrad─GEMM fc1'─... back-to-back
ag_str: AG(fc1) [AG fc2] [AG fc1' (next layer)] only AG(fc1) exposed
AG(fc2) is issued at fc1’s consume (overlaps GEMM fc1 + SReLU); AG(fc1') for the next layer is issued at fc2’s consume, so it overlaps the whole layer’s dgrad + wgrad window. The cross-layer link is what hides every region head except the very first.
Under full-iteration CUDA graphs the recompute-forward is captured; wait_async_comms(GRAPHED) drains the recompute handle too (sets _recompute_already_drained) so the captured consumer skips its cross-graph wait — the same producer-drain pattern as the fwd/bwd chains.
When not to recompute a GTP_remat weight. Recompute on a GTP_remat-sharded weight adds this extra rowwise gather. For MLP-like blocks at short context (
SeqLen ≤ 2 × HiddenSize), GTP_remat-sharding the weight saves more memory than recomputing its activations, so the better trade is to keep such modules GTP_remat-sharded and out of--recompute-modules(offload their activations if needed) — avoiding the third gather entirely. Build the recompute chain only for modules that genuinely need both.
3.2 DDP buckets with (E)GTP_remat#

(E)GTP_remat is super loosely coupled to DDP and the distributed optimizer — they stay almost completely GTP_remat-agnostic. GTP_remat is just another sub-axis of the rank grid (world = TP×CP×GTP_remat×DP); a GTP_remat-sharded weight rides the exact same code path as an ordinary param. There are no GTP_remat/EGTP_remat-specific buffers, optimizers, or bucket groups, and just one GTP_remat-specific gradient-scaling factor (the expert-buffer correction below). The entire DDP/DistOpt stack touches GTP_remat in only four narrow places:
finalize all-reduce (
_allreduce_replicated_grads_over_gtp_remat_group) — completes the gtp_remat axis for replicated (non-GTP_remat) params (SUM undercalculate_per_token_loss, AVG otherwise; see §3.2 table); a no-op when GTP_remat is inactive.is_gtp_weight_remat/allreducetags propagated onto the optimizer’s master shards — consumed only by the grad-norm dedup filter.grad-ready hook routing (
DistributedDataParallel.__init__) — for a GTP_remat param, DDP registers its backward post-hook via GTP_remat’sregister_grad_accum_hookinstead of autograd’sAccumulateGrad. GTP_remat fires it from_handle_megatron_grad_accumafter the per-param{wgrad RS → main_grad add}. This enforces the invariant below; a no-op (plain autograd path) when GTP_remat is inactive.expert-buffer prescale correction (
expert_gradient_scaling_factor,DistributedDataParallel.__init__) — only applies whencalculate_per_token_loss=False(the SUM/÷total_global_tokenspath needs no such correction; see §3.2 table). On that path, expert params can’t recover the DDP pre-scale’s1/gtp_rematshrinkage via the finalize AVG above (ranks within one gtp_remat group hold different experts, so averaging their grads would be wrong); they instead recover onlyexpert_gtp_remat’s worth via the analogous EGTP-remat finalize, so the prescale folds in anegtp_remat/gtp_rematcorrection to make up the rest.1.0— a no-op — whengtp_remat == egtp_remator GTP_remat is inactive.
Ordering invariants#
Ordering invariant (gradients). A bucket’s DDP gradient reduction (the reduce-scatter / all-to-all + local fp32 accumulation) runs strictly after every GTP_remat param in that bucket has finished
{GTP_remat wgrad RS → main_grad add}.register_grad_readyonly fires the bucket collective once all its params are ready, and for GTP_remat params “ready” is signalled by GTP_remat after the add — never by autograd’sAccumulateGrad, which (because the wgrad RS is async and itsmain_gradaccumulation is deferred to a later backward node) can fire before the add and would make the bucket reduce read a stale/emptymain_grad(notably underreduce_scatter_with_fp32_accumulation).
Parameter publication under --overlap-param-gather. DDP publishes a bucket group lazily: its parameter all-gather (and post-gather quantize, _post_param_sync) is drained from the forward pre-hook of a module owning one of that bucket’s parameters. GTP_remat consumes weights ahead of that module (§3.4), so when the consumed weight and the prefetch target sit in different bucket groups, the target may not be published yet. GTP_remat therefore asks for it first, at the top of _all_gather_weight:
time -------------------------------------------------------------------------->
compute pre-hook(fc0) -> GEMM(w0) -> [stall] -------> pre-hook(fc1): no-op
| ensure_params_ready(w1)
v
DDP AG +---------------- AG(bucket B1) --->| quantize
GTP ag_strm +-- AG(w1) reads FRESH --> GEMM(w1)
The request goes through a backend-agnostic hook:
Contract (
megatron/core/utils.py): a backend attaches a zero-argument callable underPARAM_READY_CALLBACK_ATTR; a consumer readingparam.dataoutside the owning module’s pre-hook callsensure_params_ready(params)first. Unmarked params no-op, so the contract is open to FSDP or any other backend.Neither side names the other: DDP registers one
_BucketParamReadyCallbackper bucket group (weakly held), knowing nothing of GTP_remat; GTP_remat callsensure_params_ready, knowing nothing of DDP.Forward only: backward re-reads what forward published, and recompute runs inside backward, where publishing could gather into the buffer that aliases grads under
--reuse-grad-buf-for-mxfp8-param-ag.Cost: publishing early can start an undispatched gather and chain-dispatch the next bucket, draining it about one block earlier than the pre-hook would — so that bucket loses some gather/compute overlap. Negligible when the gather was already dispatched early, as
--align-param-gatherand--overlap-param-gather-with-optimizer-stepdo.Not covered — CUDA-graph capture: no collective may be issued during capture, so the callback no-ops and the captured gather carries no dependency on DDP’s. A consumer that captures its reads must publish before launching.
Why this design works#
Everything else — bucketing, the reduce-scatter/all-reduce schedule and its overlap, master-state sharding, grad clipping, the checkpoint format — is unchanged and unaware of GTP_remat.
Free reuse of a mature stack. GTP_remat inherits DDP’s bucketing + comm/compute overlap, the distributed optimizer’s fp32-master + Adam-moment sharding, grad-norm/clip, and the existing checkpoint format — no parallel re-implementation to write or maintain (contrast FSDP, which replaces all of these).
Orthogonal composability. Because GTP_remat is a rank-grid sub-axis cut along
out_features(dim 0, whichever axis TP used), it composes with TP/EP/CP/PP and the DistOpt the same way TP does — no special nesting logic.Zero-cost when off. With GTP_remat disabled the gtp_remat axis is size-1 and the hooks become no-ops, so non-GTP_remat runs hit byte-identical behavior — GTP_remat can be toggled without forking the DDP/optimizer code paths.
Small, auditable surface. These four hooks are the whole integration contract, which is what makes the correctness argument below tractable.
Bucketing and gradient scaling#
DDP groups parameters into two buffers by is_expert_parallel (MoE tag) — a dense buffer and an expert buffer. GTP_remat/EGTP_remat shards are merged into these buffers like ordinary params (no separate GTP_remat/EGTP_remat buckets): they reduce over the replicate group (the default intra_dp_cp_group / intra_expt_dp_group).
The DP collective only covers the replicate axis; the gtp_remat axis is completed separately, and how both axes are scaled depends on the loss normalization (config.calculate_per_token_loss). In all cases each gtp_remat contribution is summed exactly once:
|
|
|
|---|---|---|
DDP pre-scale, dense buffer ( |
|
|
DDP pre-scale, expert buffer ( |
|
|
gtp_remat reduce-scatter (sharded weights) |
MEAN (pre-scale wgrad by |
SUM (plain reduce-scatter) |
finalize over gtp_remat (replicated params) |
AVG all-reduce |
SUM all-reduce |
final normalization |
net grad = full |
grads summed over all axes, then |
Default (mean) path decouples gradient scaling from the gtp_remat degree: the DP
1/replicatemean × the reduce-scatter1/gtp_rematmean (sharded weights) — or × the finalize AVG (replicated params) — equals the exact full mean, independent of the gtp_remat axis size.Expert buffer needs an extra
egtp_remat/gtp_rematcorrection because the finalize step it gets is EGTP-remat’s AVG, not GTP_remat’s — a gtp_remat group’s ranks hold different experts, so an AVG across the full gtp_remat axis (mixing different experts’ grads) would be wrong; only EGTP_remat peers hold the same expert’s replica. That AVG only recovers1/egtp_rematof the1/gtp_rematthe dense-buffer pre-scale assumed, so the expert pre-scale folds inegtp_remat/gtp_rematto make up the gap — a no-op (=1.0) whenevergtp_remat == egtp_remat, including the common case of GTP_remat off.--gtp-remat-reduce-scatter-with-fp32-accumulationswaps the collective, not the scaling — this table applies unchanged (§2.6).Per-token-loss path must SUM over gtp_remat (like the DP axis):
total_global_tokensalready counts the gtp_remat peers’ distinct tokens, so the single÷ total_global_tokensdoes all normalization. A1/gtp_rematmean here would shrink every gtp_remat gradient by1/gtp_remat(grad-norm mismatch + divergence), so the reduce-scatter mean and finalize AVG are both gated onnot calculate_per_token_loss.
average_in_collectivemust be off (the default). The default-path scaling is a pre-scale applied before a SUM collective.average_in_collective=Trueinstead uses NCCL AVG over the collective’s own (replicate) group, which interacts incorrectly with the gtp_remat completion. Asserted viaProcessGroupCollection.is_gtp_remat_activein botharguments.py(training) andDistributedDataParallel.__init__(direct megatron-core users). (Independently,calculate_per_token_lossalready forbidsaverage_in_collective.)
Buffer caching. The per-buffer lists are concatenated once at init into a single flat view for fast iteration in the grad-reduction hot path.
Single distopt instance with GTP_remat. GTP_remat currently requires
num_distributed_optimizer_instances == 1(asserted inparallel_state.py): partial-distopt sharding of the data domain would need gtp_remat-aware sizing. The dist-opt grad-stats group is therefore the full world.
3.3 Distributed checkpointing (DCP)#

GTP_remat supports PyTorch / Mcore sharded distributed checkpointing (--ckpt-format torch_dist, the megatron.core.dist_checkpointing ShardedTensor / ShardedObject format) for both model weights and distributed-optimizer state. Checkpoints are fully resharding-capable: a checkpoint saved at one (TP, GTP_remat, EGTP_remat, DP, PP) topology can be loaded at a different one — including a different GTP_remat/EGTP_remat size — without an offline conversion step.
Consistent with §3.2, GTP_remat stays loosely coupled to the checkpoint stack: there is no GTP_remat-specific checkpoint format or call path. The shared make_sharded_tensors_for_checkpoint helper became GTP_remat-aware and delegates internally to a GTP_remat variant only when the state_dict actually contains a GTPShardedParam (a no-op otherwise), so call sites are unchanged and non-GTP_remat runs are byte-identical.
Save-side call workflow. The diagram below traces the save path — from model.sharded_state_dict() through the make_* helpers down to the terminal ShardedTensor / ShardedObject sinks. The GTP_remat footprint is deliberately tiny: exactly one new function (make_sharded_tensors_for_checkpoint_with_gtp_remat, in gtp.py, which sets replica_id for the GTP_remat-duplicated entries) plus one modified function (the per-tensor make_tp_sharded_tensor_for_checkpoint in core/utils.py, made GTP_remat-aware in place to emit the GTP_remat-sharded offsets). Every other helper is untouched.

How a GTP_remat weight is described to DCP. GTP_remat always shards out_features (axis 0). The helper layers that GTP_remat split onto the existing TP offsets in the ShardedTensor, so the global tensor DCP sees is the full, unsharded weight:
Weight kind |
TP axis |
Emitted axis-0 offset |
Other axis |
|---|---|---|---|
Column-parallel (qkv, fc1) |
0 (same as GTP_remat) |
composite |
— |
Row-parallel (proj, fc2) |
1 |
GTP_remat-only |
TP offset on axis 1 |
No TP (GTP_remat-only) |
– |
|
— |
Because the offsets reconstruct the global shape, the checkpoint is independent of the save-time grid. On load, DCP reads each rank’s [offset : offset+local] slice from that global and re-tiles it onto the new grid — e.g. TP1×GTP2, TP2×GTP4, or a DP change.
replica_id. GTP_remat peers hold distinct shards (not replicas), so they’re disambiguated by their offsets; replica_id’s DP coordinate is the GTP_remat-excluded replicate rank (one elected writer per shard, per replicate group). Replicated tensors that live alongside GTP_remat weights (LayerNorm γ/β, biases, _extra_state objects) would otherwise collide across GTP_remat peers, so the helper folds gtp_rank into their replica_id — exactly one peer is then elected DCP writer per key.
_extra_state. This is TransformerEngine’s per-module FP8 calibration state — for delayed-scaling recipes it holds the recipe, the forward/backward scale tensors and amax_history buffers, plus picklable extra_fp8_variables; for BF16 (non-FP8) runs it is an empty tensor. Because it is a pickled byte blob rather than a tensor with a meaningful shape, it is emitted as a ShardedObject (via make_sharded_object_for_checkpoint), not a ShardedTensor. Its amax/scale statistics are per-tensor globals for the full weight (amax is reduced across the FP8 group), so every GTP_remat peer carries an identical copy — which is exactly why it takes the replicated path above, with gtp_rank folded into its replica_id.
Alignment padding & cross-topology reshard. When _gtp_slice_one_param pads out_features to a multiple of gtp_remat_size · pad_for_alignment, the saved global describes the padded shape, so the helper sets allow_shape_mismatch=True. DCP then tolerates a load-side topology whose alignment yields a different padded size — the unpadded data overlaps and the tail pad rows are zeros GTP_remat recomputes (§3.7 covers how that padding is laid out per rank).
Note: the SSM
in_projweights — Mamba’s (mamba_mixer.py, split[z|x|B|C|dt]) and gated-delta-product’s (gated_delta_product.py, split householder-major intoz|V*|K*|Q|b*|a) — are a special case: each all-gathers its GTP_remat shards back to the logical TP-local size and strips the pad before saving, so its global is topology-independent and needs noallow_shape_mismatch. This is required, not just tidier: the split-chunk boundaries do not line up with the GTP_remat slice boundaries, so a raw shard cannot be split at all. The checkpoint therefore matches a non-GTP_remat run byte-for-byte.On load, the split factory’s
merge_fnis wrapped to invert this: it cats the chunks back to the unpadded TP-local width, re-pads with zeros up togtp_remat_local_size · gtp_remat_size, and slices by the GTP_remat rank — mirroring_gtp_slice_one_paramso the tensor lands in the live shard’s layout.gtp_remat_size == 1skips both the gather and the pad/slice.
Optimizer state. The distributed optimizer’s master/moment ShardedObjects are keyed by dp_group_idx. Under GTP_remat/EGTP_remat each peer owns a different master shard (the optimizer shards over the gtp_remat/egtp_remat-excluded replicate group), so the index is taken from the gtp_remat/egtp_remat-merged model-parallel group (mp_group for dense, expt_tp_pp_with_egtp_remat_group for expert) — giving every peer a distinct key while replicate-group ranks remain true replicas under that key.
Pre-save forced param-sync. Before a save (and around any disable_forward_pre_hook(param_sync=True), e.g. pre-eval), the training loop force-syncs DDP params. force_param_sync / disable_forward_pre_hook first call optimizer.prepare_model_params_for_param_sync(), which copies the FP32 masters into the DDP param buffer, so the sync’s _post_param_sync copy-back re-quantizes each native-FP8 weight — GTP_remat shards included — from up-to-date masters instead of stale grad scratch under --reuse-grad-buf-for-mxfp8-param-ag. The copy-back therefore writes the correct MXFP8 shard, so the forced sync leaves GTP_remat’s self-gathered weight intact and does not perturb the next iteration’s loss — no GTP-specific preservation is needed.
3.4 Prefetch-chain construction and its design assumptions#
The prefetch chains (§3.1) are not configured — they are observed at runtime and stored in process-global state, which imposes assumptions on the weights that every feature combined with GTP_remat must be checked against.
Construction (two steps).
Classification (once, at build).
classify_gtp_chains(model)runs intraining.py’sget_modelafter the model is built. It walksnamed_parameters()and, for eachGTPShardedParam, setschain_id(via_classify_param_chain, from the activecuda_graph_modules) and the dense vs. expert chain. Membership is fixed from here on; re-classifying an already-linked param into a different chain is rejected.Routed grouped experts are the exception: their
fc1/fc2weights get their own homogeneous chains for a deeper prefetch — see Grouped-expert chains below.Linking (lazily, on the first forward). The doubly-linked list (
prev_w/next_w) is built the first time each weight is materialized insideall_gather_and_prefetch: a class-level per-chain cursor (GTPShardedParam._chain_state[chain_id]["last_weight"]) records the previously-seen weight, and the current weight links itself after it. The chain therefore encodes the forward execution order of the first step and replays it every step after to predict the next weight to prefetch. The recompute chain (_recompute_next) self-populates the same way, from the weights re-gathered whilein_fp8_activation_recompute_phase()is true.
A weight can be kept out of a chain by setting weight.prefetch_initialized = True (and _need_weight_prefetch = False) before its first materialization, which skips registration entirely. Nothing does this today: embedding and output_layer are ordinary UNGRAPHED chain members (they are the head and the tail — see the link table GTP logs on the first backward), and only run outside the CUDA-graph boundary. The hook remains available as the fallback for any weight that cannot satisfy the assumptions below.
Why this needs careful consideration. Because _chain_state is a class attribute and prev_w/next_w are strong references between GTPShardedParam instances, the chain holds the weights alive for the life of the process and assumes the first step’s behavior is representative of every step. Neither is free:
Assumption |
What breaks it |
Symptom |
|---|---|---|
Stable object identity — |
Replacing a weight object at runtime (re-wrapping, checkpoint load that rebinds |
Chain gathers/prefetches the stale object → wrong weight in the GEMM |
Deterministic, fixed forward order — the observed order is replayed every step |
Data-dependent control flow: conditional layers, early exit, MoE routing that skips experts, reordered visitation |
Predicted |
Single, non-reentrant pass — one global |
Two models in one process, an extra autograd graph, unexpected microbatch interleaving |
Corrupted cursor / async handles |
Fixed, single membership — |
A weight whose CG scope or dense/expert context changes between steps |
Unrepresentable in one linear slot |
One consume per weight per step — a linear list gives each weight one slot, so one pass of the chain issues one all-gather and expects one backward per weight |
A weight used at two points in one forward (MTP’s shared embedding / output_layer and its replayed layer, tied I/O embeddings) |
Forward: the extra consumes get no all-gather of their own. Backward: the weight is reached out of chain order and its reduce-scatters overlap. Both supported since §3.5 — anything else in this shape must be checked against it |
Build-once, run-forever lifetime — strong refs never released |
Building/tearing down GTP models in-process (successive UTs, model re-init, multi-model drivers) |
Leaks all GTP params/buffers; a new model’s chain can cross-link onto a previous model’s stale params |
The prefetched weight is already updated — the chain gathers a weight before the module that owns it runs |
DDP’s |
Gathers the pre-update weight. Widens with prefetch depth — one-block-ahead grouped chains reach furthest. |
Mitigations.
reset_gtp_state()clears the class-level cursors before an in-process rebuild (call it once beforeclassify_gtp_chains) — but it does not dropprev_w/next_wlinks already held by live weights.prefetch_initialized = Truekeeps a weight out of the chain — but it is opt-out by convention; a new weight that forgets it silently joins.
Rule of thumb: any change that creates/replaces params at runtime, makes forward order data-dependent, runs GTP_remat concurrently, or builds multiple GTP models per process must be checked against the table above. When in doubt, exclude the affected weights so they fall back to synchronous, chain-free all-gather.
Grouped-expert chains (one-block-ahead)#
Problem. A chain gives every all-gather exactly one consume-step of runway — layer i’s AG hides behind layer i−1’s GEMM — which suffices only while the transfer stays inside the NVLink domain. Routed-expert weights fail that test twice over: by volume, a block gathers 2 × num_experts / EP expert weights — NCCL-coalesced into just two all-gathers, one per role — so those two transfers carry most of the block’s bytes; by distance, EGTP_remat is the group that leaves the NVLink domain. The expert transfer therefore stays partly exposed in every MoE block, and the exposure grows as expert count rises and per-GEMM time falls.
Design. When MoE is not captured, linear_fc1 and linear_fc2 each get their own homogeneous chain (GTP_remat_grouped_fc1_ungraphed / GTP_remat_grouped_fc2_ungraphed) instead of sharing the general UNGRAPHED chain. A homogeneous chain links the same weight role of consecutive MoE blocks, so next_w points a whole block ahead rather than one GEMM ahead. The roles stay in separate chains deliberately: merging them would link layer_N.fc1 → layer_N.fc2 → layer_{N+1}.fc1 → …, so fc1 would prefetch the same block’s fc2 — one GEMM of runway again — and only fc2 would reach across the block boundary.
Result. The win is resource overlap, not faster compute and not a faster network:
Runway — an expert gather now hides behind the entire preceding MoE block instead of a single GEMM.
Utilization — the interconnect works under the dense window where it used to idle, and the GPU no longer stalls waiting on the gather: both are busy at once.
Cost — one extra buffer per weight role (see mandatory double buffering below). No extra collectives, no change to the math.
Bound — same transfers, same GEMMs, only a different schedule, so the recovered time is exactly the transfer that used to sit on the critical path.
The figure below puts both schedules on one time axis, aligned at block start (top: shared chain, bottom: per-role chains). Shaded bands mark which resource is idle — red where one side waits, green where both are busy; dashed arrows trace each gather from the GEMM that launches it to the GEMM that consumes it; the arrow at the right is the recovered time, equal to the two hatched STALL bars above it.

Three consequences:
One shared stream.
_stream_keycollapses the fc1/fc2 role, so both chains resolve to a single AG stream and their all-gathers serialize instead of splitting interconnect bandwidth. The capture-axis suffix is preserved, so eager and captured ops still never share a stream.Mandatory double buffering — this is what makes the deeper prefetch safe, and it is not optional:
the weight cache keys one buffer per
(shape, dtype, expert_idx), which assumes at most one same-key weight is live;one-block-ahead makes block N and block N+1 weights live at the same time — same key, two tensors in flight;
fix: a chain-position parity (0,1,0,1…) is folded into the cache key, so consecutive blocks alternate between exactly two buffers (counter cleared by
reset_gtp_state());without it the prefetch would overwrite the weight the running GEMM is still reading — a silent-correctness bug, not a crash;
the hazard is not exclusive to grouped chains — any chain whose neighbours share a key has it. Grouped chains are same-key throughout, so they take the blanket counter; others take the narrower
_ensure_no_shared_buffer_withcheck, which allocates only where the collision is real (see Buffer / memory management).
Eager only — the optimization disables itself under CUDA-graph capture:
_classify_param_chainevaluatesgraphed = _FULL_ITERATION or ("moe" in cuda_graph_modules)before the split, and returns the plainGRAPHEDchain when it is true;so with
--cuda-graph-impl full_iterationevery param isGRAPHED— expert weights included — and they keep the ordinary one-step-ahead prefetch;why it must:
cuda_graphs.pydrains withwait_async_comms(GTPChain.GRAPHED.value), matching the id literally, so a weight inGTP_remat_grouped_fc1_ungraphedwould never be joined at the graph boundary — a correctness hazard, not just a lost overlap;lifting it would mean draining by chain-id prefix (
_chain_is_grouped) or registering the grouped streams before capture — neither is done today.
3.5 GTP_remat + Multi-Token Prediction (MTP)#
The one thing to know: MTP consumes embedding and output_layer 1 + mtp_num_layers times per forward, not once. Everything below follows from that.
What MTP does to the chain#
Two independent violations of the one consume per weight per step assumption in §3.4:
Shared weights. Each MTP layer re-embeds its shifted input with the main
embedding, and every prediction head (main + one per depth) runs the mainoutput_layer.A replayed layer. With
--mtp-use-repeated-layera single MTP layer object is built and appliedmtp_num_layerstimes (MultiTokenPredictionBlock.forwardindexesself.layers[0]every iteration), so its weights — grouped experts included — are consumed once per depth.
The chain has one node per weight, but the model has several consumes. Linking happens on a weight’s first materialization, so a re-consumed weight is skipped rather than relinked. An L6 + 2-depth chain reads embedding → decoder.0..5 → mtp.0.eh_proj → mtp.0 attn/shared-experts → mtp.1 … → output_layer, with MTP’s routed experts on the grouped fc1/fc2 chains — 19 nodes, but 36 consumption events.
Both directions follow consumption events, not chain nodes. embedding and output_layer each contribute mtp_num_layers extra events; under --mtp-use-repeated-layer every weight of the replayed layer does too. A weight is therefore reached far from its chain position, and anything that assumed “one visit per weight, in chain order” fails.
How the chain supports it#
The chain stays a plain linear list — one slot per weight, no branching. MTP is absorbed by three rules:
Every consume needs its own all-gather. A weight is gathered by its chain neighbour — predecessor in forward, successor in backward — so one pass of the chain issues exactly one gather per node. Consumes past the first have none of their own, and the prefetched path would hand the GEMM whatever the shared buffer last held. They fall back to an on-demand gather instead: correct, at the cost of that consume’s comm/compute overlap.
Per-consume gradients accumulate. Every consume produces its own wgrad and its own reduce-scatter, and the weight’s
main_gradends up holding their sum — which is its true gradient. A weight keeps only one reduce-scatter in flight at a time, so an outstanding one is completed and accumulated before the next begins.The deferred finalize is conditional. Normally a weight finalizes its chain successor’s reduce-scatter, hiding that latency behind the next backward. Once backward stops following chain order, the successor may not have started one yet, so the finalize runs only when something is actually in flight.
The first rule always applies. The other two apply only under async_reduction; with it off, every wgrad reduce-scatters and accumulates inline.
Both hazards are silent. A stale gather keeps the loss finite and merely wrong, and a dropped reduce-scatter trains on an incomplete gradient — neither raises. The state guard that would catch the first (
check_param_states) is off outside debug builds.
Link tables are logged from the first backward all-gather — the earliest point at which every chain is complete, and one that is still reached if backward later fails.
Configuration traps#
One
/segment per depth, all identical.MEM*EM/*E/*E= 6-layer decoder + 2 MTP depths.MEM*EM/*E*E= one depth whose MTP layer is 4 layers deep — a different model.The pattern silently overrides
--mtp-num-layersto the number of/-separated segments (arguments.py, warning"conflicts with MTP depth count"). If a run appears to execute fewer MTP layers than requested, this is almost always why — trust the arg dump, not the flag.--mtp-use-repeated-layeris generated from theTransformerConfigdataclass, so it never appears as a literal string inarguments.py. Atmtp_num_layers=1it is a no-op: the loop runs once either way and the parameter set is identical.
3.6 CUDA graph integration#
GTP supports both full-iteration CUDA graphs and local/partial CUDA graphs. The common integration keeps graph and eager chains separate, builds lazy prefetch links during warmup, materializes side streams before capture, and preserves stable addresses for captured communication buffers. Full-iteration capture has no boundary between individual layer graphs. Local capture divides the model into independently replayed graph runners, so communication at a runner boundary requires an explicit completion protocol. The features below describe CUDA-graph-specific GTP optimizations and the ownership rules required to make them safe.
Cross-graph backward reduce-scatter overlap#
Problem. A local backward graph may launch GTP all-gathers and wgrad reduce-scatters on side streams. The conservative completion boundary drains both kinds of communication before releasing the next graph. This is correct, but it serializes the current graph’s RS tail with otherwise independent compute in the next graph. Releasing the next graph earlier introduces two ownership requirements: each graph must drain only its own communication, and an RS input must remain alive until NCCL has stopped reading it even if another graph has started.
Without cross-graph overlap. The conservative backward schedule drains communication in two stages:
Stage 1: graph-owned AG handles -> wait graph-owned AG streams
Stage 2: graph-owned RS handles -> finalize main_grad -> wait graph-owned RS streams
bwd_completion_event is recorded after Stage 2. The next graph therefore starts after the current graph’s AG, RS, and main_grad finalization have completed. Because the RS input lifetime cannot extend into the next graph, no persistent cross-graph wgrad ring is required.
time -------------------------------------------------------------------------------->
runner i wgrad GEMM -> launch RS_i -> Stage 1: drain AG -> Stage 2: wait RS_i
RS stream +---------------- RS_i -----------------> add main_grad_i
runner i stream -> completion_i
main stream wait completion_i
-> runner i-1
Design. Cross-graph overlap keeps the same two drain stages but moves bwd_completion_event between them. Stage 1 establishes that the graph’s all-gathers are complete, which is sufficient to release the next graph. Stage 2 remains ordered after the event and drains RS before finalizing main_grad. This creates a compute window for the RS tail without changing the collective or gradient math.
With cross-graph overlap. The main stream may launch the next backward graph while the current graph’s RS and main_grad finalization continue. Fixed-address ring slots and replay-time events protect each RS input for the longer lifetime.
time ------------------------------------------------------------------------------------>
runner i wait ready[S0] -> wgrad_i writes S0 -> Stage 1 -> completion_i
RS stream +------ RS_i(S0) ------> ready[S0] -> add_i
main stream +-> launch runner i-1
runner i-1 wait ready[S1]
wgrad_i-1 writes S1
RS stream +--- RS_i-1(S1) ---> ready[S1]
main stream +-> runner i-2
runner i-2 wait ready[S0]
S0 and S1 are allocated before capture, outside the shared graph pool.
The two modes differ only in release timing and the storage required to make early release safe:
Property |
Without cross-graph overlap |
With cross-graph overlap |
|---|---|---|
|
After Stage 2 |
Between Stage 1 and Stage 2 |
RS overlap with the next graph |
No |
Yes |
Persistent wgrad ring |
Not required |
Required |
Additional persistent memory |
None for the ring |
Bounded by |
Implementation. Cross-graph overlap is implemented by the following cooperating mechanisms:
Capture-local communication ownership.
track_gtp_capture_comms()creates oneGTPCaptureCommStateper backward capture.register_capture_comm()records the exact params, AG streams, and RS streams touched by that graph. Both drain stages passcapture_comms.paramstowait_async_comms(), so a graph drains only communication it owns.Two-stage completion protocol. Stage 1 calls
wait_async_comms(..., skip_rs=True)and joins graph-owned AG streams before recordingbwd_completion_event. Stage 2 drains graph-owned RS handles, accumulates reduced wgrads intomain_grad, and joins the RS streams.Persistent wgrad-ring allocation.
initialize_graph_wgrad_rings()runs after DDP createsmain_gradand before graph capture.allocate_graph_wgrad_rings()allocates fixed-address tensors outside the shared graph pool. Slots are keyed by communication domain, unsharded shape, padded shape, dtype, and expert index. The default ring size is two.Actual RS-input ownership.
_prepare_wgrad_reduce_scatter_inputs()registers the ring slot selected as the actual NCCL input. If one graph maps multiple parameters to the same slot, capture fails with a request to increasegraph_wgrad_ring_size.Replay fencing. Before replay writes a slot, the graph runner waits for its
ready_event. The RS stream publishes that event only after NCCL has stopped reading the slot. Different slots may remain live concurrently; reuse of an occupied slot waits.Final gradient fence.
wait_for_gtp_grad_reduction_on_current_stream()joins GTP side streams and graph-runner streams before DDP or the optimizer consumesmain_grad.
Result and cost. The ring owns the padded RS input. The wgrad GEMM writes the logical prefix, the alignment tail remains zero (§3.7 covers why that permanent zero has to be excluded from num_zeros), and a non-ring producer is copied into the logical view before reduce-scatter. The bounded memory cost is up to graph_wgrad_ring_size full unsharded wgrad buffers for each matching scheduling/shape domain, rather than one buffer per layer. The default ring size of two is sufficient when each graph has one same-key writer: one slot may remain an in-flight RS input while the next graph writes the other, and reuse waits on the older slot’s ready_event. A larger ring is needed only when one graph contains multiple same-key writers whose reduce-scatter inputs can be live together. Capture rejects unsafe same-slot reuse instead of silently aliasing it.
The feature applies only to local/partial CUDA graphs and is enabled automatically. Full-iteration CUDA graphs do not use this feature because their backward execution has no local graph boundary.
3.7 Per-parameter alignment padding#
Low-precision tiling formats need each rank’s local shard aligned to their tile size (MXFP8: 32, NVFP4: 16) — plain equal-sized AG/RS shards only need dim0 divisible by gtp_remat_size, which padding is not required for (_gtp_slice_one_param skips it and just asserts that divisibility when pad_for_alignment == 0). BF16 has no tile-size requirement, so training.py sets pad_for_alignment=1 for it — dim0 still isn’t guaranteed divisible by gtp_remat_size on its own, so padding stays on, just bounded to gtp_remat_size - 1 rows instead of a 16/32-row tile margin. A weight’s real dim0 is rarely already a multiple of pad_for_alignment × gtp_remat_size, so _gtp_slice_one_param pads the logical tensor up to the next multiple before slicing it evenly across gtp_remat_group (§1.3) — the padding lands as a contiguous suffix of that padded buffer. It is real, allocated storage, but the wgrad GEMM only ever writes the logical prefix (§3.6), so it stays exact 0.0 for the life of the run: a permanent structural zero, not a value that merely happens to be zero.
Where padding lands#
alignment = pad_for_alignment × gtp_remat_size
pad_length = amount needed to round dim0 up to a multiple of alignment
shard_size = (dim0 + pad_length) / gtp_remat_size -- same for every rank
Case A — padding fits in the tail shard (the common case).
dim0 = 48, gtp_remat_size = 2, pad_for_alignment = 16
-> alignment = 32, pad_length = 16, shard_size = 32 rows
row: 0 32 48 63
|---------- rank 0 ----------|---------- rank 1 ----------------|
|<-------------- real (48 rows) -------------->|<-- pad (16) -->|
rank 0 (32 rows): [ real real real real ................ real ] pad rows = 0
rank 1 (32 rows): [ real ............ real | pad pad .......... pad ] pad rows = 16 <- tail rank
Case B — small dim0 makes padding spill backward from the tail rank into lower-numbered ranks’ shards too.
dim0 = 1, gtp_remat_size = 4, pad_for_alignment = 16
-> alignment = 64, pad_length = 63, shard_size = 16 rows
row: 0 1 63
|---rank 0---|------ rank 1 ------|------ rank 2 ------|------ rank 3 ------|
|r|<------------------------- pad (63 rows) -------------------------------->|
rank 0 (16 rows): [ real | pad pad pad ........ pad ] pad rows = 15
rank 1 (16 rows): [ pad pad pad ...................... pad ] pad rows = 16 (fully padding)
rank 2 (16 rows): [ pad pad pad ...................... pad ] pad rows = 16 (fully padding)
rank 3 (16 rows): [ pad pad pad ...................... pad ] pad rows = 16 <- tail rank
Case A is what §1.3’s “tail slice” framing describes for the reassembled tensor — true per-shard too, whenever pad_length is smaller than one shard’s own row count. Case B is why any per-rank consumer of this layout (e.g. gtp_local_pad_zero_count in tensor_parallel/layers.py) has to compute padding from each rank’s row offset in the unsharded padded buffer, rather than assuming only the tail rank ever holds it.
Why pad this way#
Uniform shard sizes without runtime coordination.
pad_lengthis a pure function ofdim0,pad_for_alignment, andgtp_remat_size— computed once, locally, at shard-construction time. No cross-rank negotiation is needed to agree on a shard size before the first AG/RS.Equal-sized AG/RS shards fall out for free. For MXFP8/NVFP4,
pad_for_alignmentis set to the precision format’s tile size, so the same pass that satisfies tiling also leaves every rank with an equal-sized shard. For BF16,pad_for_alignment=1roundsdim0up to the next multiple ofgtp_remat_sizedirectly — no tile size to satisfy, so padding is only the minimum AG/RS itself requires.A contiguous tail keeps stripping (and resharding) cheap. Padding is always appended as a suffix before slicing, never interleaved with real data, so recovering the logical tensor is a single trailing slice (
tensor[:-pad_length]) — simple enough to stay correct even when a checkpoint reload changesgtp_remat_sizeand thus the padded size (§3.3’sallow_shape_mismatch).A predictable invariant other systems can build on. “Padding is always an exact structural zero, never written” is safe for any consumer that needs to distinguish real elements from padding — DCP’s cross-topology reshard tolerance (§3.3) and the wgrad-ring’s fixed-address buffers (§3.6) both lean on it, and it’s what lets
count_zeros_fp32exclude these permanent zeros fromnum_zerosinstead of miscounting them as converged-to-zero gradients.
Trade-off#
Wasted memory and bandwidth. Every padded row is allocated in the weight, gradient, and optimizer-state buffers, and travels over the wire on every gather/reduce-scatter — pure overhead,
pad_length / (dim0 + pad_length)of the padded tensor.Negligible in the common case, severe in the small-
dim0one. That fraction is ~0 whendim0 ≫ pad_for_alignment × gtp_remat_size, but Case B’sdim0 = 1weight pads to 64 rows — 98% (63/64) padding, with 3 of its 4 per-rank shards pure padding and contributing nothing.No automatic mitigation. Choosing
gtp_remat_sizeto divide (or nearly divide) a weight’s realdim0avoids this, but GTP does not warn when it doesn’t — it’s on the caller to notice.A bookkeeping tax on every raw-buffer consumer. Checkpointing,
num_zeros, and the wgrad ring all have to explicitly account for padding rather than assume a shard’s buffer is fully real.
4. Testing#
Whenever you add or change a GTP_remat/EGTP_remat feature, run the GTP_remat unit-test suite below as a sanity check before opening a PR. These tests exercise the full TE↔Mcore path (weight gather/RS, DDP, distributed optimizer, finalize, grad-norm) and catch silent-correctness regressions that don’t surface as crashes.
# 4 GPUs. GTP_remat requires TransformerEngine >= 2.19.
torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parallel/ -v
Test file |
What it guards |
|---|---|
|
Core GTP_remat shard/gather, cache ownership, wgrad ring, DDP bucket alignment, the |
|
GTP_remat on attention linears, loss parity vs no-GTP_remat. |
|
GTP_remat on Mamba projection weights. |
|
GTP_remat composed with tensor parallelism ( |
|
EGTP_remat on MoE routed-expert weights. |
|
End-to-end: GTP_remat per-step loss trajectory matches a no-GTP_remat baseline. |
|
Gradient + dist-opt + grad-norm numeric parity vs a DP baseline at replicate (DP) > 1. Also the fp32-accumulation reduce-scatter (§2.6): gtp_remat-axis and DDP-axis parity, plus the size-2 bypass. |
|
Capture-step grad-norm guard (§1.2): |
|
Four-layer partial-CG loss and eager-vs-replay grad-norm parity with two-slot ring reuse across independently replayed graphs (§3.5). |
|
DCP sharding metadata (§3.3): TP×GTP_remat offsets, pad reshard, |
|
Newton–Schulz per-shard parity for all three GTP_remat modes (§1.6): blockwise/duplicated/distributed at TP1, plus row- and column-parallel cases at |
|
Muon optimizer-state DCP roundtrip (§1.6): |
|
Layout-invariant split-QKV (§1.6): the decision uses the across-shards row count (with the production shape that a shard-local test rejected), the split runs after the all-gather so the GTP_remat shard equals TP1’s result restricted to this rank’s rows, and |
|
Recompute-chain buffers (§3.1): adjacent nodes never share a gather buffer, dense and grouped, plus dgrad/wgrad parity vs no-recompute. |
|
GTP_remat + MTP shared weights (§3.5), 14 cases over |
|
Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. |
|
Parameter-readiness ordering (§3.2): GTP_remat’s ahead-of-consume prefetch must not read a bucket DDP has not published. Structural and numerical (stale-value) guards on the default one-weight-ahead chain, the grouped-expert one-block-ahead chain, and the recompute exclusion. |
|
|
The fp32-accumulation primitive itself is covered outside this suite, by tests/unit_tests/distributed/test_reduce_scatter_with_fp32_accumulation.py, which does not require GTP_remat.
The parameter-readiness contract itself (§3.2) is likewise covered outside this suite, by tests/unit_tests/distributed/test_param_readiness.py — CPU-only, no GPU or GTP_remat required. It pins the branches the 4-GPU test does not exercise: align_param_gather, pre-hooks removed mid-sequence, and a collected DDP or bucket group.
The num_zeros padding correction (§3.7) has two more layers of coverage outside this suite, both CPU-only: tests/unit_tests/tensor_parallel/test_layers.py::TestGtpLocalPadZeroCount unit-tests gtp_local_pad_zero_count’s row-offset math directly (no padding, tail-only, DP-fragment overlap variants, and the small-dim0 spillover case), and tests/unit_tests/optimizer/test_clip_grads.py::TestCountZerosFp32GtpPadding checks count_zeros_fp32’s subtraction with and without an explicit .gtp_pad_zeros stamp.
All tests require ≥ 4 GPUs and TransformerEngine >= 2.19; they self-skip when those are unavailable. A green run (skips for unmet hardware/config are acceptable) is the minimum bar for any GTP_remat change.