AFD Plugin Explained

Dual-batch overlap (DBO): two stages per step

AdvancedAFD conceptsabout 6 min read + 3 min deep dives

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

In one sentence

Dual-batch overlap splits one step into exactly two stages. While one stage waits on the FFN side, the other computes attention.

The idea#

In AFD the attention side sends hidden states to the FFN side and waits for the result. If it waited with nothing else to do, the GPU would idle. Dual-batch overlap (DBO) cuts each step into two stages. The attention side sends stage 0, then works on stage 1 while the FFN side computes stage 0. The two roles overlap.

DBO is a native vLLM feature. The plugin reuses it and adds two things: a yield op that hands control to the other stage between send and receive, and scoped patches that let DBO run even with one data-parallel rank.

An everyday comparison. Think of a cook with two pans. They start pan 1, and while it simmers they start pan 2, instead of standing and watching pan 1. The comparison stops here: AFD always uses exactly two pans, never three, and a validator enforces that.

The picture#

Figure: one layer runs two stages. The yield between send and receive lets stage 1 run while the FFN side works on stage 0. Times are illustrative.
Open full size ↗

Read the top panel left to right. The overlap is the point: stage 1 of the attention side runs at the same time as stage 0 on the FFN side. The yield is what hands control across. The visual below lets you step through the same events.

InteractiveStep through a DBO step

This interactive visual steps through the send, yield, FFN compute and receive events of one step. It needs JavaScript. The steps and deep dives below say the same thing in text.

Pick the number of layers, then press Step, or press Tab then Enter. Watch columns t2 and t3, where attention and FFN work overlap.

In the code#

Step 1: Exactly two stages#

DBO turns on with --enable-dbo. In vLLM that sets the number of microbatches to two (vllm:vllm/config/parallel.py:599-601). AFD requires exactly two. The GPU validator raises for any other count (afd_plugin/validation.py:126-131).

    if (
        parallel.enable_dbo or parallel.use_ubatching
    ) and parallel.num_ubatches != GPU_MRV2_NUM_UBATCHES:
        raise RuntimeError(
            "AFD ModelRunnerV2 DBO/ubatching requires exactly two microbatches",
        )
The GPU validator: DBO or ubatching needs exactly two microbatches.

Step 2: Each stage carries a stage index on the wire#

Each stage gets a stage index, 0 or 1. The ubatch helper clones the parent AFD metadata per stage and sets the index and the stage count (afd_plugin/v1/worker/ubatch_wrapper.py:26-36).

    ubatch_slice = ubatch_slices[ubatch_idx]
    clone = afd_metadata.clone()
    clone.stage_idx = ubatch_idx
    clone.num_stages = len(ubatch_slices)
    clone.tokens_start_loc = [int(ubatch_slice.token_slice.start)]
    clone.requests_start_loc = [int(ubatch_slice.request_slice.start)]
    clone.tokens_lens = [int(ubatch_slice.num_tokens)]
    clone.tokens_unpadded_lens = [
        _resolve_ubatch_unpadded_tokens(afd_metadata, ubatch_slice, ubatch_idx),
    ]
    return clone
build_ubatch_afd_metadata: set stage_idx and num_stages for one ubatch.

The RemoteFFNProxy reads the stage index from the forward context and records it in the transfer metadata (afd_plugin/model_executor/models/deepseek_v2.py:136-143). So the FFN side knows which stage each tensor belongs to.

Step 3: The proxy yields between send and receive#

Inside the proxy, the order is: send the hidden states, yield, then receive the FFN output (afd_plugin/model_executor/models/deepseek_v2.py:146-158). The yield is what lets the other stage run while this one waits.

        afd_metadata.connector.send_attn_output(
            hidden_states,
            context,
            **send_kwargs,
        )
        hidden_states = maybe_apply_dbo_yield(
            hidden_states,
            role="attention",
        )
        return afd_metadata.connector.recv_ffn_output(
            ref_tensor=hidden_states,
            ubatch_idx=stage_idx,
        )
