Skip to content

[JAX] Add an MoE Block (Layer) that compound router, permutation, groupedGEMM and communication - #2912

Merged
tdophung merged 27 commits into
NVIDIA:mainfrom
tdophung:teddy/moe_block
May 29, 2026
Merged

[JAX] Add an MoE Block (Layer) that compound router, permutation, groupedGEMM and communication#2912
tdophung merged 27 commits into
NVIDIA:mainfrom
tdophung:teddy/moe_block

Conversation

@tdophung

@tdophung tdophung commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Description

Most of MoE building blocks integration work has been deeply coupled with Maxtext development. Now creating this MoE block to isolate the work from Maxtext and provide more room for experimentation. MoEBlock is a self-contained Flax-Linen module that wires together TE's fused router, pluggable token-dispatch backends (pure-JAX argsort or Triton sort_chunks_by_index), grouped_dense-based expert FFN, and ragged-all-to-all (A2Av) expert parallelism via shard_map

This first iteration will start with ring-of-experts EP, sharding on batch dimention for FSDP, CUBLASLt groupedGEMM and 2 permutation backend: pure JAX or Triton kernels. The block also exposes pluggable knobs for: weight layout (wi_kernel_axes/ wo_kernel_axes), permutation backend, A2A vs no-EP (single GPU) path, data-parallelism axes for true FSDP (batch sharded across (ep, fsdp) simultaneously), top-K with optional grouped/sigmoid scoring (for DSv3 workload), and optional auxiliary load-balancing loss.

Fixes #2895

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

  • New transformer_engine/jax/flax/moe.py -- MoEBlock Linen module:
    gate -> fused topk -> global permute -> A2A EP shard_map (ragged_a2a fwd, local permute, 3x grouped GEMM SwiGLU FFN, local unpermute, ragged_a2a rev) -> global combine.
  • Extended transformer_engine/jax/permutation.py with A2A param helpers (compute_ragged_all_to_all_params, compute_reverse_ragged_all_to_all_params, local_permute_after_a2a, local_unpermute_before_a2a) and the pure-JAX unfused_token_dispatch / unfused_token_combine paths
    with custom VJPs.
  • tests/jax/test_moe_block.py -- single-device shape, backward,
    cross-backend equivalence, aux-loss, group-topk, JIT determinism.
  • tests/jax/test_distributed_moe_block.py -- EP=2 x FSDP=2 mesh test using the canonical Flax-Linen sharded-init pattern (eval_shape -> get_partition_spec -> logical_to_mesh_sharding -> jit(init, out_shardings=...)) and data_parallelism_axes=("fsdp",) to exercise true FSDP (batch sharded across both axes).

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

@tdophung
tdophung marked this pull request as ready for review May 5, 2026 21:47
@greptile-apps

greptile-apps Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a self-contained MoE block (_MoEBlock / moe()) for JAX/Flax that wires together fused top-k routing, pure-JAX or Triton-backed token dispatch, grouped GEMM SwiGLU expert FFN, and ragged all-to-all expert parallelism under a single jax.custom_vjp — eliminating the nested custom_vjp boundaries that blocked end-to-end FP8 flow across the EP wire.

  • transformer_engine/jax/moe.py: New 2165-line file implementing _dispatch, _combine, _body_fwd/_body_bwd, and the top-level VJP rules with a careful _StaticShapeInfo pattern keeping Python ints out of JitTracer land.
  • transformer_engine/jax/permutation.py: Extended with pure-JAX token dispatch/combine, ragged-A2A parameter helpers, and a lazy Triton import guard.
  • transformer_engine/common/triton/permutation.py: Minor DRY refactor — four identical autotune config lists collapsed into _permutation_autotune_configs().

Confidence Score: 5/5

This PR is safe to merge. The single-VJP MoE block and its A2A helpers are logically correct, the recv_buffer_rows alignment fix is present, and the new test suite provides meaningful end-to-end coverage.

The core mathematical paths (dispatch, combine, FFN bwd, routing bwd) were reviewed and verified consistent. The only new finding is two dead operations in the PURE_JAX routing-bwd that are immediately overwritten and have no effect on correctness.

transformer_engine/jax/moe.py — the dead-code cleanup on lines 1414-1415 of the routing bwd is trivial but worth a pass before the file grows further.

