AFD Plugin Explained

Data on the wire: tensors per layer and stage

AdvancedAFD conceptsabout 6 min read + 2 min deep dives

Code version: afd-plugin @ 7452d52 · 2026-10-27 · vLLM @ ced6857

In one sentence

The data plane moves hidden-state tensors between the roles once per layer and stage. The FFN rank fans in its peers, computes, and splits the output back.

The idea#

For each layer and stage, the attention rank sends its hidden states to its FFN rank. The FFN rank may own more than one attention peer, so it concatenates the inputs along the token dimension, runs the feed-forward, then splits the output back and returns each slice to the rank that produced it.

Each transfer carries a small metadata record: the layer index, the stage index and the per-peer token lengths. The token lengths set the expected tensor shape and the split sizes. The transfers go through custom ops, so they stay usable under torch.compile and CUDA graph capture.

An everyday comparison. Think of parcels with shipping labels. The label says how many boxes and for which address, so the depot can combine shipments and later separate them again. The comparison stops here: the "parcels" also carry optional extras next to the hidden states, the router logits and the token IDs, which only some models send.

The picture#

Figure: hidden states cross A to F, the FFN output crosses back; one FFN rank fans in its peers and splits the output by the recorded sequence lengths.
Open full size ↗

Read panel 1 as one exchange for one layer and stage. Read panel 2 as the fan-in when one FFN rank owns two attention peers.

In the code#

Step 1: What the attention rank sends#

send_attn_output sends the hidden states first, then the optional router logits, then the optional token IDs (afd_plugin/connectors/gpu/p2p.py:323-390). The hidden states are a CUDA tensor of shape (num_tokens, hidden_size); the hidden size comes from the model config (afd_plugin/connectors/gpu/p2p.py:195-197).

        metadata = context.metadata
        if not torch.compiler.is_compiling() and not metadata.validate_tensor_shape(
            tuple(hidden_states.shape),
        ):
            raise ValueError(
                f"hidden_states shape {hidden_states.shape!r} does not match "
                f"AFD metadata token count {metadata.total_tokens}",
            )
        router_logits: torch.Tensor | None = kwargs.get("router_logits")
        input_ids: torch.Tensor | None = kwargs.get("input_ids")
        if (
            router_logits is not None
            and router_logits.shape[0] != hidden_states.shape[0]
        ):
            raise ValueError(
                "router_logits and hidden_states must have equal token counts",
            )
        if input_ids is not None:
            if input_ids.ndim != 1 or input_ids.shape[0] != hidden_states.shape[0]:
                raise ValueError(
                    "input_ids must be one-dimensional and token-aligned with "
                    "hidden_states",
                )
            if input_ids.dtype != _INPUT_IDS_DTYPE:
                raise ValueError("P2P AFD input_ids must use torch.int32")
The shape check, then the optional router logits and int32 token IDs.

The leading dimension must equal total_tokens, unless torch.compile is tracing (afd_plugin/connectors/gpu/p2p.py:347-353). The token IDs must be a one-dimensional int32 tensor (afd_plugin/connectors/gpu/p2p.py:363-370).

Step 2: The transfer metadata#

Each transfer carries an AFDTransferMetadata: the layer index, the stage index and the per-peer sequence lengths (afd_plugin/connectors/metadata.py:188-190). The sum of the lengths is the expected leading dimension (afd_plugin/connectors/metadata.py:198-200).

    @property
    def total_tokens(self) -> int:
        return sum(self.seq_lens)
total_tokens is the sum of the per-peer sequence lengths.

The shape check compares shape[0] with total_tokens (afd_plugin/connectors/metadata.py:230-231).

Step 3: The FFN rank fans in its peers#

recv_attn_output receives one tensor from every attention peer in the subgroup, concatenates them along the token dimension, and records each peer's length in the returned metadata (afd_plugin/connectors/gpu/p2p.py:428-552).

        hidden_states = (
            torch.cat(hidden_states_list, dim=0)
            if len(hidden_states_list) > 1
            else hidden_states_list[0]
        )
        router_logits = None
        if routing_spec is not None:
            router_logits = (
                torch.cat(router_logits_list, dim=0)
                if len(router_logits_list) > 1
                else router_logits_list[0]
            )
        input_ids = None
        if input_ids_list:
            input_ids = (
                torch.cat(input_ids_list, dim=0)
                if len(input_ids_list) > 1
                else input_ids_list[0]
            )

        metadata = AFDTransferMetadata.create_ffn_metadata(
            layer_idx=0,
            stage_idx=ubatch_idx,
            seq_lens=[tensor.shape[0] for tensor in hidden_states_list],
        )
Concatenate the peer tensors; record the per-peer sequence lengths.

With one peer the single tensor is the whole input (afd_plugin/connectors/gpu/p2p.py:520-524). All of a step's receives run in one NCCL group, so the peers' transfers proceed together (afd_plugin/connectors/gpu/p2p.py:514-519).

Step 4: The FFN rank splits the output back#

send_ffn_output splits the output along the token dimension by the recorded sequence lengths, then sends each slice to the attention rank that produced it (afd_plugin/connectors/gpu/p2p.py:554-613).

        output_slices: list[torch.Tensor] = []
        dsts: list[int] = []
        start = 0
        for dst, token_count in zip(
            range(1, self.group_size),
            split_sizes,
            strict=False,
        ):
            end = start + token_count
            output_slices.append(ffn_output[start:end])
            dsts.append(dst)
            start = end
        self._send_tensors(output_slices, dsts, self.e2a_group, self.e2a_comm_id)
