AFD Plugin Explained

Tour: One decode step

AdvancedGuided toursabout 7 min read + 1 min deep dives

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

In one sentence

Follow one decode step on 2A2F with DBO and FULL_DECODE_ONLY graphs. The control plane publishes once; each layer runs two stages.

The idea#

One decode step produces one new token per request. On AFD, the attention side runs the step and the FFN side answers each layer. With DBO, each layer runs two stages that overlap. With graphs, both sides replay instead of running eager.

This tour follows the graph recipe: 2A2F, DP1, TP2, DBO on, FULL_DECODE_ONLY (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:7-29). Rank A0 maps to F0 and A1 to F1 (afd_plugin/distributed/topology.py:135-136).

Keep the two frequencies apart. The control plane publishes DP metadata once per step, before the layer loop. The data plane sends tensors once per layer and stage. Sampling runs once per step, after the last layer.

The picture#

Figure: the control plane publishes once. Then every layer runs two stages of send and receive, with graph replay on each side. Sampling runs once after the last layer.
Open full size ↗

Read the top panel as the once-per-step work and the bottom panel as the per-layer, per-stage work. The visual below steps through the same send, yield, compute and receive events.

InteractiveStep through the decode step

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

Pick the number of layers, then press Step. Watch columns t2 and t3, where attention and FFN work overlap.

1The scheduler builds one step#

The attention server's EngineCore and scheduler build one step, then the executor runs it (vllm:vllm/v1/engine/core.py:600-601). This is native vLLM. The FFN server has no such loop; it is connector-driven (afd_plugin/v1/worker/ffn_worker.py:32-38).

2The runner wraps native execute_model#

The executor calls the AFD runner's execute_model. It wraps the native method in an AFD execution context, then delegates (afd_plugin/v1/worker/attention_model_runner_v2.py:569-595).

        # ### 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 code still does the request, attention and KV work. The wrapper adds AFD state around it. See The attention runtime: a seam over Model Runner V2.

3The AFD context installs the provider and the replay hook#

The context scopes four things: the DP1 DBO dispatch seam, the FULL replay hook, the ubatch prepare seam, and the metadata provider (afd_plugin/v1/worker/attention_model_runner_v2.py:340-349).

        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 replay hook and the metadata provider.

The replay hook is used only in FULL_DECODE_ONLY mode with a graph manager (afd_plugin/v1/worker/attention_model_runner_v2.py:323-327). See CUDA graphs in AFD: FULL_DECODE_ONLY on both roles.

4The control plane publishes DP metadata once#

Before the layer loop, the attention side publishes the DP metadata. With DBO, the ubatch prepare seam sends it after it slices the step into two stages (afd_plugin/v1/worker/attention_model_runner_v2.py:153-157).

        runner.install_mrv2_ubatch_metadata(
            prepared.slices, prepared.forward_contexts, input_batch.num_tokens
        )
        if runner.connector.control_plane is not None:
            runner.send_dp_metadata(None, prepared.slices)
        return prepared
The prepare seam sends the DP metadata once, after slicing into stages.

That call updates the local control state and sends one payload (afd_plugin/v1/worker/attention_metadata.py:115-116). It is the first thing that crosses the connector each step. See The control plane: DP metadata once per step.

Deep dive

The two publish paths, both once per step

There are two code paths to the same publish, chosen by whether the step uses DBO. Both run once per step, before the layer loop.

  1. With DBO. The ubatch prepare seam calls send_dp_metadata after it slices the step into two stages (afd_plugin/v1/worker/attention_model_runner_v2.py:153-157).
  2. Without ubatching. The metadata provider calls it from install_afd_metadata_on_forward_context (afd_plugin/v1/worker/attention_metadata.py:252-257).

The FFN side waits for exactly one payload per step either way (afd_plugin/v1/worker/ffn_worker.py:176). A per-step call must not be folded into the per-layer sequence. The payload carries the per-stage token counts that set the tensor shapes. See Data on the wire: tensors per layer and stage.

5The attention side replays its graph#

For a pure-decode step, native FULL replay runs. The replay hook publishes one padded control payload just before the replay (afd_plugin/v1/worker/attention_model_runner_v2.py:286-303). For a DBO step with two stages, the prepare seam already sent the full two-stage payload, so the hook asserts and delegates (afd_plugin/v1/worker/attention_model_runner_v2.py:275-280).

        if desc.num_ubatches > 1:
            # Native execute_model already prepared live stage inputs and our
            # prepare seam sent their complete control payload.
            assert runner._afd_pending_metadata is not None
            assert runner._afd_pending_metadata.num_stages == desc.num_ubatches
            return original_run_fullgraph(desc)
The replay hook with two stages: the full payload was already sent.

6Per layer and stage: send, yield, the other stage#

Inside each layer, the proxy sends its hidden states, yields, then receives. The yield lets the other stage run while this one waits (afd_plugin/model_executor/models/deepseek_v2.py:146-158).

        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, DBO yield, recv_ffn_output.

This runs once per layer and once per stage. With DBO there are two stages per layer. See Dual-batch overlap (DBO): two stages per step.

7The FFN side receives one DP-metadata payload per step#

The FFN server loop blocks on the control plane for exactly one payload per step (afd_plugin/v1/worker/ffn_worker.py:176), then dispatches to execute or capture (afd_plugin/v1/worker/ffn_worker.py:182-196).

            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,
                )
The FFN loop: one recv per step, then execute_model or capture_model.

So the control plane runs once per step on the FFN side too, matching the attention side.

8The FFN side looks up and replays its graph#

The FFN runner builds the FFN graph key from the DP metadata and replays the stored graph for a pure-decode step (afd_plugin/v1/worker/ffn_model_runner.py:166-177). If no graph matches the key, it runs eager instead.

        graph_key = make_ffn_graph_key(dp_metadata_list)
        cuda_graph_info = self._cuda_graphs.get(graph_key)
        run_mode = graph_run_mode(
            is_warmup=is_warmup,
            is_graph_capturing=is_graph_capturing,
            is_graph_replaying=is_graph_replaying,
            graph_enabled=bool(self.use_cuda_graph),
            graph_exists=cuda_graph_info is not None,
        )
        if run_mode is AFDGraphRunMode.REPLAY:
            self._cuda_graphs[graph_key]["graph"].replay()
            return None

        self._ffn_forward(
            dp_metadata_list=dp_metadata_list,
            is_graph_capturing=is_graph_capturing,
            is_warmup=is_warmup,
        )
        return None
execute_model: build the key, replay the stored graph, or run the forward.

See CUDA graphs in AFD: FULL_DECODE_ONLY on both roles.

9Per layer and stage: recv, compute, send#

The eager path, _ffn_forward, loops over layers and stages. 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)
The inner loop: recv_attn_output, compute, send_ffn_output.

When the FFN side replays a graph, this same work is recorded in the graph instead of run step by step. See The FFN runtime: a connector-driven server loop.

10After the last layer: sample, then output#

After the forward pass, the worker makes a separate native 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.

The FFN runner has no sampler; its sample_tokens raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406). So sampling runs once per step, on the attention side. The step is done; the next step repeats the whole path.

What can go wrong#

Why it matters. One step has one control-plane publish, then every layer and stage of send and receive, then one sampling call. The frequencies must not be mixed, or the two roles fall out of step.

Check yourself#

  1. 1
    How many times does the control plane publish per step?
    easy
    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).

  2. 2
    What does the FFN runner do when a graph matches the key?
    easy
    Show answer

    It calls graph.replay() and returns, instead of running the layers eager (afd_plugin/v1/worker/ffn_model_runner.py:175-177).

  3. 3
    In a DBO step, why does the FULL replay hook not send a new control payload?
    medium
    Show answer

    Because the ubatch prepare seam already sent the full two-stage payload. The hook asserts the staged metadata and delegates to the native replay (afd_plugin/v1/worker/attention_model_runner_v2.py:275-280).

  4. 4
    Which side samples the token, and when?
    medium
    Show answer

    The attention side, once per step, after the last layer. The AFD runner inherits the native sample_tokens (vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050); the FFN runner's raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406).

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

    Attention send_attn_output (afd_plugin/model_executor/models/deepseek_v2.py:146); DBO yield; 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). This runs for each of the two stages.