dynamo_memory/pool/cuda.rs
1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! CUDA memory pool for efficient device memory allocation in hot paths.
5//!
6//! This module provides a safe wrapper around CUDA's memory pool APIs, enabling
7//! fast async allocations that avoid the overhead of cudaMalloc/cudaFree per call.
8//! Memory is returned to the pool on free and reused for subsequent allocations.
9//!
10//! # Thread Safety
11//!
12//! [`CudaMemPool`] uses internal locking to serialize host-side calls to the CUDA
13//! driver. This is required because `cuMemAllocFromPoolAsync` is not host-thread
14//! reentrant. The GPU-side operations remain stream-ordered and asynchronous.
15
16use anyhow::{Result, anyhow};
17use cudarc::driver::sys::{
18 self, CUmemAllocationType, CUmemLocationType, CUmemPool_attribute, CUmemPoolProps,
19 CUmemoryPool, CUresult, CUstream,
20};
21use cudarc::driver::{CudaContext, CudaStream};
22use std::ptr;
23use std::sync::{Arc, Mutex};
24
25/// Builder for creating a CUDA memory pool with configurable parameters.
26///
27/// # Example
28/// ```ignore
29/// let pool = CudaMemPoolBuilder::new(context, 64 * 1024 * 1024) // 64 MiB reserve
30/// .release_threshold(32 * 1024 * 1024) // 32 MiB release threshold
31/// .build()?;
32/// ```
33pub struct CudaMemPoolBuilder {
34 /// CUDA context for the target device.
35 context: Arc<CudaContext>,
36 /// Bytes to pre-allocate to warm the pool.
37 reserve_size: usize,
38 /// Optional threshold above which memory is returned to the system on free.
39 release_threshold: Option<u64>,
40}
41
42impl CudaMemPoolBuilder {
43 /// Create a new builder with the required reserve size.
44 ///
45 /// # Arguments
46 /// * `context` - CUDA context for the device
47 /// * `reserve_size` - Number of bytes to pre-allocate to warm the pool
48 pub fn new(context: Arc<CudaContext>, reserve_size: usize) -> Self {
49 Self {
50 context,
51 reserve_size,
52 release_threshold: None,
53 }
54 }
55
56 /// Set the release threshold for the pool.
57 ///
58 /// Memory above this threshold is returned to the system when freed.
59 /// If not set, no release threshold is configured (CUDA default behavior).
60 pub fn release_threshold(mut self, threshold: u64) -> Self {
61 self.release_threshold = Some(threshold);
62 self
63 }
64
65 /// Build the CUDA memory pool.
66 ///
67 /// This will:
68 /// 1. Create the pool
69 /// 2. Set the release threshold if configured
70 /// 3. Pre-allocate and free memory to warm the pool
71 pub fn build(self) -> Result<CudaMemPool> {
72 // Initialize pool properties
73 let mut props: CUmemPoolProps = unsafe { std::mem::zeroed() };
74 props.allocType = CUmemAllocationType::CU_MEM_ALLOCATION_TYPE_PINNED;
75 props.location.type_ = CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE;
76 // CUDA 13.2 moved `id` into an anonymous union inside CUmemLocation;
77 // the field path depends on the toolkit (see build.rs).
78 #[cfg(not(cuda_mem_location_union))]
79 {
80 props.location.id = self.context.cu_device();
81 }
82 #[cfg(cuda_mem_location_union)]
83 {
84 // Writing to a union field is safe; only reads are `unsafe`.
85 props.location.__bindgen_anon_1.id = self.context.cu_device();
86 }
87
88 let mut pool: CUmemoryPool = ptr::null_mut();
89
90 // Create the pool
91 let result = unsafe { sys::cuMemPoolCreate(&mut pool, &props) };
92 if result != CUresult::CUDA_SUCCESS {
93 return Err(anyhow!("cuMemPoolCreate failed with error: {:?}", result));
94 }
95
96 // Set release threshold if configured
97 if let Some(threshold) = self.release_threshold {
98 let result = unsafe {
99 sys::cuMemPoolSetAttribute(
100 pool,
101 CUmemPool_attribute::CU_MEMPOOL_ATTR_RELEASE_THRESHOLD,
102 &threshold as *const u64 as *mut std::ffi::c_void,
103 )
104 };
105 if result != CUresult::CUDA_SUCCESS {
106 // Clean up on failure
107 unsafe { sys::cuMemPoolDestroy(pool) };
108 return Err(anyhow!(
109 "cuMemPoolSetAttribute failed with error: {:?}",
110 result
111 ));
112 }
113 }
114
115 let cuda_pool = CudaMemPool {
116 inner: Mutex::new(pool),
117 };
118
119 // Warm the pool by pre-allocating and freeing memory
120 if self.reserve_size > 0 {
121 // Create a temporary stream for warming
122 let stream = self.context.new_stream()?;
123
124 // Allocate to warm the pool (using safe variant)
125 let ptr = cuda_pool.alloc_async(self.reserve_size, &stream)?;
126
127 // Free back to pool (memory stays reserved)
128 cuda_pool.free_async(ptr, &stream)?;
129
130 // Synchronize to ensure operations complete
131 // SAFETY: stream.cu_stream() is valid for the lifetime of `stream`
132 let result = unsafe { sys::cuStreamSynchronize(stream.cu_stream()) };
133 if result != CUresult::CUDA_SUCCESS {
134 return Err(anyhow!(
135 "cuStreamSynchronize failed with error: {:?}",
136 result
137 ));
138 }
139 }
140
141 Ok(cuda_pool)
142 }
143}
144
145/// Safe wrapper around a CUDA memory pool.
146///
147/// The pool amortizes allocation overhead by maintaining a reservoir of device memory.
148/// Allocations are fast sub-allocations from this reservoir, and frees return memory
149/// to the pool rather than the OS (until the release threshold is exceeded).
150///
151/// # Thread Safety
152///
153/// This type uses internal locking to serialize host-side calls to CUDA driver APIs.
154/// `cuMemAllocFromPoolAsync` is not host-thread reentrant, so concurrent calls from
155/// multiple threads must be serialized. The GPU-side operations remain asynchronous
156/// and stream-ordered.
157///
158/// Use [`CudaMemPoolBuilder`] for configurable pool creation with pre-allocation.
159pub struct CudaMemPool {
160 /// Mutex protecting the pool handle for host-thread serialization.
161 ///
162 /// CUDA's `cuMemAllocFromPoolAsync` does not guarantee host-thread reentrancy,
163 /// so all calls to the pool must be serialized on the host side.
164 inner: Mutex<CUmemoryPool>,
165}
166
167// SAFETY: CudaMemPool is Send because the Mutex serializes all host-side access
168// to the pool handle, and CUDA driver state is thread-safe when properly serialized.
169unsafe impl Send for CudaMemPool {}
170
171// SAFETY: CudaMemPool is Sync because all access to the pool handle goes through
172// the Mutex, which serializes host-thread access. The CUDA driver requires this
173// serialization because cuMemAllocFromPoolAsync is not host-thread reentrant.
174unsafe impl Sync for CudaMemPool {}
175
176impl CudaMemPool {
177 /// Create a builder for a new CUDA memory pool.
178 ///
179 /// # Arguments
180 /// * `context` - CUDA context for the device
181 /// * `reserve_size` - Number of bytes to pre-allocate to warm the pool
182 pub fn builder(context: Arc<CudaContext>, reserve_size: usize) -> CudaMemPoolBuilder {
183 CudaMemPoolBuilder::new(context, reserve_size)
184 }
185
186 /// Allocate memory from the pool asynchronously.
187 ///
188 /// This is the safe variant that takes a `&CudaStream` reference, ensuring
189 /// the stream is valid for the duration of the call.
190 ///
191 /// The allocation is stream-ordered; the memory is available for use
192 /// after all preceding operations on the stream complete.
193 ///
194 /// # Host Serialization
195 ///
196 /// This method acquires an internal mutex because `cuMemAllocFromPoolAsync`
197 /// is not host-thread reentrant. The allocation itself is stream-ordered on
198 /// the GPU side.
199 ///
200 /// # Arguments
201 /// * `size` - Size in bytes to allocate
202 /// * `stream` - CUDA stream for async ordering
203 ///
204 /// # Returns
205 /// Device pointer to the allocated memory
206 pub fn alloc_async(&self, size: usize, stream: &CudaStream) -> Result<u64> {
207 // SAFETY: stream.cu_stream() returns a valid handle owned by the CudaStream,
208 // and the borrow ensures the stream lives for the duration of this call.
209 unsafe { self.alloc_async_raw(size, stream.cu_stream()) }
210 }
211
212 /// Allocate memory from the pool asynchronously (raw stream handle variant).
213 ///
214 /// This is the unsafe variant for use when you have a raw `CUstream` handle
215 /// from sources other than cudarc's `CudaStream`.
216 ///
217 /// # Host Serialization
218 ///
219 /// This method acquires an internal mutex because `cuMemAllocFromPoolAsync`
220 /// is not host-thread reentrant.
221 ///
222 /// # Arguments
223 /// * `size` - Size in bytes to allocate
224 /// * `stream` - Raw CUDA stream handle for async ordering
225 ///
226 /// # Returns
227 /// Device pointer to the allocated memory
228 ///
229 /// # Safety
230 ///
231 /// The caller must ensure that `stream` is a valid CUDA stream handle that
232 /// will remain valid for the duration of this call.
233 pub unsafe fn alloc_async_raw(&self, size: usize, stream: CUstream) -> Result<u64> {
234 let pool = self
235 .inner
236 .lock()
237 .map_err(|e| anyhow!("mutex poisoned: {}", e))?;
238
239 let mut ptr: u64 = 0;
240
241 let result = unsafe { sys::cuMemAllocFromPoolAsync(&mut ptr, size, *pool, stream) };
242
243 if result != CUresult::CUDA_SUCCESS {
244 return Err(anyhow!(
245 "cuMemAllocFromPoolAsync failed with error: {:?}",
246 result
247 ));
248 }
249
250 Ok(ptr)
251 }
252
253 /// Free memory back to the pool asynchronously.
254 ///
255 /// This is the safe variant that takes a `&CudaStream` reference.
256 ///
257 /// The memory is returned to the pool's reservoir (not the OS) and can be
258 /// reused by subsequent allocations. The free is stream-ordered.
259 ///
260 /// # Arguments
261 /// * `ptr` - Device pointer previously allocated from this pool
262 /// * `stream` - CUDA stream for async ordering
263 pub fn free_async(&self, ptr: u64, stream: &CudaStream) -> Result<()> {
264 // SAFETY: stream.cu_stream() returns a valid handle owned by the CudaStream,
265 // and the borrow ensures the stream lives for the duration of this call.
266 unsafe { self.free_async_raw(ptr, stream.cu_stream()) }
267 }
268
269 // NOTE: Unlike alloc_async_raw, this method does NOT acquire the pool mutex.
270 // The mutex in alloc_async_raw ensures each allocation returns a unique pointer.
271 // cuMemFreeAsync only enqueues a stream-ordered free operation for that unique
272 // pointer - multiple threads can safely enqueue frees for different unique pointers
273 // concurrently. The actual return-to-pool happens asynchronously on the GPU side.
274
275 /// Free memory back to the pool asynchronously (raw stream handle variant).
276 ///
277 /// This is the unsafe variant for use when you have a raw `CUstream` handle.
278 ///
279 /// The memory is returned to the pool's reservoir (not the OS) and can be
280 /// reused by subsequent allocations. The free is stream-ordered.
281 ///
282 /// # Arguments
283 /// * `ptr` - Device pointer previously allocated from this pool
284 /// * `stream` - Raw CUDA stream handle for async ordering
285 ///
286 /// # Safety
287 ///
288 /// The caller must ensure that:
289 /// - `ptr` is a valid device pointer previously allocated from this pool
290 /// - `stream` is a valid CUDA stream handle
291 pub unsafe fn free_async_raw(&self, ptr: u64, stream: CUstream) -> Result<()> {
292 let result = unsafe { sys::cuMemFreeAsync(ptr, stream) };
293
294 if result != CUresult::CUDA_SUCCESS {
295 return Err(anyhow!("cuMemFreeAsync failed with error: {:?}", result));
296 }
297
298 Ok(())
299 }
300}
301
302impl Drop for CudaMemPool {
303 fn drop(&mut self) {
304 // No need to lock - we have &mut self so exclusive access is guaranteed
305 let pool = self
306 .inner
307 .get_mut()
308 .expect("mutex should not be poisoned during drop");
309
310 // Destroy the pool, releasing all memory back to the system
311 let result = unsafe { sys::cuMemPoolDestroy(*pool) };
312 if result != CUresult::CUDA_SUCCESS {
313 tracing::warn!("cuMemPoolDestroy failed with error: {:?}", result);
314 }
315 }
316}
317
318#[cfg(all(test, feature = "testing-cuda"))]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_pool_creation_with_builder() {
324 // Skip if no CUDA device available
325 let context = match CudaContext::new(0) {
326 Ok(ctx) => ctx,
327 Err(e) => {
328 eprintln!("Skipping test - no CUDA device: {:?}", e);
329 return;
330 }
331 };
332
333 // Test builder with reserve size and release threshold
334 let result = CudaMemPool::builder(context.clone(), 1024 * 1024) // 1 MiB reserve
335 .release_threshold(64 * 1024 * 1024) // 64 MiB threshold
336 .build();
337
338 if result.is_err() {
339 eprintln!("Skipping test - pool creation failed: {:?}", result.err());
340 return;
341 }
342 let pool = result.unwrap();
343 drop(pool);
344 }
345
346 #[test]
347 fn test_pool_creation_no_threshold() {
348 // Skip if no CUDA device available
349 let context = match CudaContext::new(0) {
350 Ok(ctx) => ctx,
351 Err(e) => {
352 eprintln!("Skipping test - no CUDA device: {:?}", e);
353 return;
354 }
355 };
356
357 // Test builder without release threshold
358 let result = CudaMemPool::builder(context, 0).build();
359
360 if result.is_err() {
361 eprintln!("Skipping test - pool creation failed: {:?}", result.err());
362 return;
363 }
364 let pool = result.unwrap();
365 drop(pool);
366 }
367}