Skip to content

Commit 2fd4604

Browse files
[JAX] Optimize MoE block (#3354)
* TE/JAX MoEBlock optimizations Signed-off-by: Jeremy Berchtold <jberchtold@nvidia.com> * Fix lint Signed-off-by: Jeremy Berchtold <jberchtold@nvidia.com> * Fix lint Signed-off-by: Jeremy Berchtold <jberchtold@nvidia.com> * Keep arch guard consistent in EP tests Signed-off-by: Jeremy Berchtold <jberchtold@nvidia.com> --------- Signed-off-by: Jeremy Berchtold <jberchtold@nvidia.com> Co-authored-by: Teddy Do <tdophung@nvidia.com>
1 parent e6690f4 commit 2fd4604

6 files changed

Lines changed: 502 additions & 353 deletions

File tree

‎tests/jax/run_te_ep_moe.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ set -euo pipefail
1414

1515
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1616
TE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
17-
TEST_FILE="$TE_ROOT/tests/jax/test_te_ep_moe.py"
17+
TEST_FILE="${TEST_FILE:-$TE_ROOT/tests/jax/test_te_ep_moe.py}"
1818
PYTEST_INI="$TE_ROOT/tests/jax/pytest.ini"
1919

2020
NUM_GPUS="${NUM_GPUS:-$(nvidia-smi -L | wc -l)}"

‎tests/jax/test_te_ep_moe.py‎

Lines changed: 86 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131
on the block are pytest parametrize values rather than separate test
3232
classes:
3333
34-
* ``test_forward`` covers the forward across a curated set of
35-
configurations (softmax/sigmoid scoring, optional non-zero
36-
expert_bias). Each config asserts shape, dtype, finiteness and
37-
numerical parity vs the reference in one run.
38-
* ``test_backward`` mirrors that for gradients.
34+
* ``test_forward`` covers BF16 and MXFP8 forward execution across a
35+
curated set of configurations (softmax/sigmoid scoring, optional
36+
non-zero expert_bias). Each config asserts shape, dtype, finiteness
37+
and numerical parity vs the same BF16 reference in one run.
38+
* ``test_backward`` mirrors that for gradients. BF16 and MXFP8 share
39+
the full test body and differ only in the grouped-GEMM quantizer sets.
3940
* ``TestTeEpMoeAuxLoss`` covers the second return value end-to-end
4041
(returned + parity + aux-only grad propagates to gate + combined
4142
main+aux grads stay finite) in two consolidated tests.
@@ -118,8 +119,14 @@ def _read_mp_options():
118119
)
119120

120121
from transformer_engine.jax.flax import _MoEBlock as MoEBlock
121-
from transformer_engine.jax.moe import _ALIGN_SIZE, moe, record_ep_bootstrap_signature_for_moe
122+
from transformer_engine.jax.moe import (
123+
_ALIGN_SIZE,
124+
get_moe_recv_capacity_per_rank,
125+
moe,
126+
record_ep_bootstrap_signature_for_moe,
127+
)
122128
from transformer_engine.jax.ep import ep_bootstrap
129+
from transformer_engine.common.recipe import MXFP8BlockScaling
123130
from transformer_engine.jax.sharding import MeshResource, global_shard_guard
124131

125132

@@ -148,60 +155,38 @@ def _read_mp_options():
148155
DTYPE = jnp.bfloat16
149156
BATCH = EP_SIZE * FSDP_SIZE * 2 # 8 on 4-GPU, 16 on 8-GPU
150157
SEQ = 32
151-
HIDDEN = 64
158+
HIDDEN = 128
152159
INTER = 128
153160
NUM_EXPERTS = 8
154161
TOPK = 2
155162

