Skip to content

Commit bf3ffc3

Browse files
authored
feat(gtp): collapses output_layer's repeated wgrad reduce-scatters into one per iteration (#7573)
Signed-off-by: Shiqing Fan <shiqingf@nvidia.com>
1 parent 23fe961 commit bf3ffc3

8 files changed

Lines changed: 551 additions & 36 deletions

File tree

‎docs/api-guide/core/generalized_tensor_parallel.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,13 @@ The chain stays a plain linear list — one slot per weight, no branching. MTP i
727727

728728
- **Per-consume gradients accumulate.** Every consume produces its own wgrad and its own reduce-scatter, and the weight's `main_grad` ends 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.
729729

730+
**`output_layer` is the exception**: its consumes sum into one buffer *before* the collective —
731+
**one reduce-scatter per iteration**, not one per consume per microbatch. The count is learned
732+
in iteration 1; the backward fence closes the window and fires DDP grad-ready once. **Off under
733+
`--step-batch-size-schedule`**, where a rising count fires grad-ready twice and drops a
734+
contribution. It is the only opt-in — TE-backed weights (`eh_proj`, the replayed layer) never
735+
reach the hook.
736+
730737
- **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.
731738

732739
The first rule always applies. The other two apply only under `async_reduction`; with it off, every wgrad reduce-scatters and accumulates inline.
@@ -893,7 +900,7 @@ torchrun --nproc-per-node 4 -m pytest tests/unit_tests/generalized_tensor_parall
893900
| `test_gtp_muon_dcp.py` | Muon optimizer-state DCP roundtrip (§1.6): `replica_id` fold + native-FP8 backfill matching. |
894901
| `test_gtp_muon_qkv.py` | 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 `blockwise`/`distributed` fall back to whole-matrix Newton–Schulz bitwise, warning once. |
895902
| `test_gtp_recompute_chain.py` | Recompute-chain buffers (§3.1): adjacent nodes never share a gather buffer, dense and grouped, plus dgrad/wgrad parity vs no-recompute. |
896-
| `test_gtp_mtp.py` | GTP_remat + MTP shared weights (§3.5), 14 cases over `mtp_use_repeated_layer` × dense/MoE. Both MTP hazards are silent, so each needs its own guard: the async reduce-scatter path is compared numerically against the sync path on an identical model/sharding/batch, and all-gathers issued are tallied against consumes to catch a consume reading a buffer nothing gathered into. |
903+
| `test_gtp_mtp.py` | GTP_remat + MTP shared weights (§3.5), 28 cases over `mtp_use_repeated_layer` × dense/MoE. Guards three silent hazards: a stale gather, a dropped reduce-scatter, and a wrong `output_layer` consume count under pre-RS wgrad accumulation. |
897904
| `test_gtp_fp8_param_gather.py` | Native-FP8 GTP_remat (§1.3): fp8-vs-BF16 loss parity (TP1/TP2, MoE), post-save-spike guard. |
898905
| `test_gtp_ddp_param_sync_race.py` | 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. |
899906
| `test_gtp_custom_pgs.py` | `pg_collection` plumbing: a custom `gtp_remat` group (permuted ranks, same size) must give the same fwd/bwd results as the MPU groups — catches modules reading `parallel_state` instead of the collection passed to them. |

