-
Notifications
You must be signed in to change notification settings - Fork 480
[STF] Make executable graph cache stream-affine #11041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,7 +30,7 @@ | |
| #include <cuda/experimental/__stf/utility/pretty_print.cuh> | ||
| #include <cuda/experimental/__stf/utility/source_location.cuh> | ||
|
|
||
| #include <queue> // for ::std::priority_queue | ||
| #include <mutex> | ||
| #include <unordered_map> | ||
|
|
||
| namespace cuda::experimental::stf | ||
|
|
@@ -119,9 +119,11 @@ public: | |
| // One entry of the cache | ||
| struct entry | ||
| { | ||
| entry(executable_graph_cache* cache, ::std::shared_ptr<cudaGraphExec_t> exec_g_, size_t footprint) | ||
| entry( | ||
| executable_graph_cache* cache, ::std::shared_ptr<cudaGraphExec_t> exec_g_, cudaStream_t stream_, size_t footprint) | ||
| : cache(cache) | ||
| , exec_g(mv(exec_g_)) | ||
| , stream(stream_) | ||
| , footprint(footprint) | ||
| { | ||
| last_use = cache->index; | ||
|
|
@@ -135,6 +137,7 @@ public: | |
|
|
||
| executable_graph_cache* cache; | ||
| ::std::shared_ptr<cudaGraphExec_t> exec_g; | ||
| cudaStream_t stream; | ||
| size_t last_use; | ||
| size_t footprint; | ||
| }; | ||
|
|
@@ -156,15 +159,27 @@ public: | |
| // Check if there is a matching entry (and update it if necessary) | ||
| // the returned bool indicate is this is a cache hit (true = cache hit, false = cache miss) | ||
| // The graph g is only used during this call (for update or instantiate); it is never stored. | ||
| ::cuda::std::pair<::std::shared_ptr<cudaGraphExec_t>, bool> query(size_t nnodes, size_t nedges, cudaGraph_t g) | ||
| ::cuda::std::pair<::std::shared_ptr<cudaGraphExec_t>, bool> | ||
| query(size_t nnodes, size_t nedges, cudaGraph_t g, cudaStream_t stream) | ||
| { | ||
| ::std::lock_guard<::std::mutex> guard(mutex); | ||
|
|
||
| int dev_id = cuda_try<cudaGetDevice>(); | ||
| _CCCL_ASSERT(dev_id < int(cached_graphs.size()), "invalid device id value"); | ||
|
|
||
| auto range = cached_graphs[dev_id].equal_range({nnodes, nedges}); | ||
| for (auto it = range.first; it != range.second; ++it) | ||
| { | ||
| auto& e = it->second; | ||
| // Executable graphs are only reused on the stream to which the cache | ||
| // entry is bound. In addition to preventing CUDA from serializing | ||
| // concurrent launches of one executable on different streams, this | ||
| // gives us an explicit completion check before the host-side update. | ||
| if (e.stream != stream || !stream_is_idle(stream)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (reserved::try_updating_executable_graph(*e.exec_g, g)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): |
||
| { | ||
| // update the last use index for the LRU algorithm | ||
|
|
@@ -191,55 +206,58 @@ public: | |
| // If we maintain a cache, store the executable graph | ||
| if (cache_size_limit != 0) | ||
| { | ||
| cached_graphs[dev_id].insert({::std::make_pair(nnodes, nedges), entry(this, exec_g, footprint)}); | ||
| cached_graphs[dev_id].insert({::std::make_pair(nnodes, nedges), entry(this, exec_g, stream, footprint)}); | ||
| total_cache_footprint[dev_id] += footprint; | ||
| } | ||
|
|
||
| return ::cuda::std::make_pair(exec_g, false); | ||
| } | ||
|
|
||
| private: | ||
| void reclaim(int dev_id, size_t to_reclaim) | ||
| static bool stream_is_idle(cudaStream_t stream) | ||
| { | ||
| size_t reclaimed = 0; | ||
| const cudaError_t status = cudaStreamQuery(stream); | ||
| if (status == cudaSuccess) | ||
| { | ||
| return true; | ||
| } | ||
| if (status == cudaErrorNotReady) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Use a priority queue (min-heap) to track least recently used entries | ||
| using key_type = ::std::pair<size_t, size_t>; | ||
| cuda_try(status); | ||
| return false; | ||
| } | ||
|
|
||
| void reclaim(int dev_id, size_t to_reclaim) | ||
| { | ||
| size_t reclaimed = 0; | ||
| auto& device_cache = cached_graphs[dev_id]; | ||
|
|
||
| auto cmp = [&device_cache](const key_type& key_a, const key_type& key_b) { | ||
| auto iter_a = device_cache.find(key_a); | ||
| auto iter_b = device_cache.find(key_b); | ||
|
|
||
| // Directly compare last_use timestamps | ||
| return iter_a->second.last_use > iter_b->second.last_use; | ||
| }; | ||
|
|
||
| // Priority queue storing keys, ordered by least recently used | ||
| ::std::priority_queue<key_type, ::std::vector<key_type>, decltype(cmp)> lru_queue(cmp); | ||
|
|
||
| // Populate queue with keys from the cache | ||
| for (const auto& kv : device_cache) | ||
| { | ||
| lru_queue.push(kv.first); | ||
| } | ||
|
|
||
| // Reclaim least recently used entries | ||
| while (!lru_queue.empty() && reclaimed < to_reclaim) | ||
| // Reclaim the least-recently-used idle entries. cudaGraphExecDestroy must | ||
| // not race an in-flight launch, so a busy entry remains cached even if | ||
| // that temporarily leaves the cache above its configured size. | ||
| while (reclaimed < to_reclaim) | ||
| { | ||
| key_type key = lru_queue.top(); | ||
| lru_queue.pop(); | ||
|
|
||
| // Find the entry before erasing | ||
| auto it = device_cache.find(key); | ||
| if (it != device_cache.end()) | ||
| auto victim = device_cache.end(); | ||
| for (auto it = device_cache.begin(); it != device_cache.end(); ++it) | ||
| { | ||
| reclaimed += it->second.footprint; | ||
| total_cache_footprint[dev_id] -= it->second.footprint; | ||
| if (stream_is_idle(it->second.stream) | ||
| && (victim == device_cache.end() || it->second.last_use < victim->second.last_use)) | ||
| { | ||
| victim = it; | ||
| } | ||
| } | ||
|
|
||
| device_cache.erase(it); | ||
| if (victim == device_cache.end()) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| reclaimed += victim->second.footprint; | ||
| total_cache_footprint[dev_id] -= victim->second.footprint; | ||
| device_cache.erase(victim); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -253,5 +271,10 @@ private: | |
| ::std::vector<size_t> total_cache_footprint; | ||
|
|
||
| size_t cache_size_limit; | ||
|
|
||
| // A handle may be shared by multiple host threads. Serialize cache lookup, | ||
| // update, insertion, and reclaim so one executable cannot be updated by two | ||
| // queries concurrently. | ||
| ::std::mutex mutex; | ||
| }; | ||
| } // namespace cuda::experimental::stf | ||
There was a problem hiding this comment.
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.