Skip to content

Reduce decode CUDA-graph memory with shared pool and shared logits buffer#439

Open
gameofdimension wants to merge 4 commits into
NVIDIA:mainfrom
gameofdimension:enh-reduce-decode-cuda-graph-mem
Open

Reduce decode CUDA-graph memory with shared pool and shared logits buffer#439
gameofdimension wants to merge 4 commits into
NVIDIA:mainfrom
gameofdimension:enh-reduce-decode-cuda-graph-mem

Conversation

@gameofdimension

Copy link
Copy Markdown
Contributor

Description

Reduces the memory cost of the SID-GR decode CUDA graph so decode at beam_width=1024 no longer OOMs. At beam=1024 the decode graph captured ~30 entries, each reserving its own CUDA-graph private pool and a [batch, beam, vocab] logits tensor, raising idle GPU memory from ~24 GiB to ~45 GiB and triggering OOM during decode. Both are fixed without altering any computation, so outputs are numerically unchanged.

  1. Shared graph private pool — the prefill piecewise runner already shared one graph_pool_handle() across its captures; the decode runner did not, and got a fresh private pool per capture. The lazy-handle + pool= kwargs + env opt-out are hoisted onto CudaGraphCacheMixin (deduplicating the two runners' identical helpers), and each runner sets a _separate_pools_env class attribute. Decode captures now reuse one pool instead of one per entry.
    Opt-out: GR_INFERENCE_DECODE_CUDA_GRAPH_SEPARATE_POOLS / GR_INFERENCE_PREFILL_CUDA_GRAPH_SEPARATE_POOLS.
  2. Shared logits output buffer_linear_project gains an out= path (matmul out= into a caller buffer) and forward_decode_step a logits_out= arg; the decode runner allocates one [batch, beam, vocab] buffer per (active_beam_width, bucket) and writes every captured graph's lm_head output into it, so the logits lives once per shape rather than once per graph.
    Opt-out: GR_INFERENCE_DECODE_CUDA_GRAPH_SEPARATE_LOGITS.

New /status fields: decode_cuda_graph_shared_pool, decode_cuda_graph_shared_output_buffers.

Measured (NVIDIA L20 46 GiB, beam=1024, context_len=4000): idle after warmup 45.4 → 26.9 GiB (decode-graph overhead ~21 → ~3 GiB); beam=1024 decode that previously OOM'd now succeeds; decode_cuda_graph_shared_pool=1, shared_output_buffers=5, replays confirm the shared path is hit.

Accuracy: graph-ON output_ids match the eager (graph-OFF) baseline token-for-token across single / batch2 / batch4 (24/24, 0 mismatch).

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

…gits buffer

Capturing the SID-GR decode CUDA graph at beam_width=1024 reserved a
private CUDA-graph memory pool and a [batch, beam, vocab] logits tensor
per captured graph (~30 entries), raising idle GPU memory from ~24 GiB to
~45 GiB and OOM-ing decode at the configured beam width. Both are fixed
without changing any computation, so outputs are numerically unchanged.

1. Shared graph private pool (CudaGraphCacheMixin)
   The prefill piecewise runner already shared one graph_pool_handle()
   across its captures; the decode runner did not and got a fresh private
   pool per capture. Hoist the lazy handle + pool= kwargs + env opt-out
   onto CudaGraphCacheMixin (deduplicating the two runners' identical
   helpers), and have each runner set a _separate_pools_env class
   attribute. Decode captures now reuse one pool instead of ~30.
   Opt-out: GR_INFERENCE_DECODE_CUDA_GRAPH_SEPARATE_POOLS /
            GR_INFERENCE_PREFILL_CUDA_GRAPH_SEPARATE_POOLS.

2. Shared logits output buffer
   lm_head logits ([batch, beam, vocab], ~batch*297 MiB at beam=1024) were
   held once per captured graph. Add an out= path to _linear_project
   (matmul out= into a caller buffer) and a logits_out= arg to
   forward_decode_step; the decode runner allocates one buffer per
   (active_beam_width, bucket) and writes every captured graph's logits
   into it, so the tensor lives once per shape rather than once per graph.
   Opt-out: GR_INFERENCE_DECODE_CUDA_GRAPH_SEPARATE_LOGITS.

New /status fields: decode_cuda_graph_shared_pool,
decode_cuda_graph_shared_output_buffers.

Measured on NVIDIA L20 46GiB (beam=1024, context_len=4000):
- idle after warmup: 45.4 -> 26.9 GiB (graph overhead ~21 -> ~3 GiB).
- beam=1024 decode that previously OOM'd now succeeds.
- graph-ON output_ids match the eager (graph-OFF) baseline token-for-token
  across single / batch2 / batch4 (24/24, 0 mismatch); replays confirm the
  shared path is hit.

Tests: 9 CPU unit tests added (test_cuda_graph_shared_pool.py,
test_decode_shared_logits.py) covering the pool-sharing helper (opt-out,
handle reuse, dedup contract) and the shared-logits buffer (_linear_project
out= correctness, per-(beam,bucket) caching).

Signed-off-by: gameofdimension <gameofdimension@gmail.com>
Co-Authored-By: Claude Code
Co-Authored-By: GLM-5.2
@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR reduces decode CUDA graph memory use for large beam sizes. The main changes are:

  • Shared CUDA graph private pools for decode and prefill piecewise captures.
  • A shared decode logits output buffer per beam and bucket shape.
  • LRU-based cleanup for shared logits buffers when graph shapes are evicted.
  • Status fields and tests for pool sharing and shared logits reuse.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
examples/sid-gr-inference/src/gr_inference/gr_serving/decode_cuda_graph.py Adds decode pool sharing, shared logits buffers, status counters, and buffer cleanup tied to graph cache lifetime.
examples/sid-gr-inference/src/gr_inference/gr_models/qwen3/model.py Adds an optional projection output buffer path for decode logits.
examples/sid-gr-inference/src/gr_inference/gr_serving/cuda_graph_utils.py Moves CUDA graph pool-sharing helper logic into the shared cache mixin.
examples/sid-gr-inference/src/gr_inference/gr_serving/prefill_cuda_graph.py Switches prefill piecewise capture to the inherited pool-sharing helper.
examples/sid-gr-inference/tests/test_decode_shared_logits.py Adds tests for shared logits projection, reuse, eviction cleanup, and caching-disabled behavior.
examples/sid-gr-inference/tests/test_cuda_graph_shared_pool.py Adds tests for graph pool handle reuse and env opt-out behavior.

Reviews (4): Last reviewed commit: "Merge branch 'main' into enh-reduce-deco..." | Re-trigger Greptile

The shared logits cache (_shared_logits, keyed by (beam_width, bucket)) is
independent of the graph LRU, so a buffer allocated for a decode shape stayed
resident for the runner lifetime after _store_graph() evicted the last graph
using it. A transient large capture (high beam/bucket) could thus leave its
[bucket, beam, vocab] tensor resident and re-grow idle memory after eviction,
undermining the memory bound this change adds.

Override _store_graph in GRDecodeCudaGraphRunner: after the base store (which
performs LRU eviction), drop shared logits buffers whose (beam_width, bucket)
shape has no live graph entry, tying buffer lifetime to graph lifetime.

Tests: two CPU tests in test_decode_shared_logits.py — eviction frees the
orphaned buffer, and a shape shared by multiple graphs is kept until its last
graph is evicted.

Signed-off-by: gameofdimension <gameofdimension@gmail.com>
Co-Authored-By: Claude Code
Co-Authored-By: GLM-5.2
… off

With GR_INFERENCE_DECODE_CUDA_GRAPH_MAX_ENTRIES <= 0, the base _store_graph()
clears _graphs and returns without retaining the newly captured entry, so the
override's live_shapes set was empty and it deleted the shared logits buffer
that _capture had just wired into entry.output. The buffer itself was not freed
(the caller's immediate replay still reaches it via entry.output, which holds
the same tensor), but the cache entry was dropped on every capture -- defeating
per-shape reuse under max_entries=0 and leaving the override's correctness
dependent on that external reference.

Always add the just-stored entry's (beam_width, bucket) to live_shapes so its
output buffer stays in the cache through the immediate replay regardless of
whether the base store retained the entry. A no-op for max_entries > 0, where
the entry is already live.

Tests: test_shared_logits_buffer_kept_for_just_captured_entry_when_caching_disabled
covers the max_entries=0 path (fails without this change).

Signed-off-by: gameofdimension <gameofdimension@gmail.com>
Co-Authored-By: Claude Code
Co-Authored-By: GLM-5.2
@gameofdimension

Copy link
Copy Markdown
Contributor Author

@cb521 @shijieliu would you please have a look

@cb521

cb521 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@gameofdimension yes,I have a fix for this feature, I will reply the customer, THX.

@gameofdimension

Copy link
Copy Markdown
Contributor Author

@gameofdimension yes,I have a fix for this feature, I will reply the customer, THX.

Is the approach in this PR a correct solution? Does it have any chance of being merged? @cb521

@cb521

cb521 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@gameofdimension Sure, it's a common impl, I will merge it to here.

@gameofdimension

Copy link
Copy Markdown
Contributor Author

@gameofdimension Sure, it's a common impl, I will merge it to here.

I'm a bit confused...

Specifically, I'm referring to the GPU memory issue and the proposed fix in this PR.

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.

2 participants