Skip to content

Add CUDA graph support for HybridEngine generation - #8271

Merged
PKUWZP merged 1 commit into
deepspeedai:masterfrom
PKUWZP:feat/hybrid-engine-cuda-graph
Aug 19, 2026
Merged

Add CUDA graph support for HybridEngine generation#8271
PKUWZP merged 1 commit into
deepspeedai:masterfrom
PKUWZP:feat/hybrid-engine-cuda-graph

Conversation

@PKUWZP

@PKUWZP PKUWZP commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Token generation dominates a HybridEngine RLHF iteration, and it is bound by CPU work rather than by the GPU.

Measured on facebook/opt-1.3b, ZeRO-2, single H100, 256-token prompt, 128 new tokens:

batch ms / decode step tok/s
1 5.00 200
4 5.10 785
8 5.17 1549
16 5.58 2865
32 5.37 5956

32× the batch costs 1.07× the latency. The GPU is idle waiting on the host. A single decode step issues roughly 1300 kernel launches, and of the 5.22 ms step, 4.52 ms is the model forward while summed kernel time is only ~2.75 ms.

Replaying a captured CUDA graph replaces those launches with one call.

Design

Adds an opt-in hybrid_engine.enable_cuda_graph flag. Two properties of the inference kernels shape the design.

One graph per decode position. The kernels read the current sequence length from a host-side counter (InferenceContext::current_tokens() in csrc/transformer/inference/csrc/pt_binding.cpp) and pass it to kernels as a launch parameter. Capture freezes launch parameters, so a single graph would keep reading and writing one position forever. Host code does run during capture, so capturing one graph per position records the correct sequence offsets into each.

All-or-nothing per sequence. That counter is advanced from host code (advance_tokens()) and is not exposed to Python. Replay runs no host code, so it leaves the counter behind, and an eager decode step after a replay would use a stale sequence length and corrupt the KV cache. Eager and replayed steps therefore must never be mixed within a sequence. begin_sequence() makes the decision once, up front, from the pinned generation length, before any decode step runs.

Capture is followed immediately by a replay, since capture records work without executing it; the replay is what actually fills the KV cache for that position.

Safety

Graphs are refused, with a warning, wherever captured pointers would not stay valid:

  • ZeRO stage 3 — parameters are gathered into fresh buffers for each generate call, and the inference containers hold no persistent weights (attn_qkvw is None). A graph would replay whichever buffers existed at capture time, which is silently wrong rather than merely slow.
  • release_inference_cache — frees the workspace buffers the graphs write into.
  • inference_tp_size > 1 — untested here.
  • Unpinned generation length — a sequence that outruns its captured graphs cannot fall back to eager safely, so graphs engage only when min_new_tokens == max_new_tokens.

Capture failures fall back to eager execution and disable graphs, rather than failing the training job.

Weight updates were verified explicitly: reset_params() writes into the same inference buffers in place, so the captured pointers stay valid across optimizer steps. After a step that moved the logits by 11.75, replay matched a fresh eager forward to within 0.09 and differed from the pre-step result by the full 11.75 — i.e. graphs track updated weights rather than replaying stale ones.

Results

facebook/opt-1.3b, ZeRO-2, batch 8, 256-token prompt, 128 new tokens, single H100, averaged over 4 measured iterations of an RLHF-style loop (evalgeneratetrain → forward/backward/step):

phase eager CUDA graph speedup
generate 676.7 ms 360.8 ms 1.88×
train step 134.8 ms 141.0 ms 0.97×
iteration 812.8 ms 503.5 ms 1.61×
  • Generated tokens are unchanged: 1024/1024 token agreement with the eager path, all 8 sequences identical end to end.
  • Peak memory: 27.83 → 27.96 GiB (+132 MiB for 127 captured positions).
  • One-time capture cost: the first generation captures the graphs and takes ~12 s; every later generation replays.

Tests

tests/unit/hybrid_engine/test_he_cuda_graph.py:

  • 13 CPU-only tests covering the generation-length gate, the ZeRO-3 / release_inference_cache / inference_tp_size rejections, and the dispatch state machine (unknown length, over-long length, prompt forwards, and invalidation when the generation length changes).
  • One GPU end-to-end test (seq_inference marker, opt-125m) asserting that a graphed generation is token-identical to the same generation run eagerly.

Docs: a Hybrid Engine section added to docs/_pages/config-json.md, covering the config block and the new flag's requirements and restrictions.

Scope

Default is off, so nothing changes unless the flag is set; the eager path measured identically before and after this change (676.7 ms generate both ways). Only ZeRO-2 with inference_tp_size=1 was benchmarked, which is what the guards allow.

Token generation in the HybridEngine is bound by CPU work, not by the GPU.
A single decode step issues on the order of 1300 kernel launches, and the
kernels finish well before the host can queue the next step. Measured on
opt-1.3b with an H100, per-decode-step latency is essentially flat from
batch 1 to batch 32 (5.00ms -> 5.37ms) while the GPU work grows 32x, and
the model forward accounts for 4.52ms of the 5.22ms step.

Capture the decode forward into CUDA graphs, behind a new opt-in
`hybrid_engine.enable_cuda_graph` config flag.

Two properties of the inference kernels shape the design. The kernels read
the current sequence length from a host-side counter
(`InferenceContext::current_tokens()`) and pass it to kernels as a launch
parameter, which graph capture freezes, so one graph is captured per decode
position rather than one overall. That counter is also advanced from host
code and is not reachable from Python, so a replay leaves it behind and an
eager decode step after a replay would use a stale sequence length. Eager
and replayed steps therefore must not be mixed within a sequence, and the
choice is made once per sequence in `begin_sequence()` from the pinned
generation length.

Graphs are refused, with a warning, where captured pointers would not stay
valid: ZeRO stage 3 gathers parameters into fresh buffers per generate,
`release_inference_cache` frees the buffers graphs write into, and
inference tensor parallelism is untested. Capture failures fall back to
eager execution rather than failing the job.

Measured on opt-1.3b, ZeRO-2, batch 8, 256-token prompt, 128 new tokens,
single H100, over an RLHF-style loop:

  generate    676.7ms -> 360.8ms  (1.88x)
  iteration   812.8ms -> 503.5ms  (1.61x)

Generated tokens are unchanged: 1024/1024 token agreement with the eager
path across all 8 sequences. Peak memory grows by 132 MiB for 127 captured
positions. The first generation captures the graphs and is a one-time cost
of about 12s.

Signed-off-by: Zhipeng Wang <zhipeng.rainbowserie@gmail.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.

@PKUWZP
PKUWZP requested a review from delock August 18, 2026 08:33
@PKUWZP
PKUWZP added this pull request to the merge queue Aug 19, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 19, 2026
@PKUWZP
PKUWZP added this pull request to the merge queue Aug 19, 2026
Merged via the queue into deepspeedai:master with commit 80c8e5b Aug 19, 2026
15 of 19 checks passed
@PKUWZP
PKUWZP deleted the feat/hybrid-engine-cuda-graph branch August 19, 2026 14:54
@delock

delock commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Is it possible to change flag name from enable_cuda_graph to enable_graph_capture? I believe it should be universal across accelerators.

@PKUWZP

PKUWZP commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Is it possible to change flag name from enable_cuda_graph to enable_graph_capture? I believe it should be universal across accelerators.

Let me submit another PR to change it. Thanks for flagging it out:)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants