Practical how-to for building, testing, and extending the Expert-Parallel MoE
stack. For the design/architecture, see
moe_ep_architecture.md.
Tested on 1×4 GB200 GPUs.
Build the flashinfer-EP PyTorch image (pins NCCL-EP runtime wheels + mega deps:
DeepGEMM, NVSHMEM, CUTLASS DSL) into a .sqsh, then launch it:
export RW=/path/to/flashinfer/repo
# 1. Build the container image
srun --jobid="$SLURM_JOB_ID" -N1 \
--container-image=nvcr.io/nvidia/pytorch:26.05-py3 \
--container-save=$RW/flashinfer-ep-pt2605-mega_moe_ep.sqsh \
--container-mounts=$RW:/host \
bash -lc 'bash /host/flashinfer/docker/install/build_flashinfer_ep_pytorch.sh'
# 2. Launch an interactive shell in the saved image
export IMG=$RW/flashinfer-ep-pt2605-mega_moe_ep.sqsh
export ROOT=/workspace
srun --jobid="$SLURM_JOB_ID" \
--overlap \
--container-image="$IMG" \
--container-mounts="$ROOT:$ROOT" \
--container-workdir="$ROOT/flashinfer" \
--pty bash -l
# 3. (Re)build FlashInfer in editable mode (EP backends are on by default;
# NCCL-EP needs no build step — nccl4py is a base dependency)
BUILD_NIXL_EP=0 \
pip install --no-cache-dir --no-build-isolation -e .To exercise the NIXL-EP tests, drop BUILD_NIXL_EP=0 (best-effort build) or set
BUILD_NIXL_EP=1 (strict — missing build deps abort the install). See
Running the NIXL-EP tests.
Build flags (tri-state; unset = on, best-effort): BUILD_NIXL_EP=0 skips the
NIXL-EP meson build, BUILD_NIXL_EP=1 makes its missing build deps a hard
error, BUILD_NVEP=0 turns both backends off. Probe availability at runtime
with have_nccl_ep(), have_nixl_ep(), available_backends().
tests/moe_ep/run_tests.sh <target> — targets and requirements:
| Command | GPUs | Requires |
|---|---|---|
bash tests/moe_ep/run_tests.sh unit |
1 (host-only) | none — mocks + single GPU, no multirank |
bash tests/moe_ep/run_tests.sh multirank |
4 | NCCL-EP (NIXL-EP too if built) |
bash tests/moe_ep/run_tests.sh split_path_correctness_bf16 |
4 | Blackwell |
bash tests/moe_ep/run_tests.sh mega |
4 | Blackwell sm_100+; DeepGEMM + NVFP4 + MXFP8 |
- unit — host-only pytest (mocks + single-GPU).
- multirank — 4-GPU split path over NCCL-EP (and NIXL-EP when built).
- split_path_correctness_bf16 — 4-GPU bf16 split-path numerics vs a
single-process
MoELayerreference. - mega — 4-GPU DeepGEMM + NVFP4 + MXFP8 mega parity, plus a single-rank MXFP8 preprocess-vs-reference check.
all and smoke targets also exist. Split-path numerics are bf16-only for
now.
NIXL-EP is the second split-path transport (backend="nixl_ep"), currently
low-latency + EXPERT_MAJOR only. Constraints enforced by
validate_fleet_params: max_tokens_per_rank ≤ 1024, token_hidden_size ∈ {2048, 2560, 3072, 4096, 5120, 6144, 7168, 8192}, torch built for CUDA ≥ 13,
sm_90+.
1. Build with NIXL-EP enabled. Build deps: meson, ninja, pkg-config,
nvcc, UCX (pkg-config --exists ucx), libibverbs-dev. The build hook
pre-installs the nixl-cu13 wheel it links against (_ensure_nixl_wheel), so
no manual NIXL install is needed.
UCX caveat: the UCX found via pkg-config must ship the device API
(ucp/api/device/ucp_device_impl.h) — NGC images' HPC-X UCX and Ubuntu's apt
UCX both predate it, and the flashinfer-ep-pt2605* images skip this
provisioning. Follow docker/Dockerfile.flashinfer-nvep: install DOCA 3.2
host packages + GDRCopy, then build UCX v1.21.x from source with
--enable-experimental-api --with-cuda --with-verbs --with-dm and put its
lib/pkgconfig first on PKG_CONFIG_PATH. Without it, BUILD_NIXL_EP=1
fails compiling nixl_device.cuh:
BUILD_NIXL_EP=1 pip install --no-cache-dir --no-build-isolation -e .
# Verify the backend actually built (best-effort builds skip it silently):
python -c "from flashinfer.moe_ep import available_backends; print(available_backends())"
# expect: [..., 'nixl_ep']2. Smoke test (4 GPUs, single node — fixed shape: 64 tokens, 8 experts, hidden 4096, topk 4, bf16, LL):
torchrun --nproc_per_node=4 tests/moe_ep/smoke_nixl_ep.py
# each rank prints: SMOKE_RESULT: nixl_ep OK3. Multirank roundtrip + split kernels (4 GPUs). The multirank target
runs NCCL-EP first, then repeats over NIXL-EP when have_nixl_ep() is true:
bash tests/moe_ep/run_tests.sh multirankor NIXL-only, directly:
torchrun --nproc_per_node=4 -m pytest tests/moe_ep/test_moe_ep_layer_multirank.py -v \
-m "nvep and gpu_4" --backend=nixl_ep
torchrun --nproc_per_node=4 -m pytest tests/moe_ep/test_split_kernels.py -v \
-m "nvep and gpu_4" --backend=nixl_ep4. Host-only mock tests (no GPU fabric / no NIXL build needed) are part of
the unit target — tests/moe_ep/nixl_ep/test_fleet_mock.py stubs the NIXL
Buffer and checks fleet sizing, update_topology rank diffs, and combine
knob validation.
Notes:
- Rendezvous: unlike NCCL-EP (which mirrors the torch process group), the
NIXL
Bufferrendezvous needs atorch.distributed.TCPStorepassed viaBootstrapConfig(tcp_store=...). The tests open one themselves onMASTER_PORT + 1(sharing torch's port would clash) — seetests/moe_ep/smoke_nixl_ep.pyfor the pattern. NPROC_SMOKE/NPROC_MULTIRANK(default 4) override the rank count for therun_tests.shtargets.- UCX/ibverbs are build-time deps; the tests set no
NIXL_*/UCX_*env. - NIXL-EP coverage today is smoke + multirank + mocked unit tests only; the correctness/mega targets are NCCL-EP-only.
A mega kernel owns fused comm + local MoE. To wire a new one, add a subpackage
under flashinfer/moe_ep/backends/mega/kernel/<name>/. The kernel sources
themselves live under
flashinfer/moe_ep/kernel_src/cutedsl_megamoe/src/ and are exposed
through the kernel_src/cutedsl_megamoe/ public API (e.g. mxfp8_mega_moe,
get_symm_buffer_for_mxfp8_mega_moe). Use the existing mxfp8_cutedsl backend
as the reference template.
Every mega kernel exposes exactly two entry points through a thin frontend,
and the MegaKernelBackend subclass links to nothing else. Keep this contract
stable so new kernels — including future SM90 (Hopper) and SM120
(Blackwell-consumer) variants — drop in behind the same backend shape without
touching modes/ or the registry:
(a) Workspace allocator — problem sizes first, tuning knobs keyword-only;
returns a symm-buffer object with the staging views the backend fills and a
.destroy(). Model it on
get_symm_buffer_for_mxfp8_mega_moe / get_symm_buffer_for_mega_moe:
def get_symm_buffer_for_<name>_mega_moe(
num_total_experts: int,
num_max_tokens: int, # == fleet_params.max_tokens_per_rank
num_topk: int,
hidden: int, # fleet_params.token_hidden_size
intermediate: int, # post-SwiGLU width
rank: int, # self.ep_rank
world_size: int, # self.ep_world_size
*,
kind=..., # dtype selector, if applicable
# ... kernel knobs: clamps, in_kernel_fc2_reduce, token_back_by_dispatch, ...
) -> <Name>SymmBuffer: ...The returned buffer must expose the staging tensors the backend's stage_inputs
writes — at minimum x, x_sf (quantized paths), topk_idx, topk_weights —
plus destroy(). Expert weights are not owned by the workspace; they are
passed to the compute call each launch.
(b) Compute entry — output tensor first, then the two kernel-ready
(weight, scale) weight tuples, the workspace, and keyword-only knobs. Model it
on mxfp8_mega_moe / nvfp4_mega_moe:
def <name>_mega_moe(
y: torch.Tensor, # bf16 [num_tokens, hidden] output
transformed_l1, # (w13, w13_scale) kernel-ready fc1
transformed_l2, # (w2, w2_scale) kernel-ready fc2
symm_buffer: <Name>SymmBuffer,
*,
num_tokens: int | None = None,
# ... clamps, fast_math (accept for API parity even if a no-op), ...
) -> None: ...compute fuses dispatch + fc1 + fc2 + combine and writes y[:num_tokens]. The
caller (the backend's stage_inputs) must have filled symm_buffer.x and the
routing slices first.
Add both functions under
kernel_src/cutedsl_megamoe/shim/ (alongside nvfp4.py / mxfp8.py) and
re-export them from the package __init__.py (or point at your own kernel
module). Raw kernel sources live under kernel_src/cutedsl_megamoe/src/ — see
kernel_src/cutedsl_megamoe/SKILL.md for how to update that directory when the
kernel team ships a new drop. The kernel-specific tuning knobs
(intermediate size, top_k, clamps, dtype kind, fast-math, reduce/dispatch
flags) live on the config dataclass in step 2 and are threaded through to
these two calls by the backend in step 4 — so an SM90/SM120 kernel that needs
different knobs only changes its own config + these two signatures, not the
MegaKernelBackend plumbing.
@dataclass
class MyMegaMoeConfig:
intermediate_size: int # post-SwiGLU width
top_k: int
kernel_name: str = "my_mega" # MUST match the @register_mega_kernel name
# ... kernel-specific knobs (dtype kind, clamps, fast_math, ...)kernel_name is how the registry resolves a config to a backend
(_kernel_name() reads this attribute) — it must be a non-empty string equal to
the registration name.
Provide preprocess_mega_weights(weights: MoEWeightPack, ...) -> Transformed...
that turns canonical bf16 (or pre-quantized) w13/w2 into the kernel-ready
layout, and a validate_transformed_mega_weights(...) for the
preprocess_weights=False path (user supplies MegaConfig.transformed_weights).
from .....core.kernel.base import MegaKernelBackend
from .....core.kernel.registry import register_mega_kernel
@register_mega_kernel("my_mega") # == config.kernel_name
class MyMegaKernelBackend(MegaKernelBackend):
@classmethod
def kernel_name(cls) -> str:
return "my_mega"
# Required abstracts:
def _allocate_workspace(self, fleet_params): ... # call frontend allocator
def compute(self, workspace, transformed_weights, *, output): ... # call frontend kernel
# Common overrides:
def runtime_requirements(self, bootstrap): ... # add "nvshmem" if needed
def validate_init(self, bootstrap, fleet_params): ...
def preprocess_weights(self, weights, fleet_params): ...
def validate_transformed_weights(self, tw, bootstrap, fleet_params): ...
def validate_forward(self, t, fleet_params, *, quantize_input): ...
def stage_inputs(self, t, workspace, *, quantize_input): ... # copy/quantize acts
def destroy(self, workspace): ...EP rank/world/comm are available via self.ep_rank, self.ep_world_size,
self.ep_comm_group (bound by bind_ep_bootstrap, resolved lazily once dist is
up). If the kernel needs NVSHMEM, return it from runtime_requirements() (see
mxfp8_cutedsl_runtime_requirements).
<name>/__init__.py: export the backend, config, transformed-weights type, andpreprocess_mega_weights.backends/mega/kernel/__init__.py: add<name>to thefrom . import ...so@register_mega_kernelruns on import (registration is import-triggered).flashinfer/moe_ep/__init__.py: re-export the config (e.g.MyMegaMoeConfig) and anypreprocess_*helper for user imports.
from flashinfer.moe_ep import MoEEpLayer, MegaConfig, MyMegaMoeConfig
layer = MoEEpLayer(
bootstrap=..., fleet_params=...,
weights=..., # canonical MoEWeightPack, required
backend=MegaConfig(megakernel=MyMegaMoeConfig(intermediate_size=1024, top_k=4)),
)
out = layer.forward(tensors)
layer.destroy()The raw megakernel config must be wrapped in MegaConfig — MoEEpLayer routes
MegaConfig → MoEEpMegaLayer → create_mega_kernel(cfg), which looks up
cfg.kernel_name in _MEGA_KERNEL_REGISTRY.