Important Files Changed

Filename Overview
transformer_engine/jax/moe.py New 2165-line file implementing the full MoE block under a single fused custom_vjp. Minor dead code in the PURE_JAX routing-bwd (two wasted allocations immediately overwritten).
transformer_engine/jax/flax/moe.py Thin Flax-Linen wrapper around moe(). Handles param registration with logical sharding annotations. No issues found.
transformer_engine/jax/permutation.py Extended with pure-JAX token dispatch/combine, ragged-A2A parameter helpers, and lazy Triton import guard. A2A param math verified correct.
transformer_engine/common/triton/permutation.py Refactored autotune configs into a shared helper. No logic changes.
transformer_engine/jax/sharding.py Added ep_resource to MeshResource and get_active_resource_axis() helper. Clean addition.
tests/jax/test_moe_vjp.py New single-device tests comparing moe() forward+backward against a pure-JAX reference.
tests/jax/test_multiprocess_moe_vjp.py Multi-process EP=2/FSDP=2 distributed test exercising FSDP batch sharding and ragged A2A.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant moe_fwd as _moe_fwd_rule
    participant body_fwd as _body_fwd
    participant dispatch as _dispatch
    participant A2A as ragged_all_to_all
    participant FFN as grouped_gemm x3
    participant combine as _combine
    participant moe_bwd as _moe_bwd_rule
    participant body_bwd as _body_bwd

    Caller->>moe_fwd: x, gate_kernel, wi_0, wi_1, wo
    moe_fwd->>body_fwd: shard_map or direct
    body_fwd->>dispatch: inputs_2d, routing_map
    dispatch->>A2A: ragged_all_to_all fwd
    A2A-->>dispatch: recv tokens
    dispatch-->>body_fwd: sorted_x, dispatch_state
    body_fwd->>FFN: grouped_gemm
    FFN-->>body_fwd: expert_outputs
    body_fwd->>combine: expert_outputs, dispatch_state
    combine->>A2A: ragged_all_to_all reverse
    A2A-->>combine: gathered outputs
    combine-->>body_fwd: output, expert_outputs_residual
    body_fwd-->>moe_fwd: output, aux_loss, ctx
    moe_fwd-->>Caller: output, aux_loss

    Caller->>moe_bwd: d_output, ctx
    moe_bwd->>body_bwd: shard_map or direct
    body_bwd->>combine: _combine_bwd
    combine->>A2A: ragged_all_to_all fwd params
    A2A-->>combine: d_expert_outputs
    combine-->>body_bwd: d_expert_outputs, d_routing_weights
    body_bwd->>FFN: grouped_gemm bwds
    FFN-->>body_bwd: d_sorted_x, d_wi
    body_bwd->>dispatch: _dispatch_bwd
    dispatch->>A2A: ragged_all_to_all reverse params
    A2A-->>dispatch: d_inputs_2d
    dispatch-->>body_bwd: d_inputs_2d
    body_bwd-->>moe_bwd: grads
    moe_bwd-->>Caller: d_x, d_gate_kernel, d_wi_0, d_wi_1, d_wo
Loading

Reviews (13): Last reviewed commit: "[JAX] CI lint: reach pylint 10.00/10 on ..." | Re-trigger Greptile

Comment thread transformer_engine/pytorch/permutation.py
Comment thread transformer_engine/jax/flax/moe.py Outdated
tdophung added 6 commits May 5, 2026 16:35
Signed-off-by: tdophung <tdophung@nvidia.com>
…ody single GPU vs. multi GPU

Signed-off-by: tdophung <tdophung@nvidia.com>
Signed-off-by: tdophung <tdophung@nvidia.com>
Signed-off-by: tdophung <tdophung@nvidia.com>
…e and single device initial params in the MoEBlock. Tests should pass now

Signed-off-by: tdophung <tdophung@nvidia.com>
Signed-off-by: tdophung <tdophung@nvidia.com>
@tdophung
tdophung force-pushed the teddy/moe_block branch from 8a838f3 to 6aeb491 Compare May 5, 2026 23:44
pre-commit-ci Bot and others added 2 commits May 5, 2026 23:45
Signed-off-by: tdophung <tdophung@nvidia.com>
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread tests/jax/test_distributed_moe_block.py Outdated
Comment thread tests/jax/test_moe_block.py Outdated
Comment thread tests/jax/test_moe_block.py Outdated
Comment thread tests/jax/test_moe_block.py Outdated
Comment thread transformer_engine/jax/permutation.py Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
nvjax and others added 2 commits May 7, 2026 15:18
…int in C++ files, make FP8 works. Tested with current scaling

