Prhub

#51700 [2/2][Model Runner V2] FULL CUDA graph capture for microbatched steps (DBO)

原始 PR 作者 specture724 合并时间 2026-09-14 13:42 文件变更 6 提交数 5 评论 26 代码增减 +407 / -80

执行摘要

MRV2 DBO 微批步骤接入 FULL CUDA 图捕获与回放

动机来自 RFC #50738:DBO 是 DP+EP 部署中隐藏专家 all-to-all 通信延迟的关键手段,但它此前只存在于 V1 model runner(gpu_ubatch_wrapper.py / ubatching.py),Model Runner V2 完全没有 DBO 路径,RFC 中明确指出 _get_v2_model_runner_unsupported_features() 会在 --enable-dbo 与 V2 组合时直接报错,是 DP+EP 部署迁移到 V2 的真实阻塞。第一段 #50945 让微批步骤在 V2 上以 eager 方式跑通,但 PR body 指出瓶颈仍在 CPU:"at replay there are no microbatch threads, no threading.Event handoffs and no per-microbatch Python, so the CPU launch path that dominates eager decode disappears"。本 PR 因此把捕获与回放补齐,同时保持 PIECEWISE + DBO 不支持(与 V1 runner 一致)。

值得精读,尤其是三类设计决策:(a) _maybe_ubatch_twin 用与 DP 握手完全相同的阈值函数生成候选,把“可捕获”与“可分发”强行绑定,这是避免多 rank 不对称执行的关键;(b) run() 拆成 begin_capturable_run() / finish(),把线程创建、device 设置与 cuBLAS 预热留在图外,只让 handoff 与 join 进图,这是 DBO 能捕获成功的前提;(c) _capture_stream 强制捕获流与微批线程 compute stream 一致,是移植 V1 gpu_ubatch_wrapper.py::_capture_ubatches 时最容易漏掉的一点。阅读时请结合 #50945 一起看(本 PR 的 diff 建立在它之上),并重点关注 dp_utils.py 中 2 * min(num_tokens_across_dp) < ubatch_desc.num_tokens 这一守卫——它已被确认存在边界 bug,跟进补丁前不建议在生产 DP 负载高度不均的场景依赖 FULL 微批回放。此外建议跟踪 coderabbitai 提出的“永不被回放的孪生图”是否会形成显存浪费的后续处理。

讨论亮点

1) 注释风格收敛:SageMoore 在 dp_utils.py 上给出多条 suggestion,要求把成段解释压成两行(如“# Match a FULL microbatched CUDA graph, falling back to an eager descriptor if no graph matches.”),njhill 明确背书:“I agree with @SageMoore's comments. In general lets keep comments very concise.”;SageMoore 在 model_runner.py 上还有 “NIT: Let's just delete this comment.”,njhill 回复 “+1”。作者随后用 a54a49b、882f2cc 两个 commit 落实“Simplify dispatch comments and padding updates, and keep capture and cleanup constraints concise”。
2) FULL 图的请求数不变量:SageMoore 建议不要在分支里就地改写 num_reqs,而是显式写成 num_reqs = int(num_reqs_across_dp.max()) 加 if ubatch_desc.cg_mode == FULL: assert ubatch_desc.num_reqs is not None; num_reqs = ubatch_desc.num_reqs,把 FULL 图对请求数的要求变成可读不变量,已被采纳。
3) 合入后暴露的边界缺陷:Laceprndpm 指出在 DP token 数为 [64, 128]、存在 128-token 图时,“the smaller rank's second microbatch is entirely padding, but 2 * 64 < 128 still permits FULL replay”,并准备了 < → <= 的补丁与回归参数;作者回复 “I think you're right. This is a bug instead of intentional behavior. Thank you for pointing out, and please open a PR to fix it.”——这是目前唯一被确认的遗留正确性问题。
4) 候选图可能永不命中:coderabbitai 的 nit 指出 mixed 分支中可被整除的捕获尺寸可能生成 uniform_token_count > 1 的孪生图(例如 512 / 256 == 2),而 DBO 已拒绝投机解码、运行时 query 长度为 1、_is_compatible 又要求精确匹配,这些图捕获后无法被回放,只会白占显存;在可见讨论中未见回应或修复。
5) 刻意排除的失败处理缺口:aoshen02 指出 ubatching.py 中兄弟微批出错挂死、以及 __exit__ 清空 _CURRENT_CONTEXTS[self.id] 后唤醒 peer 导致 AttributeError: 'NoneType' object has no attribute 'recv_hook' 的偶发崩溃,已另开 #55586;本 PR 通过测试 docstring 记录该缺口但明确 out of scope。

实现拆解

  1. 捕获候选生成(vllm/v1/worker/gpu/cudagraph_utils.py)。CudaGraphManager.__init__ 新增 ubatch_runner 参数;新增 _maybe_ubatch_twin(desc),对每个 FULL 候选派生一个 num_ubatches=N 的孪生描述符,条件是:存在 ubatch_runner、desc.cg_mode == FULL、num_reqs 不为 None、num_tokens % num_reqs == 0 且与 desc.uniform_token_count 自洽、num_tokens >= num_ubatches、并通过 check_ubatch_thresholds(parallel_config, num_tokens, uniform_decode=True)。它在 uniform-decode 分支与 mixed 分支各调用一次;PR body 特别说明 mixed 分支是必需的,因为 MLA 这类只支持 uniform batch 的后端会被降级为 FULL_AND_PIECEWISE,其 FULL 图本来就是 uniform-decode 图,只在 mixed 分支生成会导致这些模型一张微批图都捕获不到。关键是这套判据必须是配置的纯函数:各 rank 在 all-reduce 之后独立分发,若某个 rank 回放图而另一个走 eager,专家 all-to-all 会挂死。
  2. 捕获流与线程生命周期(ubatch_utils.py + cudagraph_utils.py)。UBatchRunner 新增 capture_stream,run() 被拆成 begin_capturable_run()(创建微批线程、设置 device index、预热 cuBLAS workspace、等待 ready barrier,全部在图外)与返回的 finish() 回调(cpu_wait_event.set() 加 join,在图内),并新增 abort_pending_run() 供捕获失败时释放被 park 的线程;CudaGraphManager._capture_stream(desc) 让 torch.cuda.graph(..., stream=...) 使用微批线程的 capture_stream,capture() 用 ExitStack 注册 abort_pending_run 兜底。prepare() 新增 cg_mode 与 for_capture 参数,FULL 下按 padded size 构建每微批 attention metadata,保证捕获与回放的形状和缓冲地址一致。ModelCudaGraphManager.create_forward_fn 新增 ubatch_forward_fn 分支,并从 store_capture_output 把输出拷进持久缓冲。
  3. 分发与 DP 同步(dp_utils.py + model_runner.py)。sync_cudagraph_and_dp_padding 的微批分支不再硬编码 cg_mode=NONE,改为在 cudagraph_manager is not None 时调用 dispatch(num_reqs, ubatch_num_tokens, synced_uniform_token_count, num_active_loras=..., num_ubatches=...),并新增空第二微批守卫 2 * int(num_tokens_across_dp.min()) < ubatch_desc.num_tokens;随后按图描述符刷新 num_tokens,FULL 时用 ubatch_desc.num_reqs 覆盖 num_reqs(带 assert),DPSyncState.eager 由 ubatch_desc.cg_mode == NONE 决定。GPUModelRunner 把 self.ubatch_runner 传给 ModelCudaGraphManager,并在 ubatch_runner.prepare(...) 调用处传入 cg_mode=batch_desc.cg_mode、for_capture=dummy_run and batch_desc.cg_mode == FULL。
  4. 配置校验放开(vllm/config/vllm.py)。_get_dbo_unsupported_features() 由“任何非 NONE 的 cudagraph_mode 都拒绝”改为只拒绝 CUDAGraphMode.PIECEWISE,注释写明“DBO captures FULL graphs only”;因此此前被静默拒绝的 --enable-dbo + FULL 组合现在能真正生效。
  5. 测试配套(tests/v1/worker/test_gpu_ubatch_slicing.py)。新增 test_capturable_run_replays_as_a_cudagraph(真实用 torch.cuda.graph(graph, stream=runner.capture_stream) 捕获后 graph.replay(),验证回放读取被刷新的持久输入缓冲)、test_captured_split_survives_a_replay_with_enough_requests(回放必须保留捕获时的 request 切分)、test_microbatched_graphs_are_only_offered_to_uniform_batches、test_microbatched_graph_needs_every_rank_to_reach_the_split,并给 _sync_dp 增加 cudagraph_manager 注入点。
