Skip to content

[MoE] Apply router scores before W2 projection - #5543

Open
Mystri wants to merge 4 commits into
pytorch:mainfrom
Mystri:router-score-pre-w2-review
Open

Mystri wants to merge 4 commits into
pytorch:mainfrom
Mystri:router-score-pre-w2-review

Conversation

@Mystri

@Mystri Mystri commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

[MoE] Apply router scores before W2 projection

Supersedes #4095: same motivation, reworked so the combine of every dispatcher is one never-replayed remat region and the router scores are absorbed before W2.

Motivation

Current RoutedExperts applies router scores to each routed expert output after the W2 projection in TokenDispatcher.combine:

routed_output_RD = (
    routed_output_RD.to(torch.float32)
    * metadata.topk_scores_experts_sorted_N.reshape(-1, 1)
).to(routed_output_RD.dtype)

This multiplication causes the post-W2 routed_output_RD to retain for backward. Because W2 is linear, the router score can be applied earlier to the SwiGLU activation, immediately before W2:

routed_output_RD
 = W2(h_RF) * routed_score_R # current impl
 = W2(h_RF * routed_score_R) # our proposal

See the diagram below:

image

Expected memory gain in full model:

Combine output: T * D * 2 = 4096 * 7168 * 2 = 56.0 MiB per layer, * 58 layers = 3.17 GiB

W2 output: (T * top_k) * D * 2 = 4096 * 8 * 7168 * 2 = 448.0 MiB per layer, * 58 layers = 25.4 GiB

Total per GPU: 25.4 + 3.17 = 28.6 GiB

Expected recomputation gain in FullAC

None added: the w2 grouped GEMM still replays exactly once per MoE layer, and the combine is a never-replayed region whose body (score multiply, unpermute, combine communication) runs only on the original forward. Measured on the debug model under FullAC, the replay path also rebuilds fewer saves (15 -> 5 per forward at EP=1), because the absorbed combine is a pure reduction that saves fewer tensors.

Implementation

The dispatcher returns the per-row router scores in its dispatch metadata, GroupedLinear.forward(..., routed_scores_R=...) scales W2's input in float32 before the grouped GEMM, and combine reduces to a plain sum.

Config:

  • make_routed_experts_config(absorb_router_scores: bool | None = None) forwards the flag to the
    standard AllToAllTokenDispatcher.Config. None (auto) resolves at build time in
    RoutedExperts.__init__ to "absorb unless the experts carry an output_postprocess".
  • DeepEP/HybridEP stay opt-in: TokenDispatcherTransform(dispatcher=..., kwargs={"absorb_router_scores": True});
    their fused combines apply scores themselves, so they default to False.
  • swap_token_dispatcher (quantization) carries the flag across dispatcher swaps.

Dispatcher:

  • Standard path: the dispatch remat region exchanges the scores alongside the tokens (same
    all-to-all splits), gathers them expert-major with the same permuted_indices, and returns them
    as routed_scores_R in the metadata (None when not absorbing -- torch_remat.region supports
    None tuple leaves). TorchAO overrides the gather to zero-fill padded rows.
  • combine skips the score multiply when the scores were absorbed (_score_and_scatter_add takes
    topk_scores_N: Tensor | None).
  • DeepEP/HybridEP: dispatch takes the tracked scores out of the backend state and clears them, so
    the fused combine applies no score; the scores ride EPDispatchMetadata.routed_scores_R.

W2:

  • GroupedLinear.forward(..., routed_scores_R=None) scales the input in float32 and casts back
    before the grouped GEMM; GptOssGroupedLinear scales its post-W2 bias rows the same way.
  • RoutedExperts rejects output_postprocess + absorption (post-W2 postprocess must see unscaled
    outputs) and resolves auto at build time.

Remat region improvement

Problem:

  1. the unpermute in the combine is force-replayed, and the replay reads
    routed_output_RD from the producing w2 region's durable output slots, so routed_output_RD is
    force-required: the recompute_needs_tensor(routed_output_RD) markers pack the full
    (T * top_k) x D expert-output tensor onto the tape as SAVE_OUTPUT.
    With the scores absorbed no backward consumer is left (the scatter-add saves only indices), and the marker
    would still pin the tensor for the replay. FullAC additionally re-run the combine all-to-all.