‎megatron/core/extensions/transformer_engine.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3863,20 +3863,25 @@ def te_general_gemm(
38633863
out: Optional[torch.Tensor] = None,
38643864
bias: Optional[torch.Tensor] = None,
38653865
grad: bool = False,
3866+
accumulate: bool = False,
38663867
) -> List[torch.Tensor]:
38673868
"""
38683869
Wrapper for TE's general_gemm function.
38693870
It supports fp32, bf16, fp16, and fp8 GEMMs with TN, NN, and NT layouts.
38703871
The output dtype can be specified by `out_dtype`.
38713872
Note: not all combinations of these settings are supported. If not supported,
38723873
cublaslt will throw an error.
3874+
3875+
``accumulate=True`` makes the epilogue add into ``out`` instead of overwriting it, so a
3876+
caller that drives the same output buffer through several GEMMs gets the sum without a
3877+
separate accumulator. ``out`` must then already hold the running value.
38733878
"""
38743879
kwargs = dict(
38753880
out_dtype=out_dtype,
38763881
quantization_params=None,
38773882
gelu=None,
38783883
gelu_in=None,
3879-
accumulate=False,
3884+
accumulate=accumulate,
38803885
layout=layout,
38813886
out=out,
38823887
bias=bias,

‎megatron/core/tensor_parallel/generalized_tensor_parallelism.py‎

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,36 @@ def _wgrad_pool_get(shape: tuple, dtype: torch.dtype, device) -> torch.Tensor:
336336
return buf
337337

338338

339+
# Weights owing a flush. Entries outlive an early flush: the fence is where the count is learned.
340+
_GTP_PENDING_WGRAD_ACCUM: set = set()
341+
342+
343+
def _collect_inflight_wgrad_reduce_scatters() -> None:
344+
"""Add a still-running reduce-scatter's result into main_grad.
345+
346+
wait_async_comms() only waits for it, never adds it, so without this the gradient is lost.
347+
"""
348+
for param in list(_GTP_PENDING_WGRAD_ACCUM):
349+
if param._wgrad_rs_handle is not None:
350+
param._wait_reduce_scatter(finalize_grad=True)
351+
param._already_finalized = False # the cascade must not skip the next one
352+
353+
354+
def _close_wgrad_accumulation_windows() -> None:
355+
"""Reduce-scatter what is still accumulating, then learn each weight's consume count.
356+
357+
Most iterations flushed earlier; this catches iteration 1 and mispredicted counts.
358+
"""
359+
if not _GTP_PENDING_WGRAD_ACCUM:
360+
return
361+
for param in list(_GTP_PENDING_WGRAD_ACCUM):
362+
param.flush_accumulated_wgrad() # no-op if the last-consume trigger already flushed
363+
# Learn AFTER any re-opened window, so an undercount self-corrects.
364+
param._wgrad_accum_expected = param._wgrad_accum_consumes
365+
param._wgrad_accum_consumes = 0
366+
_GTP_PENDING_WGRAD_ACCUM.clear()
367+
368+
339369
def _wgrad_pool_put(buf: torch.Tensor):
340370
"""Return a pool-owned buffer for reuse (no-op for untagged buffers; see
341371
_wgrad_pool_get)."""
@@ -408,13 +438,20 @@ def wait_for_gtp_grad_reduction_on_current_stream() -> None:
408438
runner's replay stream (its tail = captured Phase 2 main_grad.add_). Under whole-step capture
409439
there are no per-layer runners, so that second wait is skipped. No-op when GTP is inactive.
410440
"""
441+
# Before the drain: wait_async_comms() waits without adding, losing the result.
442+
if not torch.cuda.is_current_stream_capturing():
443+
_collect_inflight_wgrad_reduce_scatters()
411444
wait_async_comms()
412445
cur = torch.cuda.current_stream()
413446
# Join the async AG/RS side streams for both the eager and CUDA-graph capture paths.
414447
for s in _AG_STREAMS.values():
415448
cur.wait_stream(s)
416449
for s in _RS_STREAMS.values():
417450
cur.wait_stream(s)
451+
# AFTER the joins: this fires grad-ready, which can dispatch a DP collective that would
452+
# race peers' main_grad.add_ still queued on rs_stream.
453+
if not torch.cuda.is_current_stream_capturing():
454+
_close_wgrad_accumulation_windows()
418455
# The per-layer CG runner replay streams exist only in the eager / per-layer-CG path; under
419456
# whole-step capture there are no runners, so stop here while capturing.
420457
if torch.cuda.is_current_stream_capturing():
@@ -466,15 +503,31 @@ def update_gtp_config(**kwargs):
466503
setattr(GTP_CONFIG, key, value)
467504

468505

506+
def _num_microbatches_can_change() -> bool:
507+
"""True when num_microbatches can change mid-run (--step-batch-size-schedule)."""
508+
# Lazy: a module-scope import here can silently flip HAVE_GTP to False.
509+
from megatron.core import num_microbatches_calculator as nmb
510+
511+
calc = nmb._GLOBAL_NUM_MICROBATCHES_CALCULATOR
512+
# Never initialized (unit tests): nothing can vary it.
513+
return calc is not None and not isinstance(calc, nmb.ConstantNumMicroBatchesCalculator)
514+
515+
469516
def tag_gtp_params_with_names(model):
470517
"""Populate _debug_name on every GTPShardedParam with its full dotted parameter name.
471518
472519
Call once after model construction so the linking log prints human-readable names
473520
instead of raw tensor ids.
474521
"""
522+
# Off under a stepped batch schedule: a rising consume count double-fires grad-ready and
523+
# drops a contribution (see _is_last_expected_consume).
524+
accum_ok = not _num_microbatches_can_change()
475525
for name, param in model.named_parameters():
476526
if is_gtp_param(param):
477527
param._debug_name = name
528+
# output_layer ONLY: the one GTP weight taking several backwards that also runs
529+
# mcore's linear backward. TE-backed weights never consume the flag.
530+
param._wgrad_accum_enabled = accum_ok and name.endswith("output_layer.weight")
478531

479532

480533
def configure_gtp_remat_from_recipe(
@@ -984,6 +1037,11 @@ def _init_gtp_runtime_attrs(obj):
9841037
# _ensure_no_shared_buffer_with.
9851038
obj._ag_ticket_recompute = None
9861039
obj._recompute_buf_parity = None
1040+
# Pre-RS wgrad accumulation (output_layer under MTP).
1041+
obj._wgrad_accum_enabled = False # static per-weight opt-in
1042+
obj._wgrad_accum_buf = None # the buffer being summed into; non-None == a flush is owed
1043+
obj._wgrad_accum_expected = 0 # consume count learned last iteration
1044+
obj._wgrad_accum_consumes = 0 # consumes so far this iteration
9871045
# Chain identity (GRAPHED/UNGRAPHED). Defaults to UNGRAPHED; classify_gtp_chains(model)
9881046
# walks the model at init (after set_cuda_graph_modules) and reclassifies on param name +
9891047
# active cuda_graph_modules.
@@ -1847,6 +1905,10 @@ def get_wgrad_tensor(self):
18471905
if ring_slot is not None:
18481906
return self._gtp_graph_wgrad_ring_view
18491907

1908+
# Mid-accumulation: hand back the buffer the first backward filled, not a fresh one.
1909+
if self._wgrad_accum_buf is not None:
1910+
return self._wgrad_accum_buf
1911+
18501912
# TODO: Merge the ring wgrad slot and symmetric wgrad slot into a single slot.
18511913
if is_gtp_symm_pool_registered(self.group):
18521914
# Lifecycle invariant: get_wgrad_tensor -> GEMM -> _prepare consumes the slot -> RS.
@@ -2206,7 +2268,7 @@ def _prepare_wgrad_reduce_scatter_inputs(self, wgrads):
22062268

22072269
return send_bufs, release_bufs
22082270

2209-
def wgrad_reduce_scatter(self, wgrad, nvtx_label=None):
2271+
def wgrad_reduce_scatter(self, wgrad, nvtx_label=None, async_op=None):
22102272
"""Reduce-scatter wgrad(s): sync for the last weight, async+deferred for others.
22112273
Accepts a single tensor (non-routed) or a list (routed experts).
22122274
@@ -2218,6 +2280,18 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None):
22182280
wgrads = list(wgrad) if batched else [wgrad]
22192281
weights = self._weights
22202282

