AFD Plugin Explained

Ascend NPU overview: the same contract, different transports

AdvancedAFD conceptsabout 6 min read + 3 min deep dives

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

In one sentence

AFD on Ascend NPU keeps the same attention/FFN split as GPU. Only the connector transport changes.

The idea#

The NPU path is the one place this site covers Ascend code. The attention/FFN contract is the same as on GPU (see The connector contract: the Attention/FFN transport): the attention side sends hidden states, the FFN side runs the experts and sends the result back. What changes is the transport. The NPU has two connectors, both backed by Ascend CAM operators.

The picture#

Figure: the attention/FFN contract is the same as GPU. CAMP2p is synchronous with a Gloo control plane; CAMAsync is asynchronous, with no control plane, driven by its receive loop.
Open full size ↗

Read the panels top to bottom. Panel 1 is the shared contract. Panel 2 is the synchronous connector, which keeps a control plane. Panel 3 is the asynchronous connector, where the receive loop itself drives each FFN layer.

In the code#

Step 1: CAMP2p, the synchronous connector with a control plane#

The synchronous connector builds its own HCCL groups for the hidden states (afd_plugin/connectors/npu/camp2p.py:418-431) and a smaller Gloo group for the DP metadata (afd_plugin/connectors/npu/camp2p.py:460-468). It sets a control plane, CAMP2pAFDControlPlane (afd_plugin/connectors/npu/camp2p.py:385), so the DP metadata travels once per step, the same timing as the GPU connector. See The control plane: DP metadata once per step.

Step 2: CAMAsync has no control plane; its receive loop drives the FFN side#