Solution: the unpermute (with the score multiply and the combine communication) becomes one
never-replayed <fqn>.combine region (recompute=False, like the routing decision and the
trailing adds). The body runs only on the original forward and reads routed_output_RD only
there, so routed_output_RD is not saved: nothing on the replay path reads it anymore, and with
absorbed scores no backward consumer is left either, so it is freed right after the forward
(under a replaying policy, w2's replay rebuilds only what w2's own backward needs, without the
unpermute re-running on top of the output). The recompute_needs_tensor(routed_output_RD)
markers are gone. Saving the combine retains only what its backward needs -- the permutation
indices, plus the scores (and the w2 output they multiply) in the baseline, indices only with
absorption -- so no policy benefits from replaying it; dispatch keeps following the policy
everywhere, like DeepEP's dispatch.

Measured memory/recompute improvment

Measured by adding:

print(remat.format_saved_tensors_report(loss))

in def _non_pp_forward_backward_microbatch in training_engine.py.

Note (cc @tianyu-l ): consider making the saved-tensors report a torchtitan configuration
instead of a debug print.

Local dispatcher

deepseek_v3_debugmodel with LocalTokenDispatcher, FullAC (T=16384, top_k=3). Under full
recompute nothing MoE-related stays resident, so the difference is what the replay path must
rebuild:

saved for backward saves rebuilt on recompute
baseline 19.00 MiB resident 15
absorb 19.00 MiB resident 5

All2All dispatcher

The config used for the FullAC A/B (the all-to-all dispatcher falls back to its local methods at
EP=1, so the numbers match the local row above):

def _deepseek_v3_debugmodel_all2all_fullac(
    seq_len: int | None, absorb_router_scores: bool
) -> Trainer.Config:
    config = deepseek_v3_debugmodel(seq_len=seq_len, attn_backend="flex")
    config.activation_remat = FullActivationRematConfig()
    return apply_transforms(
        config,
        [
            TokenDispatcherTransform(
                dispatcher=AllToAllTokenDispatcher,
                kwargs={"absorb_router_scores": absorb_router_scores},
            )
        ],
    )


def deepseek_v3_debugmodel_all2all_fullac_non_absorb(
    seq_len: int | None = DEFAULT_DEBUG_MODEL_SEQ_LEN,
) -> Trainer.Config:
    return _deepseek_v3_debugmodel_all2all_fullac(
        seq_len=seq_len, absorb_router_scores=False
    )


def deepseek_v3_debugmodel_all2all_fullac_absorb(
    seq_len: int | None = DEFAULT_DEBUG_MODEL_SEQ_LEN,
) -> Trainer.Config:
    return _deepseek_v3_debugmodel_all2all_fullac(
        seq_len=seq_len, absorb_router_scores=True
    )

Baseline output (absorb only removes rebuilt saves here, same as local):

[rank0]:saved for backward: 19.00 MiB resident -- 6 region(s) 2.81 MiB, outside regions 16.19 MiB
[rank0]:
[rank0]:regions:
[rank0]:         0 B  layers.0  (0 storages)
[rank0]:  576.01 KiB  x5  layers.1-5  (4 storages each)
[rank0]:  + 15 saves rebuilt on recompute, not resident
[rank0]:
[rank0]:outside regions: 16.19 MiB in 9 storages
[rank0]:   8.06 MiB  FusedRmsNormBackward0 (x2)
[rank0]:   8.00 MiB  _DecoderOutputGradientBackPropBackward (x1)
[rank0]:  128.00 KiB  EmbeddingBackward0 (x1)
[rank0]:       40 B  MulBackward0 (x5)

Absorb output:

