Tour: Life of a request
Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857
In one sentence
Follow one request from the HTTP call to the first token. It stays on the attention server and reaches the FFN server only through the connector.
The idea#
A client sends a request to one server. The attention server owns the API, the scheduler and the EngineCore. The FFN server owns no API for requests; a connector loop drives it. So the whole request path lives on the attention side, and the FFN side answers one piece of each model layer.
This tour uses the simplest GPU recipe: one attention server, one FFN server, and the GPU
P2P connector
(recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_eager_dbo_dp1tp2.sh:7-29).
Each server runs two ranks (--tensor-parallel-size 2), so the topology is 2A2F. Rank A0 maps to
rank F0 (afd_plugin/distributed/topology.py:135-136). We follow A0 and F0.
Keep one timing rule in mind. The control plane publishes DP metadata once per step, before the layer loop. The data plane sends tensors once per layer and stage. With DBO there are two stages per layer.
The picture#
The two diagrams split the path in half. The first shows the attention server, from the HTTP call to the connector. The second shows the connector and the FFN server loop. The control-plane arrow runs once per step; the data-plane arrows run per layer and stage.
This interactive visual steps through the stops of the tour. It needs JavaScript. The stops below say the same thing in text.
1The client calls the attention API server#
A client sends an HTTP request to the attention server. Clients must send requests only there. The FFN
server is connector-driven (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.The API server is native vLLM (api_server.py). AFD changes nothing here. The request now enters the engine.
2EngineCore and the scheduler batch the request#
The attention server's EngineCore and scheduler are native vLLM
(EngineCore.step). The scheduler builds a step, then the executor
runs it (vllm:vllm/v1/engine/core.py:600-601).
The FFN server has no such loop for requests. Its worker runs a connector loop instead
(afd_plugin/v1/worker/ffn_worker.py:32-38). So the scheduler lives on the attention side only. See
Attention and FFN roles: what each server owns.
3The executor calls the AFD attention worker#
This is the first plugin code on the path. The worker_cls was rewritten to the AFD
attention worker at start-up, so the native executor now builds and calls
AFDAttentionWorker. Its init_device checks the stack, then constructs the AFD runner
(afd_plugin/v1/worker/attention_worker.py:60-88).
def init_device(self):
"""Initialize the native GPU worker with the selected AFD runner."""
# ### PATCH START: select and directly construct the AFD runner.
assert_compatible_afd_stack(
self.vllm_config,
caller="AFDAttentionWorker.init_device",
expected_role="attention",
)
validate_gpu_model_runner_v2_config(
self.vllm_config,
expected_role="attention",
device_type=self.device_config.device_type,
)
afd_model_config = get_afd_model_config(
self.vllm_config.model_config,
device_type="cuda",
)
self.vllm_config.model_config = afd_model_config
self.model_config = afd_model_config
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 worker validates the role and the runner config (afd_plugin/v1/worker/attention_worker.py:64-73),
then substitutes AFDAttentionModelRunnerV2 for the native runner class while the native setup runs
(afd_plugin/v1/worker/attention_worker.py:82-87). See Worker selection: choosing the role worker.
4The AFD runner wraps native execute_model#
For each step, the executor calls the runner's execute_model. The AFD runner does not copy the
native method. It wraps it in an AFD execution context, then delegates
(afd_plugin/v1/worker/attention_model_runner_v2.py:570-594).
# ### PATCH START: scope AFD metadata provider/replay and profiler step.
step_afd_gpu_profiler(self.prof)
with _use_afd_execution_context(
self,
int(scheduler_output.total_num_scheduled_tokens),
):
return super().execute_model(
scheduler_output,
intermediate_tensors,
dummy_run=dummy_run,
skip_attn_for_dummy_run=skip_attn_for_dummy_run,
is_profile=is_profile,
context_len=context_len,
valid_dummy_state_slots=valid_dummy_state_slots,
)Native execute_model still does the request, attention and KV work. Sampling is a separate
native call (stop 12). The wrapper only installs AFD state around it (afd_plugin/v1/worker/attention_model_runner_v2.py:555-568).
5The execution context installs the metadata provider#
The context sets up two things that matter for AFD: a metadata provider that binds AFD metadata to the
forward context, and, for DBO, a prepare seam on the ubatch runner
(afd_plugin/v1/worker/attention_model_runner_v2.py:317-349).
try:
with (
use_gpu_mrv2_dbo_dispatch(),
replay_scope,
_use_afd_ubatch_preparation(runner),
use_afd_metadata_provider(
runner.install_afd_metadata_on_forward_context,
),
):
yieldThe metadata provider is install_afd_metadata_on_forward_context
(afd_plugin/v1/worker/attention_model_runner_v2.py:345-347). It runs when native code builds the
forward context, and it is where the control plane publish happens. See The attention runtime: a seam over Model Runner V2.
6The control plane publishes DP metadata (once per step)#
Before the layer loop, the attention side sends the DP metadata to the FFN side. The
prepare seam calls send_dp_metadata once (afd_plugin/v1/worker/attention_model_runner_v2.py:156-157).
That call updates the local control state, then sends one payload
(afd_plugin/v1/worker/attention_metadata.py:115-116).
payload = AFDControlPayload(
dp_metadata_list=dp_metadata_list,
is_graph_capturing=is_graph_capturing,
is_warmup=is_warmup,
is_graph_replaying=is_graph_replaying,
is_profile=self._afd_is_profile,
)
self.connector.control_plane.update_state_from_dp_metadata(payload)
self.connector.control_plane.send_dp_metadata_list(payload)This payload is the first thing that crosses the connector each step. It carries the per-stage token counts that set the tensor shapes. See The control plane: DP metadata once per step.
Deep diveWhy 'once per step', and the two code paths
Accuracy note from the fact checks: a per-step call must not be folded into the per-layer sequence. The publish runs before any layer sends data.
There are two paths to the same publish. With DBO, the ubatch prepare seam calls
send_dp_metadata after it slices the step into stages
(afd_plugin/v1/worker/attention_model_runner_v2.py:153-157). Without ubatching, the metadata provider
calls it from install_afd_metadata_on_forward_context
(afd_plugin/v1/worker/attention_metadata.py:252-257). Either way, one publish per step, before the
layer loop.
The FFN side waits for exactly one payload per step. Its server loop calls
recv_dp_metadata_list once (afd_plugin/v1/worker/ffn_worker.py:176), then applies it
(afd_plugin/v1/worker/ffn_model_runner.py:194-203).
7The native model forward runs the decoder layers#
Native execute_model runs the model forward. The AFD model wrapper
subclasses the native model (afd_plugin/model_executor/models/deepseek_v2.py:725), and its decoder layer
subclasses the native layer (afd_plugin/model_executor/models/deepseek_v2.py:375). On the attention
role, the layer builds the native attention module and replaces only the FFN stage
(afd_plugin/model_executor/models/deepseek_v2.py:459-517).
class RemoteFFNProxy(nn.Module):
"""Parameter-free FFN stage executed through the AFD connector."""
def __init__(self, *, layer_idx: int) -> None:
super().__init__()
self.layer_idx = layer_idx
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
return self._send_and_receive(hidden_states)A dense layer gets a RemoteFFNProxy, which holds no weights
(afd_plugin/model_executor/models/deepseek_v2.py:516-517). A MoE layer gets a native MoE shell whose
experts are the same proxy (afd_plugin/model_executor/models/deepseek_v2.py:493-500,
afd_plugin/model_executor/models/deepseek_v2.py:332-335). Either way, the proxy's forward
sends and receives through the connector instead of computing the FFN. The loop over layers is native vLLM.
8The attention side sends hidden states (per layer and stage)#
Inside each layer, the proxy sends its hidden states to the FFN side, then waits for the result. This runs
once per layer, and once per stage (afd_plugin/model_executor/models/deepseek_v2.py:127-158).
metadata = AFDTransferMetadata.create_attention_metadata(
layer_idx=self.layer_idx,
stage_idx=stage_idx,
seq_len=int(hidden_states.shape[0]),
)
context = AFDTransferContext(metadata=metadata)
afd_metadata.connector.send_attn_output(
hidden_states,
context,
**send_kwargs,
)
hidden_states = maybe_apply_dbo_yield(
hidden_states,
role="attention",
)
return afd_metadata.connector.recv_ffn_output(
ref_tensor=hidden_states,
ubatch_idx=stage_idx,
)The call to send_attn_output (afd_plugin/model_executor/models/deepseek_v2.py:146) is
the hidden-state tensor crossing the connector (the data plane). The matching
recv_ffn_output comes later in the same call
(afd_plugin/model_executor/models/deepseek_v2.py:155).
Deep diveThe shapes on the wire
The tensor is hidden_states of shape [num_tokens, hidden_size] in the model dtype
(afd_plugin/connectors/gpu/p2p.py:331-335). The transfer metadata records the layer index, the stage
index and the sequence lengths (afd_plugin/connectors/metadata.py:188-190). For DeepSeek-V2-Lite the
hidden size is 2048, which is inferred from the checkpoint config. With 8 decode tokens the tensor is
[8, 2048]. See Data on the wire: tensors per layer and stage.
9The FFN server loop receives the step#
The FFN worker runs a daemon thread, the FFN server loop. No scheduler drives it.
The loop waits for one DP-metadata payload, then runs the model
(afd_plugin/v1/worker/ffn_worker.py:160-196).
while not event.is_set():
if self.model_runner.connector.control_plane is None:
raise NotImplementedError(
"GPU FFN only supports control-plane-driven connectors; "
"connectors without a control plane (control_plane is None) "
"are not supported.",
)
payload = self.model_runner.connector.control_plane.recv_dp_metadata_list()
dp_metadata_list = payload.dp_metadata_list
is_attn_graph_capturing = payload.is_graph_capturing
is_warmup = payload.is_warmup
is_graph_replaying = payload.is_graph_replaying
if self.model_runner.use_cuda_graph and (
is_warmup or is_attn_graph_capturing
):
self.model_runner.capture_model(
dp_metadata_list=dp_metadata_list,
is_warmup=is_warmup,
is_attn_graph_capturing=is_attn_graph_capturing,
)
else:
self.model_runner.execute_model(
dp_metadata_list=dp_metadata_list,
is_graph_capturing=is_attn_graph_capturing,
is_warmup=is_warmup,
is_graph_replaying=is_graph_replaying,
)If the native scheduler ever calls the FFN worker's execute_model, it raises at once, so a
misrouted request fails fast (afd_plugin/v1/worker/ffn_worker.py:122-131). The GPU FFN side also
needs a control plane; a connector without one is rejected here
(afd_plugin/v1/worker/ffn_worker.py:169-174).
10The FFN runner computes each layer and stage#
_ffn_forward loops over the layers, and over the stages inside each layer. For each pair it
receives the hidden states, runs the FFN or MoE compute, and sends the result back
(afd_plugin/v1/worker/ffn_model_runner.py:222-269).
for stage_idx in stage_ids:
recv_kwargs: dict[str, Any] = {}
if routing_spec is not None:
recv_kwargs["routing_spec"] = routing_spec
if recv_input_ids:
recv_kwargs["recv_input_ids"] = True
payload = self.connector.recv_attn_output(
ubatch_idx=stage_idx,
**recv_kwargs,
)
hidden_states = payload.hidden_states
context = payload.context
metadata = context.metadata
metadata.layer_idx = layer_idx
metadata.stage_idx = stage_idx
if forward_context is not None:
forward_context.dp_metadata = ffn_dp_metadata_list[
metadata.stage_idx
]
forward_context.additional_kwargs["afd_metadata"] = metadata
_set_moe_layer_index(forward_context, layer_idx)
if (
uses_remote_experts
and self.afd_config.compute_gate_on_attention
):
router_logits = payload.router_logits
assert router_logits is not None
rank_ffn_output = model.compute_experts_output(
hidden_states,
layer_idx,
router_logits,
)
else:
rank_ffn_output = self._execute_eager_mode(
hidden_states,
layer_idx,
input_ids=payload.input_ids,
)
self.connector.send_ffn_output(rank_ffn_output, context)
return rank_ffn_output- Line 236.
recv_attn_outputreturns the hidden states and the transfer context (afd_plugin/v1/worker/ffn_model_runner.py:236). - Line 263.
_execute_eager_moderuns the FFN and MoE for this layer (afd_plugin/v1/worker/ffn_model_runner.py:263). - Line 268.
send_ffn_outputsends the result back to the attention side (afd_plugin/v1/worker/ffn_model_runner.py:268).
11The attention side receives the FFN output and continues the layer#
Back on the attention side, recv_ffn_output returns the FFN result into the proxy's output
(afd_plugin/model_executor/models/deepseek_v2.py:155-158). The native layer then adds the residual
and goes to the next layer.
Stops 8 to 11 repeat for every decoder layer. With DBO they repeat for both stages of each layer. The control plane does not publish again inside this loop.
12The attention side samples the token and returns it#
After the forward pass, the worker makes a second call, sample_tokens
(vllm:vllm/v1/worker/gpu_worker.py:1142-1147). Native Model Runner V2 computes the logits and samples
there (vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050). The AFD attention runner does not override
this method, so it samples natively. So sampling runs on the attention side, once per step.
def sample_tokens(self, grammar_output: Any = None) -> Any:
raise RuntimeError("FFN runners do not sample tokens")The FFN runner has no sampler; its sample_tokens raises
(afd_plugin/v1/worker/ffn_model_runner.py:405-406). The attention server returns the token to the
client over the same HTTP response. The request is done for this step.
The loop#
One request is many steps. Prefill runs one step for the prompt. Then decode runs one step per new token. Each step runs the whole path again: one control-plane publish, then every layer and stage of send and receive.
| Frequency | What runs | Where |
|---|---|---|
| Once per step | control-plane publish of DP metadata | afd_plugin/v1/worker/attention_metadata.py:115-116 / afd_plugin/v1/worker/ffn_worker.py:176 |
| Once per layer and stage | send_attn_output, recv_ffn_output | afd_plugin/model_executor/models/deepseek_v2.py:146, afd_plugin/model_executor/models/deepseek_v2.py:155 |
| Once per layer and stage | recv_attn_output, FFN compute, send_ffn_output | afd_plugin/v1/worker/ffn_model_runner.py:236, afd_plugin/v1/worker/ffn_model_runner.py:268 |
| Once per step (after the last layer) | logits and sampling (attention side) | afd_plugin/v1/worker/attention_model_runner_v2.py:586-594 |
What can go wrong#
- Requests sent to the FFN server. The FFN worker is connector-driven. A
scheduler-driven
execute_modelraises at once (afd_plugin/v1/worker/ffn_worker.py:122-131). - A connector with no control plane on GPU. The GPU FFN loop supports only
control-plane-driven connectors, and raises otherwise
(
afd_plugin/v1/worker/ffn_worker.py:169-174). - A shape that does not match the metadata. The send call checks the leading dimension
against the token count, and raises when it differs (skipped while
torch.compiletraces) (afd_plugin/connectors/gpu/p2p.py:347-353). - A host or port mismatch between the two servers. Both roles meet at the same
rendezvous (
afd_plugin/connectors/gpu/p2p.py:271). Different values make the group setup hang until the timeout (afd_plugin/connectors/gpu/p2p.py:275).
Why it matters. The attention side owns the request, and the FFN side answers only pieces of each layer. If a request reaches the FFN server, or the two roles do not meet, the step cannot complete.
Check yourself#
- 1Which server do clients send requests to, and why not the other one?easy
Show answer
The attention API server. The FFN server is connector-driven and has no request path; a scheduler-driven FFN
execute_modelfails fast (README.md:258-260,afd_plugin/v1/worker/ffn_worker.py:122-131). - 2What drives the model on the FFN server?easy
Show answer
A connector-driven daemon loop, the FFN server loop, not a scheduler. It waits for DP metadata, then runs the model (
afd_plugin/v1/worker/ffn_worker.py:160-196). - 3How many times does the control plane publish per step, and when?medium
Show answer
Once, before the layer loop. The attention side sends one DP-metadata payload (
afd_plugin/v1/worker/attention_metadata.py:115-116); the FFN loop receives one (afd_plugin/v1/worker/ffn_worker.py:176). - 4Which side samples the token?medium
Show answer
The attention side. The AFD attention runner inherits the native
sample_tokens, which computes the logits and samples (vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050). The FFN runner'ssample_tokensraises (afd_plugin/v1/worker/ffn_model_runner.py:405-406). - 5List the data-plane calls for one layer and stage, in order, across both servers.hard
Show answer
Attention
send_attn_output(afd_plugin/model_executor/models/deepseek_v2.py:146); FFNrecv_attn_output(afd_plugin/v1/worker/ffn_model_runner.py:236); FFN compute; FFNsend_ffn_output(afd_plugin/v1/worker/ffn_model_runner.py:268); attentionrecv_ffn_output(afd_plugin/model_executor/models/deepseek_v2.py:155). The data plane runs once per layer and stage; the control plane ran once before the loop.