2283+
if self._wgrad_accum_buf is not None and wgrads[0] is self._wgrad_accum_buf:
2284+
# Identity, not just "accumulating": only a wgrad that IS the buffer was summed
2285+
# into it. Anything else (autograd's own grad_output) must still reduce now.
2286+
ret = None # accumulation is single-weight; the batched shape is unreachable
2287+
if self._is_last_expected_consume():
2288+
# Sum complete: fire the one RS HERE, with backward left to overlap it; at the
2289+
# end-of-backward drain it would be fully exposed.
2290+
self.flush_accumulated_wgrad(async_op=None)
2291+
if GTP_CONFIG.async_reduction:
2292+
self._drain_next_w_reduce_scatter()
2293+
return ret
2294+
22212295
# MTP feeds embedding and output_layer into more than one GEMM per forward, so they get
22222296
# more than one backward. The previous reduce-scatter may still be running: starting
22232297
# another would reuse this weight's ticket, i.e. the same output buffer, and overwrite
@@ -2227,7 +2301,9 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None):
22272301
# Accounted for here, so the cascade below must not skip the next one.
22282302
self._already_finalized = False
22292303

2230-
if GTP_CONFIG.async_reduction and self.prev_w is not None:
2304+
if async_op is None:
2305+
async_op = GTP_CONFIG.async_reduction and self.prev_w is not None
2306+
if async_op:
22312307
# Async RS (not last weight — deferred finish). Pre-RS work on caller; NCCL wrap
22322308
# lives at the collective site inside _reduce_scatter (mirrors the AG prefetch sites).
22332309
_, rs_handle, release_bufs = self._reduce_scatter(
@@ -2257,7 +2333,14 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None):
22572333