[rank0]:saved for backward: 19.00 MiB resident -- 6 region(s) 2.81 MiB, outside regions 16.19 MiB
[rank0]:
[rank0]:regions:
[rank0]:         0 B  layers.0  (0 storages)
[rank0]:  576.01 KiB  x5  layers.1-5  (4 storages each)
[rank0]:  + 5 saves rebuilt on recompute, not resident
[rank0]:
[rank0]:outside regions: 16.19 MiB in 9 storages
[rank0]:   8.06 MiB  FusedRmsNormBackward0 (x2)
[rank0]:   8.00 MiB  _DecoderOutputGradientBackPropBackward (x1)
[rank0]:  128.00 KiB  EmbeddingBackward0 (x1)
[rank0]:       40 B  MulBackward0 (x5)
[rank0]:
[rank0]:region detail:
[rank0]:layers.1: 576.01 KiB resident in 4 storage(s)
[rank0]:layers.1::routing_decision: 384.00 KiB
[rank0]:  384.00 KiB  output at idx 0  (16384, 3)  int64
[rank0]:layers.1::feed_forward.router.aux_loss.aux_loss: 192.01 KiB
[rank0]:         4 B  saved.0          ()          float32
[rank0]:         4 B  saved.1          ()          float32
[rank0]:  192.00 KiB  output at idx 0  (16384, 3)  float32
[rank0]:  + 1 save rebuilt on recompute, not resident

At EP=2 with RegionAC save-all, the baseline combine region retains the same tensors as DeepEP
(per MoE layer; the 24.00 MiB bf16 row is the w2 output):

[rank0]:layers.1::moe.routed_experts.token_dispatcher.combine: 24.99 MiB
[rank0]:  433.09 KiB  saved.0                    (55436,)             int64
[rank0]:  192.00 KiB  saved.1                    (49152, 1)           float32
[rank0]:   24.00 MiB  saved.2                    (49152, 256)         bfloat16
[rank0]:  384.00 KiB  saved.3                    (49152, 256)         int64

With absorbed scores the combine retains only the permutation indices, as measured for DeepEP
below.

DeepEP dispatcher

deepseek_v3_debugmodel_deepep (T=16384, top_k=3, EP=2, RegionAC save-all), ep_communication.combine
region per MoE layer.

Baseline:

[rank0]:saved for backward: 2.52 GiB resident -- 6 region(s) 2.50 GiB, outside regions 16.19 MiB
[rank0]:
[rank0]:regions:
[rank0]:  379.38 MiB  layers.0  (17 storages)
[rank0]:  448.01 MiB  layers.1  (21 storages)
[rank0]:  429.44 MiB  layers.2  (21 storages)
[rank0]:  427.61 MiB  layers.3  (21 storages)
[rank0]:  441.39 MiB  layers.4  (21 storages)
[rank0]:  437.97 MiB  layers.5  (21 storages)
[rank0]:  + 118 saves rebuilt on recompute, not resident
[rank0]:
[rank0]:layers.1::moe.routed_experts.token_dispatcher.ep_communication.combine: 27.99 MiB
[rank0]:  109.81 KiB  saved.0          (56225, 1)           bfloat16
[rank0]:   27.45 MiB  saved.1          (56225, 256)         bfloat16
[rank0]:  439.26 KiB  saved.2          (56225, 256)         int64

Absorb:

[rank0]:saved for backward: 2.43 GiB resident -- 6 region(s) 2.41 GiB, outside regions 16.19 MiB
[rank0]:
[rank0]:regions:
[rank0]:  379.38 MiB  layers.0  (17 storages)
[rank0]:  414.24 MiB  layers.1  (19 storages)
[rank0]:  423.66 MiB  layers.2  (19 storages)
[rank0]:  418.56 MiB  layers.3  (19 storages)
[rank0]:  414.85 MiB  layers.4  (19 storages)
[rank0]:  419.60 MiB  layers.5  (19 storages)
[rank0]:  + 118 saves rebuilt on recompute, not resident
[rank0]:
[rank0]:layers.1::moe.routed_experts.token_dispatcher.ep_communication.combine: 394.53 KiB
[rank0]:  394.53 KiB  saved.0          (50500, 256)         int64

Debug model A/B (deepseek_v3_debugmodel_deepep, T=16384, top_k=3, EP=2, RegionAC save-all):

