Skip to main content

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}