AFD Plugin Explained

Worker selection: choosing the role worker

IntermediateAFD conceptsabout 5 min read + 2 min deep dives

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

In one sentence

AFD picks a role-specific worker by rewriting worker_cls, but only when the user left it as "auto".

The idea#

vLLM has no register API for a worker. It reads the worker class name from parallel_config.worker_cls and imports it by name (vllm:vllm/v1/worker/worker_base.py:273-277). A plugin changes the worker by setting this field. AFD does that in its config patch.

AFD must not overwrite a worker the user chose on purpose. So the rewrite runs only when the field is still "auto". When the user passed --worker-cls, AFD keeps it and checks it later instead. The right worker also depends on the role and the platform, so AFD maps the platform's default worker to the matching AFD worker.

An everyday comparison. Think of a form field set to "auto". A helper fills it only when it is still "auto"; a value you typed is left alone, but checked for fit. The comparison stops here: AFD also swaps the model runner later, inside the worker.

The picture#

Figure: the rewrite runs only for worker_cls == 'auto' (panel 1); the platform default maps to an AFD worker per role (panel 2).
Open full size ↗

Read panel 1 top to bottom as the start-up flow. Read panel 2 as the mapping used in step 3. The violet NPU row is named here only; the GPU worker is the one this site covers in depth.

In the code#

Step 1: The config patch records whether worker_cls was "auto"#

The patched create_engine_config first records whether the user left worker_cls as "auto" (afd_plugin/compat/patches/config_validation.py:60). It keeps that flag because vLLM fills the field with the platform default while it builds the config.

    # ### PATCH START: AFD config preflight
    worker_cls_was_auto = _uses_auto_worker_value(self.worker_cls)
    is_afd_npu = _apply_afd_npu_config_patches(self)
    needs_engine_args_bypass = not is_afd_npu and _should_relax_engine_args_backend(
        self
    )
    # ### PATCH END: AFD config preflight
Record worker_cls_was_auto before vLLM builds the config.

Step 2: Rewrite only when it was "auto"#

After the config is built, the patch rewrites the worker only when the flag is set (afd_plugin/compat/patches/config_validation.py:91-93). The rewrite reads the role from the AFD config and the platform default from the field (afd_plugin/compat/patches/config_validation.py:179-197).

def _select_afd_worker_for_auto(vllm_config: VllmConfig) -> None:
    afd_config = parse_optional_afd_config(vllm_config)
    if afd_config is None:
        return

    from vllm.platforms import current_platform

    platform_worker_qualname = vllm_config.parallel_config.worker_cls
    if not isinstance(platform_worker_qualname, str):
        raise ValueError(
            "platform worker_cls must be a qualname string before AFD automatic "
            f"selection, got {type(platform_worker_qualname).__name__}",
        )
    vllm_config.parallel_config.worker_cls = afd_worker_qualname_for_platform_default(
        afd_config.role,
        platform_worker_qualname,
        is_cuda=current_platform.is_cuda(),
        device_type=current_platform.device_type,
    )
_select_afd_worker_for_auto: map the platform default to the AFD worker.

Step 3: The platform default maps to an AFD worker#

afd_worker_qualname_for_platform_default normalizes the platform default and maps it to the AFD worker for the role (afd_plugin/validation.py:280-309). The table lists the mappings.

Platform default workerRoleAFD worker
vllm.v1.worker.gpu_worker.Worker (CUDA) attentionAFDAttentionWorker
ffnAFDFFNWorker
vllm_ascend.worker.worker.NPUWorker (NPU) attentionAFDNPUAttentionWorker
ffnAFDNPUFFNWorker

The names are the FQCN constants at the top of the module (afd_plugin/validation.py:26-41). A CUDA default maps by role (afd_plugin/validation.py:290-291); the standard Ascend default maps to the NPU workers (afd_plugin/validation.py:292-296). The 310P and Xlite Ascend workers are rejected (afd_plugin/validation.py:297-305).

Step 4: vLLM imports the chosen class#

