AFD Plugin Explained

CUDA graphs in AFD: FULL_DECODE_ONLY on both roles

AdvancedAFD conceptsabout 6 min read + 4 min deep dives

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

In one sentence

AFD runs CUDA graphs in one mode only, FULL_DECODE_ONLY. Each role captures and replays its own graphs, and the two sides must agree on token shapes.

The idea#

A CUDA graph records a fixed sequence of GPU work once, then replays it with almost no CPU cost. AFD splits one model across two processes, so each role records its own graph. The attention side and the FFN side capture and replay separately. They share nothing but the tensors that cross the connector.

AFD allows one graph mode: FULL_DECODE_ONLY. In this mode the graph covers pure-decode steps, where every request adds one token. Prefill and mixed batches run eager (no graph). This is set in one small policy module (afd_plugin/v1/worker/cuda_graph.py:20-21).

An everyday comparison. Think of a stage play with two actors in separate rooms, linked by a hatch. Each actor rehearses their own lines until they are automatic (capture), then performs them fast (replay). The comparison stops here: the two actors must hand the exact same size of parcel through the hatch every time, or the fast performance breaks.

The picture#

Figure: the attention side wraps native capture and replay to publish control. The FFN side keeps one CUDA graph per token shape, keyed by the DP metadata.
Open full size ↗

Read the top panel as the attention role and the bottom panel as the FFN role. The attention side does not own the graph, native vLLM does; AFD only publishes control around it. The FFN side owns its graphs in a plain dictionary, one per token shape.

In the code#

Step 1: One supported mode#

The policy module lists the supported modes. The set holds one name (afd_plugin/v1/worker/cuda_graph.py:20-21). validate_cuda_graph_mode returns a disabled policy when enforce_eager is set, and raises for any mode that is not FULL_DECODE_ONLY (afd_plugin/v1/worker/cuda_graph.py:42-85).

def validate_cuda_graph_mode(
    vllm_config: VllmConfig,
    *,
    role: str | None = None,
) -> AFDCUDAGraphPolicy:
    """Return the CUDA graph policy or raise for unsupported AFD modes."""

    enforce_eager = bool(getattr(vllm_config.model_config, "enforce_eager", False))
    mode_name = cudagraph_mode_name(vllm_config)
    graph_enabled = not enforce_eager

    if not graph_enabled:
        return AFDCUDAGraphPolicy(
            enabled=False,
            mode_name=mode_name,
            allow_attention_full_decode_only=False,
            enable_ffn_graph_cache=False,
        )

    if mode_name not in _SUPPORTED_GRAPH_MODES:
        role_suffix = f" for {role}" if role else ""
        raise RuntimeError(
            "AFD only supports CUDA graph mode "
            f"{FULL_DECODE_ONLY}{role_suffix}; got {mode_name!r}.",
        )
validate_cuda_graph_mode: eager disables graphs; any other mode raises.

On Ascend NPU, the counterpart is an ACL graph; the NPU validator accepts the FULL and FULL_DECODE_ONLY modes (afd_plugin/validation.py:233-239). The NPU runtime is covered on Ascend NPU overview: the same contract, different transports.

Step 2: The attention side wraps native capture#

The AFD attention runner does not capture graphs itself. It wraps the native capture_model and publishes one warm-up payload and one capture payload for each FULL descriptor (afd_plugin/v1/worker/attention_model_runner_v2.py:509-553). An event tracker checks that native capture prepares inputs exactly twice per descriptor, in order (afd_plugin/v1/worker/attention_model_runner_v2.py:55-97).

        try:
            with (
                _use_afd_capture_input_preparation(self, event_tracker),
                _use_afd_ubatch_preparation(self, event_tracker),
            ):
                with use_afd_metadata_provider(
                    self.install_afd_metadata_on_forward_context,
                ):
                    result = super().capture_model(profile_only=profile_only)
                event_tracker.assert_complete()
            return result
capture_model: scope the AFD capture seam, then call native capture_model.

Step 3: The attention side publishes control before each replay#

Native FULL replay does not build a forward context, so the AFD metadata provider cannot run there. The runner wraps the graph manager's run_fullgraph and sends one padded control payload just before each native replay (afd_plugin/v1/worker/attention_model_runner_v2.py:270-303). This hook is used only in FULL_DECODE_ONLY mode, and only when a graph manager exists (afd_plugin/v1/worker/attention_model_runner_v2.py:323-327).

        try:
            padded_tokens = int(desc.num_tokens)
            metadata = runner.build_afd_metadata(None, real_tokens)
            metadata.tokens_lens = [padded_tokens]
            runner._afd_pending_metadata = metadata
            runner._afd_suppress_metadata_send = True
            runner._is_warmup = False
            runner._afd_is_graph_capturing = False
            runner._afd_is_graph_replaying = True
            runner.send_dp_metadata(
                runner.build_capture_dp_metadata(padded_tokens),
                None,
            )
            result = original_run_fullgraph(desc)
        finally:
            runner._afd_is_graph_replaying = previous_is_graph_replaying
        # ### PATCH END: publish one AFD pre-replay payload.
        return result
run_fullgraph wrapper: publish one padded control payload, then run the native replay.

Step 4: The FFN side keys one graph per shape#

The FFN runner builds a hashable FFN graph key from the per-stage DP metadata, then looks up a stored graph (afd_plugin/v1/worker/ffn_model_runner.py:166-174). On a hit in replay mode it calls graph.replay() and returns (afd_plugin/v1/worker/ffn_model_runner.py:175-177).

        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 graph key, then replay the stored graph or run the forward.

The run mode is chosen by a small helper. It returns REPLAY only when the graph is enabled and a graph for this key exists (afd_plugin/v1/worker/cuda_graph.py:136-150).

Step 5: The FFN side captures its own graph#

The FFN server loop reads the control payload, then calls capture_model for warm-up and capture steps, or execute_model otherwise (afd_plugin/v1/worker/ffn_worker.py:182-196). Capture runs _dummy_run, which records one torch.cuda.CUDAGraph into the runner's own graph pool and stores it under the key (afd_plugin/v1/worker/ffn_model_runner.py:316-351).

        if mode_name == "FULL":
            if self._graph_memory_pool is None:
                self._graph_memory_pool = torch.cuda.graph_pool_handle()
            graph_key = make_ffn_graph_key(dp_metadata_list)
            cudagraph = torch.cuda.CUDAGraph()
            # DP metadata receive/update is a control-plane side effect and must
            # complete before CUDA graph capture starts.
            cast(
                AFDControlPlane, self.connector.control_plane
            ).update_state_from_dp_metadata(
                _make_dp_metadata_payload(
                    dp_metadata_list,
                    is_graph_capturing=is_attn_graph_capturing,
                ),
            )
            with torch.cuda.graph(cudagraph, pool=self._graph_memory_pool):
                output = self._ffn_forward(
                    dp_metadata_list=dp_metadata_list,
                    is_graph_capturing=is_attn_graph_capturing,
                    update_connector_state=False,
                )
            self._cuda_graphs[graph_key] = {
                "graph": cudagraph,
                "input_hidden_states": output,
                "output": output,
            }
_dummy_run: capture one CUDA graph per key, after the control-plane receive.

Step 6: Custom ops keep the transfers capturable#

The data path uses vLLM's PyNcclCommunicator send and receive, wrapped in four custom ops (afd_plugin/connectors/gpu/p2p.py:979-1122). The wrapping keeps the transfers traceable by torch.compile and capturable by a CUDA graph (afd_plugin/connectors/gpu/p2p.py:36-42).

Deep dive#

The blocks below give the capture handshake, the FFN graph cache, why the two sides must agree on shapes, and a worked example.

Deep dive

The attention capture handshake, step by step

Native vLLM prepares graph inputs once before each warm-up forward and once before each capture forward. AFD must publish control at that exact seam. The capture wrapper sets this up (afd_plugin/v1/worker/attention_model_runner_v2.py:509-553).

  1. Collect the FULL descriptors. The wrapper reads the manager's FULL capture descriptors and mirrors the native profile-only truncation (afd_plugin/v1/worker/attention_model_runner_v2.py:511-517).
  2. Make an event tracker. It expects each descriptor twice: once for warm-up, once for capture (afd_plugin/v1/worker/attention_model_runner_v2.py:62-64).
  3. Scope two input-preparation seams. One wraps the native capture input preparation; one wraps the ubatch prepare path (afd_plugin/v1/worker/attention_model_runner_v2.py:537-540).
  4. Publish one event per forward. The non-ubatch seam sends the DP metadata before the forward and marks the step as warm-up or capture (afd_plugin/v1/worker/attention_model_runner_v2.py:214-227).
  5. Check the count. After native capture, the tracker asserts it saw the exact expected count, so capture order cannot drift (afd_plugin/v1/worker/attention_model_runner_v2.py:91-97).

At run time, the run_fullgraph hook publishes one padded payload before each replay. For a DBO step with two stages, the prepare seam already sent the full two-stage payload, so the hook only asserts and delegates (afd_plugin/v1/worker/attention_model_runner_v2.py:275-280).

Deep dive

The FFN graph key and the graph cache

The FFN runner holds its graphs in a plain dictionary, _cuda_graphs, keyed by the FFN graph key (afd_plugin/v1/worker/ffn_model_runner.py:97). The key comes from make_ffn_graph_key (afd_plugin/v1/worker/cuda_graph.py:104-133).

