Attention and FFN roles: what each server owns
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#
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).
| Role | Worker class | Runner class | Owns |
|---|---|---|---|
| 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 initializationThe 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 {}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.",
)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:
- a dense layer becomes a RemoteFFNProxy
(
afd_plugin/model_executor/models/deepseek_v2.py:516-517); - an MoE layer becomes an
AFDDeepseekV2RemoteExpertsMoEshell (afd_plugin/model_executor/models/deepseek_v2.py:493-500).
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.Deep dive#
Deep diveWhat each side owns, line by line
The table maps each responsibility to the line that proves it.
| Part | Attention server | FFN server |
|---|---|---|
| API server | yes (native vLLM front end) | no: requests never arrive (README.md:258-260) |
| scheduler | yes (native) | no-op stand-in (afd_plugin/compat/patches/engine_core.py:614) |
| KV cache | yes (native, per layer) | none (afd_plugin/v1/worker/ffn_worker.py:102-105) |
| sampler / logits | native, 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 module | native self_attn (afd_plugin/model_executor/models/deepseek_v2.py:474-491) | PPMissingLayer (afd_plugin/model_executor/models/deepseek_v2.py:520) |
| feed-forward / experts | remote 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 step | the vLLM scheduler | the 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 diveThe 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:
- 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). - Receive one DP metadata payload for the step
(
afd_plugin/v1/worker/ffn_worker.py:176). - Run
capture_modelfor a warm-up or capture step, elseexecute_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 diveStart 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_modelon 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#
- 1Which 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). - 2Which 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'ssample_tokensraises (afd_plugin/v1/worker/ffn_model_runner.py:405-406). - 3On 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). - 4The 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 attcp://host:portfor the rest (afd_plugin/connectors/gpu/p2p.py:245-262). - 5What 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 callsexecute_modelorcapture_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).