The MoE handoff: where the gate runs
Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857
In one sentence
A MoE layer has a gate and experts. The compute_gate_on_attention flag decides which AFD role runs the gate, and so what crosses the connector.
The idea#
A mixture-of-experts layer has two parts: a small gate that picks experts per token, and the large experts themselves. AFD always runs the experts on the FFN role, because the expert weights are large. The gate is small, so AFD can run it on either side.
One flag chooses: compute_gate_on_attention. When it is false (the default), the gate stays with
the experts on the FFN side. When it is true, the gate runs on the attention role,
and the attention side sends the router output along with the hidden states.
An everyday comparison. Think of a mailroom. You can write the delivery route on the parcel before you hand it over (gate on the attention side), or let the mailroom read the address and decide (gate on the FFN side). Either way the parcel reaches the same shelf. The comparison stops here: the gate output is a tensor, not a label, and it must match the experts the FFN side runs.
The picture#
Read panels 1 and 2 as the two settings. The blue arrow is the data plane. Panel 3 lists the three wire payloads. The order on the wire is always hidden states, then router logits, then input ids.
In the code#
Step 1: The default: the gate runs on the FFN side#
With the flag false, the attention side ships the whole FFN or MoE block through a
remote FFN proxy. The FFN side runs the native block, gate and all. On the CUDA MoE
path the attention role still builds a remote-experts shell, but its gate is
None, so the shell ships only the hidden states
(afd_plugin/model_executor/models/deepseek_v2.py:319-335).
if compute_gate_on_attention:
self.gate = native.GateLinear(
config.hidden_size,
config.n_routed_experts,
out_dtype=router_dtype,
prefix=f"{prefix}.gate",
)
if getattr(config, "topk_method", None) == "noaux_tc":
self.gate.e_score_correction_bias = nn.Parameter(
torch.empty(config.n_routed_experts, dtype=torch.float32),
)
else:
self.gate.e_score_correction_bias = None
else:
self.gate = None
ep_size = native.get_ep_group().device_group.size()
self.n_routed_experts = int(config.n_routed_experts)
self.n_shared_experts = int(config.n_shared_experts)
self.n_redundant_experts = parallel_config.eplb_config.num_redundant_experts
self.n_logical_experts = self.n_routed_experts
self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
self.n_local_physical_experts = self.n_physical_experts // ep_size
# No local shared-expert MLP is constructed, so fused shared experts
# can never be enabled for this shell.
self.is_fused_shared_expert_enabled = False
self.experts = AFDAttentionFusedMoE(
layer_idx=layer_idx,
external_routing=compute_gate_on_attention,
)Step 2: Gate on the attention side: compute, then send the router logits#
With the flag true, the shell builds its own gate
(afd_plugin/model_executor/models/deepseek_v2.py:306-318). Its forward computes the
router logits first, then calls the experts proxy with them
(afd_plugin/model_executor/models/deepseek_v2.py:347-371).
# ### PATCH START: compute the Attention-side gate before transport.
num_tokens, hidden_dim = hidden_states.shape
hidden_states = hidden_states.view(-1, hidden_dim)
if self.is_sequence_parallel and not already_sequence_parallel:
hidden_states = native.sequence_parallel_chunk(hidden_states)
router_logits, _ = self.gate(hidden_states)
final_hidden_states = self.experts(
hidden_states=hidden_states,
router_logits=router_logits,
)The experts proxy is AFDAttentionFusedMoE, a RemoteFFNProxy that adds
router_logits to the send only when the experts use external routing
(afd_plugin/model_executor/models/deepseek_v2.py:178-191).
Step 3: The FFN side runs experts only, for expert layers only#
The FFN server loop drives the FFN model runner. Inside
_ffn_forward, the set of layers depends on the flag. With the gate on the attention side, the FFN
runs only the expert layers; otherwise it runs all layers
(afd_plugin/v1/worker/ffn_model_runner.py:211-215).
num_layers = max(int(self.num_layers or 0), 1)
experts_layer_indices = frozenset(
model.get_experts_layer_indices(),
)
layer_indices = (
tuple(sorted(experts_layer_indices))
if self.afd_config.compute_gate_on_attention
else tuple(range(num_layers))
)For each received layer, the runner picks the compute call. An expert layer with the gate on the attention
side calls compute_experts_output with the transported router logits; any other layer calls
compute_ffn_output (afd_plugin/v1/worker/ffn_model_runner.py:251-267).
if (
uses_remote_experts
and self.afd_config.compute_gate_on_attention
):
router_logits = payload.router_logits
assert router_logits is not None
rank_ffn_output = model.compute_experts_output(
hidden_states,
layer_idx,
router_logits,
)
else:
rank_ffn_output = self._execute_eager_mode(
hidden_states,
layer_idx,
input_ids=payload.input_ids,
)Step 4: compute_experts_output needs the gate cleared#
compute_experts_output runs the native experts runner with the transported router logits. It
checks that the runner gate is None, which is the v0.30 contract for external routing
(afd_plugin/model_executor/models/deepseek_v2.py:702-721).
# v0.30 runner contract: gate is None means the runner consumes the
# router logits transported from Attention (external routing).
if self.mlp.experts.gate is not None:
raise RuntimeError("FFN native runner must use external routing")
return self.mlp.experts(
hidden_states=hidden_states,
router_logits=router_logits,
)Deep dive#
Deep diveWhat crosses the wire, by case
The send order is fixed: hidden states, then router logits (if any), then input ids (if any)
(afd_plugin/connectors/gpu/p2p.py:336-338).
| Case | On the wire (A to F) | FFN compute |
|---|---|---|
| dense layer, any flag | hidden_states | compute_ffn_output (native MLP) |
| MoE layer, gate on FFN | hidden_states | compute_ffn_output (native MoE, native gate) |
| MoE layer, gate on attention | hidden_states + router_logits | compute_experts_output (external routing) |
| DeepSeek-V4 hash MoE | hidden_states + int32 input_ids | compute_ffn_output with ids |
The input_ids are a 1-D int32 tensor, token-aligned with the hidden states; they are used only
for the DeepSeek-V4 hash MoE (afd_plugin/connectors/gpu/p2p.py:210,
afd_plugin/model_executor/models/deepseek_v4.py:89-92). The Data on the wire: tensors per layer and stage page reads
the tensor shapes and the per-peer split.
Deep diveThe three attention-side MoE classes
Three classes can stand in for the MoE block on the attention role. They differ by device and by what they send.
| Class | Role / device | Sends | Line |
|---|---|---|---|
AFDDeepseekV2RemoteExpertsMoE | CUDA MoE shell | hidden states; builds the gate when the flag is set | afd_plugin/model_executor/models/deepseek_v2.py:277-335 |
AFDAttentionFusedMoE | the shell's experts proxy | hidden states + router logits (external routing) | afd_plugin/model_executor/models/deepseek_v2.py:161-191 |
GateOnlyRemoteMoE | NPU gate-on-attention | hidden states + gate outputs | afd_plugin/model_executor/models/deepseek_v2.py:197-274 |
The NPU path is covered on Ascend NPU overview: the same contract, different transports; this page reads the CUDA classes.
Deep diveThe afd_balanced routing strategy (benchmarks only)
For controlled MoE benchmarks, the plugin registers a deterministic routing strategy,
afd_balanced (afd_plugin/model_executor/routing_simulator.py:166-171). It
replaces the gate's choice with uniform weights and fixed, EP-balanced expert ids, so every rank gets an equal
share (afd_plugin/model_executor/routing_simulator.py:122-163).
- How to turn it on. Set vLLM's
VLLM_MOE_ROUTING_SIMULATION_STRATEGYtoafd_balanced(afd_plugin/model_executor/routing_simulator.py:5-8). - Weights. Uniform,
1 / top_kper selected expert (afd_plugin/model_executor/routing_simulator.py:113-118). - Expert ids. A fixed permutation (seed 1024), or the first
AFD_BENCHMARK_FORCE_LB_TOPN_PER_RANKexperts of each rank (afd_plugin/model_executor/routing_simulator.py:66-96). - Honesty. It changes model outputs and is for benchmark and profiling runs only
(
afd_plugin/model_executor/routing_simulator.py:10-11).
The strategy is registered in register_afd, outside the patch try/except
(afd_plugin/__init__.py:183-187).
What can go wrong#
- Qwen3-MoE with the gate on attention. The Qwen3-MoE wrapper requires
compute_gate_on_attention=falseand raises otherwise (afd_plugin/model_executor/models/qwen3_moe.py:84-87). README.md:50 states the same rule. - A held gate on the FFN experts.
compute_experts_outputraises if the FFN experts runner still owns a gate (afd_plugin/model_executor/models/deepseek_v2.py:716-717). - A dense layer sent to compute_experts_output. It raises if the layer is not an
attention-side-gate MoE layer (
afd_plugin/model_executor/models/deepseek_v2.py:708-711). - GPU gate-on-attention calling the wrong path. On CUDA the gated path must call
compute_experts_output, notcompute_ffn_output; the dense call raises (afd_plugin/model_executor/models/deepseek_v2.py:690-693).
Why it matters. The two sides must agree on where the gate runs. If the attention side sends router logits but the FFN side keeps its own gate, the experts would route twice and the output would be wrong.
Check yourself#
- 1What doeseasy
compute_gate_on_attentiondecide?Show answer
Which role runs the MoE gate. False (the default) keeps the gate with the experts on the FFN side; true runs it on the attention side (
afd_plugin/model_executor/models/deepseek_v2.py:306-320). - 2When the gate runs on the attention side, which layers does the FFN run?easy
Show answer
Only the expert layers.
layer_indicesis the sorted expert-layer set when the flag is set, else all layers (afd_plugin/v1/worker/ffn_model_runner.py:211-215). - 3What extra tensor crosses the wire when the gate runs on the attention side, and when is it consumed?medium
Show answer
router_logits. The FFN experts runner consumes it only when its gate isNone(external routing), which AFD arranges by clearing the runner gate (afd_plugin/model_executor/models/deepseek_v2.py:548-553,afd_plugin/model_executor/models/deepseek_v2.py:714-721). - 4What does DeepSeek-V4 add to the handoff, and why?medium
Show answer
A 1-D int32
input_idstensor, because its hash router needs the token ids to pick experts (afd_plugin/connectors/gpu/p2p.py:210,afd_plugin/model_executor/models/deepseek_v4.py:89-92). - 5What does thehard
afd_balancedstrategy return, and why is it not for production?Show answer
Uniform weights (
1 / top_k) and deterministic, EP-balanced expert ids (afd_plugin/model_executor/routing_simulator.py:99-119). It replaces the real routing, so it changes model outputs; it is for benchmark and profiling runs only (afd_plugin/model_executor/routing_simulator.py:10-11).