AFD Plugin Explained

Tour: Life of a request

IntermediateGuided toursabout 11 min read + 1 min deep dives

Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857

In one sentence

Follow one request from the HTTP call to the first token. It stays on the attention server and reaches the FFN server only through the connector.

The idea#

A client sends a request to one server. The attention server owns the API, the scheduler and the EngineCore. The FFN server owns no API for requests; a connector loop drives it. So the whole request path lives on the attention side, and the FFN side answers one piece of each model layer.

This tour uses the simplest GPU recipe: one attention server, one FFN server, and the GPU P2P connector (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_eager_dbo_dp1tp2.sh:7-29). Each server runs two ranks (--tensor-parallel-size 2), so the topology is 2A2F. Rank A0 maps to rank F0 (afd_plugin/distributed/topology.py:135-136). We follow A0 and F0.

Keep one timing rule in mind. The control plane publishes DP metadata once per step, before the layer loop. The data plane sends tensors once per layer and stage. With DBO there are two stages per layer.

The picture#

The two diagrams split the path in half. The first shows the attention server, from the HTTP call to the connector. The second shows the connector and the FFN server loop. The control-plane arrow runs once per step; the data-plane arrows run per layer and stage.

Figure: half 1. vLLM native code carries the request to the AFD runner. The plugin publishes DP metadata once per step, then sends hidden states per layer and stage. Sampling runs after the last layer on the attention side.
Open full size ↗
Figure: half 2. The FFN server runs no scheduler. A connector-driven thread waits for DP metadata, then answers each layer and stage with recv_attn_output, compute, send_ffn_output.
Open full size ↗
InteractiveStep through the request

This interactive visual steps through the stops of the tour. It needs JavaScript. The stops below say the same thing in text.

Click Next, or press Tab then Enter. Each stop shows the active server and process, what crosses the connector, and the file and function.

1The client calls the attention API server#

A client sends an HTTP request to the attention server. Clients must send requests only there. The FFN server is connector-driven (README.md:258-260).

Attention and FFN may be started in either order. Send requests only to the
Attention API server. FFN workers are connector-driven; scheduler-driven FFN
`execute_model()` calls fail fast.
The README: send requests only to the attention API server.

The API server is native vLLM (api_server.py). AFD changes nothing here. The request now enters the engine.

2EngineCore and the scheduler batch the request#

The attention server's EngineCore and scheduler are native vLLM (EngineCore.step). The scheduler builds a step, then the executor runs it (vllm:vllm/v1/engine/core.py:600-601).

The FFN server has no such loop for requests. Its worker runs a connector loop instead (afd_plugin/v1/worker/ffn_worker.py:32-38). So the scheduler lives on the attention side only. See Attention and FFN roles: what each server owns.

3The executor calls the AFD attention worker#

This is the first plugin code on the path. The worker_cls was rewritten to the AFD attention worker at start-up, so the native executor now builds and calls AFDAttentionWorker. Its init_device checks the stack, then constructs the AFD runner (afd_plugin/v1/worker/attention_worker.py:60-88).

    def init_device(self):
        """Initialize the native GPU worker with the selected AFD runner."""

        # ### PATCH START: select and directly construct the AFD runner.
        assert_compatible_afd_stack(
            self.vllm_config,
            caller="AFDAttentionWorker.init_device",
            expected_role="attention",
        )
        validate_gpu_model_runner_v2_config(
            self.vllm_config,
            expected_role="attention",
            device_type=self.device_config.device_type,
        )

        afd_model_config = get_afd_model_config(
            self.vllm_config.model_config,
            device_type="cuda",
        )
        self.vllm_config.model_config = afd_model_config
        self.model_config = afd_model_config

        native_runner_cls = gpu_model_runner_v2.GPUModelRunner
        gpu_model_runner_v2.GPUModelRunner = AFDAttentionModelRunnerV2
        try:
            super().init_device()
        finally:
            gpu_model_runner_v2.GPUModelRunner = native_runner_cls
        # ### PATCH END: select and directly construct the AFD runner.
AFDAttentionWorker.init_device: validate, then swap the runner class in for native construction.

The worker validates the role and the runner config (afd_plugin/v1/worker/attention_worker.py:64-73), then substitutes AFDAttentionModelRunnerV2 for the native runner class while the native setup runs (afd_plugin/v1/worker/attention_worker.py:82-87). See Worker selection: choosing the role worker.

4The AFD runner wraps native execute_model#