_send_and_receive: send, then maybe_apply_dbo_yield, then recv.

The yield calls a custom op, manual_dbo_yield, registered once per process (afd_plugin/v1/worker/dbo.py:27-49). The op yields only when vLLM DBO is active; otherwise it does nothing (afd_plugin/v1/worker/dbo.py:52-54).

def maybe_apply_dbo_yield(
    tensor: torch.Tensor,
    *,
    role: str,
) -> torch.Tensor:
    """Yield to the peer ubatch thread when vLLM DBO is active."""
    try:
        register_dbo_yield_custom_op()
    except ImportError:
        return tensor

    torch.ops.vllm.manual_dbo_yield(tensor)
    return tensor
maybe_apply_dbo_yield: call the custom op, which yields when DBO is on.

Step 4: The thresholds decide when a step splits#

A step splits into two stages only when it is large enough. The recipes set two thresholds: --dbo-decode-token-threshold and --dbo-prefill-token-threshold (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:23-25).

    --enable-dbo \
    --dbo-decode-token-threshold 2 \
    --dbo-prefill-token-threshold 12 \
The graph recipe turns on DBO and sets both thresholds.

vLLM reads these in check_ubatch_thresholds: a decode batch splits when its token count reaches the decode threshold, a batch with a prefill when it reaches the prefill threshold (vllm:vllm/v1/worker/ubatch_utils.py:141-149).

Step 5: DP1 DBO through scoped patches#

Native vLLM builds no ubatch runner when the data-parallel size is one. AFD coordinates the two stages locally, so it re-enables DP1 with two scoped patches (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:263-282).

@contextmanager
def use_gpu_mrv2_ubatch_factory() -> Iterator[None]:
    """Scope the DP1 factory to AFD's native KV initialization call."""
    original_factory = model_runner.maybe_build_ubatch_runner
    model_runner.maybe_build_ubatch_runner = maybe_build_ubatch_runner
    try:
        yield
    finally:
        model_runner.maybe_build_ubatch_runner = original_factory


@contextmanager
def use_gpu_mrv2_dbo_dispatch() -> Iterator[None]:
    """Scope DP1 eligibility to one AFD native execute_model call."""
    original_dispatch = model_runner.dispatch_cg_and_sync_dp
    model_runner.dispatch_cg_and_sync_dp = dispatch_cg_and_sync_dp
    try:
        yield
    finally:
        model_runner.dispatch_cg_and_sync_dp = original_dispatch
Two context managers scope the DP1 DBO seams to AFD's own calls.

Deep dive#

The blocks below give the two scoped patches, the threshold logic with numbers, and the yield mechanism.

Deep dive

The two scoped patches, in detail

AFD never edits vLLM. It swaps two module functions only for the time of one AFD call, then restores them.

The factory seam. use_gpu_mrv2_ubatch_factory replaces maybe_build_ubatch_runner while the attention runner initializes its KV cache (afd_plugin/v1/worker/attention_model_runner_v2.py:473-486). Native code returns None at DP1; the patched factory builds the native UBatchRunner and binds a DP1 child-context metadata factory (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:47-73).

The dispatch seam. use_gpu_mrv2_dbo_dispatch replaces dispatch_cg_and_sync_dp for one execute_model call (afd_plugin/v1/worker/attention_model_runner_v2.py:340-349). Native DP1 dispatch returns before the DBO threshold decision; the patch applies the native threshold and ubatch policy locally, with no DP handshake (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:194-226).

The child metadata. At DP1 the native DPMetadata.make asserts DP greater than one. The patch builds the same metadata directly, one forward context per microbatch (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:83-115).

Both seams restore the original function in a finally block, so they affect only AFD's own calls (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:268-271, afd_plugin/compat/patches/gpu_mrv2_ubatching.py:279-282).

Deep dive

The thresholds, with defaults and numbers

