vLLM Explained

Expert parallelism and MoE serving

AdvancedvLLM conceptsabout 10 min read + 7 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

vLLM runs each MoE layer as router, dispatch, local experts and combine. Expert parallelism spreads the experts over all DP × TP ranks.

The idea#

A Mixture-of-Experts layer has many expert MLPs, and a router sends each token to its top-k experts (Mixture of Experts). With expert parallelism, each GPU holds only some experts, and tokens travel to the GPUs of their experts and back (Expert parallelism). This page shows how vLLM builds such a layer, how it chooses the communication and the kernels, and which flags change it.

vLLM splits the work of one MoE layer into four parts. The router picks the experts. The dispatch moves the tokens. The experts compute. The combine brings the weighted results back. A modular MoE kernel keeps dispatch and combine in one object and the experts in another, so any communication method can work with any expert kernel. The communication method is the all2all backend.

Without --enable-expert-parallel, an MoE model still runs on several GPUs, but each expert is split with tensor parallelism over all DP × TP ranks. With the flag, each rank owns whole experts. A large MoE deployment usually runs data parallel attention plus expert parallel MoE: -dp 8 -ep.

An everyday comparison. Think of a hospital with specialists on several floors. At the front desk (the router), each patient gets a list of two specialists. Porters (dispatch) take the patients to the right floors, the specialists see them (experts), and the porters bring the patients back with the reports (combine). The comparison stops here. In vLLM, every patient on every floor moves in the same time slot, so a floor with no patients still joins each slot.

The picture#

Figure: one MoE layer with DP = 2 and expert parallelism. The token path (panels 1 and 2) and the objects that FusedMoEFactory builds (panel 3).
Open full size ↗

Follow the token ids. Rank 0's token t0 chose experts 1 and 5. So both ranks compute part of it, and the combine sums the two parts on rank 0. Panel 3 is the code view: the runner owns the layer; the kernel is "prepare/finalize + experts".

In the code#

Step 1: FusedMoEFactory builds the layer#

A model builds an MoE layer with FusedMoEFactory. It is a function, not a class, and it returns an MoERunner (vllm/model_executor/layers/fused_moe/layer.py:92-153). It builds the parts in this order:

  1. The MoE parallel config from the current TP, DP and PCP sizes (vllm/model_executor/layers/fused_moe/layer.py:238-244).
  2. The expert counts: global = model experts + redundant experts (vllm/model_executor/layers/fused_moe/layer.py:76-89).
  3. An EPLB layer state, only with EPLB on. Then the experts must divide evenly over the EP ranks (vllm/model_executor/layers/fused_moe/layer.py:269-284).
  4. An ExpertMapManager that knows which experts are local (vllm/model_executor/layers/fused_moe/layer.py:291-302).
  5. The router, unless the model passes its own (vllm/model_executor/layers/fused_moe/layer.py:306-346).
  6. A FusedMoEConfig, the RoutedExperts object that holds the expert weights, and the MoERunner (vllm/model_executor/layers/fused_moe/layer.py:361-470).

The runner calls the layer through a registered custom op: torch.ops.vllm.moe_forward, or moe_forward_shared with shared experts. So torch.compile sees one opaque node (vllm/model_executor/layers/fused_moe/runner/moe_runner.py:309-327, vllm/model_executor/layers/fused_moe/runner/moe_runner.py:198-214).

