Skip to content

[STF] Make executable graph cache stream-affine - #11041

Open
caugonnet wants to merge 2 commits into
NVIDIA:mainfrom
caugonnet:fix/executable-graph-cache-per-stream
Open

[STF] Make executable graph cache stream-affine#11041
caugonnet wants to merge 2 commits into
NVIDIA:mainfrom
caugonnet:fix/executable-graph-cache-per-stream

Conversation

@caugonnet

Copy link
Copy Markdown
Contributor

Summary

  • bind cached CUDA graph executables to their launch stream so one executable is not reused across concurrent support streams
  • require a nonblocking idle-stream check before cudaGraphExecUpdate, and skip busy entries during LRU reclamation
  • serialize cache mutations for shared async-resource handles and add focused stream-affinity/overlap coverage

Problem

The cache keyed executable graphs only by device and topology. Concurrent top-level graph scopes with compatible topology could therefore update and launch the same cudaGraphExec_t on different support streams; CUDA serializes those launches and silently removes legal overlap.

Test plan

  • Cache hit on the same idle stream
  • Cache miss for compatible graphs on different streams
  • Cache miss rather than update while the bound stream is busy
  • Two independently built top-level graph scopes overlap and produce correct output
  • Existing graph cache statistics/policy tests updated to reuse one explicit stream
  • Focused seven-test cache and stackable suite
  • Five consecutive overlap/cache regression runs
  • clang-format --dry-run --Werror and git diff --check

Known trade-offs

Busy entries are conservatively unavailable for update and reclamation, including when unrelated work occupies the bound stream. The cache may temporarily exceed its configured size when every candidate is busy; the implementation never synchronizes to enforce the limit.

Prevent concurrent scopes from reusing or updating an active executable by making cache entries stream-affine and skipping entries whose streams are busy.
@caugonnet
caugonnet requested a review from a team as a code owner August 27, 2026 21:09
@caugonnet
caugonnet requested a review from andralex August 27, 2026 21:09
@github-project-automation github-project-automation Bot moved this to Todo in CCCL Aug 27, 2026
@copy-pr-bot

copy-pr-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@cccl-authenticator-app cccl-authenticator-app Bot moved this from Todo to In Review in CCCL Aug 27, 2026
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • CUDA graph caching now tracks the underlying CUDA stream identity for reliable stream-specific reuse.
    • Cached graphs are reused only on compatible, idle streams; busy entries remain available for later use.
    • Improved handling of concurrent graph construction and execution.
  • Tests

    • Added coverage for stream affinity, cache hits and misses, busy streams, concurrent construction, and cache statistics.

Walkthrough

Changes

The graph cache now binds executable graphs to CUDA streams, reuses only idle matching entries, preserves busy entries, and serializes host access. Callers and tests pass streams explicitly. New tests cover stream affinity and concurrent scope construction.

Stream-aware graph cache

Layer / File(s) Summary
Stream propagation and cache entry contracts
cudax/include/cuda/experimental/__stf/..., cudax/include/cuda/experimental/__stf/stackable/...
Graph-cache queries now receive the submitted or support stream. Cache entries store their driver-assigned stream ID.
Stream-aware lookup and reclamation
cudax/include/cuda/experimental/__stf/internal/executable_graph_cache.cuh
Queries use mutex synchronization and stream matching. Reclamation scans for idle least-recently-used entries and retains busy or unavailable entries.
Stream affinity and concurrency validation
cudax/test/stf/...
Tests cover stream reuse, cross-stream misses, busy-stream behavior, synchronized statistics, and concurrent sibling scopes. The new test is registered in CMake.

Suggested reviewers: andralex

Merge Risk: 🟠 High · up to 73970

Concurrent use can update a cached executable before another caller launches it, causing the wrong graph to run, while destroyed streams may later be accessed during cache cleanup and trigger undefined behavior or crashes. The PR is not merge-ready until executable ownership and stream lifetime are protected.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cudax/include/cuda/experimental/__stf/internal/executable_graph_cache.cuh (1)

165-189: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

important: Keep a cache entry reserved until its launch is enqueued. cudaGraphExecUpdate modifies the executable in place. If a shared async_resources_handle performs a second query while the stream is still idle, executable_graph_cache::query can update the same cudaGraphExec_t before stackable_ctx_impl::launch_once calls cudaGraphLaunch. The first caller can then launch the second caller’s graph. Make cache update and launch one atomic operation, or return a lease that blocks updates until launch.

🧹 Nitpick comments (1)
cudax/include/cuda/experimental/__stf/graph/graph_ctx.cuh (1)

506-506: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

suggestion: Declare these unmodified local bindings const.

  • cudax/include/cuda/experimental/__stf/graph/graph_ctx.cuh#L506-L506: use const auto query_result.
  • cudax/test/stf/graph/get_cache_stats.cu#L22-L22: use const cudaStream_t stream.
  • cudax/test/stf/graph/get_cache_stats.cu#L40-L40: use auto* const st.
  • cudax/test/stf/graph/graph_cache_policy.cu#L22-L22: use const cudaStream_t stream.
  • cudax/test/stf/graph/graph_cache_policy.cu#L46-L46: use auto* const st.

As per coding guidelines: “All variables that are not modified must be declared const.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0fb4208f-1cb2-4074-88e1-bb4543acac2c

📥 Commits

Reviewing files that changed from the base of the PR and between 67c41fc and 9402ee2.

📒 Files selected for processing (8)
  • cudax/include/cuda/experimental/__stf/graph/graph_ctx.cuh
  • cudax/include/cuda/experimental/__stf/internal/async_resources_handle.cuh
  • cudax/include/cuda/experimental/__stf/internal/executable_graph_cache.cuh
  • cudax/include/cuda/experimental/__stf/stackable/stackable_ctx_impl.cuh
  • cudax/test/stf/CMakeLists.txt
  • cudax/test/stf/graph/get_cache_stats.cu
  • cudax/test/stf/graph/graph_cache_policy.cu
  • cudax/test/stf/stackable/executable_graph_cache_streams.cu

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

caugonnet added a commit to caugonnet/cugraph that referenced this pull request Aug 27, 2026
The previous pin pointed at an unrelated snapshot of the fork that lacks
the two fixes the composed centrality experiment requires. Pin the tip
of lab/cugraph-stf-sibling-fixes instead, which combines NVIDIA/cccl#11041
(stream-affine executable graph cache) and NVIDIA/cccl#11036 (preserve
nested read-only imports) on upstream main, so a fresh checkout of this
branch reproduces the measured composed-centrality behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cudaStream_t handle value can be recycled after cudaStreamDestroy, so
comparing stored handles against the caller could falsely match an entry
bound to a dead stream. Entries now bind to the driver-assigned stream
id (cudaStreamGetId, unique for the process lifetime); the handle is
kept only for idleness probes.

Stream probes go through a non-throwing tri-state helper that checks
capture status first: cudaStreamQuery on a capturing stream would
invalidate that capture, a cross-thread hazard when reclaim probes a
stream another context is capturing on. Destroyed-handle errors read as
'unavailable': such entries are skipped by reclaim (destroying the
executable is not provably safe while the dead stream's last launch may
drain) and can never match a lookup again.

