AFD Plugin Explained

The attention runtime: a seam over Model Runner V2

AdvancedAFD conceptsabout 6 min read + 2 min deep dives

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

In one sentence

The attention runner, AFDAttentionModelRunnerV2, wraps the native vLLM Model Runner V2. It adds the connector and control plane, and reuses the rest.

The idea#

On the attention role, AFD does not rewrite the vLLM model runner. It subclasses it. AFDAttentionModelRunnerV2 inherits the native GPU Model Runner V2 and an AFD metadata mixin (afd_plugin/v1/worker/attention_model_runner_v2.py:358). The native class keeps request state, input preparation, attention, the KV cache, sampling and the output. The AFD subclass adds only the connector lifecycle and the control-plane publish.

The runner's own docstring states this split: native V2 keeps execution, dummy runs and the graph lifecycle; the AFD methods are connector and context plumbing (afd_plugin/v1/worker/attention_model_runner_v2.py:358-365).

An everyday comparison. Think of a seat cover on a car seat. It adds a pocket and a strap, but the seat, the frame and the belt stay the same. The comparison stops here: the runner's "pocket" must open and close at the exact moment vLLM builds each forward context.

The picture#

Figure: the AFD runner overrides a few seams and delegates to native V2. One of two seams publishes the DP metadata once per step.
Open full size ↗

Read panel 1 as a thin layer over the native class. Every override calls super() for the real work. Panel 2 shows the one control-plane publish per step, before the layer loop; the data plane then runs once per layer and stage.

In the code#

Step 1: Construction makes the connector and checks for a control plane#

The constructor validates the V2 contract, calls super().__init__, then builds the connector through the factory (afd_plugin/v1/worker/attention_model_runner_v2.py:369-411). It requires a control-plane-driven connector and raises otherwise (afd_plugin/v1/worker/attention_model_runner_v2.py:394-397).

            self.afd_config = self.parse_config(self.vllm_config)
            rank, local_rank = _resolve_world_ranks()
            connector = AFDConnectorFactory.create_connector(
                rank,
                local_rank,
                self.vllm_config,
                self.afd_config,
            )
            self.connector = connector
            # The connector rendezvous is deferred to the end of ``load_model()``
            # so Attention and FFN weight loading overlap, matching the V1
            # Attention runner lifecycle.
            if connector.control_plane is None:
                raise RuntimeError(
                    "AFD ModelRunnerV2 requires a control-plane-driven connector",
                )
Build the connector; a connector with no control plane is rejected.

Step 2: load_model defers the connector rendezvous to the end#

load_model calls native loading first, then initializes the connector. The comment explains the order: the rendezvous waits until weights are loaded, so attention and FFN weight loading overlap (afd_plugin/v1/worker/attention_model_runner_v2.py:454-462).

    def load_model(
        self,
        load_dummy_weights: bool = False,
        *args,
        **kwargs,
    ) -> None:
        super().load_model(load_dummy_weights, *args, **kwargs)
        if not self.connector.is_initialized:
            self.connector.init_afd_connector()
load_model: native load, then the collective connector init at the end.

Step 3: execute_model wraps native V2 in the AFD context#

execute_model steps the profiler, opens _use_afd_execution_context, then delegates the whole forward to super().execute_model (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,
            )
        # ### PATCH END: scope AFD metadata provider/replay and profiler step.
The AFD context scopes the metadata provider; native V2 runs the model and samples.

The forward is native, so logits and sampling are native too. The delegation at line 586 runs the model forward (afd_plugin/v1/worker/attention_model_runner_v2.py:586). Native V2 does the sampling in a separate method, sample_tokens, after the forward (vllm:vllm/v1/worker/gpu/model_runner.py:1997, vllm:vllm/v1/worker/gpu/model_runner.py:2048). The attention runner does not override sample_tokens, so it samples natively. The FFN runner overrides it to raise (afd_plugin/v1/worker/ffn_model_runner.py:405-406). Sampling runs once per step, after the last layer, on the attention side.

Step 4: The AFD metadata provider installs the sidecar#

Model wrappers read AFD metadata from the vLLM forward context. The provider installs it as vLLM makes each context. The plugin cannot extend set_forward_context, so it wraps create_forward_context for the scope and mutates additional_kwargs (afd_plugin/model_executor/models/forward_context.py:37-67).

    original_create = forward_context_module.create_forward_context

    @wraps(original_create)
    def create_forward_context_with_afd(*args: Any, **kwargs: Any) -> ForwardContext:
        forward_context = original_create(*args, **kwargs)
        installer(forward_context)
        return forward_context

    forward_context_module.create_forward_context = create_forward_context_with_afd
    try:
        yield
    finally:
        forward_context_module.create_forward_context = original_create
use_afd_metadata_provider: wrap the native context factory, then restore it.

The installer and the publish logic live in the shared mixin, AFDMetadataProviderMixin (afd_plugin/v1/worker/attention_metadata.py:21). Both the V2 runner and the NPU runners reuse it.

Step 5: The control plane publishes once per step#