Signed-off-by: JAX Toolbox <jax@nvidia.com>
@greptile-apps

greptile-apps Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

Comment thread transformer_engine/common/util/multi_stream.cpp Outdated
Comment thread transformer_engine/jax/csrc/extensions/gemm.cpp Outdated
Comment thread tests/jax/test_moe_block.py Outdated
Comment thread transformer_engine/jax/csrc/extensions/gemm.cpp Outdated
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment thread tests/jax/test_moe_block.py Outdated
Comment thread transformer_engine/common/util/multi_stream.cpp Outdated
… grad tol to 5e-2, move arch/align_size docs into MoEBlock class

Signed-off-by: tdophung <tdophung@nvidia.com>
Comment thread transformer_engine/jax/flax/moe.py Outdated
Comment on lines +909 to +914
batch_divisor = num_ep * dp_size
if global_batch_size % batch_divisor != 0:
raise ValueError(
f"batch={global_batch_size} not divisible by prod(data_parallelism_axes)={dp_size}"
)
recv_buffer_rows = (global_batch_size // dp_size) * sequence_length * topk

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Receive buffer undersized when align_size > 0 + EP are combined

recv_buffer_rows is computed assuming unpadded token counts, but when align_size > 0 the per-expert group_sizes are the aligned counts, so the send_sizes in compute_ragged_all_to_all_params include padding tokens. The worst-case receive per shard is num_ep * ((B/(num_ep*dp_size))*S*K + num_experts_per_shard*(align_size-1)), which exceeds the current recv_buffer_rows = (B/dp_size)*S*K by up to num_experts*(align_size-1) rows. ragged_all_to_all writing beyond the buffer produces incorrect results or a crash. The correct worst-case size is:

recv_buffer_rows = (global_batch_size // dp_size) * sequence_length * topk + num_experts * (self.align_size - 1 if self.align_size > 0 else 0)

This combination (EP + align_size > 0) is not exercised by the current distributed test (which defaults to align_size=0), so the bug is latent.

@phu0ngng phu0ngng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should go with exposing GroupMLP VJP first before the MoE module to enable future possible fusions.

tdophung added 3 commits May 12, 2026 15:53
…ing None as group_topk, align_size rename,

Signed-off-by: tdophung <tdophung@nvidia.com>
Signed-off-by: tdophung <tdophung@nvidia.com>
Signed-off-by: tdophung <tdophung@nvidia.com>
Comment thread transformer_engine/jax/flax/moe.py Outdated
@tdophung
tdophung marked this pull request as draft May 15, 2026 16:46
Comment on lines +568 to +700
# Per-shard compile-time-constant shape info (Python ints / int tuples).
# See ``_compute_static_shape_info`` and the note in ``_dispatch``
# for why these are kwargs rather than state-dict entries.
num_real_tokens: int,
padding_size: int,
post_a2a_buffer_shape: Optional[Tuple[int, int]],
# EP-only:
ep_axis: Optional[str],
shard_id: Optional[jnp.ndarray] = None,
num_ep: int = 1,
) -> Tuple[jnp.ndarray, Optional[jnp.ndarray]]:
"""Inverse of :func:`_combine` on the cotangent.

Returns ``(d_expert_outputs, d_routing_weights_or_merging_probs)``.

``expert_outputs`` is the *forward* output of the FFN (same value the
fwd handed to :func:`_combine`). It's required by the TRITON
combine_bwd kernel; for PURE_JAX we don't need it but accept it for
a symmetric signature.
"""
# Step 3 inverse: global combine bwd.
d_output_2d = d_output.reshape(-1, d_output.shape[-1])
if backend is PermutationBackend.PURE_JAX:
# The pure-jax combine is:
# unsort = _sort_activations(expert_outputs, argsort(sorted_indices))
# if pad: unsort = unsort[:num_real]
# reshape -> einsum BKE,BK -> BE -> reshape to BSE
# Hand-derive the bwd in plain JAX (no custom_vjp involved):
unsort_indices = jnp.argsort(state["sorted_indices"])
topk = num_experts_per_tok
num_real = num_real_tokens
padding = padding_size
# Recover the unsorted intermediate that the fwd produced (we
# need it for the d_routing_weights pullback). Apply the same
# gather the fwd did.
unsort_intermediate = expert_outputs[unsort_indices]
if padding > 0:
unsort_intermediate = unsort_intermediate[:num_real]
# Bwd of einsum/reshape:
# output[B, E] = sum_K intermediate[B, K, E] * weights[B, K]
# d_intermediate[B, K, E] = d_output[B, E] * weights[B, K]
# d_weights[B, K] = sum_E d_output[B, E] * intermediate[B, K, E]
rw = state["routing_weights"].reshape(-1, topk)
intermediate_3d = unsort_intermediate.reshape(rw.shape[0], topk, -1)
rw_cast = rw.astype(intermediate_3d.dtype)
d_intermediate_3d = jnp.einsum("BE,BK -> BKE", d_output_2d, rw_cast)
d_routing_weights = jnp.einsum("BE,BKE -> BK", d_output_2d, intermediate_3d).astype(
state["routing_weights"].dtype
)
d_routing_weights = d_routing_weights.reshape(state["routing_weights"].shape)
d_unsort_intermediate = d_intermediate_3d.reshape(num_real, -1)
# Pad back with zeros if the fwd stripped padding.
if padding > 0:
d_unsort_intermediate = jnp.concatenate(
[
d_unsort_intermediate,
jnp.zeros(
(padding, d_unsort_intermediate.shape[-1]),
dtype=d_unsort_intermediate.dtype,
),
],
axis=0,
)
# Bwd of the gather is gather-by-original-indices:
# sorted = unsort[argsort(sorted_indices)]
# d_sorted = scatter d_unsort via argsort(sorted_indices)
# = d_unsort[sorted_indices] (gather by original sorted_indices,
# which is the inverse of argsort(sorted_indices)).
d_expert_outputs_global = d_unsort_intermediate[state["sorted_indices"]]
else:
# TRITON combine bwd: requires fwd_input (expert_outputs).
num_tokens = state["row_id_map"].shape[0]
n_experts = (state["row_id_map"].shape[1] - 1) // 2
hidden = d_output_2d.shape[-1]
num_out_tokens = expert_outputs.shape[0]
if state["pad_offsets"] is not None:
d_expert_outputs_global, d_merging_probs = unpermute_bwd_with_merging_probs_and_unpad(
d_output_2d,
state["row_id_map"],
expert_outputs,
state["merging_probs"],
state["pad_offsets"],
num_tokens,
n_experts,
num_out_tokens,
hidden,
)
# The kernel only writes positions tokens map to; padded
# positions may contain NaN. Replace with zeros (matches
# ``_token_combine_bwd_rule``).
d_expert_outputs_global = jnp.where(
jnp.isnan(d_expert_outputs_global), 0.0, d_expert_outputs_global
)
else:
d_expert_outputs_global, d_merging_probs = unpermute_bwd_with_merging_probs(
d_output_2d,
state["row_id_map"],
expert_outputs,
state["merging_probs"],
num_tokens,
n_experts,
num_out_tokens,
hidden,
)
d_routing_weights = d_merging_probs

if not ep_active:
return d_expert_outputs_global, d_routing_weights

# Step 2 (EP) inverse: bwd of reverse ragged_all_to_all is a forward
# ragged_all_to_all using the SAME forward parameters (sender /
# receiver roles swap from the reverse direction back to forward).
in_off_f, send_sz_f, out_off_f, recv_sz_f = compute_ragged_all_to_all_params(
state["all_shards_tokens_per_expert"], shard_id, num_ep
)
recv_buf_for_bwd = jnp.zeros(post_a2a_buffer_shape, dtype=d_expert_outputs_global.dtype)
d_x_send_back = jax.lax.ragged_all_to_all(
d_expert_outputs_global,
recv_buf_for_bwd,
in_off_f,
send_sz_f,
out_off_f,
recv_sz_f,
axis_name=ep_axis,
)
# Step 1 (EP) inverse: combine fwd applied is_forward=False; the
# bwd is is_forward=True with the SAME row_id_map.
recv_buffer_rows, hidden = d_x_send_back.shape
d_expert_outputs, _ = sort_chunks_by_map(
d_x_send_back,
state["local_perm_row_id_map"],
None,
recv_buffer_rows,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ctx["expert_outputs"] is the wrong tensor for the combine backward under EP

_body_fwd stores the shard-local FFN output (shape [recv_buffer_rows, hidden]) in ctx["expert_outputs"] before calling _combine. However, the global-combine step (step 3) inside _combine operates on a different tensor: the globally-permuted FFN output (shape [num_real_tokens + padding_size, hidden]) produced after the EP inverse-local-permute and reverse-ragged-A2A steps (steps 1–2). _combine_bwd then receives ctx["expert_outputs"] (shard-local, expert-major) and uses it as if it were the globally-permuted tensor.

Under PURE_JAX + EP: unsort_intermediate = expert_outputs[unsort_indices] indexes the shard-local tensor with global argsort indices; the recovered intermediate has wrong content, so d_routing_weights (and the resulting gate-kernel gradient) is silently incorrect.

Under TRITON + EP: expert_outputs is passed directly to unpermute_bwd_with_merging_probs[_and_unpad] with num_out_tokens = expert_outputs.shape[0] = recv_buffer_rows instead of num_real_tokens + padding_size; both d_expert_outputs_global and d_merging_probs are wrong, corrupting expert-weight gradients as well.

The existing distributed tests only assert finiteness and non-zero gradients; because both backends suffer the same mismatch, the backend-parity test passes even when both produce wrong gradients.

Fix: either (a) run the EP steps (inverse local permute + reverse ragged-A2A) before calling _combine / _combine_bwd, save the resulting globally-permuted tensor in ctx["expert_outputs_global_sorted"], and pass it to step 3; or (b) re-execute the EP forward steps inside _combine_bwd to recover the globally-permuted output before delegating to the step-3 backward.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tdophung is this comment valid? I see your recent commits fixed a Triton+EP issue that looks similar. Do we also need to make a corresponding fix to the pure-JAX version?

tdophung and others added 3 commits May 21, 2026 17:40
transformer_engine/jax/moe.py:
- Hoist 'import math' to module top (was two local imports).
- Trim the verbose _moe_fwd_rule arg-order block comment.
- Update PermutationBackend docstring: TRITON is the recommended
  default and is faster than PURE_JAX on current hardware.
- Rename layer_w0 / layer_w1 to gate_proj_out / up_proj_out so the
  names reflect what they are (SwiGLU projection outputs, not weights).
- moe() now rejects overlapping EP / FSDP axes up front instead of
  letting JAX produce a duplicate-axis PartitionSpec.

transformer_engine/jax/permutation.py:
- Drop reference to the temporary MaxText-fork
  compute_ragged_all_to_all_params helpers.

tests/jax/test_moe_vjp.py, tests/jax/test_multiprocess_moe_vjp.py:
- Add a module-level Blackwell (sm_100+) skip; grouped GEMM is
  Blackwell-only today.
- Move the 'triton' pytest marker from the class onto the
  triton parametrize variant only, so the pure_jax variant
  still runs in environments without Triton.

Signed-off-by: tdophung <tdophung@nvidia.com>
transformer_engine/jax/moe.py + permutation.py:
- Wrap triton_extensions imports in try/except so the modules
  still load on builds without Triton. PermutationBackend.TRITON
  callsites now raise a clear ImportError up-front via the public
  moe() entry; permutation.py exposes _require_triton_permutation
  for the same purpose.
- Replace the dict returned by _compute_static_shape_info with a
  frozen _StaticShapeInfo dataclass. Callers use attribute access.

tests/jax/test_moe_vjp.py, tests/jax/test_multiprocess_moe_vjp.py:
- Drop the _inject_moe fixture and # noqa: F821 suppressions now
  that the imports are safe to do at module top.

Signed-off-by: tdophung <tdophung@nvidia.com>
@jberchtold-nvidia jberchtold-nvidia removed the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label May 22, 2026
@jberchtold-nvidia

Copy link
Copy Markdown
Collaborator

fyi @tdophung I've removed the community-contributed tag the bot added to this PR. I think you added some commits authored with nvjax, the default git author in JAX containers, instead of your git config info and the bot picked this up as a community contribution

tdophung added 5 commits May 26, 2026 21:13
Replace the dispatch state dict carried _dispatch -> _combine /
_combine_bwd / _dispatch_bwd with one of two NamedTuples:

* _PureJaxDispatchState: group_sizes, sorted_indices, routing_weights
  + optional EP residuals.
* _TritonDispatchState: group_sizes, row_id_map, pad_offsets (Optional
  when align_size == 0), merging_probs + optional EP residuals.

_DispatchState is the Union. _build_dispatch_specs / _build_ctx_specs
now build the matching NamedTuple of P()s (with None at fields that
the value won't populate, e.g. EP-only fields on non-EP runs and
pad_offsets when align_size == 0), so the shard_map spec mirrors the
value tree leaf-for-leaf.

Signed-off-by: tdophung <tdophung@nvidia.com>
Replace the residual dict carried _body_fwd -> _body_bwd with a typed
_BodyCtx NamedTuple. Optional fields (expert_bias, aux_*) live on the
tuple as Optional[...] = None when the matching feature is disabled,
and _build_ctx_specs returns a matching _BodyCtx of P() so the
shard_map value and spec trees line up leaf-for-leaf.

The static side-info ('has_wi_bias' etc.) that used to live under the
sentinel '__static__' key is now passed alongside ctx as a separate
plain dict (returned as the residuals tuple (ctx, static) from the
fwd rule, unpacked at the top of the bwd rule). This keeps Python
ints/tuples out of the pytree leaves.

Signed-off-by: tdophung <tdophung@nvidia.com>
Swap the NamedTuple form of _PureJaxDispatchState, _TritonDispatchState
and _BodyCtx for @flax.struct.dataclass. The pytree behavior is the
same (each annotated field is a leaf, None is a non-leaf sentinel,
shard_map specs and values stay tree-aligned), but the call sites
read as dataclasses and we can reuse flax.struct.dataclass's pytree
registration instead of relying on the NamedTuple shape.

Signed-off-by: tdophung <tdophung@nvidia.com>
Under EP, _combine reassigns expert_outputs locally to the post-
ragged_all_to_all tensor before Step 3 (the global combine). Saving
the input (pre-A2A) tensor in ctx.expert_outputs meant _combine_bwd's
Step-3 inverse (unpermute_bwd_with_merging_probs) consumed a tensor
with the wrong shape and contents, silently corrupting d_expert_outputs.

_combine now returns (output, expert_outputs_post_ep). The caller
stashes the second value as the bwd residual so the Step-3 inverse
sees the same tensor the forward Step 3 saw.

Signed-off-by: tdophung <tdophung@nvidia.com>
Replace the two grouped_gemm calls (gate_proj and up_proj) with a
single fused grouped_gemm on `wi := concat([wi_0, wi_1], axis=-1)`
producing [T, 2M], which is then sliced into gate_proj_out and
up_proj_out. `sorted_x` is now quantized once per FFN (rather than
twice -- once per kernel) and the FFN bwd dgrad/wgrad also fuse: the
upstream grads concat along M, one grouped_quantize + one dgrad GEMM
+ one wgrad GEMM, then split.

ctx residuals collapse from two casted_wi_{0,1}_rhs_trans to one
casted_wi_rhs_trans [E, H, 2M]. The public API (separate wi_0, wi_1
params + biases) is unchanged so existing checkpoints and Flax
callers don't have to migrate.

FP8 caveat: per-expert amax for the fused wi is computed over
[H, 2M] instead of [H, M] per kernel, which can slightly shift the
representable range for one of the halves. Block-scaled recipes
(MXFP8/NVFP4) are largely unaffected since scales are per-block.

Signed-off-by: tdophung <tdophung@nvidia.com>
Comment on lines +2122 to +2123
if permutation_backend is PermutationBackend.TRITON:
_require_triton()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 EP + PURE_JAX silently requires Triton but has no early guard

_dispatch (and its bwd counterparts _combine, _dispatch_bwd, _combine_bwd) unconditionally call make_chunk_sort_map and sort_chunks_by_map — both Triton operations — in the EP path (lines 549–553), regardless of permutation_backend. But _require_triton() is only triggered here when permutation_backend is TRITON. A user who passes PermutationBackend.PURE_JAX with ep_axis set on a Triton-less install will see a confusing TypeError: 'NoneType' object is not callable inside JAX's JIT compilation rather than a clear error. The check should also fire when ep_axis is not None.

phu0ngng
phu0ngng previously approved these changes May 27, 2026
# via tests/jax/run_multiprocess_moe_vjp.sh (mirrors the pattern in
# examples/jax/encoder/run_test_multiprocessing_encoder.sh). Requires
# >=4 visible GPUs.
TE_PATH=$TE_PATH bash $TE_PATH/tests/jax/run_multiprocess_moe_vjp.sh \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to learn, why do we want/need to run this test with multiple processes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I encounter a bug similar to one described here: https://docs.google.com/document/d/18XCO7WNbTvadIG1WXPpO409dmu7HQaRlHG02mm5TYDA/edit?tab=t.0#heading=h.k4ivwttoxnil, but for triton kernels, where it would hang. More details were discussed in the TE/JAX meeting

Running the test with multiple process (1 process per GPU) circumvents that

@tdophung

Copy link
Copy Markdown
Collaborator Author

/te-ci

3 similar comments
@tdophung

Copy link
Copy Markdown
Collaborator Author

/te-ci

@jberchtold-nvidia

Copy link
Copy Markdown
Collaborator

/te-ci

@tdophung

Copy link
Copy Markdown
Collaborator Author

/te-ci

Two failures from the same PR run:

* CI B200 (8 GPUs) -- the test hard-coded EP_SIZE=2, FSDP_SIZE=2 ->
  4-device mesh, while jax.device_count()==8. mesh_utils.create_device_mesh
  rejected with 'Number of devices 8 must equal the product of mesh_shape
  (2, 2)'. FSDP_SIZE now derives from jax.device_count() // EP_SIZE, so
  the test scales to whatever the launcher gives us (4 GPUs -> FSDP=2,
  8 GPUs -> FSDP=4). BATCH formula already depends on EP_SIZE*FSDP_SIZE
  so it scales automatically and stays MXFP8-aligned.

* CI H100 (4 GPUs) -- the file emits pytest.skip(allow_module_level=True)
  when get_device_compute_capability(0) < 100 (Blackwell-only). pytest
  reports 'no tests collected' with exit code 5, which the multiprocess
  launcher was treating as a failure. Accept 5 alongside 0; anything
  else is still a failure.

Signed-off-by: tdophung <tdophung@nvidia.com>
@tdophung
tdophung dismissed stale reviews from phu0ngng and jberchtold-nvidia via f83928c May 28, 2026 18:19
* Drop now-unused dataclasses.field / typing.Callable / jax imports.
* Move the .flax.module._convert_to_activation_function import to the
  top of moe.py (no longer circular after the big VJP refactor).
* Remove the dead pre_a2a_buffer_shape assignment in _dispatch.
* Rename q_set_w1 to _q_set_w1 in _body_bwd (the fused FFN bwd uses
  q_set_w0 only; the placeholder keeps the unpack readable).
* Convert the two body_kwargs = dict(...) calls to {...} literals.
* Mark _combine_bwd / _body_fwd / _body_bwd / _build_dispatch_specs /
  _build_ctx_specs / _moe_fwd_rule with
  'pylint: disable=unused-argument' on the def line. These are
  custom_vjp / shard_map signature requirements: rules MUST accept
  every nondiff arg even when one branch doesn't read them, and
  shard_map bodies must accept every spec key for in_specs /
  out_specs structural compatibility.
* flax/moe.py: drop unused 'import jax'; keep the re-exported
  PartitionSpec with explicit pylint + flake8 suppressions and a
  comment explaining why.

Signed-off-by: tdophung <tdophung@nvidia.com>
@tdophung tdophung added 2.17 and removed 0.8.0 labels May 28, 2026
@tdophung

Copy link
Copy Markdown
Collaborator Author

/te-ci

@tdophung
tdophung merged commit d1920cf into NVIDIA:main May 29, 2026
40 of 42 checks passed
Baibaifan pushed a commit to Baibaifan/TransformerEngine that referenced this pull request Jun 1, 2026
…upedGEMM and communication (NVIDIA#2912)

Refactor MoEBlock into a unified MoE custom_vjp, add tests

Replace the per-primitive custom_vjp boundaries in MoEBlock with a
single jax.custom_vjp covering routing, dispatch, expert FFN, and
combine. Helper functions group permute -> ragged_all_to_all ->
local-permute into a single dispatch / combine pair, with a hand-
derived bwd that mirrors the forward and runs entirely inside the
EP shard_map body.

Add a multi-process (one-GPU-per-process) test suite for the new
unified VJP under a 2x2 (ep, fsdp) mesh:

  * tests/jax/test_multiprocess_moe_vjp.py
    -- fwd/bwd + aux_loss + PURE_JAX vs TRITON parity at
       Mixtral-ish shapes (batch=16, seq=2048, hidden=1024,
       intermediate=4096, num_experts=8, topk=2).
  * tests/jax/run_multiprocess_moe_vjp.sh
    -- launcher; forks one pytest process per visible GPU
       (mirrors examples/jax/encoder/run_test_multiprocessing_encoder.sh).
  * tests/jax/conftest.py
    -- pytest --num-process / --process-id options for the launcher.
  * qa/L0_jax_distributed_unittest/test.sh
    -- CI hook for the multiprocess smoke.

Signed-off-by: tdophung <tdophung@nvidia.com>

* [JAX] Fix EP+TRITON combine bwd: save post-A2A expert_outputs

Under EP, _combine reassigns expert_outputs locally to the post-
ragged_all_to_all tensor before Step 3 (the global combine). Saving
the input (pre-A2A) tensor in ctx.expert_outputs meant _combine_bwd's
Step-3 inverse (unpermute_bwd_with_merging_probs) consumed a tensor
with the wrong shape and contents, silently corrupting d_expert_outputs.

_combine now returns (output, expert_outputs_post_ep). The caller
stashes the second value as the bwd residual so the Step-3 inverse
sees the same tensor the forward Step 3 saw.

Signed-off-by: Teddy Do <tdophung@nvidia.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Signed-off-by: yangfan.bai <yangfan.bai@shopee.com>
Baibaifan pushed a commit to Baibaifan/TransformerEngine that referenced this pull request Jun 1, 2026
…upedGEMM and communication (NVIDIA#2912)

Refactor MoEBlock into a unified MoE custom_vjp, add tests

Replace the per-primitive custom_vjp boundaries in MoEBlock with a
single jax.custom_vjp covering routing, dispatch, expert FFN, and
combine. Helper functions group permute -> ragged_all_to_all ->
local-permute into a single dispatch / combine pair, with a hand-
derived bwd that mirrors the forward and runs entirely inside the
EP shard_map body.

Add a multi-process (one-GPU-per-process) test suite for the new
unified VJP under a 2x2 (ep, fsdp) mesh:

  * tests/jax/test_multiprocess_moe_vjp.py
    -- fwd/bwd + aux_loss + PURE_JAX vs TRITON parity at
       Mixtral-ish shapes (batch=16, seq=2048, hidden=1024,
       intermediate=4096, num_experts=8, topk=2).
  * tests/jax/run_multiprocess_moe_vjp.sh
    -- launcher; forks one pytest process per visible GPU
       (mirrors examples/jax/encoder/run_test_multiprocessing_encoder.sh).
  * tests/jax/conftest.py
    -- pytest --num-process / --process-id options for the launcher.
  * qa/L0_jax_distributed_unittest/test.sh
    -- CI hook for the multiprocess smoke.

Signed-off-by: tdophung <tdophung@nvidia.com>

* [JAX] Fix EP+TRITON combine bwd: save post-A2A expert_outputs

Under EP, _combine reassigns expert_outputs locally to the post-
ragged_all_to_all tensor before Step 3 (the global combine). Saving
the input (pre-A2A) tensor in ctx.expert_outputs meant _combine_bwd's
Step-3 inverse (unpermute_bwd_with_merging_probs) consumed a tensor
with the wrong shape and contents, silently corrupting d_expert_outputs.

_combine now returns (output, expert_outputs_post_ep). The caller
stashes the second value as the bwd residual so the Step-3 inverse
sees the same tensor the forward Step 3 saw.

Signed-off-by: Teddy Do <tdophung@nvidia.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Signed-off-by: yangfan.bai <yangfan.bai@shopee.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[JAX] Create initial MoE Block

4 participants