Tour: One decode step
Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857
In one sentence
Follow one decode step on 2A2F with DBO and FULL_DECODE_ONLY graphs. The control plane publishes once; each layer runs two stages.
The idea#
One decode step produces one new token per request. On AFD, the attention side runs the step and the FFN side answers each layer. With DBO, each layer runs two stages that overlap. With graphs, both sides replay instead of running eager.
This tour follows the graph recipe: 2A2F, DP1, TP2, DBO on, FULL_DECODE_ONLY
(recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:7-29).
Rank A0 maps to F0 and A1 to F1 (afd_plugin/distributed/topology.py:135-136).
Keep the two frequencies apart. The control plane publishes DP metadata once per step, before the layer loop. The data plane sends tensors once per layer and stage. Sampling runs once per step, after the last layer.
The picture#
Read the top panel as the once-per-step work and the bottom panel as the per-layer, per-stage work. The visual below steps through the same send, yield, compute and receive events.
This interactive visual steps through the events of one step. It needs JavaScript. The stops below say the same thing in text.
1The scheduler builds one step#
The attention server's EngineCore and scheduler build one step, then the executor runs
it (vllm:vllm/v1/engine/core.py:600-601). This is native vLLM. The FFN server has no such loop; it is
connector-driven (afd_plugin/v1/worker/ffn_worker.py:32-38).
2The runner wraps native execute_model#
The executor calls the AFD runner's execute_model. It wraps the native method in an AFD execution
context, then delegates (afd_plugin/v1/worker/attention_model_runner_v2.py:569-595).
# ### 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 code still does the request, attention and KV work. The wrapper adds AFD state around it. See The attention runtime: a seam over Model Runner V2.
3The AFD context installs the provider and the replay hook#
The context scopes four things: the DP1 DBO dispatch seam, the FULL replay hook, the ubatch prepare seam, and
the metadata provider (afd_plugin/v1/worker/attention_model_runner_v2.py:340-349).
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 replay hook is used only in FULL_DECODE_ONLY mode with a graph manager
(afd_plugin/v1/worker/attention_model_runner_v2.py:323-327). See CUDA graphs in AFD: FULL_DECODE_ONLY on both roles.
4The control plane publishes DP metadata once#
Before the layer loop, the attention side publishes the DP metadata. With DBO, the
ubatch prepare seam sends it after it slices the step into two stages
(afd_plugin/v1/worker/attention_model_runner_v2.py:153-157).
runner.install_mrv2_ubatch_metadata(
prepared.slices, prepared.forward_contexts, input_batch.num_tokens
)
if runner.connector.control_plane is not None:
runner.send_dp_metadata(None, prepared.slices)
return preparedThat call updates the local control state and sends one payload
(afd_plugin/v1/worker/attention_metadata.py:115-116). It is the first thing that crosses the connector
each step. See The control plane: DP metadata once per step.
Deep diveThe two publish paths, both once per step
There are two code paths to the same publish, chosen by whether the step uses DBO. Both run once per step, before the layer loop.
- With DBO. The ubatch prepare seam calls
send_dp_metadataafter it slices the step into two 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).
The FFN side waits for exactly one payload per step either way
(afd_plugin/v1/worker/ffn_worker.py:176). A per-step call must not be folded into the per-layer
sequence. The payload carries the per-stage token counts that set the tensor shapes. See
Data on the wire: tensors per layer and stage.
5The attention side replays its graph#
For a pure-decode step, native FULL replay runs. The replay hook publishes one padded control payload just
before the replay (afd_plugin/v1/worker/attention_model_runner_v2.py:286-303). For a DBO step with two
stages, the prepare seam already sent the full two-stage payload, so the hook asserts and delegates
(afd_plugin/v1/worker/attention_model_runner_v2.py:275-280).
if desc.num_ubatches > 1:
# Native execute_model already prepared live stage inputs and our
# prepare seam sent their complete control payload.
assert runner._afd_pending_metadata is not None
assert runner._afd_pending_metadata.num_stages == desc.num_ubatches
return original_run_fullgraph(desc)6Per layer and stage: send, yield, the other stage#
Inside each layer, the proxy sends its hidden states, yields, then receives. The
yield lets the other stage run while this one waits
(afd_plugin/model_executor/models/deepseek_v2.py:146-158).
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,
)This runs once per layer and once per stage. With DBO there are two stages per layer. See Dual-batch overlap (DBO): two stages per step.
7The FFN side receives one DP-metadata payload per step#
The FFN server loop blocks on the control plane for exactly one payload per step
(afd_plugin/v1/worker/ffn_worker.py:176), then dispatches to execute or capture
(afd_plugin/v1/worker/ffn_worker.py:182-196).
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,
)So the control plane runs once per step on the FFN side too, matching the attention side.
8The FFN side looks up and replays its graph#
The FFN runner builds the FFN graph key from the DP metadata and replays the stored
graph for a pure-decode step (afd_plugin/v1/worker/ffn_model_runner.py:166-177). If no graph matches
the key, it runs eager instead.
graph_key = make_ffn_graph_key(dp_metadata_list)
cuda_graph_info = self._cuda_graphs.get(graph_key)
run_mode = graph_run_mode(
is_warmup=is_warmup,
is_graph_capturing=is_graph_capturing,
is_graph_replaying=is_graph_replaying,
graph_enabled=bool(self.use_cuda_graph),
graph_exists=cuda_graph_info is not None,
)
if run_mode is AFDGraphRunMode.REPLAY:
self._cuda_graphs[graph_key]["graph"].replay()
return None
self._ffn_forward(
dp_metadata_list=dp_metadata_list,
is_graph_capturing=is_graph_capturing,
is_warmup=is_warmup,
)
return None9Per layer and stage: recv, compute, send#
The eager path, _ffn_forward, loops over layers and stages. 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)When the FFN side replays a graph, this same work is recorded in the graph instead of run step by step. See The FFN runtime: a connector-driven server loop.
10After the last layer: sample, then output#
After the forward pass, the worker makes a separate native 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.
The FFN runner has no sampler; its sample_tokens raises
(afd_plugin/v1/worker/ffn_model_runner.py:405-406). So sampling runs once per step, on the attention
side. The step is done; the next step repeats the whole path.
What can go wrong#
- Folding the per-step publish into the layer loop. The control plane publishes once, before
the layers; it does not publish again inside the loop
(
afd_plugin/v1/worker/attention_metadata.py:115-116). - A shape that no captured graph covers. The FFN side runs that step eager instead of
replaying (
afd_plugin/v1/worker/cuda_graph.py:148-150). - A step too small for DBO. A step below the threshold runs as one stage, with no overlap
(
vllm:vllm/v1/worker/ubatch_utils.py:144-149). - Expecting the FFN side to sample. It never does; its
sample_tokensraises (afd_plugin/v1/worker/ffn_model_runner.py:405-406).
Why it matters. One step has one control-plane publish, then every layer and stage of send and receive, then one sampling call. The frequencies must not be mixed, or the two roles fall out of step.
Check yourself#
- 1How many times does the control plane publish per step?easy
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). - 2What does the FFN runner do when a graph matches the key?easy
Show answer
It calls
graph.replay()and returns, instead of running the layers eager (afd_plugin/v1/worker/ffn_model_runner.py:175-177). - 3In a DBO step, why does the FULL replay hook not send a new control payload?medium
Show answer
Because the ubatch prepare seam already sent the full two-stage payload. The hook asserts the staged metadata and delegates to the native replay (
afd_plugin/v1/worker/attention_model_runner_v2.py:275-280). - 4Which side samples the token, and when?medium
Show answer
The attention side, once per step, after the last layer. The AFD runner inherits the native
sample_tokens(vllm:vllm/v1/worker/gpu/model_runner.py:2048-2050); the FFN runner's raises (afd_plugin/v1/worker/ffn_model_runner.py:405-406). - 5List the per-stage data-plane calls for one layer, in order, across both roles.hard
Show answer
Attention
send_attn_output(afd_plugin/model_executor/models/deepseek_v2.py:146); DBO yield; 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). This runs for each of the two stages.