Split by the recorded lengths; send each slice to its peer.

When the recorded lengths do not cover every peer, the split falls back to an even split, which raises if the tokens do not divide evenly (afd_plugin/connectors/gpu/p2p.py:590-599).

Step 5: The transfers are custom ops#

The sends and receives are wrapped in four custom ops: afd_p2p_send, afd_p2p_recv, and the _group variants (afd_plugin/connectors/gpu/p2p.py:979-1122). The wrapping keeps the transfers traceable by torch.compile and capturable by CUDA graphs (afd_plugin/connectors/gpu/p2p.py:983-986). Each op runs on the current CUDA stream (afd_plugin/connectors/gpu/p2p.py:1002-1006). The connectors/gpu/p2p.py: the GPU P2P connector page reads the custom ops in full.

Deep dive#

Deep dive

Worked example: two peers into one FFN rank (4A2F)

Take a 4A2F topology, so FFN rank 0 owns attention ranks A0 and A1 (afd_plugin/distributed/topology.py:136). The hidden size is 2048 for DeepSeek-V2-Lite; this value is inferred from the checkpoint config, not stored in either repository. Take one decode step with 8 tokens on A0 and 8 on A1, in bf16 (inferred from the recipe).

  • A0 sends: hidden_states of shape [8, 2048], metadata seq_lens = [8].
  • A1 sends: hidden_states of shape [8, 2048], metadata seq_lens = [8].
  • On the wire: each tensor is 8 × 2048 × 2 bytes = 32,768 bytes, per layer, per direction.
  • F0 receives and concatenates: [16, 2048], with seq_lens = [8, 8] (afd_plugin/connectors/gpu/p2p.py:540-544).
  • F0 computes and splits back: two slices of [8, 2048]; the first returns to A0, the second to A1 (afd_plugin/connectors/gpu/p2p.py:601-613).

The FFN rank derives the per-peer shapes from the DP metadata before it receives (afd_plugin/connectors/gpu/p2p.py:776-807); see The control plane: DP metadata once per step.

Deep dive

The two optional tensors

Two tensors cross next to the hidden states, in this wire order: hidden states, then router logits, then token IDs (afd_plugin/connectors/gpu/p2p.py:336-338).

TensorShape / dtypeWhen it is sent
hidden_states[n, hidden_size], model dtypealways
router_logits[n, num_experts]when the gate runs on the attention side (afd_plugin/v1/worker/ffn_model_runner.py:225-229)
input_idsint32 [n]for the DeepSeek-V4 hash MoE (afd_plugin/connectors/gpu/p2p.py:210-214)

The FFN side asks for the router logits only when compute_gate_on_attention is set, through a routing spec (afd_plugin/v1/worker/ffn_model_runner.py:225-229). It asks for the token IDs only when the model needs them (afd_plugin/v1/worker/ffn_model_runner.py:221). The router logits width and dtype come from the model (afd_plugin/connectors/gpu/p2p.py:481-491). The The MoE handoff: where the gate runs page reads the gate placement.

Deep dive

Receive buffers and the current stream

Each receive writes into a buffer. In eager mode the connector allocates a fresh tensor per receive. When CUDA graphs are on, the receive reuses a buffer preallocated during the control-plane update, so the allocation stays stable for capture and replay (afd_plugin/connectors/gpu/p2p.py:472-477, afd_plugin/connectors/gpu/p2p.py:911-936). The attention side's recv_ffn_output takes a preallocated ref_tensor for the same reason (afd_plugin/connectors/gpu/p2p.py:392-426). The custom ops issue the transfers on the current CUDA stream, inside one NCCL group when there is more than one peer (afd_plugin/connectors/gpu/p2p.py:1037-1070).

What can go wrong#

Why it matters. The two roles must agree on the shapes. The recorded sequence lengths are the only record of which tokens belong to which peer, so the FFN rank uses them to split the output back.

Check yourself#

  1. 1
    What crosses from the attention rank to the FFN rank for one layer and stage?
    easy
    Show answer

    The hidden states [n, hidden_size], then optional router logits and optional int32 token IDs, in that order (afd_plugin/connectors/gpu/p2p.py:336-338).

  2. 2
    How does the FFN rank know how to split its output back?
    easy
    Show answer

    From the recorded sequence lengths. recv_attn_output records each peer's token length (afd_plugin/connectors/gpu/p2p.py:540-544), and send_ffn_output splits by those lengths (afd_plugin/connectors/gpu/p2p.py:590-613).

  3. 3
    Why are the sends and receives wrapped in custom ops?
    medium
    Show answer

    So the transfers stay traceable by torch.compile and capturable by CUDA graphs (afd_plugin/connectors/gpu/p2p.py:983-986). The connector registers four ops, with fake implementations for tracing (afd_plugin/connectors/gpu/p2p.py:979-1122).

  4. 4
    When does the attention side send the int32 token IDs?
    medium
    Show answer

    For the DeepSeek-V4 hash MoE, when the model asks for them (afd_plugin/connectors/gpu/p2p.py:210-214, afd_plugin/v1/worker/ffn_model_runner.py:221).

  5. 5
    In 4A2F with 8 tokens on A0 and 8 on A1, what shape does F0 run the FFN on, and what does it send back?
    hard
    Show answer

    F0 concatenates to [16, 2048] with seq_lens = [8, 8] (afd_plugin/connectors/gpu/p2p.py:540-544), then splits the output into two [8, 2048] slices, one per peer (afd_plugin/connectors/gpu/p2p.py:601-613). The 2048 is inferred from the DeepSeek-V2-Lite checkpoint.