For each step, the executor calls the runner's execute_model. The AFD runner does not copy the native method. It wraps it in an AFD execution context, then delegates (afd_plugin/v1/worker/attention_model_runner_v2.py:570-594).

        # ### PATCH START: scope AFD metadata provider/replay and profiler step.
        step_afd_gpu_profiler(self.prof)
        with _use_afd_execution_context(
            self,
            int(scheduler_output.total_num_scheduled_tokens),
        ):
            return super().execute_model(
                scheduler_output,
                intermediate_tensors,
                dummy_run=dummy_run,
                skip_attn_for_dummy_run=skip_attn_for_dummy_run,
                is_profile=is_profile,
                context_len=context_len,
                valid_dummy_state_slots=valid_dummy_state_slots,
            )
execute_model: scope the AFD context, then call native execute_model.

Native execute_model still does the request, attention and KV work. Sampling is a separate native call (stop 12). The wrapper only installs AFD state around it (afd_plugin/v1/worker/attention_model_runner_v2.py:555-568).

5The execution context installs the metadata provider#

The context sets up two things that matter for AFD: a metadata provider that binds AFD metadata to the forward context, and, for DBO, a prepare seam on the ubatch runner (afd_plugin/v1/worker/attention_model_runner_v2.py:317-349).

    try:
        with (
            use_gpu_mrv2_dbo_dispatch(),
            replay_scope,
            _use_afd_ubatch_preparation(runner),
            use_afd_metadata_provider(
                runner.install_afd_metadata_on_forward_context,
            ),
        ):
            yield
_use_afd_execution_context: the metadata provider and the ubatch prepare seam.

The metadata provider is install_afd_metadata_on_forward_context (afd_plugin/v1/worker/attention_model_runner_v2.py:345-347). It runs when native code builds the forward context, and it is where the control plane publish happens. See The attention runtime: a seam over Model Runner V2.

6The control plane publishes DP metadata (once per step)#

Before the layer loop, the attention side sends the DP metadata to the FFN side. The prepare seam calls send_dp_metadata once (afd_plugin/v1/worker/attention_model_runner_v2.py:156-157). That call updates the local control state, then sends one payload (afd_plugin/v1/worker/attention_metadata.py:115-116).

        payload = AFDControlPayload(
            dp_metadata_list=dp_metadata_list,
            is_graph_capturing=is_graph_capturing,
            is_warmup=is_warmup,
            is_graph_replaying=is_graph_replaying,
            is_profile=self._afd_is_profile,
        )
        self.connector.control_plane.update_state_from_dp_metadata(payload)
        self.connector.control_plane.send_dp_metadata_list(payload)
send_dp_metadata: update the control state, then send one payload per step.

This payload is the first thing that crosses the connector each step. It carries the per-stage token counts that set the tensor shapes. See The control plane: DP metadata once per step.

Deep dive

Why 'once per step', and the two code paths

Accuracy note from the fact checks: a per-step call must not be folded into the per-layer sequence. The publish runs before any layer sends data.

There are two paths to the same publish. With DBO, the ubatch prepare seam calls send_dp_metadata after it slices the step into stages (afd_plugin/v1/worker/attention_model_runner_v2.py:153-157). Without ubatching, the metadata provider calls it from install_afd_metadata_on_forward_context (afd_plugin/v1/worker/attention_metadata.py:252-257). Either way, one publish per step, before the layer loop.

The FFN side waits for exactly one payload per step. Its server loop calls recv_dp_metadata_list once (afd_plugin/v1/worker/ffn_worker.py:176), then applies it (afd_plugin/v1/worker/ffn_model_runner.py:194-203).

7The native model forward runs the decoder layers#

Native execute_model runs the model forward. The AFD model wrapper subclasses the native model (afd_plugin/model_executor/models/deepseek_v2.py:725), and its decoder layer subclasses the native layer (afd_plugin/model_executor/models/deepseek_v2.py:375). On the attention role, the layer builds the native attention module and replaces only the FFN stage (afd_plugin/model_executor/models/deepseek_v2.py:459-517).

class RemoteFFNProxy(nn.Module):
    """Parameter-free FFN stage executed through the AFD connector."""

    def __init__(self, *, layer_idx: int) -> None:
        super().__init__()
        self.layer_idx = layer_idx

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        return self._send_and_receive(hidden_states)
RemoteFFNProxy: a parameter-free module that stands in for the FFN stage.

A dense layer gets a RemoteFFNProxy, which holds no weights (afd_plugin/model_executor/models/deepseek_v2.py:516-517). A MoE layer gets a native MoE shell whose experts are the same proxy (afd_plugin/model_executor/models/deepseek_v2.py:493-500, afd_plugin/model_executor/models/deepseek_v2.py:332-335). Either way, the proxy's forward sends and receives through the connector instead of computing the FFN. The loop over layers is native vLLM.

