Skip to content

Commit 0da1e74

Browse files
Implement CUDA event pool to minimize runtime resource allocation overhead (libcurl multi poll-based backend 2/n) (#919)
## Summary This PR adds a CUDA event pool, a thread-safe singleton that caches reusable CUDA events organized per CUDA context. This eliminates the per-acquisition `cuEventCreate` / `cuEventDestroy` cost in hot paths that need short-lived events (e.g. bounce-buffer recycling). This PR facilitates the implementation of device buffer support for the remote I/O multiplexing backend. ## Related PR Depends on #917 Addresses part of #914 Authors: - Tianyu Liu (https://github.com/kingcrimsontianyu) Approvers: - Kyle Edwards (https://github.com/KyleFromNVIDIA) - Mads R. B. Kristensen (https://github.com/madsbk) URL: #919
1 parent 81eb154 commit 0da1e74

7 files changed

Lines changed: 666 additions & 0 deletions

File tree

cpp/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ set(SOURCES
169169
"src/logger.cpp"
170170
"src/mmap.cpp"
171171
"src/detail/env.cpp"
172+
"src/detail/event.cpp"
172173
"src/detail/nvtx.cpp"
173174
"src/detail/posix_io.cpp"
174175
"src/detail/stream.cpp"
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
#pragma once
6+
7+
#include <mutex>
8+
#include <unordered_map>
9+
#include <vector>
10+
11+
#include <kvikio/shim/cuda.hpp>
12+
13+
namespace kvikio::detail {
14+
/**
15+
* @brief Thread-safe singleton pool for reusable CUDA events
16+
*
17+
* Manages a pool of CUDA events organized by CUDA context. Events are retained and reused across
18+
* calls to minimize allocation overhead. Each context maintains its own separate pool of events
19+
* since CUDA events are context-specific resources.
20+
*
21+
* All events are created with `CU_EVENT_DISABLE_TIMING` for minimal overhead.
22+
*
23+
* Call `CudaEventPool::instance().get()` to acquire an event that is bound to the CUDA context
24+
* currently set on the calling thread. The event will be automatically returned to the pool when it
25+
* goes out of scope (RAII).
26+
*
27+
* @note The destructor intentionally does NOT call `cuEventDestroy` on cached events.
28+
* `CudaEventPool::instance()` is a function-local static destructed after `main` returns, and
29+
* making CUDA driver API calls from a static object's destructor at that point is undefined
30+
* behavior per the CUDA programming guide:
31+
* https://docs.nvidia.com/cuda/cuda-programming-guide/02-basics/intro-to-cuda-cpp.html#runtime-initialization
32+
* The OS reclaims process memory at exit regardless.
33+
*/
34+
class CudaEventPool {
35+
public:
36+
/**
37+
* @brief RAII wrapper for a pooled CUDA event
38+
*
39+
* Automatically returns the event to the pool when destroyed. Provides access to the underlying
40+
* CUevent handle and common event operations (record, synchronize).
41+
*
42+
* @note Non-copyable but movable to allow transfer of ownership while maintaining RAII
43+
*/
44+
class CudaEvent {
45+
friend class CudaEventPool;
46+
47+
private:
48+
CudaEventPool* _pool{};
49+
CUevent _event{};
50+
CUcontext _cuda_context{};
51+
52+
/**
53+
* @brief Construct a CudaEvent wrapping a CUDA event handle
54+
*
55+
* @param pool The owning CudaEventPool to return this event to on destruction
56+
* @param event The CUDA event handle to wrap
57+
* @param context The CUDA context associated with this event
58+
*/
59+
explicit CudaEvent(CudaEventPool* pool, CUevent event, CUcontext context) noexcept;
60+
61+
public:
62+
~CudaEvent() noexcept;
63+
64+
// Move-only
65+
CudaEvent(CudaEvent const&) = delete;
66+
CudaEvent& operator=(CudaEvent const&) = delete;
67+
CudaEvent(CudaEvent&& o) noexcept;
68+
CudaEvent& operator=(CudaEvent&& o) noexcept;
69+
70+
/**
71+
* @brief Get the underlying CUDA event handle
72+
*
73+
* @return The CUevent handle wrapped by this object
74+
*/
75+
[[nodiscard]] CUevent get() const noexcept;
76+
77+
/**
78+
* @brief Get the CUDA context associated with this event
79+
*
80+
* @return The CUcontext this event belongs to. Returns nullptr for a moved-from CudaEvent.
81+
*/
82+
[[nodiscard]] CUcontext cuda_context() const noexcept;
83+
84+
/**
85+
* @brief Record the event on a CUDA stream
86+
*
87+
* Records the event to capture the current state of the stream. The event will be signaled when
88+
* all preceding operations on the stream have completed.
89+
*
90+
* @param stream The CUDA stream to record the event on. Must belong to the same context as this
91+
* event. Otherwise CUDA returns an error.
92+
*
93+
* @exception kvikio::CUfileException if the record operation fails
94+
*/
95+
void record(CUstream stream);
96+
97+
/**
98+
* @brief Block the calling thread until the event has been signaled
99+
*
100+
* Waits for all work captured by a preceding record() call to complete.
101+
*
102+
* @exception kvikio::CUfileException if the synchronize operation fails
103+
*/
104+
void synchronize();
105+
106+
/**
107+
* @brief Non-blocking check whether all work captured by the event has completed.
108+
*
109+
* Returns true if all work captured by a preceding `record()` call has completed, false if work
110+
* is still pending. This is the non-blocking counterpart to `synchronize()`.
111+
*
112+
* @note An event that has never been recorded by `cudaAPI::instance().EventRecord()` reports
113+
* `is_done() == true`, since CUDA's `cuEventQuery` returns `CUDA_SUCCESS` when there is no
114+
* captured work. Users that rely on `is_done() == true` as a clean baseline signal should
115+
* `synchronize()` before dropping the event so that the next pool acquirer
116+
* `CudaEventPool::instance().get()` sees an idle state.
117+
*
118+
* @return true if the event has completed (or has never been recorded), false if work is still
119+
* in progress.
120+
*
121+
* @exception kvikio::CUfileException if the underlying `cuEventQuery` returns an error other
122+
* than `CUDA_SUCCESS` or `CUDA_ERROR_NOT_READY`.
123+
*/
124+
[[nodiscard]] bool is_done() const;
125+
};
126+
127+
private:
128+
std::mutex mutable _mutex;
129+
// Per-context pools of free events
130+
std::unordered_map<CUcontext, std::vector<CUevent>> _pools;
131+
132+
CudaEventPool() = default;
133+
134+
// Intentionally `noexcept = default`. See the class-level @note above: issuing CUDA driver
135+
// API calls (e.g., cuEventDestroy) from this destructor would be UB because the singleton is
136+
// destructed after main returns. The defaulted destructor runs ~_pools, which tears down the
137+
// std::vector<CUevent> entries without touching the handles.
138+
~CudaEventPool() noexcept = default;
139+
140+
/**
141+
* @brief Return an event to the pool for reuse
142+
*
143+
* Called by CudaEvent's destructor (and move-assignment operator) via the friend declaration.
144+
* Adds the event to the pool associated with its context for future reuse.
145+
*
146+
* @param event The CUDA event handle to return
147+
* @param context The CUDA context associated with the event
148+
*
149+
* @note noexcept: any failure inside push_back (e.g., allocator failure) is caught and logged.
150+
* The event is then destroyed instead of being cached.
151+
*/
152+
void put(CUevent event, CUcontext context) noexcept;
153+
154+
public:
155+
// Non-copyable, non-movable singleton
156+
CudaEventPool(CudaEventPool const&) = delete;
157+
CudaEventPool& operator=(CudaEventPool const&) = delete;
158+
CudaEventPool(CudaEventPool&&) = delete;
159+
CudaEventPool& operator=(CudaEventPool&&) = delete;
160+
161+
/**
162+
* @brief Acquire a CUDA event for the CUDA context currently set on the calling thread.
163+
*
164+
* Returns a cached event for the current CUDA context if available, otherwise creates a new one.
165+
* The returned CudaEvent object will automatically return the event to the pool when it goes out
166+
* of scope.
167+
*
168+
* @return RAII CudaEvent object wrapping the acquired CUDA event
169+
* @exception kvikio::CUfileException if no CUDA context is current or event creation fails
170+
*/
171+
[[nodiscard]] CudaEvent get();
172+
173+
/**
174+
* @brief Get the number of free events for a specific context
175+
*
176+
* @param context The CUDA context to query
177+
* @return The number of events available for reuse in that context's pool
178+
*/
179+
[[nodiscard]] std::size_t num_free_events(CUcontext context) const;
180+
181+
/**
182+
* @brief Get the total number of free events across all contexts
183+
*
184+
* @return The total count of events available for reuse
185+
*/
186+
[[nodiscard]] std::size_t total_free_events() const;
187+
188+
/**
189+
* @brief Get the singleton instance of the event pool
190+
*
191+
* @return Reference to the singleton CudaEventPool instance
192+
*/
193+
static CudaEventPool& instance();
194+
};
195+
} // namespace kvikio::detail

cpp/include/kvikio/shim/cuda.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ class cudaAPI {
9292

9393
decltype(cuPointerGetAttribute)* PointerGetAttribute{nullptr};
9494
decltype(cuPointerGetAttributes)* PointerGetAttributes{nullptr};
95+
decltype(cuCtxCreate)* CtxCreate{nullptr};
96+
decltype(cuCtxDestroy)* CtxDestroy{nullptr};
9597
decltype(cuCtxPushCurrent)* CtxPushCurrent{nullptr};
9698
decltype(cuCtxPopCurrent)* CtxPopCurrent{nullptr};
9799
decltype(cuCtxGetCurrent)* CtxGetCurrent{nullptr};
@@ -108,6 +110,11 @@ class cudaAPI {
108110
decltype(cuStreamCreate)* StreamCreate{nullptr};
109111
decltype(cuStreamDestroy)* StreamDestroy{nullptr};
110112
decltype(cuDriverGetVersion)* DriverGetVersion{nullptr};
113+
decltype(cuEventSynchronize)* EventSynchronize{nullptr};
114+
decltype(cuEventCreate)* EventCreate{nullptr};
115+
decltype(cuEventDestroy)* EventDestroy{nullptr};
116+
decltype(cuEventRecord)* EventRecord{nullptr};
117+
decltype(cuEventQuery)* EventQuery{nullptr};
111118

112119
private:
113120
cudaAPI();

cpp/src/detail/event.cpp

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
#include <exception>
7+
#include <utility>
8+
9+
#include <kvikio/detail/event.hpp>
10+
#include <kvikio/detail/nvtx.hpp>
11+
#include <kvikio/error.hpp>
12+
#include <kvikio/logger.hpp>
13+
#include <kvikio/shim/cuda.hpp>
14+
15+
namespace kvikio::detail {
16+
17+
CudaEventPool::CudaEvent::CudaEvent(CudaEventPool* pool,
18+
CUevent event,
19+
CUcontext cuda_context) noexcept
20+
: _pool(pool), _event(event), _cuda_context(cuda_context)
21+
{
22+
}
23+
24+
CudaEventPool::CudaEvent::~CudaEvent() noexcept
25+
{
26+
if (_event != nullptr) { _pool->put(_event, _cuda_context); }
27+
}
28+
29+
CudaEventPool::CudaEvent::CudaEvent(CudaEvent&& o) noexcept
30+
: _pool(std::exchange(o._pool, nullptr)),
31+
_event(std::exchange(o._event, nullptr)),
32+
_cuda_context(std::exchange(o._cuda_context, nullptr))
33+
{
34+
}
35+
36+
CudaEventPool::CudaEvent& CudaEventPool::CudaEvent::operator=(CudaEvent&& o) noexcept
37+
{
38+
if (this != &o) {
39+
if (_event != nullptr) {
40+
// Return this event to the pool
41+
_pool->put(_event, _cuda_context);
42+
}
43+
_pool = std::exchange(o._pool, nullptr);
44+
_event = std::exchange(o._event, nullptr);
45+
_cuda_context = std::exchange(o._cuda_context, nullptr);
46+
}
47+
return *this;
48+
}
49+
50+
CUevent CudaEventPool::CudaEvent::get() const noexcept { return _event; }
51+
52+
CUcontext CudaEventPool::CudaEvent::cuda_context() const noexcept { return _cuda_context; }
53+
54+
void CudaEventPool::CudaEvent::record(CUstream stream)
55+
{
56+
KVIKIO_CUDA_DRIVER_TRY(cudaAPI::instance().EventRecord(_event, stream));
57+
}
58+
59+
void CudaEventPool::CudaEvent::synchronize()
60+
{
61+
KVIKIO_NVTX_FUNC_RANGE();
62+
KVIKIO_CUDA_DRIVER_TRY(cudaAPI::instance().EventSynchronize(_event));
63+
}
64+
65+
bool CudaEventPool::CudaEvent::is_done() const
66+
{
67+
auto const status = cudaAPI::instance().EventQuery(_event);
68+
if (status == CUDA_SUCCESS) { return true; }
69+
if (status == CUDA_ERROR_NOT_READY) { return false; }
70+
// Any other return code is an error.
71+
KVIKIO_CUDA_DRIVER_TRY(status);
72+
// Unreachable. Macro throws on non-success codes.
73+
return false;
74+
}
75+
76+
CudaEventPool::CudaEvent CudaEventPool::get()
77+
{
78+
KVIKIO_NVTX_FUNC_RANGE();
79+
CUcontext ctx{};
80+
KVIKIO_CUDA_DRIVER_TRY(cudaAPI::instance().CtxGetCurrent(&ctx));
81+
KVIKIO_EXPECT(ctx != nullptr, "No CUDA context is current");
82+
83+
CUevent event{};
84+
{
85+
std::lock_guard const lock(_mutex);
86+
// If the key (`ctx`) is found from the pool, assign the search result to `event`
87+
if (auto it = _pools.find(ctx); it != _pools.end() && !it->second.empty()) {
88+
event = it->second.back();
89+
it->second.pop_back();
90+
}
91+
}
92+
93+
if (event == nullptr) {
94+
// Create an event outside the lock to improve performance. The pool is not updated here. The
95+
// returned CudaEvent object will automatically return the event to the pool when it goes out
96+
// of scope
97+
KVIKIO_CUDA_DRIVER_TRY(cudaAPI::instance().EventCreate(&event, CU_EVENT_DISABLE_TIMING));
98+
}
99+
100+
return CudaEvent(this, event, ctx);
101+
}
102+
103+
void CudaEventPool::put(CUevent event, CUcontext cuda_context) noexcept
104+
{
105+
KVIKIO_NVTX_FUNC_RANGE();
106+
if (event == nullptr) { return; }
107+
108+
try {
109+
std::lock_guard const lock(_mutex);
110+
_pools[cuda_context].push_back(event);
111+
} catch (std::exception const& e) {
112+
// push_back can throw on allocator failure (e.g., out-of-memory). The event cannot stay
113+
// cached, so destroy it to release its CUDA resources.
114+
KVIKIO_LOG_ERROR(e.what());
115+
try {
116+
KVIKIO_CUDA_DRIVER_TRY(cudaAPI::instance().EventDestroy(event));
117+
} catch (std::exception const& e) {
118+
KVIKIO_LOG_ERROR(e.what());
119+
}
120+
}
121+
}
122+
123+
std::size_t CudaEventPool::num_free_events(CUcontext cuda_context) const
124+
{
125+
std::lock_guard const lock(_mutex);
126+
auto it = _pools.find(cuda_context);
127+
return (it != _pools.end()) ? it->second.size() : 0;
128+
}
129+
130+
std::size_t CudaEventPool::total_free_events() const
131+
{
132+
std::lock_guard const lock(_mutex);
133+
std::size_t total{0};
134+
for (auto const& [_, events] : _pools) {
135+
total += events.size();
136+
}
137+
return total;
138+
}
139+
140+
CudaEventPool& CudaEventPool::instance()
141+
{
142+
static CudaEventPool pool;
143+
return pool;
144+
}
145+
146+
} // namespace kvikio::detail

cpp/src/shim/cuda.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ cudaAPI::cudaAPI()
2626
get_symbol(MemcpyAsync, lib, KVIKIO_STRINGIFY(cuMemcpyAsync));
2727
get_symbol(PointerGetAttribute, lib, KVIKIO_STRINGIFY(cuPointerGetAttribute));
2828
get_symbol(PointerGetAttributes, lib, KVIKIO_STRINGIFY(cuPointerGetAttributes));
29+
get_symbol(CtxCreate, lib, KVIKIO_STRINGIFY(cuCtxCreate));
30+
get_symbol(CtxDestroy, lib, KVIKIO_STRINGIFY(cuCtxDestroy));
2931
get_symbol(CtxPushCurrent, lib, KVIKIO_STRINGIFY(cuCtxPushCurrent));
3032
get_symbol(CtxPopCurrent, lib, KVIKIO_STRINGIFY(cuCtxPopCurrent));
3133
get_symbol(CtxGetCurrent, lib, KVIKIO_STRINGIFY(cuCtxGetCurrent));
@@ -42,6 +44,11 @@ cudaAPI::cudaAPI()
4244
get_symbol(StreamCreate, lib, KVIKIO_STRINGIFY(cuStreamCreate));
4345
get_symbol(StreamDestroy, lib, KVIKIO_STRINGIFY(cuStreamDestroy));
4446
get_symbol(DriverGetVersion, lib, KVIKIO_STRINGIFY(cuDriverGetVersion));
47+
get_symbol(EventSynchronize, lib, KVIKIO_STRINGIFY(cuEventSynchronize));
48+
get_symbol(EventCreate, lib, KVIKIO_STRINGIFY(cuEventCreate));
49+
get_symbol(EventDestroy, lib, KVIKIO_STRINGIFY(cuEventDestroy));
50+
get_symbol(EventRecord, lib, KVIKIO_STRINGIFY(cuEventRecord));
51+
get_symbol(EventQuery, lib, KVIKIO_STRINGIFY(cuEventQuery));
4552

4653
KVIKIO_CUDA_DRIVER_TRY(DriverGetVersion(&driver_version));
4754

0 commit comments

Comments
 (0)