Dual-batch overlap (DBO): two stages per step
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#
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.
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.
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",
)
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 cloneThe 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,
)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 tensorStep 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 \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_dispatchDeep dive#
The blocks below give the two scoped patches, the threshold logic with numbers, and the yield mechanism.
Deep diveThe 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 diveThe thresholds, with defaults and numbers
The thresholds are fields on the vLLM parallel config. Their defaults are large:
| Field | Default | Set in the recipe |
|---|---|---|
dbo_decode_token_threshold | 32 (vllm:vllm/config/parallel.py:227) | 2 |
dbo_prefill_token_threshold | 512 (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):
- If ubatching is off, do not split.
- For a uniform decode batch, split when the token count reaches the decode threshold.
- 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 diveThe 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#
- The wrong number of ubatches. Any count other than two raises at config validation
(
afd_plugin/validation.py:126-131). - Thresholds set below the microbatch count. vLLM requires each threshold to be at least
the number of microbatches (
vllm:vllm/config/parallel.py:1093-1099). - A step that is too small. A step under the threshold does not split, so it runs with no
overlap (
vllm:vllm/v1/worker/ubatch_utils.py:144-149). This is expected, not an error. - DBO with a graph but not two ubatches. The graph policy raises when ubatching is on with
any count other than two (
afd_plugin/v1/worker/cuda_graph.py:72-77).
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#
- 1How many stages does DBO split a step into?easy
Show answer
Exactly two.
--enable-dbosets 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). - 2What 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). - 3A 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_thresholdsreturns 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). - 4Why does the yield op have a fake implementation that does nothing?medium
Show answer
So
torch.compilecan 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). - 5Native 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_factorybuilds the runner at DP1 during KV initialization;use_gpu_mrv2_dbo_dispatchapplies the threshold policy locally during oneexecute_modelcall. 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).