156-
# bf16 grouped_gemm + softmax-topk + ep all-to-all stack drifts ~1e-1 vs a
157-
# fp32 numpy reference. Keep these tight enough to catch real bugs but
158-
# loose enough to absorb expected bf16 rounding.
159-
FWD_ATOL = 5e-2
160-
FWD_RTOL = 5e-2
161-
GRAD_FFN_ATOL = 1e-1
162-
GRAD_FFN_RTOL = 1e-1
163-
GRAD_GATE_ATOL = 5e-1
164-
GRAD_GATE_RTOL = 5e-1
165-
166-
# Two TE EP runs that should be bitwise-equal modulo XLA fusion order
167-
# (slot alignment rounding, etc.).
168-
TE_TO_TE_ATOL = 5e-3
169-
TE_TO_TE_RTOL = 5e-3
163+
# MXFP8 grouped GEMMs have measurably more quantization drift than BF16.
164+
# These bounds are rounded slightly above the worst error observed across
165+
# the forward and backward configuration matrix.
166+
FWD_TOLERANCE = {
167+
"bf16": {"atol": 5e-4, "rtol": 5e-4},
168+
"mxfp8": {"atol": 7e-3, "rtol": 7e-3},
169+
}
170+
GRAD_FFN_TOLERANCE = {
171+
"bf16": {"atol": 1e-7, "rtol": 1e-7},
172+
"mxfp8": {"atol": 1.3e-6, "rtol": 1.3e-6},
173+
}
174+
GRAD_GATE_TOLERANCE = {
175+
"bf16": {"atol": 7e-8, "rtol": 7e-8},
176+
"mxfp8": {"atol": 8e-7, "rtol": 8e-7},
177+
}
170178

171179
# Aux loss is computed in float32 from the SAME logits as the routing
172180
# path. Numerical drift between TE-EP and the reference is dominated by
173181
# the bf16-rounded softmax inside the topk kernel.
174-
AUX_ATOL = 1e-3
175-
AUX_RTOL = 1e-3
182+
AUX_TOLERANCE = {"atol": 1e-6, "rtol": 1e-6}
176183

177184

178185
# -----------------------------------------------------------------------------
179186
# Fixtures
180187
# -----------------------------------------------------------------------------
181188

182189

183-
def _compute_worst_case_recv_pr():
184-
"""Per-rank recv buffer the bootstrap must reserve.
185-
186-
NCCL EP HT expert-major uses one flat recv buffer with variable
187-
per-expert zones. Each non-empty expert zone is padded to
188-
``_ALIGN_SIZE`` slots, so the reserve must cover the worst-case
189-
total assignments plus independent per-zone padding.
190-
"""
191-
num_procs = jax.device_count()
192-
num_local_experts = NUM_EXPERTS // EP_SIZE
193-
max_tokens_per_rank = (BATCH // num_procs) * SEQ
194-
tokens_per_ep_group = EP_SIZE * max_tokens_per_rank
195-
max_local_assignments = tokens_per_ep_group * min(TOPK, num_local_experts)
196-
max_nonempty_experts = min(num_local_experts, max_local_assignments)
197-
padded_total_bound = max_local_assignments + (_ALIGN_SIZE - 1) * max_nonempty_experts
198-
aligned_total_bound = ((padded_total_bound + _ALIGN_SIZE - 1) // _ALIGN_SIZE) * _ALIGN_SIZE
199-
per_expert_bound = (
200-
num_local_experts * ((tokens_per_ep_group + _ALIGN_SIZE - 1) // _ALIGN_SIZE) * _ALIGN_SIZE
201-
)
202-
return min(per_expert_bound, aligned_total_bound)
203-
204-
205190
@pytest.fixture(scope="module")
206191
def mesh():
207192
if jax.device_count() < NUM_DEVICES_REQUIRED:
@@ -217,7 +202,15 @@ def mesh():
217202

218203
num_procs = jax.process_count()
219204
max_tokens_per_rank = (BATCH // num_procs) * SEQ
220-
recv_capacity_per_rank = _compute_worst_case_recv_pr()
205+
# Worst-case recv capacity per rank
206+
# TODO(jberchtold) support configurations other than worst-case by refactoring tests
207+
# but if possible avoid bootstrap/teardown for each test
208+
recv_capacity_per_rank = get_moe_recv_capacity_per_rank(
209+
num_experts=NUM_EXPERTS,
210+
num_experts_per_tok=TOPK,
211+
max_tokens_per_rank=max_tokens_per_rank,
212+
ep_size=EP_SIZE,
213+
)
221214

222215
# Eager bootstrap: ep_bootstrap does a host-side NCCL UID allgather
223216
# and cannot run from inside jax.jit. Sized to the worst-case recv_pr
@@ -275,8 +268,7 @@ def mesh():
275268
def _pure_jax_moe_reference(
276269
x,
277270
gate_kernel,
278-
wi_0,
279-
wi_1,
271+
wi,
280272
wo,
281273
expert_bias=None,
282274
*,
@@ -317,6 +309,7 @@ def _pure_jax_moe_reference(
317309
# FFN. ``apply_topk_weights_early`` is a fusion knob that doesn't
318310
# change the math (wo is linear), so the reference is identical for
319311
# both placements.
312+
wi_0, wi_1 = jnp.split(wi, 2, axis=-1)
320313
layer_w0 = jnp.einsum("th,ehm->tem", x_2d, wi_0)
321314
layer_w1 = jnp.einsum("th,ehm->tem", x_2d, wi_1)
322315
# Activation runs in x.dtype (typically bf16) to mirror the impl --
@@ -365,6 +358,7 @@ def _make_block(
365358
score_function="softmax",
366359
expert_bias_init=None,
367360
input_axes=("batch", None, None),
361+
quantization_recipe=None,
368362
):
369363
kwargs = dict(
370364
num_experts=NUM_EXPERTS,
@@ -377,6 +371,7 @@ def _make_block(
377371
score_function=score_function,
378372
dtype=DTYPE,
379373
input_axes=input_axes,
374+
quantization_recipe=quantization_recipe,
380375
)
381376
# Custom expert_bias_init lets tests inject a non-zero expert_bias without
382377
# poking variables['params'] post-init.
@@ -436,7 +431,14 @@ def _init_apply(block, mesh, x, key):
436431
return variables, output, aux
437432

438433

439-
def _grad_step(block, variables, mesh, x, *, include_aux=False):
434+
def _grad_step(
435+
block,
436+
variables,
437+
mesh,
438+
x,
439+
*,
440+
include_aux=False,
441+
):
440442
"""Run jax.grad of mean(out^2) [+ aux if include_aux] vs (params, x).
441443
442444
Returns ``(grads_variables, grad_x)`` so callers can check both the
@@ -503,6 +505,13 @@ def _make_inputs(key):
503505
return jax.random.normal(key, (BATCH, SEQ, HIDDEN), dtype=DTYPE)
504506

505507

508+
def _quantization_recipe(quantization):
509+
if quantization == "bf16":
510+
return None
511+
assert quantization == "mxfp8"
512+
return MXFP8BlockScaling()
513+
514+
506515
# -----------------------------------------------------------------------------
507516
# Tests
508517
# -----------------------------------------------------------------------------
@@ -546,6 +555,13 @@ def _make_inputs(key):
546555
),
547556
]
548557

558+
_QUANTIZATION_CASES = [
559+
pytest.param("bf16", id="bf16"),
560+
]
561+
562+
if get_device_compute_capability(0) >= 100:
563+
_QUANTIZATION_CASES.append(pytest.param("mxfp8", id="mxfp8"))
564+
549565

550566
def _reference_kwargs_from_config(config, params_np):
551567
"""Pick out the reference-relevant pieces of a parametrize config."""
@@ -564,8 +580,9 @@ class TestTeEpMoeForward:
564580
finiteness AND numerical parity vs the pure-JAX reference."""
565581

566582
@pytest.mark.parametrize("config", _CONFIGS)
567-
def test_forward(self, mesh, config):
568-
block = _make_block(**config)
583+
@pytest.mark.parametrize("quantization", _QUANTIZATION_CASES)
584+
def test_forward(self, mesh, config, quantization):
585+
block = _make_block(**config, quantization_recipe=_quantization_recipe(quantization))
569586
x = _make_inputs(jax.random.PRNGKey(0))
570587
variables, output, aux = _init_apply(block, mesh, x, jax.random.PRNGKey(1))
571588

@@ -584,8 +601,7 @@ def test_forward(self, mesh, config):
584601
out_ref, _ = _pure_jax_moe_reference(
585602
jnp.asarray(x_np),
586603
jnp.asarray(params_np["gate_kernel"]),
587-
jnp.asarray(params_np["wi_0"]),
588-
jnp.asarray(params_np["wi_1"]),
604+
jnp.asarray(params_np["wi"]),
589605
jnp.asarray(params_np["wo"]),
590606
num_experts=NUM_EXPERTS,
591607
num_experts_per_tok=TOPK,
@@ -594,9 +610,8 @@ def test_forward(self, mesh, config):
594610
np.testing.assert_allclose(
595611
out_te_np.astype(np.float32),
596612
np.asarray(jax.device_get(out_ref)).astype(np.float32),
597-
atol=FWD_ATOL,
598-
rtol=FWD_RTOL,
599-
err_msg=f"forward parity breach for config={config}",
613+
**FWD_TOLERANCE[quantization],
614+
err_msg=f"forward parity breach for config={config}, quantization={quantization}",
600615
)
601616

602617

@@ -605,8 +620,9 @@ class TestTeEpMoeBackward:
605620
grads finite, non-zero AND parity vs the pure-JAX reference."""
606621

607622
@pytest.mark.parametrize("config", _CONFIGS)
608-
def test_backward(self, mesh, config):
609-
block = _make_block(**config)
623+
@pytest.mark.parametrize("quantization", _QUANTIZATION_CASES)
624+
def test_backward(self, mesh, config, quantization):
625+
block = _make_block(**config, quantization_recipe=_quantization_recipe(quantization))
610626
x = _make_inputs(jax.random.PRNGKey(2))
611627
variables, _, _ = _init_apply(block, mesh, x, jax.random.PRNGKey(3))
612628
grads_te, grad_x_te = _grad_step(block, variables, mesh, x)
@@ -623,8 +639,7 @@ def loss_fn(params, x):
623639
out, _ = _pure_jax_moe_reference(
624640
x,
625641
params["gate_kernel"],
626-
params["wi_0"],
627-
params["wi_1"],
642+
params["wi"],
628643
params["wo"],
629644
ref_expert_bias,
630645
num_experts=NUM_EXPERTS,
@@ -640,22 +655,19 @@ def loss_fn(params, x):
640655
grads_ref_np = {k: np.asarray(jax.device_get(v)) for k, v in grads_ref.items()}
641656
grad_x_ref_np = np.asarray(jax.device_get(grad_x_ref))
642657

643-
for name in ("gate_kernel", "wi_0", "wi_1", "wo"):
658+
for name in ("gate_kernel", "wi", "wo"):
644659
# Per-tensor: finite + non-zero + parity in one pass.
645660
g_te = _to_global_numpy(_unwrap(grads_te["params"][name]), mesh)
646661
assert np.all(np.isfinite(g_te)), f"{name} grad has NaN/Inf [config={config}]"
647662
assert np.any(g_te != 0.0), f"{name} grad identically zero [config={config}]"
648-
atol, rtol = (
649-
(GRAD_GATE_ATOL, GRAD_GATE_RTOL)
650-
if name == "gate_kernel"
651-
else (GRAD_FFN_ATOL, GRAD_FFN_RTOL)
652-
)
663+
tolerances = GRAD_GATE_TOLERANCE if name == "gate_kernel" else GRAD_FFN_TOLERANCE
653664
np.testing.assert_allclose(
654665
g_te.astype(np.float32),
655666
grads_ref_np[name].astype(np.float32),
656-
atol=atol,
657-
rtol=rtol,
658-
err_msg=f"grad parity breach on {name} [config={config}]",
667+
**tolerances[quantization],
668+
err_msg=(
669+
f"grad parity breach on {name} [config={config}, quantization={quantization}]"
670+
),
659671
)
660672

661673
# d_x: the gradient propagated back to the previous layer. Checks
@@ -675,9 +687,8 @@ def loss_fn(params, x):
675687
np.testing.assert_allclose(
676688
grad_x_te_np.astype(np.float32),
677689
grad_x_ref_np.astype(np.float32),
678-
atol=GRAD_FFN_ATOL,
679-
rtol=GRAD_FFN_RTOL,
680-
err_msg=f"d_x parity breach [config={config}]",
690+
**GRAD_FFN_TOLERANCE[quantization],
691+
err_msg=f"d_x parity breach [config={config}, quantization={quantization}]",
681692
)
682693

683694

@@ -710,8 +721,7 @@ def test_aux_loss(self, mesh):
710721
_, aux_ref = _pure_jax_moe_reference(
711722
jnp.asarray(x_np),
712723
jnp.asarray(params_np["gate_kernel"]),
713-
jnp.asarray(params_np["wi_0"]),
714-
jnp.asarray(params_np["wi_1"]),
724+
jnp.asarray(params_np["wi"]),
715725
jnp.asarray(params_np["wo"]),
716726
num_experts=NUM_EXPERTS,
717727
num_experts_per_tok=TOPK,
@@ -720,8 +730,7 @@ def test_aux_loss(self, mesh):
720730
np.testing.assert_allclose(
721731
float(aux_np),
722732
float(jax.device_get(aux_ref)),
723-
atol=AUX_ATOL,
724-
rtol=AUX_RTOL,
733+
**AUX_TOLERANCE,
725734
)
726735

727736
# Aux-only bwd must propagate to gate_kernel — proves the
@@ -741,7 +750,7 @@ def test_combined_loss_grads(self, mesh):
741750
x = _make_inputs(jax.random.PRNGKey(22))
742751
variables, _, _ = _init_apply(block, mesh, x, jax.random.PRNGKey(23))
743752
grads, _ = _grad_step(block, variables, mesh, x, include_aux=True)
744-
for name in ("gate_kernel", "wi_0", "wi_1", "wo"):
753+
for name in ("gate_kernel", "wi", "wo"):
745754
g_local = np.asarray(jax.device_get(_unwrap(grads["params"][name]).addressable_data(0)))
746755
assert np.all(np.isfinite(g_local)), f"{name} grad NaN/Inf under main+aux"
747756
assert np.any(g_local != 0.0), f"{name} grad zero under main+aux"

‎transformer_engine/jax/cpp_extensions/quantization.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,9 +1293,18 @@ def grouped_quantize(
12931293
return quantizer.quantize(x, flatten_axis=flatten_axis, group_sizes=group_sizes)
12941294
n_groups = group_sizes.size
12951295
original_shape = x.shape
1296-
assert n_groups == len(
1297-
quantizer.quantizers
1298-
), f"n_groups={n_groups} != n_quantizers = {len(quantizer.quantizers)}"
1296+
n_quantizers = len(quantizer.quantizers)
1297+
if quantizer.scaling_mode.is_mxfp8_scaling:
1298+
# Stateless MXFP8 quantizers may describe a global grouped operation
1299+
# while this primitive is traced inside shard_map on only the local
1300+
# groups. The recipe is identical for every group, and no per-group
1301+
# state is selected here, so the global descriptor only needs to cover
1302+
# the local operation.
1303+
assert (
1304+
n_groups <= n_quantizers
1305+
), f"local n_groups={n_groups} exceeds global n_quantizers={n_quantizers}"
1306+
else:
1307+
assert n_groups == n_quantizers, f"n_groups={n_groups} != n_quantizers={n_quantizers}"
12991308
scale = jnp.ones((n_groups,), jnp.float32)
13001309

13011310
if quantizer.scaling_mode == ScalingMode.DELAYED_TENSOR_SCALING:

0 commit comments

Comments
 (0)