文件 模块 状态 重要度
vllm/v1/worker/gpu/cudagraph_utils.py 图捕获管理 modified 8.66
vllm/v1/worker/gpu/ubatch_utils.py 微批执行 modified 8.27
vllm/v1/worker/gpu/dp_utils.py 数据并行同步 modified 6.7
tests/v1/worker/test_gpu_ubatch_slicing.py 微批测试 modified 7.24
vllm/v1/worker/gpu/model_runner.py 模型执行器 modified 5.64
vllm/config/vllm.py 配置校验 modified 4.93

关键符号

_maybe_ubatch_twin _capture_stream store_capture_output ubatch_forward_fn UBatchRunner.begin_capturable_run UBatchRunner.abort_pending_run UBatchRunner.prepare sync_cudagraph_and_dp_padding GPUModelRunner.execute_model

关键源码片段

vllm/v1/worker/gpu/cudagraph_utils.py core-logic

本 PR 的核心:新增微批候选生成 `_maybe_ubatch_twin`、捕获流选择 `_capture_stream`、`ubatch_forward_fn` 捕获分支与 `store_capture_output` 持久输出拷贝,并让 `CudaGraphManager` / `ModelCudaGraphManager` 接收 `ubatch_runner`。

# vllm/v1/worker/gpu/cudagraph_utils.py —— 生成微批孪生图候选,并选定捕获流
​
def _maybe_ubatch_twin(
    self, desc: BatchExecutionDescriptor
) -> BatchExecutionDescriptor | None:
    """Return a microbatched capture candidate when eligible."""
    # 只有启用 DBO 且是 FULL 模式时才可能生成微批图
    if self.ubatch_runner is None or desc.cg_mode != CUDAGraphMode.FULL:
        return None
    if desc.num_reqs is None:
        return None
​
    # 能整除说明整个 batch 的 query 长度一致;只有这种形状才能在回放时
    # 复现捕获时的 request 切分,否则切分比例会随 batch 漂移
    uniform_token_count, remainder = divmod(desc.num_tokens, desc.num_reqs)
    if remainder or desc.uniform_token_count not in (None, uniform_token_count):
        return None
​
    parallel_config = self.vllm_config.parallel_config
    num_ubatches = get_num_ubatches(parallel_config)
    if desc.num_tokens < num_ubatches:
        return None
    # 复用 DP 握手投票用的同一套阈值:候选生成必须是配置的纯函数,
    # 否则会出现某 rank 回放图、另一 rank 走 eager,专家 all-to-all 直接挂住
    if not check_ubatch_thresholds(
        parallel_config, desc.num_tokens, uniform_decode=True
    ):
        return None
​
    return replace(
        desc, num_ubatches=num_ubatches, uniform_token_count=uniform_token_count
    )
​
​
def _capture_stream(self, desc: BatchExecutionDescriptor) -> torch.cuda.Stream:
    """Capture on the stream used by the microbatch threads."""
    if desc.num_ubatches > 1:
        assert self.ubatch_runner is not None
        # 微批线程只把 kernel 发到 capture_stream;torch.cuda.graph 默认另开
        # side stream,不显式指定会把线程的工作留在图外,等于什么都没捕获
        return self.ubatch_runner.capture_stream
    return current_stream()
​
​
# capture() 中的事务边界:失败时用 abort_pending_run 释放被 park 的线程
with graph_capture(device=self.device), ExitStack() as stack:
    if self.ubatch_runner is not None:
        stack.callback(self.ubatch_runner.abort_pending_run)
    ...
    with torch.cuda.graph(graph, self.pool, stream=self._capture_stream(desc)):
        forward_fn(CUDAGraphMode.NONE)
vllm/v1/worker/gpu/ubatch_utils.py dependency-wiring