The asynchronous connector sets control_plane = None (afd_plugin/connectors/npu/async_cam.py:283). The CAM dispatch/combine operators own both the data motion and its routing metadata, so there is no separate DP metadata path (afd_plugin/connectors/npu/async_cam.py:277-282).

    second. CAM dispatch/combine operators own both the collective data motion
    and its routing metadata, so this connector has no DP metadata control
    plane (``control_plane`` stays ``None``) and FFN work is triggered directly
    by the connector receive loop.
    """

    control_plane = None
The async connector: no control plane; FFN work is triggered by the receive loop.

So what drives the FFN side? The NPU async FFN runner loops over the layers and, for each one, calls recv_ffn_work_item (afd_plugin/v1/worker/npu/async_ffn_model_runner.py:442-446). Each work item carries its own layer index, read from the CAM metadata of the dispatch-receive, not from a control-plane payload (afd_plugin/connectors/npu/async_cam.py:497-541).


        for _ in self.layer_ids[:1] if single else self.layer_ids:
            work_item = connector.recv_ffn_work_item(
                stage_idx=stage_idx,
                max_num_tokens=self.max_num_tokens,
            )
            hidden_states = work_item.hidden_states
            metadata = work_item.context.metadata
            legacy_states = work_item.context.states
            if not isinstance(legacy_states, AFDAsyncTransferState):
                raise RuntimeError(
                    "CAM async FFN work item requires AFDAsyncTransferState",
                )
            layer_idx = work_item.layer_idx
            num_tokens = work_item.num_tokens
            afd_metadata = AFDForwardContextMetadata(
                tokens_start_loc=[0],
                requests_start_loc=[0],
                stage_idx=stage_idx,
                connector=self.connector,
                tokens_lens=[num_tokens],
                num_stages=1,
                tokens_unpadded_lens=[num_tokens],
            )

            with ascend_forward_context(
                vllm_config=self.vllm_config,
                afd_metadata=afd_metadata,
                model_instance=self.model,
                num_tokens=num_tokens,
                skip_mc2_mask=self.afd_config.compute_gate_on_attention,
            ) as forward_context:
                forward_context.dp_metadata = None
                forward_context.additional_kwargs["afd_metadata"] = metadata
                _set_moe_layer_index(forward_context, layer_idx)
The connector-driven FFN loop: recv_ffn_work_item gives the layer index; dp_metadata stays None.

The forward context sets dp_metadata = None for each work item (afd_plugin/v1/worker/npu/async_ffn_model_runner.py:473). The connector receive is the clock: when a dispatch arrives, the runner computes that layer and sends the result back. This is the mechanism the The control plane: DP metadata once per step page names in one line.

Step 3: The operator loader#

Both connectors need the plugin's Ascend operators. The loader ensure_cam_p2p_ops_available sets the custom op-API environment, imports afd_plugin._C_ascend, and asserts the torch.ops.afd_ascend namespace (afd_plugin/compat/npu/ops.py:73-91). ensure_afd_ascend_ops_loaded is a backward-compatible alias for it (afd_plugin/compat/npu/ops.py:94-97).

@lru_cache(maxsize=1)
def ensure_cam_p2p_ops_available() -> None:
    """Import the custom operators used by ``CAMP2pAFDConnector``.

    The extension is optional at package import time.  It is built by default
    in an Ascend environment unless ``AFD_BUILD_ASCEND_OPS=0`` is set.
    """

    _ensure_afd_custom_opp_env()
    try:
        import torch

        import afd_plugin._C_ascend  # noqa: F401
    except ImportError as exc:
        raise RuntimeError(
            "CAMP2P Ascend custom ops are not available. Build the package with "
            "Ascend ops enabled in a torch-npu/CANN environment.",
        ) from exc
    _assert_afd_namespace_registered(torch)
The loader imports the built extension and checks the operator namespace. No kernel code here.

The async connector needs four more operators, so it calls ensure_cam_async_ops_available, which checks the four afd_async_* ops (afd_plugin/compat/npu/ops.py:107-119). The extension afd_plugin._C_ascend is built from csrc/npu/pybind (setup.py:116-120), gated on AFD_BUILD_ASCEND_OPS or an Ascend platform (setup.py:43-54) and the SOC_VERSION (setup.py:67, setup.py:88). See Environment variables reference.

Step 4: Async data-parallel patches, gated to NPU#

The async path needs two extra patches. They install only when the role is attention and async_dp is on (afd_plugin/compat/npu/runtime.py:44-53), and the whole NPU patch set installs only when the device is npu (afd_plugin/__init__.py:199-209). On GPU, async_dp must be false. See Compat patches: how AFD changes vLLM without editing it.

Deep dive#

Deep dive

NPU workers and runners

The NPU runtime classes live under afd_plugin/v1/worker/npu/. vLLM-Ascend loads them by their explicit class paths, chosen by the worker-selection code (afd_plugin/validation.py:34-41).

FileClassRoleSource
attention_worker.pyAFDNPUAttentionWorker Builds the AFD-aware vLLM-Ascend attention runner. afd_plugin/v1/worker/npu/attention_worker.py:30
attention_model_runner_v2.pyAFDNPUAttentionModelRunnerV2 A thin AFD seam over the native Ascend Model Runner V2. afd_plugin/v1/worker/npu/attention_model_runner_v2.py:208
ffn_worker.pyAFDNPUFFNWorker Owns a connector-driven NPU daemon loop. afd_plugin/v1/worker/npu/ffn_worker.py:67
ffn_model_runner.pyAFDNPUFFNModelRunner The synchronous CAMP2p FFN runner. afd_plugin/v1/worker/npu/ffn_model_runner.py:56
async_ffn_model_runner.pyAsyncNPUFFNModelRunner The async CAM FFN runtime, driven by the connector receive loop. afd_plugin/v1/worker/npu/async_ffn_model_runner.py:73
async_ffn_two_stream.pyAsyncFFNTwoStream Two-slot async receive/compute with FIFO send. afd_plugin/v1/worker/npu/async_ffn_two_stream.py:41
ubatch_runner.pyNPUUBatchRunner Ascend inputs and contexts for native Model Runner V2 microbatching. afd_plugin/v1/worker/npu/ubatch_runner.py:67
mla_graph.pyhelpers Graph-local MLA parameter helpers for Ascend ubatch execution. afd_plugin/v1/worker/npu/mla_graph.py:23
Deep dive

NPU model wrappers and Ascend patches

Two NPU model wrappers matter for the overview.

The Ascend compat patches live under afd_plugin/compat/patches/npu/. They install only on an NPU device (afd_plugin/__init__.py:199-209).

PatchWhat it doesSource
ascend_config.pyKeeps AFD settings out of Ascend's strict config namespace. afd_plugin/compat/patches/npu/ascend_config.py:3
ascend_platform.pyPatches vLLM-Ascend platform config for AFD-owned DBO. afd_plugin/compat/patches/npu/ascend_platform.py:3
mla_graph.pyRoutes Ascend MLA graph parameters through AFD forward contexts. afd_plugin/compat/patches/npu/mla_graph.py:3
mrv2_ubatching.pyScoped Ascend seams for the native UBatchRunner. afd_plugin/compat/patches/npu/mrv2_ubatching.py:3-8
Deep dive

The NPU recipes and what they report

The recipe index lists both NPU connectors as "integrated v0.30 hardware validation pending"; the measured numbers come from older runtimes (recipe/README.md:36-38).

The NPU recipes also include result images, described with their own captions on the Tuning: the knobs the repo exposes page. This page does not restate those numbers as current results.

Deep dive

Where to read more: the NPU docs

The docs/npu/ folder documents the NPU path in depth. These are hints; this page cites the code.

DocumentTopic
CAM_P2P_CONNECTOR_USER_GUIDE.md The CAMP2p connector contract and rank mapping.
CAM_ASYNC_CONNECTOR_USER_GUIDE.md The CAMAsync connector, rank derivation and native-DBO distinction.
CAM_ASYNC_ROUTED_OPS.md The routed-only async operators.
ASYNC_FFN_RUNTIME.md The async CAM FFN eager/FULL runtime.
LAYERED_W2_MDL_VALIDATION.md The layered W13/W2 graph validation.
TESTING.md, TROUBLESHOOTING.md NPU validation and troubleshooting.

What can go wrong#

Why it matters. The NPU keeps the AFD contract but changes the transport and the driving mechanism. The async connector folds the control plane into the data operators, so the FFN side follows the receive loop, not a metadata step.

Check yourself#

  1. 1
    Which NPU connector has a control plane, and which does not?
    easy
    Show answer

    CAMP2pAFDConnector has one, CAMP2pAFDControlPlane (afd_plugin/connectors/npu/camp2p.py:385). CAMAsyncAFDConnector sets control_plane = None (afd_plugin/connectors/npu/async_cam.py:283).

  2. 2
    What two collective libraries does CAMP2p use, and for what?
    easy
    Show answer

    HCCL for the hidden states, and a smaller Gloo group for the DP metadata (afd_plugin/connectors/npu/camp2p.py:418-431, afd_plugin/connectors/npu/camp2p.py:460-468).

  3. 3
    How does the async FFN side know which layer to compute without a control plane?
    medium
    Show answer

    The runner calls recv_ffn_work_item in a loop; each item carries its layer index, read from the CAM dispatch metadata (afd_plugin/v1/worker/npu/async_ffn_model_runner.py:442-446, afd_plugin/connectors/npu/async_cam.py:497-541).

  4. 4
    How does the plugin load the Ascend operators, and what does it not load?
    medium
    Show answer

    It imports the CMake-built afd_plugin._C_ascend and asserts the torch.ops.afd_ascend namespace (afd_plugin/compat/npu/ops.py:73-91). It does not import the vendored bin/ artifacts, and the kernel internals are out of scope.

  5. 5
    When do the async-DP patches install, and why can they not run on GPU?
    hard
    Show answer

    Only when the role is attention and async_dp is on (afd_plugin/compat/npu/runtime.py:44-53), and only when the device is npu (afd_plugin/__init__.py:199-209). On GPU async_dp must be false, so the gate never opens.