ep_communication.combine retained
baseline (score in combine) 27.99 MiB = w2 output 27.45 MiB + scores 109.8 KiB + indices 439.3 KiB
absorb 394.5 KiB = indices only

Whole-run resident: 2.52 GiB -> 2.43 GiB. Nothing else in the report changes.

671B (T=4096, top_k=8, D=7168, 58 MoE layers, per GPU): w2 output R*D*2 = 448 MiB/layer

  • scores 1 MiB/layer (absorption-attributable, 25.4 GiB total) + combine output T*D*2 =
    56 MiB/layer (independent marker cleanup, 3.17 GiB) = 28.6 GiB/GPU avoided retention, with no
    recompute cost in exchange (w2 GEMM and combine are not replayed).

HybridEP dispatcher

Same mechanism as DeepEP: dispatch takes the per-row scores the HybridEP backend tracks and
clears them from the dispatch state, so hybridep.combine_tokens skips its fused multiply and the
scores ride EPDispatchMetadata.routed_scores_R. Opt-in like DeepEP. No separate A/B run: the
HybridEP buffer is GB200/NVLink72-only.

Tests and verifications

  • fp64 parity, standard dispatchers (tests/unit_tests/gpu/distributed/test_standard_dispatchers_absorb_router_scores.py,
    GPU, world 2): local, all-to-all at EP=1 (local fallback) and EP=2, TorchAO at EP=1 (padded
    permute, zero-sentinel scores) and EP=2. With rounding gone, the absorbed and post-combine
    paths compute the same function, so a dropped, doubled or misaligned score is an O(1) error;
    loss and every input/parameter gradient are asserted at the float32 scale W2's input scaling
    applies (rtol 1e-5).
  • fp64 parity, DeepEP (tests/unit_tests/cpu/distributed/test_deepep_absorb_router_scores.py,
    CPU, gloo, world 2): the deep_ep kernels are bf16-only, so the test stubs ElasticBuffer with
    a pure-PyTorch implementation (all_gather + local selection, deterministic layout,
    autograd-correct dispatch/combine backward) and runs the dispatcher, the torchtitan deepep
    wrapper, its custom ops and their autograd in float64. Same assertions; verified to fail on
    double-applied and on misaligned (rolled) scores.
  • Remat (CPU, tests/unit_tests/cpu/models/common/test_token_dispatcher.py): the combine body
    runs exactly once under every policy while the w2 projection's replay still follows the policy;
    with absorbed scores and w2 saved, the w2 output storage is freed after the forward; loss and
    grads match the un-checkpointed run bitwise.
  • Remat dumps for the local/all2all/deepep dispatchers: the A/B reports above.

Verification reports

  • Local (CPU, real torch_remat + RegionAC): absorb vs post-combine scoring are numerically
    equivalent (rel diff ~0.25% = bf16 rounding); gradients flow to the scores in the absorbed path.
  • Local fake-EP harness exercises the real AllToAll dispatch/combine regions: backward clean,
    w2.grouped_mm retains nothing with bf16 params, combine retains indices only, backward runs
    exactly one grouped GEMM (the w13 replay).
  • GPU debugmodel A/B above: w2 output and combine output rows removed from
    format_saved_tensors_report.
  • fp64 parity between the absorbed and post-combine versions: the standard dispatchers (GPU) and
    DeepEP through a pure-PyTorch deep_ep stub (CPU) agree in loss and all gradients at the
    float32 scale the absorb path applies (~1e-7 relative; asserted at rtol 1e-5). The DeepEP test
    fails on double-applied and on misaligned scores.
  • remat dumps for local/all2all/deepep dispatchers.
  • Loss convergence on a representative dataset (e.g. C4) at equal steps, absorbed vs baseline.

FusedMOE

The fused MoE path is an optional, SM100+-only backend that only activates when explicitly selected, so on every path we actually run (all-to-all / DeepEP / HybridEP) this optimization applies unchanged — and even when selected, it replaces the module wholesale and is unaffected rather than broken.

mystri added 4 commits October 19, 2026 09:57
Apply router scores to the expert activations before the down (W2)
projection instead of multiplying the expert output in combine, so the
combine reduces to a plain sum:

- Token dispatchers expose absorb_router_scores. When set, dispatch
  returns routed_scores_R in its metadata and combine skips its score
  multiply. Covered backends: standard (local + all-to-all), TorchAO
  (EP=1 padded and EP>1), and DeepEP/HybridEP (opt-in).
- GroupedLinear.forward takes routed_scores_R and scales the projection
  input in float32, restoring the input dtype before the grouped GEMM.
  RoutedExperts passes it to W2 only, and only when a score is actually
  absorbed, so W2 modules that do not know about scores are unaffected.
- GptOssGroupedLinear scales its post-W2 bias rows by the same scores, so
  the result stays score * (h @ W2 + b2).
- DeepEP/HybridEP dispatchers take the per-row scores the backend already
  tracks and clear them from the dispatch state, which is what makes the
  backend combine skip its fused score multiply.
- make_routed_experts_config leaves the standard backend on auto and the
  DeepEP family off. Auto resolves when RoutedExperts is built: the score
  is absorbed unless output_postprocess owns the expert outputs, because
  absorbing scales W2's input while the postprocess runs after W2, and a
  scale-invariant postprocess (e.g. post-expert RMSNorm) would drop the
  score. Asking for both explicitly raises a ValueError.
- swap_token_dispatcher propagates the flag through MoE quantization.

Replay: with router scores absorbed, combine no longer scales expert
outputs, so the DeepEP compact combine would read the W2 output from a
bare _unpermute_tokens on the replay path. Fold the score multiply,
unpermute and combine kernel into one never-replayed region, which keeps
the W2 and combine outputs off the saved-tensor set. On the standard
all-to-all path the absorbed scores are an output of the saved dispatch
region, so mark them before W2's bare scaling; that marker is a no-op on
the DeepEP/HybridEP paths and without an active checkpoint.

Squashed from the router-score-pre-w2-review series and rebased onto
current upstream main. Absorbed and post-combine paths agree to bf16
rounding; loss convergence on a representative dataset is still pending.
Give the local, all-to-all and TorchAO dispatchers the combine structure
DeepEP got with router-score absorption: one never-replayed
"<fqn>.combine" region whose body reads the w2 output, instead of bare
ops guarded by recompute_needs_tensor markers.

- Local and TorchAO EP=1 ran the scatter-add (and the padded unpermute)
  as bare ops, so replay needed the w2 output persisted: the marker
  pinned the full expert-output tensor (R x D) per MoE layer even with
  absorbed scores, where no backward consumer needs it (the scatter-add
  saves only indices).
- The EP>1 combine region was policy-replayable. Replaying it re-ran the
  combine all-to-all and required the w2 output real on the replay path
  (pinning it under policies that save w2), while saving it retains only
  the small permutation indices and scores, so no policy benefits from
  replaying it. Dispatch stays policy-driven everywhere, like DeepEP's
  dispatch.

Replay: the combine body runs only on the original forward; replay skips
it, so nothing on the replay path reads the w2 or combine outputs, and
with absorbed scores the w2 output is freed after the forward. Also fix
the stale DeepEP docs and AC test left by the absorption commit: the
DeepEP combine region is never replayed, so only dispatch call counts
grow under the full policy.

Tests:
- New tests/unit_tests/cpu/models/common/test_token_dispatcher.py: the
  combine body runs exactly once under every policy while the w2
  projection's replay still follows the policy; with absorbed scores and
  w2 saved, the w2 output storage is freed after the forward; loss and
  grads match the un-checkpointed run bitwise. It fails on the previous
  code.
- test_remat_all_to_all: the combine never replays, so the full policy
  replays 2 collectives (was 3) and saving dispatch alone stops all
  replay; the w2-retention test now saves only w2.grouped_mm, pinning
  that the combine is always saved.

Docs: REMAT.md and the activation-remat config docstrings now state that
the dispatcher combines are always saved and only dispatch follows the
policy.