把原来的 `run()`(线程创建 + barrier + join 一体化)拆成图外的 `begin_capturable_run()` 与图内的 `finish()`,新增 `capture_stream`、cuBLAS 预热、device index 设置与 `abort_pending_run()`,并让 `prepare()` 接受 `cg_mode` / `for_capture`。

# vllm/v1/worker/gpu/ubatch_utils.py —— 图外 begin + 图内 finish 的两段式执行
​
def begin_capturable_run(
    self,
    model: Any,
    model_inputs: dict[str, Any],
    ubatch_state: UBatchState,
    for_capture: bool = False,
) -> Callable[[], Any]:
    """Start microbatch threads and return a callback to run and join them."""
    ubatch_slices = ubatch_state.slices
    assert len(ubatch_slices) == self.num_ubatches
​
    # 捕获时线程必须和 torch.cuda.graph 落在同一条流上
    compute_stream = self.capture_stream if for_capture else current_stream()
    ubatch_contexts = make_ubatch_contexts(
        num_micro_batches=self.num_ubatches,
        comm_stream=self.comm_stream,
        compute_stream=compute_stream,
        forward_contexts=ubatch_state.forward_contexts,
        ready_barrier=self.ready_barrier,
    )
​
    @torch.inference_mode()
    def run_ubatch(ubatch_context, inputs):
        try:
            # 新建线程不继承主线程的 device index,必须显式设置
            torch.accelerator.set_device_index(self.device.index)
            if for_capture:
                # cuBLAS 首次在某条流上使用才分配 workspace,捕获中途分配非法,
                # 所以提前把 compute_stream 与 comm_stream 都预热一遍
                for stream in (compute_stream, self.comm_stream):
                    with torch.cuda.stream(stream):
                        torch.cuda.current_blas_handle()
            with ubatch_context:
                outputs[ubatch_context.id] = model(**inputs)
        except BaseException as e: # noqa: BLE001
            errors[ubatch_context.id] = e
​
    # 线程与 ready barrier 留在图外,把它们 park 住后返回回调
    ...
    self.ready_barrier.wait()
​
    def finish() -> Any:
        # 图内只有事件置位与 join,没有任何逐微批的 Python 调度逻辑
        try:
            ubatch_contexts[0].cpu_wait_event.set()
            for thread in threads:
                thread.join()
        finally:
            self._pending_finish = None
            stack.close()
        ...
​
    self._pending_finish = finish
    return finish
​
​
def abort_pending_run(self) -> None:
    """Release and join parked threads after a failed capture."""
    if self._pending_finish is None:
        return
    try:
        self._pending_finish()
    except Exception:
        logger.exception("Failed to join microbatch threads after an abort")
    finally:
        self._pending_finish = None
vllm/v1/worker/gpu/dp_utils.py core-logic

DP 同步是微批图分发的决策点:`sync_cudagraph_and_dp_padding` 从硬编码 `cg_mode=NONE` 改为向 cudagraph manager 请求微批 FULL 图,并新增空第二微批守卫(即合入后被确认存在边界 bug 的 `2 * min < num_tokens` 判断)。

# vllm/v1/worker/gpu/dp_utils.py —— 微批分支的图分发与守卫
​
if check_ubatch_thresholds(parallel_config, int(num_tokens_across_dp.min()),
                           uniform_decode=uniform_decode_across_dp):
    # 专家 all-to-all 要求所有 rank 等量切分并 pad 到相同 token 数,
    # 空微批按 dummy batch 运行
    ubatch_num_tokens = int(num_tokens_across_dp.max())
    num_ubatches = get_num_ubatches(parallel_config)
    ubatch_desc = None
    if cudagraph_manager is not None:
        # 用同步后的 uniform token count 请求微批 FULL 图,
        # dispatch 匹配不到时会回退成 NONE 描述符
        ubatch_desc = cudagraph_manager.dispatch(
            num_reqs,
            ubatch_num_tokens,
            synced_uniform_token_count,
            num_active_loras=num_active_loras,
            num_ubatches=num_ubatches,
        )
        # 若最小 rank 连第二个微批都填不满(2 * min 覆盖不了图的 padded 大小),
        # 则退回 eager,避免用全 padding 的微批回放 FULL 图
        if 2 * int(num_tokens_across_dp.min()) < ubatch_desc.num_tokens:
            ubatch_desc = None
    if ubatch_desc is None:
        ubatch_desc = BatchExecutionDescriptor(
            cg_mode=CUDAGraphMode.NONE,
            num_tokens=ubatch_num_tokens,
            num_reqs=num_reqs,
            num_ubatches=num_ubatches,
        )
    # 刷新 token 数,把 CUDA 图 padding 也算进去
    ubatch_num_tokens = ubatch_desc.num_tokens
    num_reqs = int(num_reqs_across_dp.max())
    if ubatch_desc.cg_mode == CUDAGraphMode.FULL:
        # FULL 图对请求数有固定要求,必须用捕获时的 num_reqs
        assert ubatch_desc.num_reqs is not None
        num_reqs = ubatch_desc.num_reqs
    return ubatch_desc, DPSyncState(
        num_tokens_across_dp=torch.full_like(num_tokens_across_dp, ubatch_num_tokens),
        uniform_token_count=synced_uniform_token_count,
        eager=ubatch_desc.cg_mode == CUDAGraphMode.NONE,
        num_reqs=num_reqs,
    )