Step 2: does this layer use expert parallelism?#

        use_ep = (
            dp_size_ * pcp_size_ * tp_size_ > 1
            and vllm_parallel_config.enable_expert_parallel
        )

        dp_size = dp_size_
        dp_rank = get_dp_group().rank_in_group if dp_size > 1 else 0
        pcp_size = pcp_size_
        pcp_rank = get_pcp_group().rank_in_group if pcp_size > 1 else 0
        tp_size, tp_rank = FusedMoEParallelConfig.flatten_tp_across_dp_and_pcp(
            tp_size_, dp_size_, dp_rank, pcp_size_, pcp_rank
        )

        if not use_ep:
            return FusedMoEParallelConfig(
                tp_size=tp_size,
                tp_rank=tp_rank,
                pcp_size=pcp_size,
                pcp_rank=pcp_rank,
                dp_size=dp_size,
                dp_rank=dp_rank,
                ep_size=1,
                ep_rank=0,
                sp_size=sp_size_,
                use_ep=False,
                all2all_backend=vllm_parallel_config.all2all_backend,
                enable_eplb=vllm_parallel_config.enable_eplb,
            )
        # DP + EP / TP + EP / DP + TP + EP
        assert use_ep
        # In EP, each device owns a set of experts fully. There is no tensor
        # parallel update tp_size, tp_rank, ep_size and ep_rank to reflect that.
        ep_size = tp_size
        ep_rank = tp_rank
        return FusedMoEParallelConfig(
            tp_size=1,
            tp_rank=0,
            pcp_size=pcp_size,
            pcp_rank=pcp_rank,
            dp_size=dp_size,
            dp_rank=dp_rank,
            ep_size=ep_size,
            ep_rank=ep_rank,
            sp_size=sp_size_,
            use_ep=True,
            all2all_backend=vllm_parallel_config.all2all_backend,
            enable_eplb=vllm_parallel_config.enable_eplb,
  1. Lines 1198-1201. EP is used only when there is more than one rank and --enable-expert-parallel is set. The flag is off by default (vllm/config/parallel.py:190-191).
  2. Lines 1207-1209. The TP size of the MoE layer is "flattened" over DP and PCP: DP × PCP × TP ranks (vllm/model_executor/layers/fused_moe/config.py:1105-1114).
  3. Lines 1211-1226. Without EP, the experts are split with TP over all those ranks, and ep_size is 1.
  4. Lines 1230-1231. With EP, the flattened TP size becomes the EP size, and the MoE TP size becomes 1: each rank owns whole experts.

The docstring lists six cases. One is "TP = 2, DP(PCP) = 2 and EP = True … the experts are split between the 4 devices" (vllm/model_executor/layers/fused_moe/config.py:1187-1195). The process group behind it is the EP group, built for every MoE model (Process groups: how vLLM slices its ranks).

Step 3: which experts live on which rank#

--expert-placement-strategy is linear by default: with 4 experts and 2 ranks, rank 0 gets [0, 1] and rank 1 gets [2, 3]. round_robin gives [0, 2] and [1, 3] (vllm/config/parallel.py:210-219). The expert_map is a tensor of shape [global_num_experts] with the local index of each local expert and −1 for the others. It exists only when ep_size > 1 (vllm/model_executor/layers/fused_moe/expert_map_manager.py:152-180).

Step 4: the modular kernel: prepare/finalize plus experts#

The design comment in modular_kernel.py names the four parts: "[Router] → [Quantize-Dispatch] → [Permute-Experts-Unpermute] → [Combine]" (vllm/model_executor/layers/fused_moe/modular_kernel.py:51-84). Two abstract classes carry them:

FusedMoEKernel joins one of each; both must be "modular" or both "monolithic". A monolithic pair works on the router logits instead of top-k ids (vllm/model_executor/layers/fused_moe/modular_kernel.py:1678-1710).

Step 5: selecting the experts implementation#

Each quantization type has an "oracle" that picks the experts class (vllm/model_executor/layers/fused_moe/oracle/base.py:1-20). For BF16 weights on CUDA, the priority list is FlashInfer TRT-LLM, FlashInfer CUTLASS, Triton, batched Triton. On Hopper (compute capability 9.x) both FlashInfer entries move to the back, because they are slower there. With DP > 1 FlashInfer CUTLASS moves back too:

    if current_platform.is_rocm():
        _AVAILABLE_BACKENDS = [
            UnquantizedMoeBackend.AITER,
            UnquantizedMoeBackend.TRITON,
            UnquantizedMoeBackend.BATCHED_TRITON,
        ]
    elif current_platform.is_cuda():
        _AVAILABLE_BACKENDS = [
            UnquantizedMoeBackend.FLASHINFER_TRTLLM,
            UnquantizedMoeBackend.FLASHINFER_CUTLASS,
            UnquantizedMoeBackend.TRITON,
            UnquantizedMoeBackend.BATCHED_TRITON,
        ]

        # On Hopper (SM90), the FlashInfer unquantized MoE kernels are slower
        # than Triton, so prefer Triton by default.
        if current_platform.is_device_capability_family(90):
            _move_to_back(_AVAILABLE_BACKENDS, UnquantizedMoeBackend.FLASHINFER_TRTLLM)
            _move_to_back(_AVAILABLE_BACKENDS, UnquantizedMoeBackend.FLASHINFER_CUTLASS)

        # HACK: Qwen3.5 has crash with FLASHINFER_CUTLASS BF16 if DEP.
        # Updating the oracle querying logic is out of the scope of this
        # PR. Need to fix the kernel or update structure in follow up.
        if moe_config.moe_parallel_config.dp_size > 1:
            _move_to_back(_AVAILABLE_BACKENDS, UnquantizedMoeBackend.FLASHINFER_CUTLASS)

The oracle takes the first backend whose kernel class accepts the config, and logs "Using <backend> Unquantized MoE backend out of potential backends: [...]" (vllm/model_executor/layers/fused_moe/oracle/unquantized.py:263-268, vllm/model_executor/layers/fused_moe/oracle/unquantized.py:359-372). --moe-backend forces a choice; its default is auto (vllm/config/kernel.py:302-306). Then the oracle builds the prepare/finalize object and logs its class name too (vllm/model_executor/layers/fused_moe/oracle/unquantized.py:518-530). Kernel internals are out of scope for this site.

Step 6: selecting dispatch and combine: --all2all-backend#

--all2all-backend defaults to allgather_reducescatter (vllm/config/parallel.py:220-234). The removed names naive and pplx fall back to it with a warning (vllm/config/parallel.py:537-543). maybe_make_prepare_finalize maps the choice to a class:

def maybe_make_prepare_finalize(
    moe: FusedMoEConfig,
    quant_config: FusedMoEQuantConfig | None,
    routing_tables: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None,
    allow_new_interface: bool = False,
    use_monolithic: bool = False,
    all2all_manager: Any | None = None,
    input_dtype: torch.dtype | None = None,
) -> FusedMoEPrepareAndFinalize | None:
    if moe.moe_parallel_config.use_passthrough_all2all:
        # The experts dispatch and combine themselves, in every EP topology.
        return PassThroughPrepareAndFinalize()

    if not moe.moe_parallel_config.use_all2all_kernels:
        if not allow_new_interface:
            return None

        # Opt-in XPU batched path: reorganize tokens into E x T x K locally
        # (no all-to-all) so BatchedTritonExperts (moe_mmk TD) can run.
        if current_platform.is_xpu() and moe.moe_backend == "batched_triton":
            return BatchedPrepareAndFinalize(
                max_num_tokens=moe.max_num_tokens,
                num_local_experts=moe.num_local_experts,
                num_dispatchers=1,
                rank=moe.moe_parallel_config.ep_rank,
            )

        # For DP/TP case, fall back to naive P/F.
        if moe.moe_parallel_config.dp_size > 1:
            logger.info_once(
                "Detected DP deployment with no --enable-expert-parallel. "
                "Falling back to AllGather+ReduceScatter dispatch/combine."
            )
            if all2all_manager is None:
                all2all_manager = get_ep_all2all_manager()
            return make_moe_prepare_and_finalize_naive_dp_ep(
                is_sequence_parallel=moe.moe_parallel_config.is_sequence_parallel,
                num_dispatchers=all2all_manager.world_size,
                use_monolithic=use_monolithic,
            )
        else:
            return make_moe_prepare_and_finalize_no_dp_ep(use_monolithic)

    if all2all_manager is None:
        all2all_manager = get_ep_all2all_manager()

    prepare_finalize: FusedMoEPrepareAndFinalize | None = None

    if moe.use_deepep_ht_kernels:
        assert moe.dp_size == all2all_manager.dp_world_size
  1. Lines 191-193. passthrough: the MoE backend dispatches and combines by itself. vLLM binds it automatically for such backends; the CLI does not offer it (vllm/engine/arg_utils.py:1148-1151, vllm/config/kernel.py:194-229).
  2. Lines 195-223. No all2all kernels (no EP, or one rank). With DP > 1 the experts are TP-split over all DP ranks, so each rank still needs every rank's tokens. vLLM logs "Detected DP deployment with no --enable-expert-parallel" and uses the all-gather/reduce-scatter object. Without DP, a local object needs no communication.
  3. Lines 225-230 and on. With EP, the code takes the all2all manager of the EP group's device communicator. Then it builds one class per backend: DeepEP high-throughput, low-latency and v2, MoonEP, MoRI, FlashInfer NVLink, all-gather/reduce-scatter, NIXL-EP (lines 232-420).

The managers themselves are made by the EP group's CudaCommunicator when use_all2all is true (vllm/distributed/device_communicators/cuda_communicator.py:202-283). The default manager is AgRsAll2AllManager: dispatch is an all-gather, combine a reduce-scatter, over the DP group or, with sequence-parallel MoE, the EP group (vllm/distributed/device_communicators/all2all.py:67-173).

Step 7: what DP + EP looks like for a large MoE#

vllm serve <large MoE model> -dp 8 --enable-expert-parallel      # one node, 8 GPUs, TP = 1

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

The all2all backends at a glance

--all2all-backendManager (EP communicator)Prepare/finalizeNotes from the code
allgather_reducescatter (default)AgRsAll2AllManagernaive DP/EP (all-gather + reduce-scatter)works with any EP + DP setup; every rank receives every token
deepep_high_throughputDeepEPHTAll2AllManagerDeepEPHTPrepareAndFinalizedoc string: "deepep high-throughput kernels"
deepep_low_latencyDeepEPLLAll2AllManagerDeepEPLLPrepareAndFinalizebatched expert format; FP8 dispatch for block-FP8 models
deepep_v2DeepEPV2All2AllManagerDeepEPV2PrepareAndFinalizenot in the doc list of the field; in the type (vllm/config/parallel.py:45-60)
mori_high_throughput, mori_low_latencyMoriAll2AllManagerMoriPrepareAndFinalizeMoRI EP for multi-node
moonepMoonEPAll2AllManagerMoonEPPrepareAndFinalize"balanced EP with dynamic redundant experts (NVLink)"
nixl_epNixlEPAll2AllManagerNIXL-EPbatched expert format
flashinfer_nvlink_one_sided, flashinfer_nvlink_two_sidedFlashInfer managersFlashInfer NVLinkflashinfer_all2allv is a deprecated alias of the two-sided one
passthroughPassThroughAll2AllManagerPassThroughPrepareAndFinalizebound automatically for MoE backends that communicate themselves

Sources: managers vllm/distributed/device_communicators/cuda_communicator.py:202-283; prepare/finalize vllm/model_executor/layers/fused_moe/all2all_utils.py:182-420; descriptions vllm/config/parallel.py:220-234. A backend needs its library to be installed (DeepEP, NIXL, MoRI, FlashInfer); vLLM does not ship them.

Deep dive

Worked example: expert counts and memory with EPLB

The EPLB module docstring gives the example: DeepSeek-R1 has 256 logical experts per MoE layer. With 32 redundant experts there are 288 physical experts, and with 32 EP ranks each GPU holds 288 / 32 = 9 (vllm/distributed/eplb/eplb_state.py:3-25).

The same model with -dp 8 -ep (EP size 8):

  1. No EPLB: 256 / 8 = 32 experts per GPU. num_redundant_experts must stay 0, or the config raises (vllm/config/parallel.py:585-592).
  2. --enable-eplb --eplb-config '{"num_redundant_experts": 8}': global experts = 256 + 8 = 264 (vllm/model_executor/layers/fused_moe/layer.py:82). 264 / 8 = 33 per GPU; the division must be exact, else ValueError (vllm/model_executor/layers/fused_moe/layer.py:272-279).
  3. num_redundant_experts counts the extra experts of the whole model, not per rank (derived from line 82). The docs table says "Additional global experts per EP rank beyond equal distribution", which reads differently (docs/serving/expert_parallel_deployment.md:156).
Deep dive

EPLB: when and how the experts move

EPLBConfig fields and defaults (vllm/config/parallel.py:63-119):

FieldDefaultMeaning
window_size1000steps of expert load that are kept
step_interval3000steps between two rearrangements
num_redundant_experts0extra physical copies of popular experts
log_balancedness, log_balancedness_intervalFalse, 1log average/max load per rank (costs a sync)
use_asyncTruemove weights in the background
policydefaultthe rebalancing algorithm (adapted from DeepSeek EPLB, vllm/distributed/eplb/policy/default.py:3-11)
communicatorNone: XCCL on XPU, else NIXL if present, else gloohow expert weights travel

Validation: EPLB needs --enable-expert-parallel, a CUDA, ROCm or XPU platform, and TP × PCP × DP > 1 (vllm/config/parallel.py:563-583). Async EPLB refuses the torch_nccl and pynccl communicators (vllm/config/parallel.py:121-130).

How one cycle runs (Model Runner V2):

  1. The router kernels record how many tokens each physical expert got in this step (expert_load_pass).
  2. After each sample_tokens (and each dummy run, marked as dummy), a decorator calls EplbState.step() (vllm/v1/worker/gpu/eplb_utils.py:46-63, vllm/v1/worker/gpu/model_runner.py:2113-2117).
  3. step() copies the step's load into a sliding window of window_size slots. It records only in the last window_size steps before a rearrangement (or for the balancedness log), and never for dummy steps (vllm/distributed/eplb/eplb_state.py:647-660, vllm/distributed/eplb/eplb_state.py:697-717). It counts every step, dummy or not, so all ranks stay in sync (lines 662-666).
  4. When the counter reaches step_interval, rearrange() runs: it sums the window across the EP ranks, runs the policy, and moves expert weights between GPUs (vllm/distributed/eplb/eplb_state.py:681-693, vllm/distributed/eplb/eplb_state.py:758-800).

The weight moves use the separate EPLB process group, so they do not deadlock with the MoE collectives of the forward pass (vllm/distributed/parallel_state.py:2347-2350).

Deep dive

DBO: dual batch overlap

DBO overlaps the MoE all-to-all with compute. The model runner splits a batch into two microbatches and runs each in its own CPU thread. It switches between them at yield points in the modular kernel. While one microbatch waits for dispatch or combine, the other computes (docs/design/dbo.md:5-11). The design doc shows the schedule (lines 15-28).

Deep dive

Elastic EP in one paragraph

--enable-elastic-ep lets a running MoE deployment change its DP (and so EP) size. The config requires EPLB, no PP and no hybrid load balancing; async EPLB then needs NIXL (vllm/config/parallel.py:975-997). The DP, EP and EPLB groups become stateless groups on a TCP store, so they can be rebuilt (vllm/distributed/parallel_state.py:2297-2305). Only one API server runs: a larger count is capped to 1 with a warning (vllm/entrypoints/cli/serve.py:138-145). You scale with POST /scale_elastic_ep and a body {"new_data_parallel_size": N, "drain_timeout": 120} (vllm/entrypoints/serve/elastic_ep/api_router.py:28-66). In internal load-balancing mode, the scale-up and scale-down code needs --data-parallel-backend ray (vllm/v1/engine/core_client.py:1920-1922).

The route is registered for every server of a generative model, also without --enable-elastic-ep (vllm/entrypoints/launchers/api_server/routers.py:55-66). Its path is not under the prefixes that --api-key checks (vllm/entrypoints/serve/middleware/authenticate.py:11). Without elastic EP, a call gives these results (traced in the code):

In these cases the error comes before set_scaling_elastic_ep(True), so the server does not hold back new requests (vllm/v1/engine/async_llm.py:1310, vllm/v1/engine/async_llm.py:1329). The code in this path does not check --enable-elastic-ep itself (vllm/v1/engine/core_client.py:1913-1952). One case remains: the Ray DP backend and a smaller size that passes the expert check. Then the client sends reconfiguration calls to the engines. This site does not trace that case further.

What can go wrong#

Why it matters. The largest open models are MoE models. Their throughput depends on how tokens move between GPUs, so the all2all backend, the expert kernel and EPLB are among the most important serving choices.

Check yourself#

  1. 1
    What does FusedMoEFactory return?
    easy
    Show answer

    An MoERunner, which holds the router and the RoutedExperts (vllm/model_executor/layers/fused_moe/layer.py:447-470).

  2. 2
    What is the default --all2all-backend, and how does it dispatch?
    easy
    Show answer

    allgather_reducescatter (vllm/config/parallel.py:220). Dispatch is an all-gather of all tokens; combine is a reduce-scatter (vllm/distributed/device_communicators/all2all.py:67-173).

  3. 3
    With -tp 2 -dp 2 --enable-expert-parallel, what are the MoE TP size and EP size of each layer?
    medium
    Show answer

    The flattened TP size is DP × PCP × TP = 4. With EP, ep_size = 4 and the MoE TP size = 1 (vllm/model_executor/layers/fused_moe/config.py:1105-1114, vllm/model_executor/layers/fused_moe/config.py:1227-1244).

  4. 4
    On an H100, which unquantized MoE backend does the oracle try first, and why?
    hard
    Show answer

    Triton. The CUDA list starts with the two FlashInfer backends. On compute capability 9.x both move to the back, because they are slower than Triton there (vllm/model_executor/layers/fused_moe/oracle/unquantized.py:68-80).

  5. 5
    EPLB is on with the defaults. A server runs 2,000 steps per minute. How often are the experts rearranged, and how much history is used?
    hard
    Show answer

    Every 3,000 steps, so about every 90 seconds, using the load of the last 1,000 recorded steps (vllm/config/parallel.py:67-75, vllm/distributed/eplb/eplb_state.py:681-693). Dummy steps advance the counter but record no load (vllm/distributed/eplb/eplb_state.py:587-590, vllm/distributed/eplb/eplb_state.py:662-666).