AFD Plugin Explained

Tour: Start-up

AdvancedGuided toursabout 9 min read + 1 min deep dives

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

In one sentence

Follow both AFD servers from vllm serve to ready. The plugin loads, the config patch picks the role worker, the two roles meet at the connector, and both sides capture graphs.

The idea#

AFD runs two vllm serve processes, one per role, with the same model. Each process loads the afd-plugin, which rewrites the worker class for its role. The attention server comes up like a normal vLLM server. The FFN server stops its EngineCore early and runs a connector loop instead. The two roles then meet at the connector and capture CUDA graphs.

This tour uses the graph recipe for one attention server and one FFN server: 2A2F, DP1, TP2, with DBO and FULL_DECODE_ONLY graphs (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:7-32).

Keep one rule in mind. The two roles start as separate processes and can come up in either order. They synchronize at one point: the collective connector handshake. Everything before it is per-process; the handshake is where the roles meet.

The picture#

The two diagrams split the start-up in half. The first follows the attention server; the second follows the FFN server. Read each top to bottom.

Figure: half 1. The attention server loads the plugin, the config patch picks the AFD worker, and the runner swap builds the AFD model runner. The connector init is deferred to the end of load_model.
Open full size ↗
Figure: half 2. The FFN EngineCore returns after the executor; its worker loads the model, joins the connector, and runs a daemon loop driven by the control plane.
Open full size ↗

1vllm serve starts both roles#

Each role is a separate vllm serve command with its own --additional-config. The only difference between the two in this recipe is role (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:11-19).

    --additional-config '{
        "afd": {
            "role": "attention",
            "connector": "P2pNcclAFDConnector",
            "host": "127.0.0.1",
            "port": 6269,
            "num_attention_ranks": 2,
            "num_ffn_ranks": 2
        }
    }' \
    --max-num-seqs 64 \
    --max-num-batched-tokens 64 \
    --enable-dbo \
    --dbo-decode-token-threshold 2 \
    --dbo-prefill-token-threshold 12 \
    --max-cudagraph-capture-size 64 \
    --compilation-config '{
        "cudagraph_mode": "FULL_DECODE_ONLY", "cudagraph_capture_sizes":[64]
    }' \
The attention server: role, DBO flags, and FULL_DECODE_ONLY graphs.

Both commands set VLLM_USE_V2_MODEL_RUNNER=1 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:5). AFD needs the V2 model runner on. See Worker selection: choosing the role worker.

2vLLM loads the plugin in process 0#

vLLM loads general plugins at the start of add_cli_args, in the launch process (vllm:vllm/engine/arg_utils.py:3009), and again when EngineArgs is built (vllm:vllm/engine/arg_utils.py:839). Both run in process 0 only. The loader has a once-per-process guard, so repeat calls are cheap (vllm:vllm/plugins/__init__.py:82-85).

The plugin entry point is afd = "afd_plugin:register_afd" (pyproject.toml:45-46). The loader calls it. See How vLLM plugins work.

3register_afd installs the patches#

register_afd runs once per process. It checks the vLLM version (warn only), imports the four patch modules, registers the routing strategy and the DBO op, and registers the AFD model wrappers (afd_plugin/__init__.py:142-216).

    try:
        import afd_plugin.compat.patches.async_dp_engine  # noqa: F401
        import afd_plugin.compat.patches.async_dp_forward_context  # noqa: F401
        import afd_plugin.compat.patches.config_validation  # noqa: F401
        import afd_plugin.compat.patches.engine_core  # noqa: F401
    except Exception:
        _logger.debug(
            "AFD plugin: compatibility patches could not be applied",
            exc_info=True,
        )

    from afd_plugin.model_executor.routing_simulator import (
        register_afd_balanced_routing_strategy,
    )

    register_afd_balanced_routing_strategy()

    try:
        from afd_plugin.v1.worker.dbo import register_dbo_yield_custom_op

        register_dbo_yield_custom_op()
register_afd: import the patch modules, then register the routing strategy and the DBO op.

The patch imports install the compat patches at import time, behind the version gate. One of them, config_validation, is what rewrites the worker class next. See Compat patches: how AFD changes vLLM without editing it.

4The config patch rewrites worker_cls#

When vLLM builds the engine config, the patched create_engine_config runs. It rewrites worker_cls to the AFD role worker, but only when the value was "auto" (afd_plugin/compat/patches/config_validation.py:60, afd_plugin/compat/patches/config_validation.py:92-93).