Also make the entry constructor advance the LRU counter so entries
inserted between refreshes do not share one timestamp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@andralex andralex left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Grégoire (posting from Andrei's account). The design direction is correct and the PR does what it says: binding executables to their launch stream removes the false serialization, the busy-check before update is the right guard, and the test plan has the right shape (same-stream hit, cross-stream miss, busy miss, real overlap). Two defects and two smaller items found; the defects are fixed in 739706b, pushed to this branch.

Fixed in 739706b:

  1. Raw-handle identity was unsound (hot path, not just reclaim): a cudaStream_t handle value can be recycled after cudaStreamDestroy, so e.stream != stream could falsely MATCH an entry bound to a dead stream against an unrelated new stream on any lookup. Entries now bind to the driver-assigned stream id (cudaStreamGetId, unique for the process lifetime, CUDA >= 12.0); the raw handle is kept only for idleness probes.

  2. Stream probes could throw or corrupt a capture: stream_is_idle's cuda_try(status) turned a destroyed stored handle into an exception thrown mid-reclaim, and cudaStreamQuery on a stream that another thread is capturing on INVALIDATES that capture (STF does legacy capture, so this was a real cross-thread hazard). Probes now go through a non-throwing tri-state helper that checks cudaStreamIsCapturing first (capture-legal) and reads errors as unavailable. An unavailable entry is skipped by reclaim: destroying the executable is not provably safe while the dead stream's last launch may still drain.

  3. LRU tie: the entry constructor set last_use = cache->index without advancing, so entries inserted between refreshes shared a timestamp and evicted in arbitrary order. Constructor now increments too.

Answers to the review-thread questions: the weak paths were two, not one — identity aliasing was in the hot lookup (every query after any stream churn), while unsafe probes of stored handles were reclaim-only (in query() the only stream probed is the caller's, alive by definition). And no, the cache never learns a stream died, nor can it: CUDA has no liveness query (a recycled handle actively lies), no destruction callback, and no is-this-id-alive API. Entries bound to destroyed streams are permanent zombies — unmatched forever under id binding, skipped by reclaim. That is benign when cache-bound streams outlive the cache, which pool streams from async_resources_handle do by construction; the remaining design decision is inline below.

/* This will lookup in the cache (if any) and update an existing entry, or
* instantiate a graph if none is found. */
auto query_result = async_resources().cached_graphs_query(nnodes, nedges, *g);
auto query_result = async_resources().cached_graphs_query(nnodes, nedges, *g, state.submitted_stream);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one open design decision after 739706b: this path binds cache entries to state.submitted_stream, i.e. the USER's stream, whose lifetime STF does not control. With id binding a recycled handle can no longer false-match, but entries bound to destroyed user streams become permanent zombies (never matched, never reclaimed — reclaim cannot prove the dead stream's last launch drained). Options: (a) accept bounded zombie growth (one entry per destroyed-stream x topology, ~10KB/node estimate) and document it; (b) don't insert into the cache on this path — user-stream submits instantiate uncached, losing reuse but keeping the cache zombie-free; (c) have graph_ctx wrap user streams into pool-owned proxies at creation so the ownership invariant covers everything. The stackable path (pick_stream()) is pool-owned and safe by construction either way. My lean is (a) now with a comment, (c) as the eventual clean state.

continue;
}

if (reserved::try_updating_executable_graph(*e.exec_g, g))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for a comment rather than a change: the idle-check-then-update sequence is protected by the cache mutex against other QUERIES, but nothing stops a second host thread from LAUNCHING onto this same stream between the check and the update. The design is sound under the invariant "one support stream is submitted to by one thread at a time", which holds today for pool streams and single-ctx user streams, but it is implicit. One sentence here would make it an invariant instead of luck.

Related, pre-existing (the function itself is outside this diff so noting it here at its call site): try_updating_executable_graph's blind cudaGetLastError() also swallows any unrelated earlier pending async error, reporting it as "update failed" (a silent miss) instead of surfacing it. Cheap hardening: check the return value of cudaGraphExecUpdate itself and only clear-and-classify when that call is what failed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f662496f-829d-4191-bc05-a25e50e852ed

📥 Commits

Reviewing files that changed from the base of the PR and between 9402ee2 and 739706b.

📒 Files selected for processing (1)
  • cudax/include/cuda/experimental/__stf/internal/executable_graph_cache.cuh

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

{
reclaimed += it->second.footprint;
total_cache_footprint[dev_id] -= it->second.footprint;
if (query_stream_state(it->second.stream) == stream_state::idle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

important: Do not probe entry::stream after its owner can call cudaStreamDestroy. A cached entry can retain this raw handle after destruction, and query_stream_state then calls cudaStreamIsCapturing through it during reclamation. CUDA defines use of a stream handle after cudaStreamDestroy as undefined, so the intended unavailable path cannot safely prevent a crash or other invalid behavior. Track launch completion with cache-owned events, or enforce that every bound stream outlives the cache. (docs.nvidia.com)

As per path instructions, focus on lifetime/resource ownership.

Source: Path instructions

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

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

2 participants