The key is built from the per-stage token counts in the DP metadata. When the attention count is a whole multiple of the FFN count, the helper sums each FFN rank's group of attention counts, so the key matches what the FFN rank will actually receive (afd_plugin/v1/worker/cuda_graph.py:177-199).

The run mode comes from a pure function:

ConditionMode
is_warmupWARMUP (afd_plugin/v1/worker/cuda_graph.py:144-145)
is_graph_capturingCAPTURE (afd_plugin/v1/worker/cuda_graph.py:146-147)
replaying, enabled, and a graph exists for the keyREPLAY (afd_plugin/v1/worker/cuda_graph.py:148-149)
otherwiseEAGER (afd_plugin/v1/worker/cuda_graph.py:150)

The graph cache is on only when the policy enables it for the FFN role (afd_plugin/v1/worker/ffn_model_runner.py:94-96, afd_plugin/v1/worker/cuda_graph.py:83).

Deep dive

Why both sides must agree on shapes

A CUDA graph records fixed tensor addresses. A replay reuses them, so the FFN side must receive into stable buffers of the exact captured shape. The control plane prepares these.

When a DP metadata payload arrives, update_state_from_dp_metadata computes the per-stage tensor metadata. On an FFN rank, and only when graphs are enabled, it preallocates receive buffers so capture and replay reuse stable allocations (afd_plugin/connectors/gpu/p2p.py:820-851). Hidden-state receive buffers are cached only while capturing; eager steps allocate per receive (afd_plugin/connectors/gpu/p2p.py:829-840).

So the shape chain is: the attention side sends the token counts in the DP metadata; both sides build the same FFN graph key from those counts; the FFN side preallocates buffers for that shape. If the shapes do not match a captured key, the FFN side falls back to eager for that step (afd_plugin/v1/worker/cuda_graph.py:148-150). This is why FULL_DECODE_ONLY fits AFD: pure-decode steps have one token per request, so the shapes are stable and few.

Deep dive

Worked example: a 2A2F decode step with graphs on

Take the graph recipe: 2A2F, DP1, TP2, FULL_DECODE_ONLY, capture size 64 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:26-29).

  1. Start-up. The attention runner calls native capture_model. For the size-64 descriptor it sends one warm-up payload, then one capture payload (afd_plugin/v1/worker/attention_model_runner_v2.py:509-553).
  2. FFN capture. The FFN loop receives the warm-up and capture payloads, and capture_model records one graph for that key (afd_plugin/v1/worker/ffn_worker.py:182-189).
  3. Decode. A pure-decode step arrives. The attention side replays its native graph; the run_fullgraph hook sends one padded payload first (afd_plugin/v1/worker/attention_model_runner_v2.py:295-299).
  4. FFN replay. The FFN side builds the same key, finds the stored graph, and calls graph.replay() (afd_plugin/v1/worker/ffn_model_runner.py:175-177).
  5. A prefill arrives. The token count differs, so no key matches; both sides run eager for that step.

What can go wrong#

Why it matters. A CUDA graph replays fixed addresses. The two roles must agree on the token shapes, or the FFN side cannot reuse a graph. AFD keeps the shapes stable by limiting graphs to pure-decode steps and keying each graph on the DP metadata.

Check yourself#

  1. 1
    Which CUDA graph mode does AFD support on GPU?
    easy
    Show answer

    Only FULL_DECODE_ONLY. The supported-modes set holds one name (afd_plugin/v1/worker/cuda_graph.py:20-21), and any other mode raises (afd_plugin/v1/worker/cuda_graph.py:61-66).

  2. 2
    Where does the FFN side store its captured graphs?
    easy
    Show answer

    In a plain dictionary, _cuda_graphs, keyed by the FFN graph key (afd_plugin/v1/worker/ffn_model_runner.py:97, afd_plugin/v1/worker/cuda_graph.py:104-133).

  3. 3
    Native FULL replay does not build a forward context. How does the attention side still publish control?
    medium
    Show answer

    It wraps the graph manager's run_fullgraph and sends one padded control payload just before each native replay (afd_plugin/v1/worker/attention_model_runner_v2.py:270-303).

  4. 4
    A prefill step arrives while graphs are on. What happens?
    medium
    Show answer

    The token count does not match any captured key, so the run mode is EAGER and both sides run that step without a graph (afd_plugin/v1/worker/cuda_graph.py:148-150). Graphs cover pure-decode steps only.

  5. 5
    Why can the FFN side reuse one captured graph across decode steps, and what prepares the receive buffers?
    hard
    Show answer

    Pure-decode steps have stable token shapes, so the FFN graph key repeats. When graphs are enabled, update_state_from_dp_metadata preallocates receive buffers of the captured shape while capturing, so replay reuses stable addresses (afd_plugin/connectors/gpu/p2p.py:820-851).