评论区精华

注释冗长,要求大幅精简 style

SageMoore 在 dp_utils.py 给出多条 suggestion,要求改成 “# Match a FULL microbatched CUDA graph, falling back to an eager descriptor if no graph matches.” 这类两行注释;在 model_runner.py 上提出 “NIT: Let's just delete this comment.”。njhill 明确表态:“I agree with @SageMoore's comments. In general lets keep comments very concise.”,并对删除注释的提议回复 “+1”。

结论:作者在 a54a49b(Address DBO CUDA graph review nits)与 882f2cc(Shorten DBO CUDA graph comments)两个 commit 中落实,合并评论中确认“Your suggestions are applied and I made the comments very concise.” · 已解决

FULL 图的请求数不变量显式化 设计

SageMoore 建议不要在小分支里就地改写 num_reqs,而是集中成 “num_reqs = int(num_reqs_across_dp.max()); if ubatch_desc.cg_mode == CUDAGraphMode.FULL: assert ubatch_desc.num_reqs is not None; num_reqs = ubatch_desc.num_reqs”。

结论:已采纳,FULL 微批图对 num_reqs 的固定要求变成可读的显式契约,并在断言保护下生效。 · 已解决

空第二微批守卫的边界错误 正确性

Laceprndpm 在合并后指出:DP token 数为 `[64, 128]`、存在 128-token 图时,较小 rank 的第二个微批全是 padding,但 `2 * 64 < 128` 仍允许 FULL 回放,并提供了 `<` → `<=` 的补丁与一个回归参数(未在多 GPU MoE 上复现输出错误,定位为守卫一致性问题)。

结论:作者确认:“I think you're right. This is a bug instead of intentional behavior. Thank you for pointing out, and please open a PR to fix it.”,修复留给 follow-up;这是本 PR 目前唯一被确认的遗留正确性缺陷。 · unresolved

部分微批孪生图永远不会被回放 性能

coderabbitai 的 nit 指出 FULL mixed 分支中可整除的捕获尺寸会生成 uniform_token_count > 1 的孪生图(例如 512 / 256 == 2),而 DBO 拒绝投机解码、运行时 query 长度为 1、_is_compatible 又要求精确匹配,这些图捕获后无法命中,只会增加显存占用;建议在 _maybe_ubatch_twin 中补 `if uniform_token_count != self.decode_query_len` 的 gate。

结论:可见讨论中未见回应,问题保持开启;考虑到显存开销实测已从 0.46 升到 0.96 GiB,值得作为后续清理项跟踪。 · unresolved

ubatching.py 微批失败处理与 sporadic 崩溃 正确性

aoshen02 依据测试 docstring “A microbatch that fails while its sibling is parked at a yield hangs the step” 指出两条独立缺陷:兄弟微批出错时 step 挂死;以及 __exit__ 先清 _CURRENT_CONTEXTS[self.id] 再唤醒 peer,导致 survivor 在 dbo_register_recv_hook 中遇到 “AttributeError: 'NoneType' object has no attribute 'recv_hook'”。