def _select_afd_worker_for_auto(vllm_config: VllmConfig) -> None:
    afd_config = parse_optional_afd_config(vllm_config)
    if afd_config is None:
        return

    from vllm.platforms import current_platform

    platform_worker_qualname = vllm_config.parallel_config.worker_cls
    if not isinstance(platform_worker_qualname, str):
        raise ValueError(
            "platform worker_cls must be a qualname string before AFD automatic "
            f"selection, got {type(platform_worker_qualname).__name__}",
        )
    vllm_config.parallel_config.worker_cls = afd_worker_qualname_for_platform_default(
        afd_config.role,
        platform_worker_qualname,
        is_cuda=current_platform.is_cuda(),
        device_type=current_platform.device_type,
    )
_select_afd_worker_for_auto: pick the AFD role worker for the platform default.

The attention role gets AFDAttentionWorker; the FFN role gets AFDFFNWorker (afd_plugin/validation.py:290-291). See Worker selection: choosing the role worker.

5The FFN EngineCore returns early#

The EngineCore process builds the model executor. For an FFN config, the patched __init__ returns right after that, so it sets up no real scheduler (only a no-op stand-in) and no KV cache (afd_plugin/compat/patches/engine_core.py:56-68).

    # ### PATCH START: AFD FFN EngineCore daemon initialization
    # FFN ranks are connector daemons, so stop EngineCore initialization after
    # executor construction instead of setting up KV cache and scheduler state.
    if _is_afd_ffn_config(vllm_config):
        _initialize_ffn_engine_core(
            self,
            core_module,
            vllm_config,
            executor_class,
            log_stats,
            executor_fail_callback,
        )
        return
    # ### PATCH END: AFD FFN EngineCore daemon initialization
FFN EngineCore: initialize the daemon, then return before scheduler and KV setup.

The FFN path sets zero GPU and CPU blocks and installs a no-op scheduler, which is not None so the native readiness handshake still works (afd_plugin/compat/patches/engine_core.py:605-614). The attention EngineCore is unchanged.

6The workers load the plugin and swap the runner#

Each worker process loads the plugin again before the worker class is resolved (vllm:vllm/v1/worker/worker_base.py:271), then resolves the class (vllm:vllm/v1/worker/worker_base.py:273-277). The guard makes the repeat load a no-op.

In init_device, the AFD worker validates the stack, then swaps the native runner class for the AFD runner while native construction runs (afd_plugin/v1/worker/attention_worker.py:82-87).

        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.
The attention worker swaps in AFDAttentionModelRunnerV2 for native construction.

The FFN worker does the same swap for GPUFFNModelRunner (afd_plugin/v1/worker/ffn_worker.py:93-98). See Worker selection: choosing the role worker.

7load_model defers the connector init (attention)#

The attention runner loads the model, then initializes the connector at the end of load_model, 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 weight loading, then init_afd_connector at the end.

The connector itself is built earlier, in the runner constructor (afd_plugin/v1/worker/attention_model_runner_v2.py:384-390). Only the rendezvous is deferred.

8The two roles meet at the connector handshake#

init_afd_connector is a collective call: every attention and FFN rank runs it, and the two roles meet here (afd_plugin/connectors/base.py:124). The P2P connector builds its process groups at a rendezvous on the configured host and port (afd_plugin/connectors/gpu/p2p.py:245-276).

        afd_pg = init_afd_process_group(
            backend="nccl",
            init_method=f"tcp://{self.afd_config.host}:{self.afd_config.port}",
            world_size=self.ffn_size + self.attn_size,
            rank=self.world_rank,
            group_name="afd",
            timeout=timedelta(seconds=self.afd_config.afd_process_group_timeout_s),
        )
The AFD world group: all ranks, with the configured timeout.

Either role can reach this point first. The group setup blocks until both roles arrive, or until the timeout (afd_plugin/connectors/gpu/p2p.py:271). See Topology and role ranks: the AFD world.

Deep dive

The three process groups and their timeouts

init_afd_connector builds three groups (afd_plugin/connectors/gpu/p2p.py:245-316):

  1. The AFD world group, named "afd". It holds every attention and FFN rank. Its timeout is afd_process_group_timeout_s, 120 seconds by default (afd_plugin/connectors/gpu/p2p.py:269-276).
  2. The control group, named "p2p". It holds the FFN ranks and the first min_size attention ranks, with a fixed 30-minute timeout (afd_plugin/connectors/gpu/p2p.py:306-314).
  3. The data-plane subgroups. Each FFN rank and its attention peers share one subgroup, built on a PrefixStore over the world rendezvous, with a 300-second timeout (afd_plugin/connectors/gpu/p2p.py:278-304).

