AFD Plugin Explained

The connector contract: the Attention/FFN transport

IntermediateAFD conceptsabout 6 min read + 3 min deep dives

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#

Figure: the attention side sends then receives; the FFN side receives, computes, then sends. The control plane is optional.
Open full size ↗

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.
    """
The connector base class and its three responsibilities.

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 NotImplementedError
close, init_afd_connector and is_initialized.

Step 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:
The two attention-side calls (signatures and the start of each docstring).

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:
The two FFN-side calls.

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 = None
The optional control-plane attribute.

A 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.
The control-plane base class and its first call.

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 NotImplementedError
The per-connector config hook.

Step 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,
        )
create_connector: name to instance.

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",
)
The three registered connectors.

Deep dive#

The blocks below give the full call order, a worked example with real shapes, and the differences between the three connectors.

Deep dive

The 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:

  1. Attention side: the model produces hidden states for the layer.
  2. Attention side: send_attn_output(hidden_states, context) from the model proxy (afd_plugin/model_executor/models/deepseek_v2.py:146).
  3. FFN side: recv_attn_output(ubatch_idx) returns the hidden states and the context (afd_plugin/v1/worker/ffn_model_runner.py:236).
  4. FFN side: runs the FFN and MoE compute (not in the connector).
  5. FFN side: send_ffn_output(ffn_output, context) (afd_plugin/v1/worker/ffn_model_runner.py:268).
  6. 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 dive

Worked 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_states of shape [8, 2048], in the model dtype (bf16, inferred from the recipe).
  • Metadata: seq_lens = [8], so total_tokens = 8 (afd_plugin/connectors/metadata.py:198-200). The send call checks shape[0] == 8 (afd_plugin/connectors/metadata.py:230-231).
  • On the wire (A0 to F0): one tensor of 8 × 2048 × 2 bytes = 32,768 bytes per layer, per direction.
  • FFN side: F0 receives [8, 2048], runs the FFN, and sends back [8, 2048]. A0 gets it through recv_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 dive

The 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.

ConnectorDeviceTimingControl planeTransport
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#

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#

  1. 1
    Which two calls does the attention side make, and in which order?
    easy
    Show answer

    First send_attn_output, then recv_ffn_output (afd_plugin/connectors/base.py:145, afd_plugin/connectors/base.py:173).

  2. 2
    Which connector is the default?
    easy
    Show answer

    P2pNcclAFDConnector, the GPU connector. The default is set in the AFD config (afd_plugin/config.py:52).

  3. 3
    One connector sets control_plane = None. Which one, and what drives its FFN steps then?
    medium
    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).

  4. 4
    Why does recv_ffn_output take a preallocated ref_tensor?
    medium
    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).

  5. 5
    On 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 metadata total_tokens is 8, and the send checks shape[0] == total_tokens (afd_plugin/connectors/metadata.py:198-200, afd_plugin/connectors/metadata.py:230-231).