结论:本 PR 明确 out of scope(需改共享的 ubatching.py 交接协议),已被另开 #55586 处理;作者在测试 docstring 中保留了对该缺口的说明。 · unresolved

风险与影响

1) 空第二微批守卫边界(已确认 bug):vllm/v1/worker/gpu/dp_utils.py 中 2 * int(num_tokens_across_dp.min()) < ubatch_desc.num_tokens 未覆盖相等情形,DP 负载极不均衡(如 [64, 128] 对 128-token 图)时仍会用实际内容全为 padding 的微批去回放 FULL 图,属于合入后遗留的正确性缺口,需按 Laceprndpm 的补丁把 < 改为 <= 并补回归参数。
2) 跨 rank 候选集合一致性靠约定维持:_maybe_ubatch_twin 必须与 UBatchRunner.wants_ubatch、DP 握手投票使用同一判据(check_ubatch_thresholds + get_num_ubatches),一旦有人只改一侧,就会出现“某 rank 回放、另一 rank eager”的不对称执行,专家 all-to-all 会挂死;当前没有运行时断言兜底。
3) 捕获路径的两条隐性约束易被回归:捕获必须落在微批线程的 capture_stream 上,且线程需在捕获前完成 device index 设置与 cuBLAS workspace 预热——PR body 明确说这两点是让 draft “捕获不到任何东西”的原因;后续重构 begin_capturable_run 或改动 make_ubatch_contexts 的流参数时容易再次踩坑。
4) 持久输出缓冲的首次分配时机:store_capture_output 在 hidden_states / aux_hidden_states / intermediate_tensors 为 None 时使用 torch.empty_like 分配,因此依赖捕获前的 warmup 调用顺序;若 create_forward_fn(..., warmup=...) 的调用序列被调整,分配将落入捕获区而报错。
5) 显存与配置行为变化:config/vllm.py 放开 DBO + FULL 后,此前在配置校验阶段被拒绝的组合会真正为每个 FULL 候选多捕获一张微批图,Qwen3-30B-A3B 实测捕获开销从 0.46 GiB 增至 0.96 GiB,小显存或捕获尺寸密集的部署需要重新评估;PIECEWISE 仍然被拒绝,用户若用 PIECEWISE 会得到与以前不同的报错文案。
6) 验证强度有限:精度证据是 GSM8K 单次运行(run-to-run 波动约 1pp),DeepSeek-V2-Lite 上 0.3503 与 no-DBO 0.3457 的差异落在噪声范围内;作者自述 “Perf validation is deferred to P4 and needs cross-node EP”,本 PR 未提供吞吐量数据,FULL 图带来的收益目前只有结构性论证与“分支确实被回放”的日志证据。

对用户:使用 VLLM_USE_V2_MODEL_RUNNER=1 的 DP+EP MoE 部署(DeepSeek-V2-Lite、Qwen3-30B-A3B-Thinking-2507-FP8 等)现在可以开启 --enable-dbo 并走 FULL CUDA 图回放,decode 步的 CPU 发射路径与微批交接开销从热路径消失;只支持 uniform batch 的 MLA 后端在 mixed batch 上仍按设计回退 eager 微批,因此收益是按后端与负载形态分层的。显存占用会因多捕获一组微批图而上升,需要重新核算。对系统:BatchExecutionDescriptor 的 num_ubatches 从“仅是描述字段”变成了图选择的关键键(_is_compatible 要求精确匹配),MRV2 的 dispatch 契约、捕获契约(图外准备 + 图内 finish)、以及 DP 同步契约(同步后再 dispatch)都被本次改动同时收紧,后续所有涉及 cudagraph 的改动都要考虑这一维度。对团队:MRV2/DBO 主线推进到 P4 前夜,DP+EP 迁移 V2 的最后一个已知阻塞被移除;协作上呈现“作者 + 社区多轮迭代(5 个 commit 中 2 个是 review 后的注释精简、2 个是 rebase)”,并明确把边界修复与跨节点性能验证留给 follow-up。

核心捕获路径变更 合并后遗留边界 bug 多 rank 一致性依赖约定 显存占用上升 缺少端到端性能验证

关联 Issue

#50738 [RFC]: Dual Batch Overlap (DBO) for Model Runner V2

完整报告

参与讨论