AFD Plugin Explained

Attention and FFN roles: what each server owns

IntermediateAFD conceptsabout 6 min read + 2 min deep dives

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

In one sentence

AFD runs one model as two servers. The attention role keeps vLLM's front end; the FFN role is a connector-driven loop.

The idea#

A normal vLLM server owns the whole request path: the API server, the scheduler, the KV cache, the model and the sampler. AFD keeps all of that on the attention server. The FFN server is different. It has no API server, no KV cache and no real scheduler (only a no-op stand-in). A connector loop drives it: it waits for work, runs the feed-forward and MoE experts, and sends the result back.

Each side also owns a different part of every decoder layer. The attention server builds the real attention module and sends its output to the FFN side. The FFN server builds the real experts and drops the attention module.

An everyday comparison. Think of a kitchen with a front counter. The counter takes the order, tracks the bill and hands finished plates to the customer. The cooks only cook what the counter passes through. The comparison stops here: the FFN server is not a separate service. It runs part of the same model's forward pass, once per layer, for every step.

The picture#

Figure: the attention server keeps vLLM's front end; the FFN server drops the scheduler, KV cache and sampler and runs a connector loop.
Open full size ↗

Read panel 1 as a comparison of what each server owns. Read panel 2 as the split of one decoder layer: the same layer is built with different modules on each side.

In the code#

Step 1: Two workers, two runners#

AFD does not register its role workers through a vLLM API. A patch rewrites parallel_config.worker_cls when it is "auto"; see Worker selection: choosing the role worker. Each role worker then swaps in its own model runner inside init_device (afd_plugin/v1/worker/attention_worker.py:82-87, afd_plugin/v1/worker/ffn_worker.py:93-98).

RoleWorker classRunner classOwns
attention AFDAttentionWorker (afd_plugin/v1/worker/attention_worker.py:22) AFDAttentionModelRunnerV2 (afd_plugin/v1/worker/attention_model_runner_v2.py:358) API server, scheduler, KV cache, sampler, native self_attn, remote FFN proxy
FFN AFDFFNWorker (afd_plugin/v1/worker/ffn_worker.py:32) GPUFFNModelRunner (afd_plugin/v1/worker/ffn_model_runner.py:52) native MoE / MLP experts, the connector server loop

Step 2: The FFN EngineCore returns early#

A patch on EngineCore.__init__ checks the role. For an FFN config, it builds the model executor and then returns, before the KV cache and the scheduler are set up (afd_plugin/compat/patches/engine_core.py:59-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
The FFN EngineCore stops after the executor is built.

The FFN EngineCore sets the KV block counts to zero and installs a no-op scheduler. The no-op scheduler is not None, because a native readiness handshake calls a method on it (afd_plugin/compat/patches/engine_core.py:605-606, afd_plugin/compat/patches/engine_core.py:608-614).

Step 3: The FFN worker has no KV cache and does not sample#

The FFN worker reports an empty KV cache spec, so vLLM allocates no KV blocks for it (afd_plugin/v1/worker/ffn_worker.py:102-105). The FFN runner repeats this (afd_plugin/v1/worker/ffn_model_runner.py:147-148).

    def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]:
        """FFN workers do not allocate KV cache."""

        return {}
The FFN worker allocates no KV cache.

Two methods guard against the native scheduler path. The scheduler-driven execute_model raises, and the FFN runner's sample_tokens raises (afd_plugin/v1/worker/ffn_worker.py:122-131, afd_plugin/v1/worker/ffn_model_runner.py:405-406).

    def execute_model(
        self,
        scheduler_output: SchedulerOutput,
    ) -> ModelRunnerOutput | AsyncModelRunnerOutput | None:
        """Fail fast if the default scheduler tries to execute FFN work."""

        raise RuntimeError(
            "AFD FFN workers are connector-driven; scheduler-driven "
            "execute_model() is not supported.",
        )
Scheduler-driven FFN execution fails fast.

Step 4: Each role builds half of a decoder layer#

The model wrapper is role-aware. AFDDeepseekV2DecoderLayer reads the role, then builds only the modules that role owns (afd_plugin/model_executor/models/deepseek_v2.py:383-396). On the attention role it builds the native attention module (afd_plugin/model_executor/models/deepseek_v2.py:474-491) and replaces the feed-forward:

On the FFN role it drops attention (a PPMissingLayer) and keeps the native experts (afd_plugin/model_executor/models/deepseek_v2.py:520, afd_plugin/model_executor/models/deepseek_v2.py:538-559). The Model wrappers: the role-aware decoder layer page reads this split in full.

Step 5: Where requests go, and the start order#

Requests go only to the attention API server. The two servers may start in either order (README.md:258-260).

Attention and FFN may be started in either order. Send requests only to the
Attention API server. FFN workers are connector-driven; scheduler-driven FFN
`execute_model()` calls fail fast.
From the README.

Deep dive#

Deep dive

What each side owns, line by line