22582334
# Wait for last reduce scatter if it was async
22592335
# Currently only support reduce scattering in reverse order
2260-
if GTP_CONFIG.async_reduction and self.next_w is not None:
2336+
if GTP_CONFIG.async_reduction:
2337+
self._drain_next_w_reduce_scatter()
2338+
2339+
return ret
2340+
2341+
def _drain_next_w_reduce_scatter(self):
2342+
"""Cascade step: finalize the chain successor's in-flight reduce-scatter, if any."""
2343+
if self.next_w is not None:
22612344
# Backward normally walks the chain in reverse, so next_w has already started its
22622345
# reduce-scatter by now. That only holds while each weight is used once per forward.
22632346
# MTP's second embedding lookup sits late in the forward, so the embedding's backward
@@ -2287,8 +2370,6 @@ def wgrad_reduce_scatter(self, wgrad, nvtx_label=None):
22872370
self._handle_megatron_grad_accum(w)
22882371
cache.release(w._rs_ticket)
22892372

2290-
return ret
2291-
22922373
def batched_wgrad_reduce_scatter(self, wgrad_list, nvtx_label=None):
22932374
"""Batched version of wgrad_reduce_scatter."""
22942375
assert self.is_routed_expert and self.weight_list is not None
@@ -2313,6 +2394,57 @@ def finalize_group_grads(self, wgrads, nvtx_label=None):
23132394
"""Protocol: reduce-scatter the group's freshly computed weight grad(s)."""
23142395
return self.wgrad_reduce_scatter(wgrads, nvtx_label=nvtx_label)
23152396

2397+
def _can_accumulate_wgrad(self) -> bool:
2398+
"""Eligibility, not state: opted in, and not on a graphed or ring-slot path.
2399+
2400+
Whether it IS accumulating right now is ``_wgrad_accum_buf is not None``.
2401+
"""
2402+
if not self._wgrad_accum_enabled:
2403+
return False
2404+
# A graph bakes in the schedule and the ring slot owns its own lifecycle.
2405+
if _chain_is_graphed(self.chain_id) or torch.cuda.is_current_stream_capturing():
2406+
return False
2407+
return getattr(self, "_gtp_graph_wgrad_ring_slot", None) is None
2408+
2409+
def record_wgrad_consume(self, buf) -> bool:
2410+
"""Record this backward; True if it should ADD into ``buf`` rather than overwrite.
2411+
2412+
False on the first -- that overwrite IS the zero-fill. Taking ``buf`` here, not in
2413+
get_wgrad_tensor, is what makes _buf mean exactly "a flush is owed".
2414+
"""
2415+
if not self._can_accumulate_wgrad():
2416+
return False
2417+
first = self._wgrad_accum_buf is None
2418+
self._wgrad_accum_buf = buf
2419+
self._wgrad_accum_consumes += 1
2420+
_GTP_PENDING_WGRAD_ACCUM.add(self)
2421+
return not first
2422+
2423+
def _is_last_expected_consume(self) -> bool:
2424+
"""A PREDICTION: this is consume number N, and last iteration had N of them.
2425+
2426+
'==', not '>=': _consumes resets only at the fence, so >= would re-fire on every later
2427+
consume -- one RS each, and DDP's golden grad-ready count breaks.
2428+
2429+
A rising count would fire grad-ready twice and drop that iteration's last consume, so
2430+
tag_gtp_params_with_names leaves the feature off under --step-batch-size-schedule.
2431+
"""
2432+
return self._wgrad_accum_consumes == self._wgrad_accum_expected
2433+
2434+
def flush_accumulated_wgrad(self, async_op=False):
2435+
"""Reduce-scatter the accumulated wgrad once and fold it into main_grad.
2436+
2437+
Clearing _buf first closes the window AND lets wgrad_reduce_scatter run its ordinary
2438+
path. async_op: None = that path's own rule, False = forced sync for the fence, which
2439+
has already drained the async comms.
2440+
"""
2441+
if self._wgrad_accum_buf is None:
2442+
return
2443+
wgrad = self._wgrad_accum_buf
2444+
self._wgrad_accum_buf = None
2445+
label = f"{self._debug_name}.gtp_wgrad_accum_flush"
2446+
return self.wgrad_reduce_scatter(wgrad, nvtx_label=label, async_op=async_op)
2447+
23162448
def grad_buffer(self):
23172449
"""Protocol: the wgrad accumulation scratch buffer for this weight."""
23182450
return self.get_wgrad_tensor()
@@ -2701,6 +2833,13 @@ def reset_gtp_state():
27012833
GTPShardedParam._link_tables_flushed = False
27022834
GTPShardedParam._recompute_link_tables_flushed = False
27032835
_GTP_GROUPED_BUF_PARITY_COUNTER.clear()
2836+
# A backward that never reached the fence leaves entries pointing at a torn-down model, and
2837+
# a surviving _buf would make the next iteration's first backward ADD onto a stale sum.
2838+
for param in _GTP_PENDING_WGRAD_ACCUM:
2839+
param._wgrad_accum_buf = None
2840+
param._wgrad_accum_consumes = 0
2841+
param._wgrad_accum_expected = 0
2842+
_GTP_PENDING_WGRAD_ACCUM.clear()
27042843
clear_graph_wgrad_rings()
27052844