The subgroups reuse the world's rendezvous store, so they need no port of their own (afd_plugin/connectors/gpu/p2p.py:280-288). See Topology and role ranks: the AFD world.

9KV cache on the attention side only#

Only the attention side owns a KV cache. The FFN worker returns an empty KV spec, so vLLM allocates no FFN blocks (afd_plugin/v1/worker/ffn_worker.py:102-105). The FFN worker then joins the connector and starts its loop instead of allocating a cache (afd_plugin/v1/worker/ffn_worker.py:107-113).

    def initialize_from_config(self, kv_cache_config: KVCacheConfig) -> None:
        """Skip KV cache allocation and start the FFN connector loop."""

        self.cache_config.num_gpu_blocks = kv_cache_config.num_blocks
        self.model_runner.initialize_kv_cache(kv_cache_config)
        self.model_runner.initialize_afd_connector()
        self.start_ffn_server_loop()
initialize_from_config: skip KV allocation, join the connector, start the loop.

The attention runner allocates its KV cache as usual, with a scoped seam that enables DP1 DBO (afd_plugin/v1/worker/attention_model_runner_v2.py:473-486). See Attention and FFN roles: what each server owns.

10Both sides capture CUDA graphs#

The attention runner wraps native capture_model to publish warm-up and capture control for each FULL descriptor (afd_plugin/v1/worker/attention_model_runner_v2.py:509-553). The FFN worker performs no warm-up of its own (afd_plugin/v1/worker/ffn_worker.py:115-120); its graphs are captured from the server loop when the warm-up and capture payloads arrive (afd_plugin/v1/worker/ffn_worker.py:182-189).

So graph capture is driven by the attention side and mirrored on the FFN side. See CUDA graphs in AFD: FULL_DECODE_ONLY on both roles.

11The FFN server loop starts#

The FFN EngineCore busy loop starts the worker loop with a collective RPC (afd_plugin/compat/patches/engine_core.py:650-659). The worker starts a daemon thread, the FFN server loop (afd_plugin/v1/worker/ffn_worker.py:133-158).

def _run_ffn_busy_loop(self, core_module: Any) -> None:
    started = False
    try:
        self.model_executor.collective_rpc("start_ffn_server_loop")
        started = True
        # Emit readiness only after the collective start RPC succeeded; the
        # worker connector loop may not be ready to receive before this point.
        core_module.logger.info(
            "AFD FFN EngineCore started; workers run connector loop."
        )
The FFN EngineCore runs the connector loop through collective_rpc.

The loop blocks on the control plane for the first payload. The FFN server is now driven by the attention side (afd_plugin/v1/worker/ffn_worker.py:168-176). See v1/worker: the FFN worker and runner.

12Ready#

Both servers are up. The attention API server accepts requests; the FFN server runs its loop. The Tour: Life of a request tour follows one request from here, and Tour: One decode step follows one step in detail.

What can go wrong#

Why it matters. The two roles start apart and meet once, at the connector handshake. If the patches do not install, or the roles do not meet, start-up cannot complete.

Check yourself#

  1. 1
    What is the only difference between the two vllm serve commands in this recipe?
    easy
    Show answer
  2. 2
    Why does the FFN EngineCore not set up a scheduler or KV cache?
    easy
    Show answer

    For an FFN config it returns right after the executor is built; it runs a connector loop instead (afd_plugin/compat/patches/engine_core.py:56-68).

  3. 3
    The two roles start as separate processes. Where do they synchronize?
    medium
    Show answer

    At the collective connector handshake, init_afd_connector. Every rank runs it, and the group setup blocks until both roles arrive (afd_plugin/connectors/base.py:124, afd_plugin/connectors/gpu/p2p.py:269-276).

  4. 4
    Why is the attention connector rendezvous deferred to the end of load_model?
    medium
    Show answer

    So attention and FFN weight loading overlap across the two roles (afd_plugin/v1/worker/attention_model_runner_v2.py:454-462).

  5. 5
    The FFN worker does no warm-up of its own. How are its CUDA graphs captured?
    hard
    Show answer

    The attention side drives capture by sending warm-up and capture control. The FFN server loop reads those payloads and calls capture_model (afd_plugin/v1/worker/ffn_worker.py:115-120, afd_plugin/v1/worker/ffn_worker.py:182-189).