Skip to main content

j2k_cuda_runtime/memory/pool/
cache_policy.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use super::{
4    size_buckets::CudaBufferPoolSizeBuckets, CudaBufferPool, CudaBufferPoolFree,
5    CudaBufferPoolInner, CudaBufferPoolState,
6};
7use crate::{
8    allocation::host_allocation_error, context::CudaContext, error::select_resource_release_error,
9    memory::CudaDeviceBuffer, CudaError,
10};
11use std::sync::{Arc, Mutex};
12
13const DEFAULT_MAX_CACHED_BYTES: usize = 512 * 1024 * 1024;
14const DEFAULT_MAX_CACHED_BUFFERS: usize = 128;
15const DEFAULT_MAX_SIZE_BUCKETS: usize = 64;
16
17#[doc(hidden)]
18/// Retention limits shared by first-fit and best-fit CUDA buffer pools.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CudaBufferPoolLimits {
21    /// Maximum total byte length of completed device allocations retained for reuse.
22    pub max_cached_bytes: usize,
23    /// Maximum number of completed device allocations retained for reuse.
24    pub max_cached_buffers: usize,
25    /// Maximum number of distinct allocation sizes retained by a best-fit pool.
26    pub max_size_buckets: usize,
27}
28
29impl Default for CudaBufferPoolLimits {
30    fn default() -> Self {
31        Self {
32            max_cached_bytes: DEFAULT_MAX_CACHED_BYTES,
33            max_cached_buffers: DEFAULT_MAX_CACHED_BUFFERS,
34            max_size_buckets: DEFAULT_MAX_SIZE_BUCKETS,
35        }
36    }
37}
38
39#[doc(hidden)]
40/// Shared retention and high-water diagnostics for a CUDA buffer pool.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct CudaBufferPoolDiagnostics {
43    /// Retention policy used by this pool.
44    pub limits: CudaBufferPoolLimits,
45    /// Completed buffers currently available for reuse.
46    pub cached_buffers: usize,
47    /// Actual device-allocation bytes currently available for reuse.
48    pub cached_bytes: usize,
49    /// Distinct allocation sizes currently retained by a best-fit pool.
50    pub cached_size_buckets: usize,
51    /// Buffers retained until queued work establishes completion.
52    pub deferred_buffers: usize,
53    /// Actual device-allocation bytes retained until completion.
54    pub deferred_bytes: usize,
55    /// Active guards preventing deferred buffers from becoming reusable.
56    pub reuse_holds: usize,
57    /// Highest completed-buffer count observed by this pool.
58    pub peak_cached_buffers: usize,
59    /// Highest completed allocation-byte total observed by this pool.
60    pub peak_cached_bytes: usize,
61    /// Highest best-fit bucket count observed by this pool.
62    pub peak_cached_size_buckets: usize,
63    /// Highest deferred-buffer count observed by this pool.
64    pub peak_deferred_buffers: usize,
65    /// Highest deferred allocation-byte total observed by this pool.
66    pub peak_deferred_bytes: usize,
67    /// Completed buffers evicted to admit a newer completed buffer.
68    pub evicted_buffers: usize,
69    /// Completed buffers rejected because one allocation cannot fit the policy.
70    pub rejected_buffers: usize,
71    /// Completed buffers not retained after host cache-metadata allocation failed.
72    pub metadata_failures: usize,
73}
74
75#[derive(Clone, Copy, Debug, Default)]
76pub(super) struct CudaBufferPoolMetrics {
77    pub(super) peak_cached_buffers: usize,
78    pub(super) peak_cached_bytes: usize,
79    pub(super) peak_cached_size_buckets: usize,
80    pub(super) peak_deferred_buffers: usize,
81    pub(super) peak_deferred_bytes: usize,
82    pub(super) evicted_buffers: usize,
83    pub(super) rejected_buffers: usize,
84    pub(super) metadata_failures: usize,
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88struct CacheInventory {
89    buffers: usize,
90    bytes: usize,
91    size_buckets: usize,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95enum CacheAdmissionDecision {
96    Admit,
97    Evict,
98    Reject,
99}
100
101impl CudaBufferPool {
102    /// Create a new first-fit pool for `context` with bounded default retention.
103    pub fn new(context: CudaContext) -> Self {
104        Self::with_limits(context, CudaBufferPoolLimits::default())
105    }
106
107    #[doc(hidden)]
108    /// Create a first-fit pool with explicit retention limits.
109    pub fn with_limits(context: CudaContext, limits: CudaBufferPoolLimits) -> Self {
110        Self::with_free(context, limits, CudaBufferPoolFree::FirstFit(Vec::new()))
111    }
112
113    pub(in super::super) fn new_size_buckets(context: CudaContext) -> Self {
114        Self::best_fit_with_limits(context, CudaBufferPoolLimits::default())
115    }
116
117    #[doc(hidden)]
118    /// Create a best-fit pool with explicit retention and size-bucket limits.
119    pub fn best_fit_with_limits(context: CudaContext, limits: CudaBufferPoolLimits) -> Self {
120        Self::with_free(
121            context,
122            limits,
123            CudaBufferPoolFree::SizeBuckets(CudaBufferPoolSizeBuckets::new()),
124        )
125    }
126
127    fn with_free(
128        context: CudaContext,
129        limits: CudaBufferPoolLimits,
130        free: CudaBufferPoolFree,
131    ) -> Self {
132        Self {
133            inner: Arc::new(CudaBufferPoolInner {
134                context,
135                limits,
136                state: Mutex::new(CudaBufferPoolState {
137                    free,
138                    deferred: Vec::new(),
139                    deferred_bytes: 0,
140                    reuse_holds: 0,
141                    metrics: CudaBufferPoolMetrics::default(),
142                }),
143            }),
144        }
145    }
146
147    #[doc(hidden)]
148    /// Snapshot diagnostics shared by every clone of this pool.
149    pub fn diagnostics(&self) -> Result<CudaBufferPoolDiagnostics, CudaError> {
150        self.inner
151            .context
152            .inner
153            .ensure_resource_lifetime_available()?;
154        let state = self
155            .inner
156            .state
157            .lock()
158            .map_err(|error| CudaError::StatePoisoned {
159                message: error.to_string(),
160            })?;
161        let inventory = state.free.inventory();
162        Ok(CudaBufferPoolDiagnostics {
163            limits: self.inner.limits,
164            cached_buffers: inventory.buffers,
165            cached_bytes: inventory.bytes,
166            cached_size_buckets: inventory.size_buckets,
167            deferred_buffers: state.deferred.len(),
168            deferred_bytes: state.deferred_bytes,
169            reuse_holds: state.reuse_holds,
170            peak_cached_buffers: state.metrics.peak_cached_buffers,
171            peak_cached_bytes: state.metrics.peak_cached_bytes,
172            peak_cached_size_buckets: state.metrics.peak_cached_size_buckets,
173            peak_deferred_buffers: state.metrics.peak_deferred_buffers,
174            peak_deferred_bytes: state.metrics.peak_deferred_bytes,
175            evicted_buffers: state.metrics.evicted_buffers,
176            rejected_buffers: state.metrics.rejected_buffers,
177            metadata_failures: state.metrics.metadata_failures,
178        })
179    }
180}
181
182impl CudaBufferPoolInner {
183    pub(super) fn recycle_completed_buffer(
184        &self,
185        buffer: CudaDeviceBuffer,
186    ) -> Result<(), CudaError> {
187        let mut candidate = Some(buffer);
188        loop {
189            let Some(candidate_buffer) = candidate.as_ref() else {
190                return Err(CudaError::InternalInvariant {
191                    what: "completed CUDA cache candidate ownership was lost",
192                });
193            };
194            let candidate_bytes = candidate_buffer.byte_len();
195            let mut state = match self.state.lock() {
196                Ok(state) => state,
197                Err(error) => {
198                    // The cache state is unknown after poisoning. Conservatively
199                    // retain the allocation token instead of risking an unsafe free.
200                    std::mem::forget(candidate.take());
201                    return Err(CudaError::StatePoisoned {
202                        message: error.to_string(),
203                    });
204                }
205            };
206            let inventory = state.free.inventory();
207            let adds_size_bucket = state.free.candidate_adds_size_bucket(candidate_bytes);
208            match cache_admission_decision(
209                self.limits,
210                inventory,
211                candidate_bytes,
212                adds_size_bucket,
213            ) {
214                CacheAdmissionDecision::Reject => {
215                    state.metrics.rejected_buffers =
216                        state.metrics.rejected_buffers.saturating_add(1);
217                    let rejected = candidate.take();
218                    drop(state);
219                    // This path is reached only after completion is known. Drop
220                    // outside the pool lock so CUDA release cannot block peers.
221                    drop(rejected);
222                    return self.context.inner.ensure_resource_lifetime_available();
223                }
224                CacheAdmissionDecision::Evict => {
225                    let Some(evicted) = state.free.evict_deterministic() else {
226                        let unretained = candidate.take();
227                        drop(state);
228                        drop(unretained);
229                        return Err(CudaError::InternalInvariant {
230                            what: "CUDA buffer cache selected eviction without a victim",
231                        });
232                    };
233                    state.metrics.evicted_buffers = state.metrics.evicted_buffers.saturating_add(1);
234                    drop(state);
235                    // Eviction is legal only for completed buffers. Release the
236                    // driver allocation without holding the shared cache mutex.
237                    drop(evicted);
238                    if let Err(error) = self.context.inner.ensure_resource_lifetime_available() {
239                        // A failed device release quarantines the context. Keep
240                        // the still-owned candidate from attempting another free.
241                        std::mem::forget(candidate.take());
242                        return Err(error);
243                    }
244                }
245                CacheAdmissionDecision::Admit => {
246                    let Some(admitted) = candidate.take() else {
247                        drop(state);
248                        return Err(CudaError::InternalInvariant {
249                            what: "CUDA buffer cache admitted a missing candidate",
250                        });
251                    };
252                    if let Err((error, unretained)) = state.free.try_recycle(admitted) {
253                        state.metrics.metadata_failures =
254                            state.metrics.metadata_failures.saturating_add(1);
255                        drop(state);
256                        drop(unretained);
257                        if let Err(release_error) =
258                            self.context.inner.ensure_resource_lifetime_available()
259                        {
260                            return Err(select_resource_release_error(error, release_error));
261                        }
262                        return Err(error);
263                    }
264                    observe_cache_high_water(&mut state);
265                    return Ok(());
266                }
267            }
268        }
269    }
270}
271
272impl CudaBufferPoolFree {
273    fn inventory(&self) -> CacheInventory {
274        match self {
275            Self::FirstFit(buffers) => CacheInventory {
276                buffers: buffers.len(),
277                bytes: buffers.iter().fold(0usize, |total, buffer| {
278                    total.saturating_add(buffer.byte_len())
279                }),
280                size_buckets: 0,
281            },
282            Self::SizeBuckets(buckets) => CacheInventory {
283                buffers: buckets.cached_count(),
284                bytes: buckets.cached_bytes(),
285                size_buckets: buckets.bucket_count(),
286            },
287        }
288    }
289
290    fn candidate_adds_size_bucket(&self, candidate_bytes: usize) -> bool {
291        match self {
292            Self::FirstFit(_) => false,
293            Self::SizeBuckets(buckets) => !buckets.contains_size(candidate_bytes),
294        }
295    }
296
297    fn evict_deterministic(&mut self) -> Option<CudaDeviceBuffer> {
298        match self {
299            Self::FirstFit(buffers) => (!buffers.is_empty()).then(|| buffers.remove(0)),
300            Self::SizeBuckets(buckets) => buckets.evict_largest_oldest(),
301        }
302    }
303
304    fn try_recycle(
305        &mut self,
306        buffer: CudaDeviceBuffer,
307    ) -> Result<(), (CudaError, CudaDeviceBuffer)> {
308        match self {
309            Self::FirstFit(buffers) => {
310                if buffers.try_reserve(1).is_err() {
311                    let error =
312                        host_allocation_error::<CudaDeviceBuffer>(buffers.len().saturating_add(1));
313                    return Err((error, buffer));
314                }
315                buffers.push(buffer);
316                Ok(())
317            }
318            Self::SizeBuckets(buckets) => buckets.try_recycle(buffer),
319        }
320    }
321}
322
323fn cache_admission_decision(
324    limits: CudaBufferPoolLimits,
325    inventory: CacheInventory,
326    candidate_bytes: usize,
327    adds_size_bucket: bool,
328) -> CacheAdmissionDecision {
329    if limits.max_cached_buffers == 0
330        || candidate_bytes > limits.max_cached_bytes
331        || (adds_size_bucket && limits.max_size_buckets == 0)
332    {
333        return CacheAdmissionDecision::Reject;
334    }
335
336    let next_buffers = inventory.buffers.checked_add(1);
337    let next_bytes = inventory.bytes.checked_add(candidate_bytes);
338    let next_buckets = inventory
339        .size_buckets
340        .checked_add(usize::from(adds_size_bucket));
341    let fits = next_buffers.is_some_and(|count| count <= limits.max_cached_buffers)
342        && next_bytes.is_some_and(|bytes| bytes <= limits.max_cached_bytes)
343        && next_buckets.is_some_and(|count| count <= limits.max_size_buckets);
344    if fits {
345        CacheAdmissionDecision::Admit
346    } else if inventory.buffers == 0 {
347        CacheAdmissionDecision::Reject
348    } else {
349        CacheAdmissionDecision::Evict
350    }
351}
352
353pub(super) fn checked_deferred_bytes(current: usize, added: usize) -> Result<usize, CudaError> {
354    current
355        .checked_add(added)
356        .ok_or(CudaError::InternalInvariant {
357            what: "CUDA deferred buffer byte accounting overflow",
358        })
359}
360
361pub(super) fn observe_deferred_high_water(state: &mut CudaBufferPoolState) {
362    state.metrics.peak_deferred_buffers = state
363        .metrics
364        .peak_deferred_buffers
365        .max(state.deferred.len());
366    state.metrics.peak_deferred_bytes = state.metrics.peak_deferred_bytes.max(state.deferred_bytes);
367}
368
369fn observe_cache_high_water(state: &mut CudaBufferPoolState) {
370    let inventory = state.free.inventory();
371    state.metrics.peak_cached_buffers = state.metrics.peak_cached_buffers.max(inventory.buffers);
372    state.metrics.peak_cached_bytes = state.metrics.peak_cached_bytes.max(inventory.bytes);
373    state.metrics.peak_cached_size_buckets = state
374        .metrics
375        .peak_cached_size_buckets
376        .max(inventory.size_buckets);
377}
378
379#[cfg(test)]
380mod tests;