The table maps each responsibility to the line that proves it.

PartAttention serverFFN server
API serveryes (native vLLM front end)no: requests never arrive (README.md:258-260)
scheduleryes (native)no-op stand-in (afd_plugin/compat/patches/engine_core.py:614)
KV cacheyes (native, per layer)none (afd_plugin/v1/worker/ffn_worker.py:102-105)
sampler / logitsnative, every step (afd_plugin/v1/worker/attention_model_runner_v2.py:582-594)raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406)
attention modulenative self_attn (afd_plugin/model_executor/models/deepseek_v2.py:474-491)PPMissingLayer (afd_plugin/model_executor/models/deepseek_v2.py:520)
feed-forward / expertsremote proxy (afd_plugin/model_executor/models/deepseek_v2.py:516-517)native MoE / MLP (afd_plugin/model_executor/models/deepseek_v2.py:538-559)
what drives a stepthe vLLM schedulerthe connector loop (afd_plugin/v1/worker/ffn_worker.py:160-196)

The attention side keeps logits and sampling because only it holds the whole token state and the KV cache. The sampling runs once per step, after the last layer, on every step (afd_plugin/v1/worker/attention_model_runner_v2.py:582-594).

Deep dive

The FFN server loop

The FFN worker does not wait for the scheduler. After the connector is initialized, it starts a daemon thread that runs the FFN server loop (afd_plugin/v1/worker/ffn_worker.py:107-113, afd_plugin/v1/worker/ffn_worker.py:133-158). The loop body runs one step at a time:

  1. If the connector has no control plane, raise. GPU FFN needs a control-plane-driven connector (afd_plugin/v1/worker/ffn_worker.py:169-174).
  2. Receive one DP metadata payload for the step (afd_plugin/v1/worker/ffn_worker.py:176).
  3. Run capture_model for a warm-up or capture step, else execute_model (afd_plugin/v1/worker/ffn_worker.py:182-196).

The The FFN runtime: a connector-driven server loop page reads the loop and the per-layer forward in full. The The control plane: DP metadata once per step page reads the DP metadata payload.

Deep dive

Start order and collective init

The README says the two servers may start in either order (README.md:258-260). The order does not matter because the connector setup is a collective rendezvous. Every attention and FFN rank must call init_afd_connector (afd_plugin/connectors/base.py:124); the first ranks wait at the rendezvous until the rest arrive (afd_plugin/connectors/gpu/p2p.py:245-262). The FFN worker builds and initializes its connector inside initialize_from_config, then starts the loop (afd_plugin/v1/worker/ffn_worker.py:107-113).

What can go wrong#

  • A request sent to the FFN server. The FFN server has no API server, so there is nothing to answer it. Send requests only to the attention API server (README.md:258-260).
  • The scheduler reaches the FFN worker. A scheduler-driven execute_model on the FFN worker raises at once (afd_plugin/v1/worker/ffn_worker.py:122-131).
  • A connector with no control plane on GPU. The GPU FFN loop needs a control-plane-driven connector and raises for one that sets control_plane = None (afd_plugin/v1/worker/ffn_worker.py:169-174).

Why it matters. The two servers do very different work. If you treat the FFN server like a normal vLLM server, nothing schedules its work and no token comes back.

Check yourself#

  1. 1
    Which three parts does the FFN server not have?
    easy
    Show answer

    No API server, no KV cache and no real scheduler (only a no-op stand-in). Its EngineCore returns after the executor is built (afd_plugin/compat/patches/engine_core.py:59-68), and the worker reports an empty KV cache spec (afd_plugin/v1/worker/ffn_worker.py:102-105).

  2. 2
    Which server samples the next token?
    easy
    Show answer

    The attention server. Its runner wraps native sampling, which runs every step (afd_plugin/v1/worker/attention_model_runner_v2.py:582-594). The FFN runner's sample_tokens raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406).

  3. 3
    On the FFN role, what replaces the attention module of a decoder layer?
    medium
    Show answer

    A PPMissingLayer: the FFN role builds no attention (afd_plugin/model_executor/models/deepseek_v2.py:520). It keeps the native experts instead (afd_plugin/model_executor/models/deepseek_v2.py:538-559).

  4. 4
    The two servers can start in either order. Why is that safe?
    medium
    Show answer

    Connector setup is a collective rendezvous. Every rank calls init_afd_connector (afd_plugin/connectors/base.py:124) and the early ranks wait at tcp://host:port for the rest (afd_plugin/connectors/gpu/p2p.py:245-262).

  5. 5
    What drives one FFN step, if not the scheduler?
    hard
    Show answer

    The connector loop. It receives one DP metadata payload per step (afd_plugin/v1/worker/ffn_worker.py:176), then calls execute_model or capture_model (afd_plugin/v1/worker/ffn_worker.py:182-196). The loop is a daemon thread started after connector init (afd_plugin/v1/worker/ffn_worker.py:133-158).