AFD Plugin Explained

Deployment topologies: how to place the roles

IntermediateAFD conceptsabout 7 min read + 2 min deep dives

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

In one sentence

AFD runs as two or more vllm serve processes. A topology says how many attention and FFN ranks there are, and where they run.

The idea#

AFD splits one model into an attention role and an FFN role. Each role is a separate server process. A topology name xAyF gives the counts: x attention ranks and y FFN ranks (xAyF). The GPU recipes ship five shapes of this split.

Two rules fix the rank counts. For each role, the rank count must equal DP × TP: the attention ranks equal the attention server's data-parallel size times its tensor-parallel size, and the same for FFN (afd_plugin/validation.py:107-117). The attention ranks must also be at least the FFN ranks, A >= F (afd_plugin/distributed/topology.py:52-58).

An everyday comparison. Think of a kitchen with a front counter and a back kitchen. The counter takes orders (attention); the kitchen cooks (FFN). You can add counters or cooks, but you need at least as many counters as cooks, and each team's size is set when you open. The comparison stops at the wire: the two teams talk only through the connector.

The picture#

Figure: the five GPU shapes. Colocation puts both roles on one host; disaggregation adds prefillers and a proxy; multipod puts one role per k8s pod. The rank rule A = DP x TP, F = DP x TP, A >= F holds in every shape.
Open full size ↗

Read the three panels top to bottom. Panel 1 is the common case, two servers on one host. Panel 2 adds a separate prefill stage and a proxy. Panel 3 splits the same roles across k8s pods. The blue boxes are the attention role, the emerald boxes the FFN role.

In the code#

Step 1: The rank rule (A = DP x TP, F = DP x TP)#

The validator reads the role's rank count and compares it with DP × TP of that server. A mismatch raises before the run starts (afd_plugin/validation.py:107-117).

    configured_ranks = (
        afd_config.num_attention_ranks
        if expected_role == "attention"
        else afd_config.num_ffn_ranks
    )
    distributed_ranks = parallel.data_parallel_size * parallel.tensor_parallel_size
    if configured_ranks != distributed_ranks:
        raise RuntimeError(
            f"AFD ModelRunnerV2 {expected_role} ranks must match DP * TP: "
            f"configured={configured_ranks}, distributed={distributed_ranks}",
        )
The configured ranks must equal data-parallel size times tensor-parallel size.

So a 2A2F topology has two ways to reach two attention ranks: DP1 TP2 or DP2 TP1. Both give 2 = DP × TP. The Topology and role ranks: the AFD world page covers how a role rank maps to a world rank.

Step 2: Colocation 2A2F (two servers, four GPUs)#

The simplest recipe runs one attention server on GPUs 0 and 1, and one FFN server on GPUs 2 and 3. Each server sets num_attention_ranks: 2 and num_ffn_ranks: 2, so the topology is 2A2F (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:7-32).

CUDA_VISIBLE_DEVICES=0,1 uv run vllm serve "$MODEL_PATH" \
    --data-parallel-size 1 \
    --tensor-parallel-size 2 \
    --enable-expert-parallel \
    --additional-config '{
        "afd": {
            "role": "attention",
            "connector": "P2pNcclAFDConnector",
            "host": "127.0.0.1",
            "port": 6269,
            "num_attention_ranks": 2,
            "num_ffn_ranks": 2
        }
    }' \
The attention server: GPUs 0,1, DP1 TP2, role attention, 2A2F.

The DP/TP split is a choice. The dp1tp2 variant uses DP1 TP2 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:8-9); the dp2tp1 variant uses DP2 TP1 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_eager_dbo_dp2tp1.sh:8-9). Both give two ranks per role.

Step 3: Colocation 4A4F (double both roles)#

The 4A4F recipe doubles both sides: four attention ranks on GPUs 0-3, four FFN ranks on GPUs 4-7, each server DP2 TP2 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/4a4f_graph_dbo_dp2tp2.sh:7-18).