The thresholds are fields on the vLLM parallel config. Their defaults are large:

FieldDefaultSet in the recipe
dbo_decode_token_threshold32 (vllm:vllm/config/parallel.py:227)2
dbo_prefill_token_threshold512 (vllm:vllm/config/parallel.py:232)12

The recipe lowers both so small test batches still split (recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:24-25). The check is simple (vllm:vllm/v1/worker/ubatch_utils.py:141-149):

  1. If ubatching is off, do not split.
  2. For a uniform decode batch, split when the token count reaches the decode threshold.
  3. Otherwise, split when the token count reaches the prefill threshold.

A worked example. With the recipe values and a pure-decode step of 8 tokens, 8 is at least 2, so the step splits into two stages of 4. A decode step of 1 token does not reach 2, so it runs as a single batch, with no DBO.

At DP1 this decision runs inside the patched dispatch, which also guards against an empty trailing stage (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:213-215). At DP greater than one, native code makes the decision across ranks.

Deep dive

The yield mechanism

The yield is a no-return custom op, torch.ops.vllm.manual_dbo_yield. Its real implementation calls dbo_yield only when dbo_enabled is true (afd_plugin/v1/worker/dbo.py:33-34, afd_plugin/v1/worker/dbo.py:52-54). Its fake implementation does nothing, so torch.compile can trace through it (afd_plugin/v1/worker/dbo.py:36-37).

Registration is idempotent. A second call returns early, and a RuntimeError that says the op is already registered is swallowed (afd_plugin/v1/worker/dbo.py:30-49). The op is registered once at plugin start-up (afd_plugin/__init__.py:190-192).

The op takes the hidden-state tensor as its argument and marks it as mutated, so the compiler keeps the yield in the right place relative to the send and the receive (afd_plugin/v1/worker/dbo.py:40-45). The tensor value is not changed.

What can go wrong#

Why it matters. DBO keeps both roles busy. The attention side does useful work on one stage while the FFN side works on the other, instead of waiting. The two-stage limit and the yield placement are what make the overlap correct.

Check yourself#

  1. 1
    How many stages does DBO split a step into?
    easy
    Show answer

    Exactly two. --enable-dbo sets the microbatch count to two (vllm:vllm/config/parallel.py:599-601), and the validator rejects any other count (afd_plugin/validation.py:126-131).

  2. 2
    What does the proxy do between the send and the receive?
    easy
    Show answer

    It calls the DBO yield op, manual_dbo_yield, which hands control to the other stage (afd_plugin/model_executor/models/deepseek_v2.py:151-154, afd_plugin/v1/worker/dbo.py:33-34).

  3. 3
    A pure-decode step has 1 token, with the recipe thresholds. Does it use DBO?
    medium
    Show answer

    No. 1 is below the decode threshold of 2, so check_ubatch_thresholds returns false and the step runs as a single batch (vllm:vllm/v1/worker/ubatch_utils.py:144-149, recipe/gpu/P2pNcclAFDConnector/deepseek_v2_lite/prefill_decode_colocation/2a2f_graph_dbo_dp1tp2.sh:24).

  4. 4
    Why does the yield op have a fake implementation that does nothing?
    medium
    Show answer

    So torch.compile can trace through it. The real op yields only when DBO is active; the fake op returns None (afd_plugin/v1/worker/dbo.py:36-37, afd_plugin/v1/worker/dbo.py:52-54).

  5. 5
    Native vLLM does not build a ubatch runner at DP1. How does AFD run DBO there without editing vLLM?
    hard
    Show answer

    It installs two scoped patches. use_gpu_mrv2_ubatch_factory builds the runner at DP1 during KV initialization; use_gpu_mrv2_dbo_dispatch applies the threshold policy locally during one execute_model call. Both restore the original on exit (afd_plugin/compat/patches/gpu_mrv2_ubatching.py:47-73, afd_plugin/compat/patches/gpu_mrv2_ubatching.py:194-226).