Tour: Start-up
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.
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]
}' \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()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,
)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 initializationThe 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 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()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),
)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 diveThe three process groups and their timeouts
init_afd_connector builds three groups (afd_plugin/connectors/gpu/p2p.py:245-316):
- The AFD world group, named
"afd". It holds every attention and FFN rank. Its timeout isafd_process_group_timeout_s, 120 seconds by default (afd_plugin/connectors/gpu/p2p.py:269-276). - The control group, named
"p2p". It holds the FFN ranks and the firstmin_sizeattention ranks, with a fixed 30-minute timeout (afd_plugin/connectors/gpu/p2p.py:306-314). - The data-plane subgroups. Each FFN rank and its attention peers share one subgroup, built
on a
PrefixStoreover 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()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 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#
- A host or port mismatch. The two roles meet at the same rendezvous. Different values make
the handshake block until the timeout (
afd_plugin/connectors/gpu/p2p.py:271-276). - An explicit non-AFD worker. The config patch rewrites
worker_clsonly when it was"auto"; an explicit non-AFD worker is rejected later (afd_plugin/validation.py:357-367). - A mismatched vLLM. On a non-matching, non-dev vLLM the install gate skips the patches, so
worker_clsis never rewritten and AFD does not take effect (afd_plugin/compat/vllm.py:53-68). - V2 runner off. AFD needs the V2 model runner on; the validator raises when V2 resolves to
off (
afd_plugin/validation.py:90-91). The recipes set the env variable (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:5).
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#
- 1What is the only difference between the twoeasy
vllm servecommands in this recipe?Show answer
The
rolefield in the AFD config: one isattention, one isffn(recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:13,recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:39). - 2Why 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). - 3The 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). - 4Why is the attention connector rendezvous deferred to the end ofmedium
load_model?Show answer
So attention and FFN weight loading overlap across the two roles (
afd_plugin/v1/worker/attention_model_runner_v2.py:454-462). - 5The 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).