CUDA_VISIBLE_DEVICES=0,1,2,3 uv run vllm serve "$MODEL_PATH" \
    --data-parallel-size 2 \
    --tensor-parallel-size 2 \
    --enable-expert-parallel \
    --additional-config '{
        "afd": {
            "role": "attention",
            "connector": "P2pNcclAFDConnector",
            "host": "127.0.0.1",
            "port": 6269,
            "num_attention_ranks": 4,
            "num_ffn_ranks": 4
4A4F: eight GPUs, DP2 TP2 per role, four ranks each.

Step 4: Disaggregation 2P1A1F (prefillers and a proxy)#

The disaggregation recipe adds a prefill stage. It runs five processes: two prefillers, one attention decode server, one FFN decode server, and a proxy (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/README.md:40-50). The attention and FFN servers each use 1A1F (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_disaggregation/2p1a1f_eager_dbo.sh:56-98).

# ----- Disaggregation proxy (client-facing endpoint, HTTP 18305) -----
# vLLM-native NIXL load-balance proxy. For each request it prefills on a
# prefiller (do_remote_decode), reads back the prefiller's NIXL handshake
# metadata (remote_engine_id / remote_block_ids / remote_host / remote_port) in
# kv_transfer_params, then hands it to the decoder with do_remote_prefill so the
# decoder pulls the KV. Send benchmark/client traffic to 18305, NOT to 18303.
# Wait for the prefill/attn/ffn servers to print "Application startup complete"
# before this proxy starts accepting traffic.
uv run python "$SCRIPT_DIR/../../../../../tools/proxy_server.py" \
    --host 127.0.0.1 \
    --port 18305 \
    --prefiller-hosts 127.0.0.1 127.0.0.1 \
    --prefiller-ports 18301 18302 \
    --decoder-hosts 127.0.0.1 \
    --decoder-ports 18303 \
    > proxy.log 2>&1
The proxy: a NIXL load-balance proxy in front of the prefillers and the decode pair.

Step 5: Multipod on Kubernetes#

The multipod recipes put one role per pod. Each vllm serve is wrapped in an if [ "$POD" == ... ] block, so with POD unset neither block runs (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/multipod_2a_2f_graph_dbo_dp1tp2.sh:15-16). These scripts are for the deploy-afd-k8s skill, not for a local run (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/README.md:62-69).

AFD_CONNECTOR_HOST=${AFD_CONNECTOR_HOST:-vllm-ffn-p2p-service}
AFD_CONNECTOR_PORT=${AFD_CONNECTOR_PORT:-6269}

if [ "$POD" == "ATTENTION_0" ]; then
  CUDA_VISIBLE_DEVICES=0,1 uv run vllm serve "$MODEL_PATH" \
The POD gate and the connector host, resolved inside the cluster.

The Qwen3.5 122B FP8 recipe is a three-pod 4A2F split: two attention pods and one FFN pod. The second attention pod sets ATTENTION_HEADLESS=1 and ATTENTION_DP_START_RANK=2 (recipe/gpu/P2pNcclAFDConnector/qwen3_5_122b_a10b_fp8/prefill_decode_colocation/multipod_2a_2a_2f_graph.sh:6-8). Each attention pod runs --data-parallel-size 4 with --data-parallel-size-local 2 (recipe/gpu/P2pNcclAFDConnector/qwen3_5_122b_a10b_fp8/prefill_decode_colocation/multipod_2a_2a_2f_graph.sh:33-34). So the two pods together hold the four attention ranks: global DP4, two local ranks per pod, with the second pod starting at rank 2 (recipe/gpu/P2pNcclAFDConnector/qwen3_5_122b_a10b_fp8/prefill_decode_colocation/multipod_2a_2a_2f_graph.sh:27-28). The FFN pod runs DP2 (recipe/gpu/P2pNcclAFDConnector/qwen3_5_122b_a10b_fp8/prefill_decode_colocation/multipod_2a_2a_2f_graph.sh:70).

Deep dive#

Deep dive

Every GPU topology at a glance

The table lists the AFD serve processes of each GPU recipe. The Recipes reference page generates the full table from the scripts.

TopologyProcessesGPUsDP x TP (per role) Ranks (A / F)Recipe
2A2F2 serve0,1 and 2,3DP1 TP2 or DP2 TP12 / 2 2a2f_*
4A4F2 serve0-3 and 4-7DP2 TP24 / 4 4a4f_*
2P1A1F5 (2 prefill + A + F + proxy)0,1,2,3 + proxyDP1 TP1 (decode) 1 / 1 2p1a1f_*
multipod 2A2F2 pods0,1 per podDP1 TP22 / 2 multipod_2a_2f
multipod 4A2F (Qwen3.5)3 pods0,1 per pod A: DP4 TP1 (DP2 local x2); F: DP2 TP14 / 2 multipod_2a_2a_2f

The Qwen3.5 topology is the one case where A > F (4 > 2). The attention ranks spread over the FFN ranks in blocks that differ in size by at most one (afd_plugin/distributed/topology.py:107-115). See Topology and role ranks: the AFD world.

Deep dive

Eager and graph variants

Each DeepSeek-V2-Lite colocation topology ships an eager and a graph variant. They differ in two flags.

On GPU, only FULL_DECODE_ONLY is allowed (afd_plugin/v1/worker/cuda_graph.py:20-21). The CUDA graphs in AFD: FULL_DECODE_ONLY on both roles page covers capture and replay, and Tuning: the knobs the repo exposes covers the capture size.

Deep dive

Start order and ports

A recipe backgrounds its serve processes, writes attn.log and ffn.log, then waits (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:29-61).

  1. Both roles start together. They meet at the AFD rendezvous host:port, default 127.0.0.1:1239; the recipes set 6269 (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:15-16).
  2. Wait for Application startup complete in each log before you send traffic (recipe/gpu/P2pNcclAFDConnector/README.md:39-43).
  3. Send requests only to the client-facing HTTP endpoint. In colocation that is the attention server's HTTP port (18305) (README.md:258-260, recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/README.md:40-50). In disaggregation that is the proxy (18305), not the decode-attention server's own port (18303) (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_disaggregation/2p1a1f_eager_dbo.sh:110-115).

The two ports differ in meaning. The AFD port (6269) is where the two roles set up the connector groups. The client-facing HTTP port (18305) is where clients send requests: the attention server in colocation, the proxy in disaggregation.

What can go wrong#

Why it matters. The topology fixes the process count, the GPU count and the rank mapping. A wrong rank count or a wrong host stops the run before the first token.

Check yourself#

  1. 1
    A 2A2F topology can use which two DP/TP splits per role?
    easy
    Show answer

    DP1 TP2 or DP2 TP1. Both give two ranks, because the rank count must equal DP × TP (afd_plugin/validation.py:107-117).

  2. 2
    How many vllm serve processes does the 2P1A1F recipe start, and what are they?
    easy
    Show answer

    Five: two prefillers, one attention decode server, one FFN decode server, and a proxy (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/README.md:40-50).

  3. 3
    What does the proxy in 2P1A1F do, and why is it not part of the attention/FFN split?
    medium
    Show answer

    It load-balances prefill and decode with a NIXL KV transfer (tools/proxy_server.py:1). The attention/FFN split happens on the decode pair over AFD port 6269, a different path (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_disaggregation/2p1a1f_eager_dbo.sh:81-85).

  4. 4
    In the Qwen3.5 multipod recipe, how do two attention pods make four attention ranks?
    medium
    Show answer
  5. 5
    The graph variants replace one flag. Which flag, with what, and which graph mode is allowed on GPU?
    hard
    Show answer

    They replace --enforce-eager with --max-cudagraph-capture-size 64 and a --compilation-config that sets cudagraph_mode to FULL_DECODE_ONLY (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:26-29). GPU AFD allows only FULL_DECODE_ONLY (afd_plugin/v1/worker/cuda_graph.py:20-21).