Numerics: remat-structure change only. On CPU, loss and gradients are
bitwise identical to the un-checkpointed run (and to the previous code)
for the local and EP=1 paths and for the EP>1 path through a 1-rank EP
mesh, absorbed and not, under the full, default and save-w2-only
policies.
…hers

Add two named debug recipes so the token-dispatcher backend matrix is
runnable on DeepSeek V3, alongside the existing HybridEP config:

- deepseek_v3_debugmodel_alltoall: the stock dispatcher at EP=2, so
  routed tokens actually cross ranks. The plain deepseek_v3_debugmodel
  (EP=1) already runs the same dispatcher's local fallback, which covers
  the local backend without a duplicate config.
- deepseek_v3_debugmodel_deepep: DeepEP v2 at EP=2 via
  TokenDispatcherTransform, following qwen3_moe_deepep: the compact,
  host-synced training path, buffer capacity from the microbatch shape,
  CUDA graphs off like the stock all-to-all, plus the NVLink-only env
  notes for local devgpu.

Both keep the default activation-remat policy, under which every
dispatcher's combine is saved (never replayed) and the standard backend
absorbs router scores before W2, so these exercise the paths changed by
the never-replayed-combine work. Registered in the debug-config defaults
test.

Validated on CPU: both configs build, DeepEPTokenDispatcher.Config gets
hidden_dim=256 (from w13.in_features) and num_max_tokens_per_rank=16384
(the 8 x 2048 microbatch over CP x TP), and the base EP=1 config keeps
CUDA graphs on while both EP=2 configs disable them.
One fp64 parity test per dispatcher family: with rounding gone, the
absorbed and post-combine paths compute the same function, so a dropped,
doubled or misaligned router score is an O(1) error. Both tests assert
the loss and every input/parameter gradient at the float32 scale W2's
input scaling applies.

- Standard dispatchers (GPU, world 2): local, all-to-all at EP=1 (local
  fallback) and EP=2, TorchAO at EP=1 (padded permute, zero-sentinel
  scores) and EP=2, through the existing _grouped_mm override seam.
- DeepEP (CPU, gloo, world 2): stub deep_ep's ElasticBuffer with a
  pure-PyTorch implementation (all_gather + local selection,
  deterministic layout, autograd-correct dispatch/combine backward), so
  the dispatcher, the torchtitan deepep wrapper, its custom ops and
  their autograd run in float64 without the bf16-only CUDA kernels. The
  stub module is injected only when deep_ep is absent, and the CPU op
  impls reuse the wrapper's CUDA impls.

Replaces the bf16-tolerance and activation-replay variants and the
HybridEP-only file. Verified the DeepEP test fails on double-applied
and on misaligned (rolled) scores.
@pytorch-bot pytorch-bot Bot added ciflow/fake-pg Run 1-GPU Fake PG integration tests ciflow/rl labels Oct 19, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 19, 2026
@pytorch-bot

pytorch-bot Bot commented Oct 19, 2026

Copy link
Copy Markdown

The following ciflow label(s) have been added but CI has not been triggered yet because the workflows are awaiting approval:

  • ciflow/rl
  • ciflow/fake-pg

Once a maintainer approves the workflows (scroll to the bottom of the PR page), the corresponding CI jobs will be triggered automatically. Please ping one of the reviewers if you do not have access to approve and run workflows.

1 similar comment
@pytorch-bot

pytorch-bot Bot commented Oct 19, 2026

Copy link
Copy Markdown

The following ciflow label(s) have been added but CI has not been triggered yet because the workflows are awaiting approval:

  • ciflow/rl
  • ciflow/fake-pg

Once a maintainer approves the workflows (scroll to the bottom of the PR page), the corresponding CI jobs will be triggered automatically. Please ping one of the reviewers if you do not have access to approve and run workflows.

@tianyu-liu

tianyu-liu commented Oct 20, 2026 •

Copy link
Copy Markdown

Hi @Mystri — I think the @ tianyu-liu mention in the PR description was intended for @ tianyu-l, who is listed as a requested reviewer. Could you please update that mention to @ tianyu-l? Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/fake-pg Run 1-GPU Fake PG integration tests ciflow/rl CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants