The connector contract: the Attention/FFN transport
Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857
In one sentence
A connector moves hidden states between the attention role and the FFN role. One abstract base class sets its contract.
The idea#
AFD splits a model into two server processes. The attention side computes attention and must hand its hidden states to the FFN side. The FFN side runs the feed-forward and MoE experts, then hands the result back. A connector owns this exchange.
Every connector subclasses one abstract base class, AFDConnectorBase
(afd_plugin/connectors/base.py:37). The base class fixes three jobs: set up and release the
transport, move hidden states each way, and apply the optional control plane. Each
connector picks its own transport, but the call names and the order stay the same.
An everyday comparison. Think of a courier contract. The sender hands over a parcel; the courier delivers it and brings back a reply. The comparison stops here: a connector also sets up and tears down its own transport, and it runs this exchange once per model layer.
The picture#
Read panel 1 as a cycle. The blue calls run on the attention side, the emerald calls on the FFN side. The solid arrows are hidden-state tensors (the data plane). The dashed violet arrow is the optional control plane. Panels 2 and 3 show the lifecycle and the three connectors.
In the code#
Step 1: One base class, three jobs#
The base class docstring names the three jobs of every connector (afd_plugin/connectors/base.py:37-54).
class AFDConnectorBase(ABC):
"""Base class for plugin-owned AFD Attention/FFN connectors.
An AFD connector owns the communication contract between the Attention
runtime and the FFN runtime. Implementations are responsible for three
kinds of work:
1. Initializing and releasing backend communication resources.
2. Moving hidden states between Attention and FFN ranks.
3. Applying DP metadata control-plane payloads so later tensor transfers
can use backend-specific shapes, buffers, or connector data.
Implementations may use very different transport mechanisms. For example,
the GPU P2P connector uses explicit point-to-point tensor transfers, while
the NPU CAMP2P connector carries additional backend state through its
``AFDTransferState`` subclass on ``AFDTransferContext.states``. This base
class documents the common runtime contract shared by those implementations.
"""Step 2: The lifecycle#
The owning worker creates the connector, calls init_afd_connector once, uses the data path
many times, then calls close. is_initialized is true between the two.
init_afd_connector is a collective call: every attention and FFN rank must run it
(afd_plugin/connectors/base.py:124).
@abstractmethod
def close(self) -> None:
"""Release connector-owned communication resources.
Implementations should destroy process groups, communicators, cached
handles, and backend-specific state owned by the connector. The method
should be safe to call during worker shutdown and should leave
``is_initialized`` false after successful cleanup.
"""
raise NotImplementedError
@abstractmethod
def init_afd_connector(self) -> None:
"""Initialize backend communication resources.
This method is called once the owning runtime is ready to create AFD
communication state. Implementations typically create process groups,
register custom ops, initialize backend communicators, and precompute
topology-derived rank lists.
"""
raise NotImplementedError
@property
@abstractmethod
def is_initialized(self) -> bool:
"""Return whether backend communication resources are initialized."""
raise NotImplementedErrorStep 3: The attention-side calls#
The attention side calls send_attn_output to send its hidden states, then
recv_ffn_output to get the FFN result back. recv_ffn_output takes a preallocated
ref_tensor, because the backends cannot always allocate their own output
(afd_plugin/connectors/base.py:185-192).
@abstractmethod
def send_attn_output(
self,
hidden_states: torch.Tensor,
context: AFDTransferContext,
**kwargs: Any,
) -> None:
"""Send Attention hidden states to the FFN runtime.
This method is called on Attention-side ranks after an Attention block
produces hidden states for one layer/stage.
Args:
hidden_states: Tensor to send to the FFN side. The leading
dimension should match ``context.metadata.total_tokens`` unless
the backend explicitly supports a different compiled/captured
calling convention.
context: Per-transfer context describing layer, stage, and token
layout. Backends may also consume ``context.states``.
**kwargs: Optional backend-specific arguments.
Raises:
ValueError: If tensor shape does not match metadata.
RuntimeError: If the connector is not initialized or required
backend-specific metadata is missing.
"""
raise NotImplementedError
@abstractmethod
def recv_ffn_output(
self,
ref_tensor: torch.Tensor,
ubatch_idx: int = 0,
**kwargs: Any,
) -> torch.Tensor:Step 4: The FFN-side calls#
The FFN side calls recv_attn_output to get the hidden states, runs the FFN compute, then
calls send_ffn_output to send the result back. recv_attn_output returns both the
tensor and the transfer context that the matching send needs
(afd_plugin/connectors/base.py:216-227).
@abstractmethod
def recv_attn_output(
self,
ubatch_idx: int = 0,
**kwargs: Any,
) -> AFDA2FTransferPayload:
"""Receive Attention hidden states on the FFN side.
This method is called by FFN-side execution before running the FFN
compute for a layer/stage. It returns both the hidden states and the
metadata needed by later FFN-side calls.
Args:
ubatch_idx: Microbatch/stage index to receive. Defaults to ``0``.
**kwargs: Optional backend-specific receive arguments.
Returns:
``AFDA2FTransferPayload`` containing received hidden states and the
transfer context (metadata + backend-produced transfer state)
for the receive operation.
"""
raise NotImplementedError
@abstractmethod
def send_ffn_output(
self,
ffn_output: torch.Tensor,
context: AFDTransferContext,
**kwargs: Any,
) -> None:Step 5: The optional control plane#
The base class holds an optional control_plane attribute. When it is set, the per-stage token
counts travel over the control plane, and the arrival of a payload drives each FFN step. When it is
None, the connector has no control plane and the receive loop drives the FFN side
(afd_plugin/connectors/base.py:56-60).
# An optional DP metadata control plane. When set, FFN steps are driven by
# DP metadata received over the control plane. When ``None``, the connector
# has no control plane and FFN steps are driven directly by the connector
# receive loop.
control_plane: AFDControlPlane | None = NoneA control plane is itself an abstract class, AFDControlPlane, with three calls: apply a
payload locally, send a payload, and receive one (afd_plugin/connectors/base.py:258). The
The control plane: DP metadata once per step page covers it.
class AFDControlPlane(ABC):
"""DP metadata control plane"""
@abstractmethod
def update_state_from_dp_metadata(
self,
payload: AFDControlPayload,
) -> None:
"""Apply a DP metadata payload to local connector state.Step 6: The config hook#
Each connector parses its own options through parse_extra_config, a class method on the base
class (afd_plugin/connectors/base.py:64-72). The base __init__ calls it and stores the
result on extra_info (afd_plugin/connectors/base.py:105-107). The raw values come from
connector_extra_config inside the AFD config
(connector_extra_config_from_source).
@classmethod
@abstractmethod
def parse_extra_config(
cls,
raw: Mapping[str, Any] | None,
) -> ConnectorExtraInfo:
"""Parse connector-owned config using this connector's schema."""
raise NotImplementedErrorStep 7: The factory#
The factory maps a connector name to a class and builds the instance. create_connector reads
the name from the AFD config, resolves the role rank, and constructs the connector
(afd_plugin/connectors/factory.py:49-68). An unknown name raises
ValueError (afd_plugin/connectors/factory.py:58-59).
@classmethod
def create_connector(
cls,
rank: int,
local_rank: int,
vllm_config: VllmConfig,
afd_config: AFDConfig | None = None,
) -> AFDConnectorBase:
config = afd_config or parse_afd_config(vllm_config)
if config.connector not in cls._registry:
raise ValueError(f"unsupported AFD connector type: {config.connector}")
connector_cls = cls._registry[config.connector]()
role_rank = resolve_role_rank(vllm_config, config)
return connector_cls(
rank,
local_rank,
vllm_config,
config,
role_rank,
)Three connectors are registered at import time (afd_plugin/connectors/factory.py:88-102). The
default name is "P2pNcclAFDConnector" (afd_plugin/config.py:52).
AFDConnectorFactory.register_connector(
"P2pNcclAFDConnector",
"afd_plugin.connectors.gpu.p2p",
"P2pNcclAFDConnector",
)
AFDConnectorFactory.register_connector(
"CAMP2pAFDConnector",
"afd_plugin.connectors.npu.camp2p",
"CAMP2pAFDConnector",
)
AFDConnectorFactory.register_connector(
"CAMAsyncAFDConnector",
"afd_plugin.connectors.npu.async_cam",
"CAMAsyncAFDConnector",
)Deep dive#
The blocks below give the full call order, a worked example with real shapes, and the differences between the three connectors.
Deep diveThe full call order, side by side
The control plane runs once per step, before the layer loop. The data path then runs once per layer and stage. The data-plane calls are in bold.
Once per step (control plane, when the connector has one): the attention runner applies
the DP metadata locally, then sends it to the FFN side
(afd_plugin/v1/worker/attention_metadata.py:115-116). Each FFN step waits for the payload, then
applies it (afd_plugin/v1/worker/ffn_worker.py:176,
afd_plugin/v1/worker/ffn_model_runner.py:194-197).
Then, for each layer and stage:
- Attention side: the model produces hidden states for the layer.
- Attention side:
send_attn_output(hidden_states, context)from the model proxy (afd_plugin/model_executor/models/deepseek_v2.py:146). - FFN side:
recv_attn_output(ubatch_idx)returns the hidden states and the context (afd_plugin/v1/worker/ffn_model_runner.py:236). - FFN side: runs the FFN and MoE compute (not in the connector).
- FFN side:
send_ffn_output(ffn_output, context)(afd_plugin/v1/worker/ffn_model_runner.py:268). - Attention side:
recv_ffn_output(ref_tensor, ubatch_idx)returns the result, which continues through the rest of the layer (afd_plugin/model_executor/models/deepseek_v2.py:155).
The stage is the DBO ubatch index. With DBO the connector runs two stages per layer. See Dual-batch overlap (DBO): two stages per step.
Deep diveWorked example: hidden-state shapes for DeepSeek-V2-Lite, 2A2F
Take a 2A2F topology: two attention ranks (A0, A1) and two FFN ranks (F0, F1). Each FFN rank owns one
attention peer, so A0 maps to F0 and A1 maps to F1 (afd_plugin/connectors/gpu/p2p.py:11-20,
afd_plugin/distributed/topology.py:135-136).
The hidden size comes from the model config, hf_text_config.hidden_size
(afd_plugin/connectors/gpu/p2p.py:195-197). For DeepSeek-V2-Lite this is 2048.
This value is inferred from the DeepSeek-V2-Lite checkpoint config; it is not stored in either
repository.
Take one decode step with 8 tokens on A0. The numbers:
- Tensor:
hidden_statesof shape[8, 2048], in the model dtype (bf16, inferred from the recipe). - Metadata:
seq_lens = [8], sototal_tokens = 8(afd_plugin/connectors/metadata.py:198-200). The send call checksshape[0] == 8(afd_plugin/connectors/metadata.py:230-231). - On the wire (A0 to F0): one tensor of
8 × 2048 × 2 bytes = 32,768 bytesper layer, per direction. - FFN side: F0 receives
[8, 2048], runs the FFN, and sends back[8, 2048]. A0 gets it throughrecv_ffn_output.
When one FFN rank owns more than one attention peer, it concatenates the inputs. For 2A1F with 8 tokens
each, F0 would receive two tensors, concatenate them to [16, 2048], and split the output back by
the recorded sequence lengths (afd_plugin/connectors/gpu/p2p.py:21-25).
Deep diveThe three connectors, compared
The factory registers three connectors (afd_plugin/connectors/factory.py:88-102). They differ by
device, by send/receive timing, and by whether they use a control plane.
| Connector | Device | Timing | Control plane | Transport |
|---|---|---|---|---|
P2pNcclAFDConnector |
GPU (CUDA) | sync | yes (afd_plugin/connectors/gpu/p2p.py:222) |
PyNcclCommunicator point-to-point |
CAMP2pAFDConnector |
NPU (Ascend) | sync (afd_plugin/connectors/npu/camp2p.py:3-8) |
yes (afd_plugin/connectors/npu/camp2p.py:385) |
CAM P2P operators, HCCL + Gloo |
CAMAsyncAFDConnector |
NPU (Ascend) | async | none (afd_plugin/connectors/npu/async_cam.py:283) |
CAM dispatch/combine operators |
The config hook also differs. The GPU connector accepts no options, so it rejects a non-empty
connector_extra_config (afd_plugin/connectors/gpu/p2p.py:150-157). The async NPU
connector parses typed fields and rejects unknown ones
(afd_plugin/connectors/npu/async_cam.py:286-291). The NPU connectors are covered on the
Ascend NPU overview: the same contract, different transports page.
What can go wrong#
- Shape mismatch. A hidden-state tensor whose leading dimension is not
total_tokensraisesValueErrorin the send call (skipped whiletorch.compiletraces) (afd_plugin/connectors/gpu/p2p.py:347-353). - Unknown connector name. A name that is not registered raises
ValueErrorin the factory (afd_plugin/connectors/factory.py:58-59). - Options for a connector that takes none. A non-empty
connector_extra_configfor the GPU connector raisesValueError(afd_plugin/connectors/gpu/p2p.py:154-157). - Wrong device. The GPU connector needs CUDA and NCCL. It has no automatic fallback to
another transport on NPU (
afd_plugin/connectors/gpu/p2p.py:46-48).
Why it matters. The two roles must agree on the contract, the shapes and the connector name, or the collective setup fails or the first transfer raises.
Check yourself#
- 1Which two calls does the attention side make, and in which order?easy
Show answer
First
send_attn_output, thenrecv_ffn_output(afd_plugin/connectors/base.py:145,afd_plugin/connectors/base.py:173). - 2Which connector is the default?easy
Show answer
P2pNcclAFDConnector, the GPU connector. The default is set in the AFD config (afd_plugin/config.py:52). - 3One connector setsmedium
control_plane = None. Which one, and what drives its FFN steps then?Show answer
The async NPU connector,
CAMAsyncAFDConnector(afd_plugin/connectors/npu/async_cam.py:283). With no control plane, the connector receive loop drives the FFN side (afd_plugin/connectors/base.py:56-60). - 4Why doesmedium
recv_ffn_outputtake a preallocatedref_tensor?Show answer
The backends cannot always allocate their own output tensor. The P2P connector also uses it as a stable buffer for CUDA graph capture (
afd_plugin/connectors/base.py:185-192). - 5On DeepSeek-V2-Lite with 8 decode tokens, what tensor shape does the attention side send, and which value checks it?hard
Show answer
Shape
[8, 2048]: 8 tokens by the hidden size 2048 (the 2048 is inferred from the checkpoint). The metadatatotal_tokensis 8, and the send checksshape[0] == total_tokens(afd_plugin/connectors/metadata.py:198-200,afd_plugin/connectors/metadata.py:230-231).