Later, in the worker process, vLLM imports worker_cls by its qualified name (vllm:vllm/v1/worker/worker_base.py:273-277). By then the field holds the AFD worker, so vLLM builds the AFD worker.

Step 5: The worker swaps the model runner in init_device#

The AFD worker's init_device first checks the stack, then swaps the native model runner class for the AFD one and delegates to the native init (afd_plugin/v1/worker/attention_worker.py:60-88). The FFN worker does the same with its own runner (afd_plugin/v1/worker/ffn_worker.py:81-99).

        native_runner_cls = gpu_model_runner_v2.GPUModelRunner
        gpu_model_runner_v2.GPUModelRunner = AFDAttentionModelRunnerV2
        try:
            super().init_device()
        finally:
            gpu_model_runner_v2.GPUModelRunner = native_runner_cls
        # ### PATCH END: select and directly construct the AFD runner.
Swap GPUModelRunner for AFDAttentionModelRunnerV2 around the native init.

Deep dive#

Deep dive

An explicit wrong worker is rejected, not fixed

When the user passes --worker-cls, the rewrite does not run. The stack check in init_device then verifies the field. assert_compatible_afd_stack compares the resolved worker with the expected AFD worker for the role (afd_plugin/validation.py:312-369).

  1. If the field is still "auto", it raises. This means the general plugin did not run before the config was built (afd_plugin/validation.py:343-348).
  2. If the worker does not match the expected AFD worker, it raises and names both, and tells the user to remove --worker-cls or pass the right one (afd_plugin/validation.py:357-367).
  3. For the async CAM connector, the expected worker is the NPU worker instead (afd_plugin/validation.py:331-335).

So AFD never silently replaces a worker the user set. It either accepts the right one or stops with a clear message.

Deep dive

Why the runner swap is a scoped module-attribute patch

vLLM 0.30.0 builds its model runner inside Worker.init_device, with no runner factory to inject. The AFD worker works around this with a scoped patch. It replaces the module attribute GPUModelRunner, calls the native init_device, then restores the attribute in a finally (afd_plugin/v1/worker/attention_worker.py:82-87).

This works because the native code does a local import of GPUModelRunner from the same module inside init_device, after the patch is in place (vllm:vllm/v1/worker/gpu_worker.py:475-482). A module-attribute patch changes only later attribute lookups, so the swap must be active during that import. The finally keeps the swap from leaking past the call.

Both roles also need the V2 model runner. The GPU runner check raises when the V2 model runner is off (afd_plugin/validation.py:90-91). vLLM reads the flag from VLLM_USE_V2_MODEL_RUNNER; the AFD recipes set it to 1.

What can go wrong#

Why it matters. The two servers must run the matching role workers. AFD picks them when the field is "auto", and refuses a wrong one, so a mismatch cannot reach the model.

Check yourself#

  1. 1
    When does AFD rewrite worker_cls?
    easy
    Show answer

    Only when it is still "auto". The patch records this before the config is built (afd_plugin/compat/patches/config_validation.py:60, afd_plugin/compat/patches/config_validation.py:91-93).

  2. 2
    On CUDA, which worker does the attention role use?
    easy
    Show answer

    AFDAttentionWorker. The CUDA default maps to it by role (afd_plugin/validation.py:290-291).

  3. 3
    The user passes --worker-cls some.other.Worker. What happens?
    medium
    Show answer

    The rewrite is skipped, and assert_compatible_afd_stack rejects the worker because it does not match the role's AFD worker (afd_plugin/validation.py:357-367).

  4. 4
    Why does the runner swap use a finally block?
    medium
    Show answer

    The swap is a module-attribute patch active only during the native init_device. The finally restores the native class so the swap does not leak past the call (afd_plugin/v1/worker/attention_worker.py:82-87).

  5. 5
    The worker class resolves, but the model runner is never swapped to the AFD one. Give one setup that produces this.
    hard
    Show answer

    If the plugin loaded after the config was built, worker_cls stays "auto" and the stack check raises before the swap (afd_plugin/validation.py:343-348). The swap runs only inside the AFD worker's init_device (afd_plugin/v1/worker/attention_worker.py:82-87).