The DP metadata crosses once per step, before the layer loop. One of two seams sends it. With DBO, the ubatch prepare seam sends it after it stages the microbatches (afd_plugin/v1/worker/attention_model_runner_v2.py:153-158).

        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
DBO path: publish the control payload from the prepare seam.

Without DBO, the forward-context provider sends it once, unless a graph lifecycle already pre-sent it (afd_plugin/v1/worker/attention_metadata.py:248-257). Both paths end in send_dp_metadata, which updates the connector state and sends the payload (afd_plugin/v1/worker/attention_metadata.py:115-116).

Deep dive#

Deep dive

_use_afd_execution_context, scope by scope

The context manager opens several scopes together, then restores the runner's sidecar state when it exits (afd_plugin/v1/worker/attention_model_runner_v2.py:316-355).

  1. use_gpu_mrv2_dbo_dispatch() lets native V2 run DBO on DP1 (afd_plugin/v1/worker/attention_model_runner_v2.py:342).
  2. _use_afd_fullgraph_replay_hook, but only in FULL_DECODE_ONLY graph mode (afd_plugin/v1/worker/attention_model_runner_v2.py:323-339).
  3. _use_afd_ubatch_preparation wraps the native ubatch prepare to attach the sidecar and publish control (afd_plugin/v1/worker/attention_model_runner_v2.py:344).
  4. use_afd_metadata_provider installs the sidecar on every new context (afd_plugin/v1/worker/attention_model_runner_v2.py:345-347).

The finally block restores the pending metadata, the suppress flag and the warm-up and graph flags (afd_plugin/v1/worker/attention_model_runner_v2.py:350-355).

Deep dive

How DBO and graph replay plug in

DBO. Dual-batch overlap runs two stages per layer. initialize_kv_cache scopes a factory so native V2 builds a DP1 ubatch runner (afd_plugin/v1/worker/attention_model_runner_v2.py:473-486). At run time the prepare seam stages the two microbatches and publishes one control payload for both (afd_plugin/v1/worker/attention_model_runner_v2.py:153-158). The Dual-batch overlap (DBO): two stages per step page reads the yield op.

Graph replay. AFD supports FULL_DECODE_ONLY graphs. capture_model wraps the native capture so each descriptor publishes one warm-up and one capture payload (afd_plugin/v1/worker/attention_model_runner_v2.py:509-553). Native FULL replay builds no context, so a per-manager hook publishes one padded control payload before each replay (afd_plugin/v1/worker/attention_model_runner_v2.py:270-303). The CUDA graphs in AFD: FULL_DECODE_ONLY on both roles page reads capture and replay in full.

Deep dive

What stays native

The AFD subclass never copies these; it inherits them from native V2 and the vLLM stack.

PartOwner
scheduler, token batchingvLLM EngineCore, native
request and input state, attention, KV cachenative GPUModelRunnerV2
the model forwardnative, through the AFD model wrapper
logits + sampling, once per step after the last layernative, on the attention side
ModelRunnerOutputnative

The attention side keeps the full vLLM serving loop. The connector and control plane are the only additions. The The FFN runtime: a connector-driven server loop page shows the FFN side, which drops all of this.

What can go wrong#

Why it matters. The runner must publish the DP metadata before the layers start, and attach the sidecar on every context. If a seam misses, the FFN side waits for a payload that never arrives, or a layer runs with no connector in scope.

Check yourself#

  1. 1
    Which native class does the attention runner subclass?
    easy
    Show answer

    The GPU GPUModelRunner (Model Runner V2), imported as GPUModelRunnerV2 (afd_plugin/v1/worker/attention_model_runner_v2.py:358).

  2. 2
    Where does sampling run in AFD?
    easy
    Show answer

    On the attention side, in native V2, once per step after the last layer. Native V2 samples in sample_tokens, which the attention runner inherits (vllm:vllm/v1/worker/gpu/model_runner.py:2048). The FFN runner overrides it to raise (afd_plugin/v1/worker/ffn_model_runner.py:405-406).

  3. 3
    Why does load_model initialize the connector at the end?
    medium
    Show answer

    So the collective rendezvous waits until weights are loaded, letting attention and FFN weight loading overlap (afd_plugin/v1/worker/attention_model_runner_v2.py:454-462).

  4. 4
    The plugin cannot extend set_forward_context. How does it attach AFD metadata to the forward context?
    medium
    Show answer

    It wraps create_forward_context for the scope and mutates additional_kwargs right after vLLM makes the context (afd_plugin/model_executor/models/forward_context.py:37-67).

  5. 5
    There are two control-plane publish seams. When is each used, and how often do they send per step?
    hard
    Show answer

    The DBO ubatch prepare seam sends when DBO stages microbatches (afd_plugin/v1/worker/attention_model_runner_v2.py:156-157); the forward-context provider sends on the non-DBO path, unless a graph lifecycle pre-sent it (afd_plugin/v1/worker/attention_metadata.py:248-257). Either way, the control plane sends once per step, before the layer loop.