8The attention side sends hidden states (per layer and stage)#

Inside each layer, the proxy sends its hidden states to the FFN side, then waits for the result. This runs once per layer, and once per stage (afd_plugin/model_executor/models/deepseek_v2.py:127-158).

        metadata = AFDTransferMetadata.create_attention_metadata(
            layer_idx=self.layer_idx,
            stage_idx=stage_idx,
            seq_len=int(hidden_states.shape[0]),
        )
        context = AFDTransferContext(metadata=metadata)
        afd_metadata.connector.send_attn_output(
            hidden_states,
            context,
            **send_kwargs,
        )
        hidden_states = maybe_apply_dbo_yield(
            hidden_states,
            role="attention",
        )
        return afd_metadata.connector.recv_ffn_output(
            ref_tensor=hidden_states,
            ubatch_idx=stage_idx,
        )
_send_and_receive: send_attn_output, a DBO yield, then recv_ffn_output.

The call to send_attn_output (afd_plugin/model_executor/models/deepseek_v2.py:146) is the hidden-state tensor crossing the connector (the data plane). The matching recv_ffn_output comes later in the same call (afd_plugin/model_executor/models/deepseek_v2.py:155).

Deep dive

The shapes on the wire

The tensor is hidden_states of shape [num_tokens, hidden_size] in the model dtype (afd_plugin/connectors/gpu/p2p.py:331-335). The transfer metadata records the layer index, the stage index and the sequence lengths (afd_plugin/connectors/metadata.py:188-190). For DeepSeek-V2-Lite the hidden size is 2048, which is inferred from the checkpoint config. With 8 decode tokens the tensor is [8, 2048]. See Data on the wire: tensors per layer and stage.

9The FFN server loop receives the step#

The FFN worker runs a daemon thread, the FFN server loop. No scheduler drives it. The loop waits for one DP-metadata payload, then runs the model (afd_plugin/v1/worker/ffn_worker.py:160-196).

        while not event.is_set():
            if self.model_runner.connector.control_plane is None:
                raise NotImplementedError(
                    "GPU FFN only supports control-plane-driven connectors; "
                    "connectors without a control plane (control_plane is None) "
                    "are not supported.",
                )

            payload = self.model_runner.connector.control_plane.recv_dp_metadata_list()
            dp_metadata_list = payload.dp_metadata_list
            is_attn_graph_capturing = payload.is_graph_capturing
            is_warmup = payload.is_warmup
            is_graph_replaying = payload.is_graph_replaying

            if self.model_runner.use_cuda_graph and (
                is_warmup or is_attn_graph_capturing
            ):
                self.model_runner.capture_model(
                    dp_metadata_list=dp_metadata_list,
                    is_warmup=is_warmup,
                    is_attn_graph_capturing=is_attn_graph_capturing,
                )
            else:
                self.model_runner.execute_model(
                    dp_metadata_list=dp_metadata_list,
                    is_graph_capturing=is_attn_graph_capturing,
                    is_warmup=is_warmup,
                    is_graph_replaying=is_graph_replaying,
                )
_run_ffn_server_loop: receive DP metadata, then execute the model (or capture a graph).

If the native scheduler ever calls the FFN worker's execute_model, it raises at once, so a misrouted request fails fast (afd_plugin/v1/worker/ffn_worker.py:122-131). The GPU FFN side also needs a control plane; a connector without one is rejected here (afd_plugin/v1/worker/ffn_worker.py:169-174).

10The FFN runner computes each layer and stage#

_ffn_forward loops over the layers, and over the stages inside each layer. For each pair it receives the hidden states, runs the FFN or MoE compute, and sends the result back (afd_plugin/v1/worker/ffn_model_runner.py:222-269).

                for stage_idx in stage_ids:
                    recv_kwargs: dict[str, Any] = {}
                    if routing_spec is not None:
                        recv_kwargs["routing_spec"] = routing_spec
                    if recv_input_ids:
                        recv_kwargs["recv_input_ids"] = True
                    payload = self.connector.recv_attn_output(
                        ubatch_idx=stage_idx,
                        **recv_kwargs,
                    )
                    hidden_states = payload.hidden_states
                    context = payload.context
                    metadata = context.metadata
                    metadata.layer_idx = layer_idx
                    metadata.stage_idx = stage_idx
                    if forward_context is not None:
                        forward_context.dp_metadata = ffn_dp_metadata_list[
                            metadata.stage_idx
                        ]
                        forward_context.additional_kwargs["afd_metadata"] = metadata
                        _set_moe_layer_index(forward_context, layer_idx)
                    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,
                        )
                    self.connector.send_ffn_output(rank_ffn_output, context)
        return rank_ffn_output
The inner loop: recv_attn_output, FFN/MoE compute, send_ffn_output.
  1. Line 236. recv_attn_output returns the hidden states and the transfer context (afd_plugin/v1/worker/ffn_model_runner.py:236).
  2. Line 263. _execute_eager_mode runs the FFN and MoE for this layer (afd_plugin/v1/worker/ffn_model_runner.py:263).
  3. Line 268. send_ffn_output sends the result back to the attention side (afd_plugin/v1/worker/ffn_model_runner.py:268).

11The attention side receives the FFN output and continues the layer#

Back on the attention side, recv_ffn_output returns the FFN result into the proxy's output (afd_plugin/model_executor/models/deepseek_v2.py:155-158). The native layer then adds the residual and goes to the next layer.

Stops 8 to 11 repeat for every decoder layer. With DBO they repeat for both stages of each layer. The control plane does not publish again inside this loop.

12The attention side samples the token and returns it#

After the forward pass, the worker makes a second call, sample_tokens (vllm:vllm/v1/worker/gpu_worker.py:1142-1147). Native Model Runner V2 computes the logits and samples there (vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050). The AFD attention runner does not override this method, so it samples natively. So sampling runs on the attention side, once per step.

    def sample_tokens(self, grammar_output: Any = None) -> Any:
        raise RuntimeError("FFN runners do not sample tokens")
The FFN runner never samples: sample_tokens raises.

The FFN runner has no sampler; its sample_tokens raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406). The attention server returns the token to the client over the same HTTP response. The request is done for this step.

The loop#

One request is many steps. Prefill runs one step for the prompt. Then decode runs one step per new token. Each step runs the whole path again: one control-plane publish, then every layer and stage of send and receive.

FrequencyWhat runsWhere
Once per stepcontrol-plane publish of DP metadata afd_plugin/v1/worker/attention_metadata.py:115-116 / afd_plugin/v1/worker/ffn_worker.py:176
Once per layer and stagesend_attn_output, recv_ffn_output afd_plugin/model_executor/models/deepseek_v2.py:146, afd_plugin/model_executor/models/deepseek_v2.py:155
Once per layer and stagerecv_attn_output, FFN compute, send_ffn_output afd_plugin/v1/worker/ffn_model_runner.py:236, afd_plugin/v1/worker/ffn_model_runner.py:268
Once per step (after the last layer)logits and sampling (attention side) afd_plugin/v1/worker/attention_model_runner_v2.py:586-594

What can go wrong#

Why it matters. The attention side owns the request, and the FFN side answers only pieces of each layer. If a request reaches the FFN server, or the two roles do not meet, the step cannot complete.

Check yourself#

  1. 1
    Which server do clients send requests to, and why not the other one?
    easy
    Show answer

    The attention API server. The FFN server is connector-driven and has no request path; a scheduler-driven FFN execute_model fails fast (README.md:258-260, afd_plugin/v1/worker/ffn_worker.py:122-131).

  2. 2
    What drives the model on the FFN server?
    easy
    Show answer

    A connector-driven daemon loop, the FFN server loop, not a scheduler. It waits for DP metadata, then runs the model (afd_plugin/v1/worker/ffn_worker.py:160-196).

  3. 3
    How many times does the control plane publish per step, and when?
    medium
    Show answer

    Once, before the layer loop. The attention side sends one DP-metadata payload (afd_plugin/v1/worker/attention_metadata.py:115-116); the FFN loop receives one (afd_plugin/v1/worker/ffn_worker.py:176).

  4. 4
    Which side samples the token?
    medium
    Show answer

    The attention side. The AFD attention runner inherits the native sample_tokens, which computes the logits and samples (vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050). The FFN runner's sample_tokens raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406).

  5. 5
    List the data-plane calls for one layer and stage, in order, across both servers.
    hard
    Show answer

    Attention send_attn_output (afd_plugin/model_executor/models/deepseek_v2.py:146); FFN recv_attn_output (afd_plugin/v1/worker/ffn_model_runner.py:236); FFN compute; FFN send_ffn_output (afd_plugin/v1/worker/ffn_model_runner.py:268); attention recv_ffn_output (afd_plugin/model_executor/models/deepseek_v2.py:155). The data plane runs once per layer and stage; the control plane ran once before the loop.