27062845

‎megatron/core/tensor_parallel/layers.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,9 +635,13 @@ def linear_with_frozen_weight(
635635
return LinearWithFrozenWeight.apply(*args)
636636

637637

638-
def _wgrad_gemm(out, grad_output, total_input):
638+
def _wgrad_gemm(out, grad_output, total_input, accumulate=False):
639639
"""Weight-gradient GEMM into ``out``, which may be wider than the inputs (bf16 -> fp32).
640640
641+
``accumulate=True`` adds into ``out`` rather than overwriting it, which lets a weight that is
642+
consumed several times in one backward build its total wgrad in place -- no second buffer, and
643+
the first consume (``accumulate=False``) doubles as the zero-fill.
644+
641645
Returns ``out``, filled with the weight gradient.
642646
"""
643647
# Import here to avoid circular import
@@ -646,8 +650,17 @@ def _wgrad_gemm(out, grad_output, total_input):
646650
if te_general_gemm is not None:
647651
# torch.matmul cannot widen via out=, so TE's GEMM does the mixed-precision output.
648652
te_general_gemm(
649-
total_input, grad_output, out_dtype=out.dtype, layout="NT", out=out, grad=True
653+
total_input,
654+
grad_output,
655+
out_dtype=out.dtype,
656+
layout="NT",
657+
out=out,
658+
grad=True,
659+
accumulate=accumulate,
650660
)
661+
elif accumulate:
662+
# matmul rejects an out= of a different dtype, so land in the compute dtype and add.
663+
out.add_(grad_output.t().matmul(total_input))
651664
else:
652665
# matmul rejects an out= of a different dtype, so land in the compute dtype and cast.
653666
out.copy_(grad_output.t().matmul(total_input))
@@ -849,12 +862,23 @@ def backward(ctx, grad_output):
849862
#
850863
# This branch widens the epilogue to main_grad's dtype, so the RS no longer rounds
851864
# across ranks before the fp32 accum sees the value.
852-
grad_weight = _wgrad_gemm(sharded_weight.get_wgrad_tensor(), grad_output, total_input)
865+
wgrad_buf = sharded_weight.get_wgrad_tensor()
866+
grad_weight = _wgrad_gemm(
867+
wgrad_buf,
868+
grad_output,
869+
total_input,
870+
accumulate=sharded_weight.record_wgrad_consume(wgrad_buf),
871+
)
853872
else:
854873
if ctx.gtp_remat_size > 1 and sharded_weight.use_zero_copy_wgrad(grad_output.dtype):
855874
# GTP: write the wgrad straight into the reduce-scatter send buffer.
856875
grad_weight = sharded_weight.get_wgrad_tensor()
857-
torch.matmul(grad_output.t(), total_input, out=grad_weight)
876+
# Consume the flag here too or this path never collapses; keep plain matmul
877+
# otherwise, so the kernel is unchanged for every other GTP weight.
878+
if sharded_weight.record_wgrad_consume(grad_weight):
879+
_wgrad_gemm(grad_weight, grad_output, total_input, accumulate=True)
880+
else:
881+
torch.matmul(grad_output.t(), total_input, out=grad_weight)
858882
else:
859883
grad_weight = grad_output.t().matmul(total_input)
860884
grad_bias = grad_output.sum(dim=0) if use_bias else None

0 commit comments

Comments
 (0)