Skip to main content

cubecl_runtime/memory_management/
memory_manage.rs

1use super::{
2    MemoryConfiguration, MemoryPoolOptions, MemoryReport, MemoryUsage, PoolType,
3    memory_pool::{
4        DirectPool, ExclusiveMemoryPool, MemoryPool, PageMapping, PersistentPool, SlicedPool,
5    },
6};
7use crate::{
8    config::{
9        CubeClRuntimeConfig, RuntimeConfig,
10        memory::{
11            MemoryLogLevel, MemoryPoolConfig, MemoryPoolsConfig, MemoryPoolsPreset,
12            PersistentMemory,
13        },
14    },
15    logging::ServerLogger,
16    memory_management::{BytesFormat, memory_pool::Slice},
17    server::IoError,
18    storage::{ComputeStorage, StorageHandle},
19};
20
21use alloc::format;
22use alloc::string::{String, ToString};
23#[cfg(not(exclusive_memory_only))]
24use alloc::vec;
25use alloc::vec::Vec;
26use cubecl_environment::backtrace::BackTrace;
27use cubecl_environment::collections::HashSet;
28use cubecl_environment::sync::Arc;
29use cubecl_ir::MemoryDeviceProperties;
30
31pub use super::memory_pool::{ManagedMemoryBinding, handle::*};
32
33// These are 288 bytes vs 64 bytes. Adding boxing isn't really worth
34// saving the 200 bytes.
35#[allow(clippy::large_enum_variant)]
36enum DynamicPool {
37    Sliced(SlicedPool),
38    Exclusive(ExclusiveMemoryPool),
39    Direct(DirectPool),
40}
41
42impl MemoryPool for DynamicPool {
43    fn accept(&self, size: u64) -> bool {
44        match self {
45            DynamicPool::Sliced(pool) => pool.accept(size),
46            DynamicPool::Exclusive(pool) => pool.accept(size),
47            DynamicPool::Direct(pool) => pool.accept(size),
48        }
49    }
50
51    fn find(&self, binding: &ManagedMemoryBinding) -> Result<&Slice, IoError> {
52        match self {
53            DynamicPool::Sliced(m) => m.find(binding),
54            DynamicPool::Exclusive(m) => m.find(binding),
55            DynamicPool::Direct(m) => m.find(binding),
56        }
57    }
58
59    #[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
60    fn try_reserve(&mut self, size: u64) -> Option<ManagedMemoryHandle> {
61        match self {
62            DynamicPool::Sliced(m) => m.try_reserve(size),
63            DynamicPool::Exclusive(m) => m.try_reserve(size),
64            DynamicPool::Direct(m) => m.try_reserve(size),
65        }
66    }
67
68    #[cfg_attr(
69        feature = "tracing",
70        tracing::instrument(level = "trace", skip(self, storage))
71    )]
72    fn alloc<Storage: ComputeStorage>(
73        &mut self,
74        storage: &mut Storage,
75        size: u64,
76        mapping: PageMapping,
77    ) -> Result<ManagedMemoryHandle, IoError> {
78        match self {
79            DynamicPool::Sliced(m) => m.alloc(storage, size, mapping),
80            DynamicPool::Exclusive(m) => m.alloc(storage, size, mapping),
81            DynamicPool::Direct(m) => m.alloc(storage, size, mapping),
82        }
83    }
84
85    fn materialize<Storage: ComputeStorage>(
86        &mut self,
87        storage: &mut Storage,
88        binding: &ManagedMemoryBinding,
89    ) -> Result<(), IoError> {
90        match self {
91            DynamicPool::Sliced(m) => m.materialize(storage, binding),
92            DynamicPool::Exclusive(m) => m.materialize(storage, binding),
93            DynamicPool::Direct(m) => m.materialize(storage, binding),
94        }
95    }
96
97    fn get_memory_usage(&self) -> MemoryUsage {
98        match self {
99            DynamicPool::Sliced(m) => m.get_memory_usage(),
100            DynamicPool::Exclusive(m) => m.get_memory_usage(),
101            DynamicPool::Direct(m) => m.get_memory_usage(),
102        }
103    }
104
105    fn cleanup<Storage: ComputeStorage>(
106        &mut self,
107        storage: &mut Storage,
108        alloc_nr: u64,
109        explicit: bool,
110    ) {
111        match self {
112            DynamicPool::Sliced(m) => m.cleanup(storage, alloc_nr, explicit),
113            DynamicPool::Exclusive(m) => m.cleanup(storage, alloc_nr, explicit),
114            DynamicPool::Direct(m) => m.cleanup(storage, alloc_nr, explicit),
115        };
116        storage.flush();
117    }
118
119    fn bind(
120        &mut self,
121        reserved: ManagedMemoryHandle,
122        assigned: ManagedMemoryHandle,
123        cursor: u64,
124    ) -> Result<(), IoError> {
125        match self {
126            DynamicPool::Sliced(m) => m.bind(reserved, assigned, cursor),
127            DynamicPool::Exclusive(m) => m.bind(reserved, assigned, cursor),
128            DynamicPool::Direct(m) => m.bind(reserved, assigned, cursor),
129        }
130    }
131}
132
133impl core::fmt::Display for DynamicPool {
134    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
135        match self {
136            DynamicPool::Sliced(pool) => write!(f, "{pool}"),
137            DynamicPool::Exclusive(pool) => write!(f, "{pool}"),
138            DynamicPool::Direct(pool) => write!(f, "{pool}"),
139        }
140    }
141}
142
143impl DynamicPool {
144    fn report(&self) -> super::MemoryPoolReport {
145        match self {
146            DynamicPool::Sliced(m) => m.report(),
147            DynamicPool::Exclusive(m) => m.report(),
148            DynamicPool::Direct(m) => m.report(),
149        }
150    }
151}
152
153#[derive(Default, Clone, Copy, Debug)]
154/// The mode of allocation used.
155pub enum MemoryAllocationMode {
156    /// Use the automatic memory management strategy for allocation.
157    #[default]
158    Auto,
159    /// Use a persistent memory management strategy, meaning that all allocations are for data that is
160    /// likely never going to be freed.
161    Persistent,
162}
163
164/// Reserves and keeps track of chunks of memory in the storage, and slices upon these chunks.
165pub struct MemoryManagement<Storage> {
166    name: String,
167    persistent: PersistentPool,
168    pools: Vec<DynamicPool>,
169    /// Dynamic pools that have already reported hitting their cap, so the
170    /// warning stays one per pool per layout rather than one per allocation.
171    /// Cleared by [`install_pools`](Self::install_pools).
172    capacity_warned: HashSet<usize>,
173    storage: Storage,
174    alloc_reserve_count: u64,
175    mode: MemoryAllocationMode,
176    /// Open persistent windows (see [`mode`](Self::mode)): the effective mode
177    /// stays `Persistent` until every nested window has closed.
178    persistent_windows: u64,
179    config: PersistentMemory,
180    logger: Arc<ServerLogger>,
181    /// State of the active graph capture, if any.
182    capture: Option<CaptureState>,
183}
184
185/// While a graph capture is active, allocations are forced into the persistent
186/// pool; slices there stay freely reusable during the window (warmup populates
187/// them, the capture run reuses them), and `capture_end` hands the graph exactly
188/// the slices the window touched.
189struct CaptureState {
190    /// The mode to restore at `capture_end`. Mid-capture [`mode`] changes land
191    /// here instead of taking effect, so they can't reroute capture allocations
192    /// away from the persistent pool.
193    restore_mode: MemoryAllocationMode,
194    /// Ids of every persistent slice handed out (reserved or freshly allocated)
195    /// while the window was open — exactly the slices the graph's recorded
196    /// kernels may replay against. `capture_end` retains these and nothing else,
197    /// so a slice the window never touched is not over-retained, and a
198    /// pre-existing slice freed and reused mid-window is still pinned.
199    touched: HashSet<ManagedMemoryId>,
200    /// Whether the warmup (priming) phase is still running, i.e. the capture window has not opened
201    /// yet. While set, every slice handed out is retained in `primed` instead of being recycled.
202    priming: bool,
203    /// Slices retained during priming, released by
204    /// [`capture_priming_end`](MemoryManagement::capture_priming_end).
205    ///
206    /// Warmup exists to leave the pool able to serve the recorded run without allocating — an
207    /// allocation inside the window is recorded as a memory node, and CUDA refuses to relaunch a
208    /// graph holding one. Letting warmup recycle its own slices defeats that: the pool only ever
209    /// grows to a warmup pass's transient *peak*, which depends on how far the host runs ahead of
210    /// the device and can land below what the recorded run asks for. Holding every slice instead
211    /// forces the pool up to the pass's full distinct working set — an upper bound on any peak —
212    /// so once these are released the recorded run cannot ask for a slice the pool lacks.
213    primed: Vec<ManagedMemoryHandle>,
214}
215
216fn generate_bucket_sizes(
217    start_size: u64,
218    end_size: u64,
219    max_buckets: usize,
220    alignment: u64,
221) -> Vec<u64> {
222    let mut buckets = Vec::with_capacity(max_buckets);
223    let log_min = (start_size as f64).ln();
224    let log_max = (end_size as f64).ln();
225    let log_range = log_max - log_min;
226
227    // Pure exponential performed best, but let's try slightly denser in lower-mid range
228    for i in 0..max_buckets {
229        let p = i as f64 / (max_buckets - 1) as f64;
230        // Slight bias toward lower-mid range with less aggressive curve than sigmoid
231        let log_size = log_min + log_range * p;
232        let size = log_size.exp() as u64;
233        let aligned_size = size.next_multiple_of(alignment);
234        buckets.push(aligned_size);
235    }
236
237    buckets.dedup();
238    buckets
239}
240
241const DEALLOC_SCALE_MB: u64 = 1024 * 1024 * 1024;
242const BASE_DEALLOC_PERIOD: u64 = 5000;
243
244/// The options for creating a new [`MemoryManagement`] instance.
245#[derive(Debug)]
246pub struct MemoryManagementOptions {
247    /// The name of the memory management.
248    name: String,
249    /// The [`MemoryAllocationOption`] used by this instance.
250    memory: MemoryAllocationOption,
251}
252
253impl MemoryManagementOptions {
254    /// Creates a new [`MemoryManagementOptions`].
255    pub fn new<S: Into<String>>(name: S) -> Self {
256        Self {
257            name: name.into(),
258            memory: MemoryAllocationOption::FromConfig,
259        }
260    }
261
262    /// Forces the [`MemoryAllocationMode`] during execution to always be the provided one.
263    pub fn mode(mut self, mode: MemoryAllocationMode) -> Self {
264        self.memory = MemoryAllocationOption::Provided(mode);
265        self
266    }
267}
268
269#[derive(Default, Debug)]
270/// Determines which [`MemoryAllocationMode`] is used during allocations.
271enum MemoryAllocationOption {
272    #[default]
273    /// Uses the [`GlobalConfig`] to determine the mode of allocation.
274    FromConfig,
275    /// Use the provided [`MemoryAllocationMode`].
276    Provided(MemoryAllocationMode),
277}
278
279/// Why a `memory.pools` config could not be turned into a pool layout.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum PoolConfigError {
282    /// `memory.pools` was an empty list.
283    EmptyPoolList,
284    /// A size field that must be non-zero was zero.
285    ZeroSize {
286        /// The offending field.
287        field: &'static str,
288    },
289    /// `max_slice_size` exceeds `page_size` (a slice can never span pages).
290    SliceLargerThanPage {
291        /// The page size in bytes (after alignment).
292        page_size: u64,
293        /// The maximum slice size in bytes (after alignment).
294        max_slice_size: u64,
295    },
296    /// `max_pool_size` is smaller than `page_size` (the cap can't fit one page).
297    CapSmallerThanPage {
298        /// The page size in bytes (after alignment).
299        page_size: u64,
300        /// The pool capacity in bytes.
301        max_pool_size: u64,
302    },
303    /// `max_pool_size` spans more pages of `page_size` than a pool can hold.
304    TooManyPages {
305        /// The number of pages the configuration asks for.
306        pages: u64,
307    },
308    /// The pool list has more entries than the pool routing can address.
309    TooManyPools {
310        /// The number of entries in the configuration.
311        count: usize,
312    },
313    /// The preset is not available in this build.
314    PresetUnavailable {
315        /// The preset name.
316        preset: &'static str,
317    },
318    /// Sliced pools are not available in this build (`exclusive_memory_only`).
319    SlicedPoolsUnavailable,
320}
321
322impl core::fmt::Display for PoolConfigError {
323    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
324        match self {
325            PoolConfigError::EmptyPoolList => write!(f, "the pool list is empty"),
326            PoolConfigError::ZeroSize { field } => write!(f, "`{field}` must be non-zero"),
327            PoolConfigError::SliceLargerThanPage {
328                page_size,
329                max_slice_size,
330            } => write!(
331                f,
332                "`max_slice_size` ({max_slice_size}) exceeds `page_size` ({page_size}); a slice can never span pages"
333            ),
334            PoolConfigError::CapSmallerThanPage {
335                page_size,
336                max_pool_size,
337            } => write!(
338                f,
339                "`max_pool_size` ({max_pool_size}) is smaller than `page_size` ({page_size}); the cap can't fit a single page"
340            ),
341            PoolConfigError::TooManyPages { pages } => write!(
342                f,
343                "`max_pool_size` spans {pages} pages of `page_size`, exceeding the maximum of {}; increase `page_size` or lower the cap",
344                u16::MAX
345            ),
346            PoolConfigError::TooManyPools { count } => write!(
347                f,
348                "the pool list has {count} entries, exceeding the maximum of {} dynamic pools",
349                PERSISTENT_POOL_POS - 1
350            ),
351            PoolConfigError::PresetUnavailable { preset } => {
352                write!(f, "the `{preset}` preset is not available in this build")
353            }
354            PoolConfigError::SlicedPoolsUnavailable => {
355                write!(
356                    f,
357                    "sliced pools are not available in this build (exclusive memory only)"
358                )
359            }
360        }
361    }
362}
363
364impl core::error::Error for PoolConfigError {}
365
366/// Why installing a dynamic pool layout did not take effect.
367///
368/// The layout itself was already valid — that is
369/// [`PoolConfigError`](PoolConfigError), reported when the configuration is
370/// resolved. This is about the pools' *state* at the moment of the swap.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum InstallMemoryPoolsError {
373    /// The dynamic pools still hold live allocations, so the old layout was
374    /// kept. A live slice carries its pool position, and swapping the pool
375    /// list under it would leave that position pointing at a different pool.
376    ///
377    /// Transient: retry once whatever holds them drains. A cleanup that does
378    /// not clear it usually means a cache is holding slices (the metadata
379    /// info cache) or a captured graph is pinning them.
380    PoolsInUse {
381        /// Bytes still live in the dynamic pools.
382        bytes_in_use: u64,
383    },
384    /// The calling stream could not be resolved because it is already in an
385    /// error state. The layout still applies to streams created afterwards;
386    /// the underlying failure surfaces at the next flush or sync, as usual.
387    StreamUnavailable,
388    /// This server has no configurable dynamic pools. Permanent — unlike
389    /// [`PoolsInUse`](Self::PoolsInUse), retrying will never succeed.
390    Unsupported,
391}
392
393impl core::fmt::Display for InstallMemoryPoolsError {
394    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
395        match self {
396            InstallMemoryPoolsError::PoolsInUse { bytes_in_use } => write!(
397                f,
398                "the dynamic pools kept their layout: {bytes_in_use} bytes are still live in them"
399            ),
400            InstallMemoryPoolsError::StreamUnavailable => write!(
401                f,
402                "the calling stream kept its layout: it is already in an error state"
403            ),
404            InstallMemoryPoolsError::Unsupported => {
405                write!(f, "this server has no configurable dynamic memory pools")
406            }
407        }
408    }
409}
410
411impl core::error::Error for InstallMemoryPoolsError {}
412
413impl MemoryConfiguration {
414    /// Resolve a programmatic [`MemoryPoolsConfig`] override against the
415    /// runtime-chosen configuration for the **main GPU** pool.
416    ///
417    /// When `pools` is `None`, the runtime's own `self` is kept unchanged;
418    /// when present, it wins. There is deliberately no config-file pathway for
419    /// pool layouts — they are dynamic (set per model just before a load) and
420    /// must not freeze at startup; the override reaches the server through
421    /// [`install_memory_pools`](crate::client::ComputeClient::install_memory_pools).
422    ///
423    /// `page_size` is deliberately not validated against
424    /// [`MemoryDeviceProperties::max_page_size`]: that value is a sizing
425    /// heuristic for the default layouts (CUDA/HIP report a quarter of the
426    /// device memory), not an allocation limit, and a large arena is exactly
427    /// what an explicit pool override is for. An unallocatable page fails at
428    /// allocation time.
429    pub fn resolve(
430        self,
431        pools: Option<&MemoryPoolsConfig>,
432        properties: &MemoryDeviceProperties,
433    ) -> Result<Self, PoolConfigError> {
434        let Some(pools) = pools else {
435            return Ok(self);
436        };
437
438        match pools {
439            MemoryPoolsConfig::Preset(MemoryPoolsPreset::SubSlices) => {
440                #[cfg(exclusive_memory_only)]
441                {
442                    Err(PoolConfigError::PresetUnavailable {
443                        preset: "sub-slices",
444                    })
445                }
446                #[cfg(not(exclusive_memory_only))]
447                {
448                    Ok(MemoryConfiguration::SubSlices)
449                }
450            }
451            MemoryPoolsConfig::Preset(MemoryPoolsPreset::ExclusivePages) => {
452                Ok(MemoryConfiguration::ExclusivePages)
453            }
454            MemoryPoolsConfig::Explicit(entries) => {
455                if entries.is_empty() {
456                    return Err(PoolConfigError::EmptyPoolList);
457                }
458                // Slices route through their pool's position, and the
459                // persistent pool owns the sentinel position, so the list must
460                // stay addressable below it — checked here so the caller gets
461                // the error instead of a panic on the device thread.
462                if entries.len() >= PERSISTENT_POOL_POS as usize {
463                    return Err(PoolConfigError::TooManyPools {
464                        count: entries.len(),
465                    });
466                }
467                let pool_options = entries
468                    .iter()
469                    .map(|entry| pool_options_from_entry(entry, properties))
470                    .collect::<Result<Vec<_>, _>>()?;
471                Ok(MemoryConfiguration::Custom { pool_options })
472            }
473        }
474    }
475}
476
477/// Convert one config entry into runtime pool options, aligning sizes up to
478/// the device alignment (a device constraint, not a user error).
479fn pool_options_from_entry(
480    entry: &MemoryPoolConfig,
481    properties: &MemoryDeviceProperties,
482) -> Result<MemoryPoolOptions, PoolConfigError> {
483    let alignment = properties.alignment.max(1);
484    match entry {
485        MemoryPoolConfig::Exclusive {
486            max_alloc_size,
487            dealloc_period,
488        } => {
489            // 0 stays 0: a pool dedicated to zero-sized allocations, as used by
490            // the `SubSlices` preset.
491            let max_alloc_size = max_alloc_size.bytes().next_multiple_of(alignment);
492            Ok(MemoryPoolOptions {
493                pool_type: PoolType::ExclusivePages { max_alloc_size },
494                dealloc_period: *dealloc_period,
495            })
496        }
497        MemoryPoolConfig::Direct { reclaim_at } => Ok(MemoryPoolOptions {
498            pool_type: PoolType::Direct {
499                reclaim_at: reclaim_at.map(|size| size.bytes()),
500            },
501            // `dealloc_period` has no meaning here: the pool reclaims on
502            // memory pressure, not on an allocation count.
503            dealloc_period: None,
504        }),
505        // Sliced pools break the invariant `exclusive_memory_only` builds rely
506        // on (e.g. wgpu on wasm assumes a buffer is never shared between
507        // slices), so an explicit list must be rejected just like the
508        // `sub-slices` preset is.
509        #[cfg(exclusive_memory_only)]
510        MemoryPoolConfig::Sliced { .. } => Err(PoolConfigError::SlicedPoolsUnavailable),
511        #[cfg(not(exclusive_memory_only))]
512        MemoryPoolConfig::Sliced {
513            page_size,
514            max_slice_size,
515            max_pool_size,
516            dealloc_period,
517        } => {
518            if page_size.bytes() == 0 {
519                return Err(PoolConfigError::ZeroSize { field: "page_size" });
520            }
521
522            let page_size = page_size.bytes().next_multiple_of(alignment);
523            let max_slice_size = match max_slice_size {
524                Some(size) if size.bytes() == 0 => {
525                    return Err(PoolConfigError::ZeroSize {
526                        field: "max_slice_size",
527                    });
528                }
529                Some(size) => size.bytes().next_multiple_of(alignment),
530                None => page_size,
531            };
532            if max_slice_size > page_size {
533                return Err(PoolConfigError::SliceLargerThanPage {
534                    page_size,
535                    max_slice_size,
536                });
537            }
538            if let Some(cap) = max_pool_size {
539                let cap = cap.bytes();
540                if cap == 0 {
541                    return Err(PoolConfigError::ZeroSize {
542                        field: "max_pool_size",
543                    });
544                }
545                if cap < page_size {
546                    return Err(PoolConfigError::CapSmallerThanPage {
547                        page_size,
548                        max_pool_size: cap,
549                    });
550                }
551                let pages = cap / page_size;
552                if pages > u16::MAX as u64 {
553                    return Err(PoolConfigError::TooManyPages { pages });
554                }
555            }
556
557            Ok(MemoryPoolOptions {
558                pool_type: PoolType::SlicedPages {
559                    page_size,
560                    max_slice_size,
561                    max_pool_size: max_pool_size.map(|size| size.bytes()),
562                },
563                dealloc_period: *dealloc_period,
564            })
565        }
566    }
567}
568
569/// The pool position stamped on persistent-pool slices, routing their binds
570/// and lookups to the persistent pool. A fixed sentinel (rather than "one past
571/// the dynamic pools") so live persistent slices stay routable when
572/// [`MemoryManagement::install_pools`] rebuilds the dynamic pools with a
573/// different count.
574const PERSISTENT_POOL_POS: u8 = u8::MAX;
575
576/// Build the dynamic pools for `config` — the shared core of
577/// [`MemoryManagement::from_configuration`] and
578/// [`MemoryManagement::install_pools`].
579fn build_pools(
580    properties: &MemoryDeviceProperties,
581    config: MemoryConfiguration,
582    logger: &Arc<ServerLogger>,
583    name: &str,
584) -> Vec<DynamicPool> {
585    let pool_options = match config {
586        #[cfg(not(exclusive_memory_only))]
587        MemoryConfiguration::SubSlices => {
588            // Round chunk size to be aligned.
589            let memory_alignment = properties.alignment;
590            let max_page = properties.max_page_size;
591            let mut pools = Vec::new();
592
593            const MB: u64 = 1024 * 1024;
594
595            // Add in a pool for allocations that are smaller than the min alignment,
596            // as they can't use offsets at all (on wgpu at least).
597            pools.push(MemoryPoolOptions {
598                pool_type: PoolType::ExclusivePages { max_alloc_size: 0 },
599                dealloc_period: None,
600            });
601
602            let mut current = max_page;
603            let mut max_sizes = vec![];
604            let mut page_sizes = vec![];
605            let mut base = pools.len() as u32;
606
607            while current >= 32 * MB {
608                current /= 4;
609
610                // Make sure every pool has an aligned size.
611                current = current.next_multiple_of(memory_alignment);
612
613                max_sizes.push(current / 2u64.pow(base));
614                page_sizes.push(current);
615                base += 1;
616            }
617
618            max_sizes.reverse();
619            page_sizes.reverse();
620
621            for i in 0..max_sizes.len() {
622                let max = max_sizes[i];
623                let page_size = page_sizes[i];
624
625                pools.push(MemoryPoolOptions {
626                    // Creating max slices lower than the chunk size reduces fragmentation.
627                    pool_type: PoolType::SlicedPages {
628                        page_size,
629                        max_slice_size: max,
630                        max_pool_size: None,
631                    },
632                    dealloc_period: None,
633                });
634            }
635
636            // Allocations bigger than the sliced ladder get exact-size
637            // exclusive pages. A sliced tail pool here would materialize a
638            // whole `max_page` page (a quarter of device memory) for the
639            // first allocation that lands in it — on unified-memory devices
640            // that alone can consume a large share of host RAM. Exclusive
641            // pages allocate exactly what is requested and are released once
642            // they sit unused for a full dealloc period.
643            let max_alloc = max_page / memory_alignment * memory_alignment;
644            let dealloc_period = (BASE_DEALLOC_PERIOD as f64
645                * (1.0 + max_alloc as f64 / (DEALLOC_SCALE_MB as f64)).round())
646                as u64;
647            pools.push(MemoryPoolOptions {
648                pool_type: PoolType::ExclusivePages {
649                    max_alloc_size: max_alloc,
650                },
651                dealloc_period: Some(dealloc_period),
652            });
653            pools
654        }
655        MemoryConfiguration::ExclusivePages => {
656            // Add all bin sizes. Nb: because of alignment some buckets
657            // end up as the same size, so only want unique ones,
658            // but also keep the order, so a BTree will do.
659            const MIN_BUCKET_SIZE: u64 = 1024 * 32;
660            const NUM_POOLS: usize = 24;
661
662            let sizes = generate_bucket_sizes(
663                MIN_BUCKET_SIZE,
664                properties.max_page_size,
665                NUM_POOLS,
666                properties.alignment,
667            );
668
669            sizes
670                .iter()
671                .map(|&size| {
672                    let dealloc_period = (BASE_DEALLOC_PERIOD as f64
673                        * (1.0 + size as f64 / (DEALLOC_SCALE_MB as f64)).round())
674                        as u64;
675
676                    MemoryPoolOptions {
677                        pool_type: PoolType::ExclusivePages {
678                            max_alloc_size: size,
679                        },
680                        dealloc_period: Some(dealloc_period),
681                    }
682                })
683                .collect()
684        }
685        MemoryConfiguration::Custom { pool_options } => pool_options,
686    };
687
688    logger.log_memory(
689        |level| !matches!(level, MemoryLogLevel::Disabled),
690        || {
691            let mut msg = String::new();
692            for pool in pool_options.iter() {
693                msg += &format!("[{name}] Using memory pool: \n {pool:?}\n");
694            }
695            msg
696        },
697    );
698
699    assert!(
700        pool_options.len() < PERSISTENT_POOL_POS as usize,
701        "at most {} dynamic pools are supported",
702        PERSISTENT_POOL_POS - 1
703    );
704
705    pool_options
706        .iter()
707        .enumerate()
708        .map(|(pool_pos, pool)| {
709            let pool_pos = pool_pos as u8;
710
711            match pool.pool_type {
712                PoolType::SlicedPages {
713                    page_size,
714                    max_slice_size,
715                    max_pool_size,
716                } => DynamicPool::Sliced(SlicedPool::new(
717                    page_size,
718                    max_slice_size,
719                    properties.alignment,
720                    pool_pos,
721                    max_pool_size,
722                )),
723                PoolType::ExclusivePages { max_alloc_size } => {
724                    DynamicPool::Exclusive(ExclusiveMemoryPool::new(
725                        max_alloc_size,
726                        properties.alignment,
727                        pool.dealloc_period.unwrap_or(u64::MAX),
728                        pool_pos,
729                    ))
730                }
731                PoolType::Direct { reclaim_at } => {
732                    DynamicPool::Direct(DirectPool::new(properties.alignment, pool_pos, reclaim_at))
733                }
734            }
735        })
736        .collect()
737}
738
739impl<Storage: ComputeStorage> MemoryManagement<Storage> {
740    /// Creates the options from device limits.
741    pub fn from_configuration(
742        storage: Storage,
743        properties: &MemoryDeviceProperties,
744        config: MemoryConfiguration,
745        logger: Arc<ServerLogger>,
746        options: MemoryManagementOptions,
747    ) -> Self {
748        let pools = build_pools(properties, config, &logger, &options.name);
749
750        let config = CubeClRuntimeConfig::get().memory.persistent_memory.clone();
751
752        let mode = match options.memory {
753            MemoryAllocationOption::Provided(mode) => mode,
754            MemoryAllocationOption::FromConfig => match config {
755                PersistentMemory::Enabled | PersistentMemory::SizeMatch => {
756                    MemoryAllocationMode::Auto
757                }
758                PersistentMemory::Disabled => MemoryAllocationMode::Auto,
759                PersistentMemory::Enforced => MemoryAllocationMode::Persistent,
760            },
761        };
762
763        Self {
764            name: options.name,
765            persistent: PersistentPool::new(
766                properties.max_page_size,
767                properties.alignment,
768                PERSISTENT_POOL_POS,
769            ),
770            pools,
771            capacity_warned: HashSet::new(),
772            storage,
773            alloc_reserve_count: 0,
774            mode,
775            persistent_windows: 0,
776            config,
777            logger,
778            capture: None,
779        }
780    }
781
782    /// Replace the dynamic pools with ones built from a new layout.
783    ///
784    /// The old pools are cleaned up first (every currently-free page returned
785    /// to the driver) and then discarded — this installs new pools, it does
786    /// not re-tune the existing ones. That is why it only happens when no live
787    /// allocation remains in them: a live slice carries its pool position, so
788    /// swapping the pool list under it would leave that position pointing at a
789    /// different pool. The caller installs at a quiescent point (e.g. right
790    /// after unloading a model), so a refusal is the exceptional path, not the
791    /// normal one.
792    ///
793    /// Rebuilding resets each pool's high-water marks, which is what lets a
794    /// measured plan be read from a pass that follows a rebuild rather than
795    /// from the process's whole history.
796    ///
797    /// The persistent pool is untouched: its slices route through a fixed
798    /// sentinel position and its layout is model-agnostic.
799    ///
800    /// # Errors
801    ///
802    /// [`PoolsInUse`](InstallMemoryPoolsError::PoolsInUse) when something is
803    /// still live in the dynamic pools; the old layout is kept and nothing is
804    /// disturbed. Retry once the work holding them drains.
805    pub fn install_pools(
806        &mut self,
807        config: MemoryConfiguration,
808        properties: &MemoryDeviceProperties,
809    ) -> Result<(), InstallMemoryPoolsError> {
810        self.cleanup(true);
811
812        // Only the dynamic pools are rebuilt, so only their live slices block
813        // (persistent usage — weights of another workload — doesn't).
814        let dynamic_in_use: u64 = self
815            .pools
816            .iter()
817            .map(|pool| pool.get_memory_usage().bytes_in_use)
818            .sum();
819        if dynamic_in_use > 0 {
820            return Err(InstallMemoryPoolsError::PoolsInUse {
821                bytes_in_use: dynamic_in_use,
822            });
823        }
824
825        self.pools = build_pools(properties, config, &self.logger, &self.name);
826        // A new layout is a new plan, and whether it is short is a fresh
827        // question — every pool gets to report its first spill again.
828        self.capacity_warned.clear();
829        Ok(())
830    }
831
832    /// Begin a graph capture: force every allocation into the persistent pool
833    /// — exact-fit slices with no bucket padding, which is what a graph's
834    /// static shapes want — and start recording which slices the window hands
835    /// out (see [`reserve`](Self::reserve)). Every slice the window touches
836    /// belongs to the graph at [`capture_end`](Self::capture_end); anything it
837    /// never touches (pre-existing live buffers, idle free slices) does not.
838    /// Slices stay reusable *within* the window — warmup populates the pool, then
839    /// the capture run reuses those slices without a fresh device allocation
840    /// (illegal mid-capture). Sets the mode directly, overriding the config gate
841    /// that [`mode`](Self::mode) honors. If a capture is already active, only the
842    /// mode is re-forced — the original capture keeps its touched set and restore
843    /// state.
844    pub fn capture_begin(&mut self) {
845        if self.capture.is_none() {
846            self.capture = Some(CaptureState {
847                restore_mode: self.mode,
848                touched: HashSet::new(),
849                priming: true,
850                primed: Vec::new(),
851            });
852        }
853        self.mode = MemoryAllocationMode::Persistent;
854    }
855
856    /// End the priming phase and release the slices warmup retained, returning them to the pool as
857    /// free. Call immediately before the capture window opens.
858    ///
859    /// After this the pool holds every slice a warmup pass touched, all of them free, so the
860    /// recorded run reuses them instead of growing the pool (see [`CaptureState::primed`]). No-op
861    /// when no capture is active or priming already ended.
862    pub fn capture_priming_end(&mut self) {
863        if let Some(capture) = &mut self.capture {
864            capture.priming = false;
865            // Dropping the handles makes the slices free again; the slices themselves stay in the
866            // pool, which is the point.
867            capture.primed.clear();
868        }
869    }
870
871    /// End a graph capture: restore the previous allocation mode and return a
872    /// retained handle to every persistent slice the window touched — exactly
873    /// the memory the graph's recorded kernels replay against. The caller pins
874    /// these on the graph so the pool never reuses graph memory (which a replay
875    /// would corrupt); dropping the graph drops the handles and releases the
876    /// slices. Slices the window never touched are left alone, so a pre-existing
877    /// live buffer keeps its reuse and in-place (`can_mut`) semantics. Empty if
878    /// no capture was active.
879    pub fn capture_end(&mut self) -> Vec<ManagedMemoryHandle> {
880        match self.capture.take() {
881            Some(capture) => {
882                self.mode = capture.restore_mode;
883                self.persistent.retain_touched(&capture.touched)
884            }
885            None => Vec::new(),
886        }
887    }
888
889    /// Change the mode of allocation.
890    ///
891    /// Persistent windows **nest**: a `Persistent` call opens one, an `Auto`
892    /// call closes one, and the effective mode stays `Persistent` while any
893    /// window is open. Callers routinely nest without knowing it — a module
894    /// load opens a window around the whole load while the parameter machinery
895    /// underneath opens one per parameter — and without the depth, the first
896    /// inner window's exit would flip the rest of the outer window back to
897    /// `Auto`: weights landing in the dynamic pools, which then refuse every
898    /// later rebuild ([`install_pools`](Self::install_pools)) for the model's whole
899    /// life.
900    pub fn mode(&mut self, mode: MemoryAllocationMode) {
901        // We override the mode based on the cubecl config.
902        let mode = match self.config {
903            PersistentMemory::Enabled | PersistentMemory::SizeMatch => mode,
904            PersistentMemory::Disabled | PersistentMemory::Enforced => return,
905        };
906
907        match mode {
908            MemoryAllocationMode::Persistent => self.persistent_windows += 1,
909            MemoryAllocationMode::Auto => {
910                self.persistent_windows = self.persistent_windows.saturating_sub(1)
911            }
912        }
913        let mode = match self.persistent_windows > 0 {
914            true => MemoryAllocationMode::Persistent,
915            false => MemoryAllocationMode::Auto,
916        };
917
918        self.logger.log_memory(
919            |level| !matches!(level, MemoryLogLevel::Disabled),
920            || {
921                format!(
922                    "[{}] Setting memory allocation mode: from {:?} => {mode:?}",
923                    self.name, self.mode
924                )
925            },
926        );
927
928        // A capture owns the effective mode until it ends: changing it now
929        // would route capture allocations away from the persistent pool. Defer
930        // the change to `capture_end`.
931        match &mut self.capture {
932            Some(capture) => capture.restore_mode = mode,
933            None => self.mode = mode,
934        }
935    }
936
937    /// Cleanup allocations in pools that are deemed unnecessary.
938    pub fn cleanup(&mut self, explicit: bool) {
939        self.logger.log_memory(
940            |level| !matches!(level, MemoryLogLevel::Disabled) && explicit,
941            || "Manual memory cleanup ...".to_string(),
942        );
943
944        // Nothing may be freed during a capture. The persistent window's free
945        // slices are exactly what the capture run reuses (deallocating one
946        // forces a fresh device allocation mid-capture, which faults), and
947        // the storage frees behind the dynamic pools can synchronize the
948        // device (e.g. `hipFree`), which invalidates the capture. Everything
949        // stays queued until the capture ends.
950        if self.capture.is_some() {
951            return;
952        }
953
954        self.persistent
955            .cleanup(&mut self.storage, self.alloc_reserve_count, explicit);
956
957        for pool in self.pools.iter_mut() {
958            pool.cleanup(&mut self.storage, self.alloc_reserve_count, explicit);
959        }
960
961        // The pools only queue their page deallocations in the storage; an
962        // explicit cleanup means "release the memory now", so push them to the
963        // driver instead of leaving them pending.
964        if explicit {
965            self.storage.flush();
966        }
967    }
968
969    /// Returns the storage from the specified binding
970    pub fn get_cursor(&self, binding: ManagedMemoryBinding) -> Result<u64, IoError> {
971        let slice = self.find(binding)?;
972        Ok(slice.cursor)
973    }
974
975    /// Returns the storage from the specified binding
976    fn find(&self, binding: ManagedMemoryBinding) -> Result<&Slice, IoError> {
977        let id = binding.descriptor();
978
979        if id.location().init == 0 {
980            return Err(IoError::NotFound {
981                backtrace: BackTrace::capture(),
982                reason: "Memory location was never initialized".into(),
983            });
984        }
985
986        let slice = if id.location().pool == PERSISTENT_POOL_POS {
987            self.persistent.find(&binding)?
988        } else {
989            let pool =
990                self.pools
991                    .get(id.location().pool as usize)
992                    .ok_or_else(|| IoError::NotFound {
993                        backtrace: BackTrace::capture(),
994                        reason: format!("Pool {} doesn't exist", id.location().pool).into(),
995                    })?;
996
997            pool.find(&binding)?
998        };
999
1000        // A stale location (e.g. a page that was deallocated and whose index a
1001        // later cleanup reassigned) must surface as `NotFound`, never as another
1002        // allocation's slice.
1003        if slice.handle.descriptor() != binding.descriptor() {
1004            return Err(IoError::NotFound {
1005                backtrace: BackTrace::capture(),
1006                reason: "Memory location points to a different allocation".into(),
1007            });
1008        }
1009
1010        Ok(slice)
1011    }
1012
1013    /// Returns the storage from the specified binding.
1014    ///
1015    /// This is the funnel every buffer dereference passes through
1016    /// ([`get_resource`](Self::get_resource) delegates here), so it is where
1017    /// a lazily-carved allocation gets its real device backing: the handle
1018    /// returned always refers to mapped memory.
1019    pub fn get_storage(&mut self, binding: ManagedMemoryBinding) -> Result<StorageHandle, IoError> {
1020        self.materialize(&binding)?;
1021        let slice = self.find(binding)?;
1022        Ok(slice.storage.clone())
1023    }
1024
1025    /// Install real backing behind `binding` when its allocation was carved
1026    /// lazily under a dry run. Lookup errors are left for
1027    /// [`find`](Self::find) to report with its usual diagnostics.
1028    fn materialize(&mut self, binding: &ManagedMemoryBinding) -> Result<(), IoError> {
1029        let location = binding.descriptor().location();
1030        if location.init == 0 {
1031            return Ok(());
1032        }
1033        match location.pool {
1034            PERSISTENT_POOL_POS => self.persistent.materialize(&mut self.storage, binding),
1035            pool => match self.pools.get_mut(pool as usize) {
1036                Some(pool) => pool.materialize(&mut self.storage, binding),
1037                None => Ok(()),
1038            },
1039        }
1040    }
1041
1042    /// Returns the resource from the storage at the specified handle
1043    pub fn get_resource(
1044        &mut self,
1045        binding: ManagedMemoryBinding,
1046        offset_start: Option<u64>,
1047        offset_end: Option<u64>,
1048    ) -> Result<Storage::Resource, IoError> {
1049        let handle = self.get_storage(binding)?;
1050
1051        let handle = match offset_start {
1052            Some(offset) => handle.offset_start(offset),
1053            None => handle,
1054        };
1055        let handle = match offset_end {
1056            Some(offset) => handle.offset_end(offset),
1057            None => handle,
1058        };
1059        self.storage().get(&handle)
1060    }
1061
1062    /// Record a persistent slice as touched by the active capture window, so
1063    /// [`capture_end`](Self::capture_end) retains exactly the slices the window
1064    /// handed out. A no-op outside a capture.
1065    ///
1066    /// Called with a slice's **final** identity: from [`reserve`](Self::reserve)
1067    /// for a handle used as-is (e.g. pinned staging), and from [`bind`](Self::bind)
1068    /// for a buffer whose reserved handle is replaced by an assigned one. A
1069    /// reserved id later superseded by `bind` also lands here but harmlessly —
1070    /// ids are unique, so it matches no live slice at `capture_end`.
1071    fn capture_touch(&mut self, handle: &ManagedMemoryHandle) {
1072        if let Some(capture) = &mut self.capture {
1073            capture.touched.insert(handle.descriptor().id);
1074            if capture.priming {
1075                // Retain it so warmup cannot recycle this slice, forcing the pool to grow to the
1076                // pass's full working set rather than its transient peak.
1077                capture.primed.push(handle.clone());
1078            }
1079        }
1080    }
1081
1082    /// Finds a spot in memory for a resource with the given size in bytes, and returns a handle to it
1083    #[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
1084    pub fn reserve(&mut self, size: u64) -> Result<ManagedMemoryHandle, IoError> {
1085        // If this happens every nanosecond, counts overflows after 585 years, so not worth thinking too
1086        // hard about overflow here.
1087        self.alloc_reserve_count += 1;
1088
1089        // Drive the pools' periodic deallocation. Each pool gates itself on
1090        // its own `dealloc_period` (pools without one no-op), so this is a few
1091        // comparisons per reservation — without it, pages freed long ago are
1092        // never returned to the driver until an explicit cleanup, which on
1093        // long-running processes lets every stream's pools grow monotonically.
1094        self.cleanup(false);
1095
1096        let mapping = PageMapping::current();
1097
1098        // In an explicit persistent window the pool always serves the
1099        // allocation (reusing a freed same-size slice when one exists).
1100        // Outside a window, the pool participates only under the `size-match`
1101        // config: recurring weight-shaped allocations reuse the buckets — a
1102        // training-friendly heuristic that would otherwise pull inference
1103        // activations into exact-sized persistent slices.
1104        let persistent_mode = matches!(self.mode, MemoryAllocationMode::Persistent);
1105        let size_match = matches!(self.config, PersistentMemory::SizeMatch);
1106
1107        if (persistent_mode || size_match)
1108            && let Some(val) = self.persistent.try_reserve(size)
1109        {
1110            self.logger.log_memory(
1111                |level| matches!(level, MemoryLogLevel::Full),
1112                || {
1113                    format!(
1114                        "[{}] Reserved memory {size} using persistent memory",
1115                        self.name
1116                    )
1117                },
1118            );
1119            self.capture_touch(&val);
1120            return Ok(val);
1121        }
1122
1123        if persistent_mode || (size_match && self.persistent.has_size(size)) {
1124            let allocated = self.persistent.alloc(&mut self.storage, size, mapping);
1125
1126            self.logger.log_memory(
1127                |level| !matches!(level, MemoryLogLevel::Disabled),
1128                || {
1129                    format!(
1130                        "[{}] Allocated a new memory page using persistent memory, \n{}",
1131                        self.name, self,
1132                    )
1133                },
1134            );
1135            if let Ok(handle) = &allocated {
1136                self.capture_touch(handle);
1137            }
1138            return allocated;
1139        }
1140
1141        self.logger.log_memory(
1142            |level| matches!(level, MemoryLogLevel::Full),
1143            || {
1144                format!(
1145                    "[{}] Reserved memory {} using dynamic pool",
1146                    self.name,
1147                    BytesFormat::new(size)
1148                )
1149            },
1150        );
1151
1152        // Serve from the first pool that accepts this size and has capacity. A
1153        // hard-capped pool that is full falls through to the next accepting
1154        // pool instead of failing outright, so a growable tail pool can act as
1155        // an escape hatch behind a measured arena. Deliberate: a cap is a plan,
1156        // and a plan that turns out to be short should cost memory, not kill
1157        // the workload. Where the cap is a hard budget rather than a plan,
1158        // configure no pool behind it — then a full pool still errors, which is
1159        // what keeps the budget-vs-device-OOM distinction schedulers rely on.
1160        let mut capacity_exceeded = None;
1161        let mut reserved = None;
1162
1163        for (index, pool) in self
1164            .pools
1165            .iter_mut()
1166            .enumerate()
1167            .filter(|(_, pool)| pool.accept(size))
1168        {
1169            if let Some(slice) = pool.try_reserve(size) {
1170                return Ok(slice);
1171            }
1172
1173            match pool.alloc(&mut self.storage, size, mapping) {
1174                Ok(handle) => {
1175                    reserved = Some(handle);
1176                    break;
1177                }
1178                Err(err @ IoError::PoolCapacityExceeded { .. }) => {
1179                    // Loud on purpose: a spill means the cap was under-planned
1180                    // (e.g. a workload the dry run never measured), and the
1181                    // escape hatch serving it must not hide that. Once per pool
1182                    // per layout, though — a workload that runs above its cap
1183                    // spills on *every* reservation, and a warning per
1184                    // allocation buries the one that mattered. `install_pools`
1185                    // clears the latch: a new layout is a new plan to judge.
1186                    if self.capacity_warned.insert(index) {
1187                        log::warn!(
1188                            "[{}] memory pool {index} is at capacity (first hit at an \
1189                             allocation of {size} B); spilling to the next accepting pool. \
1190                             The measured plan is short for this workload.",
1191                            self.name
1192                        );
1193                    }
1194                    capacity_exceeded = Some(err);
1195                }
1196                // A spill already in hand is the more useful diagnosis: it says
1197                // the plan was short, where this one only says the pool behind
1198                // it also failed.
1199                Err(err) => return Err(capacity_exceeded.unwrap_or(err)),
1200            }
1201        }
1202
1203        let Some(reserved) = reserved else {
1204            return Err(capacity_exceeded.unwrap_or_else(|| IoError::BufferTooBig {
1205                size,
1206                backtrace: BackTrace::capture(),
1207            }));
1208        };
1209
1210        self.logger.log_memory(
1211            |level| matches!(level, MemoryLogLevel::Full),
1212            || {
1213                format!(
1214                    "[{}], Allocated a new memory page, current usage: \n{}",
1215                    self.name, self
1216                )
1217            },
1218        );
1219
1220        Ok(reserved)
1221    }
1222
1223    /// Fetch the storage used by the memory manager.
1224    ///
1225    /// # Notes
1226    ///
1227    /// The storage should probably not be used for allocations since the handles won't be
1228    /// compatible with the ones provided by the current trait. Prefer using the
1229    /// [alloc](ComputeStorage::alloc) and [dealloc](ComputeStorage::dealloc) functions.
1230    ///
1231    /// This is useful if you need to time the deallocations based on async computation, or to
1232    /// change the mode of storage for different reasons.
1233    pub fn storage(&mut self) -> &mut Storage {
1234        &mut self.storage
1235    }
1236
1237    /// Get the current memory usage.
1238    pub fn memory_usage(&self) -> MemoryUsage {
1239        let memory_usage = self.pools.iter().map(|x| x.get_memory_usage()).fold(
1240            MemoryUsage {
1241                number_allocs: 0,
1242                bytes_in_use: 0,
1243                bytes_padding: 0,
1244                bytes_reserved: 0,
1245            },
1246            |m1, m2| m1.combine(m2),
1247        );
1248        memory_usage.combine(self.persistent.get_memory_usage())
1249    }
1250
1251    /// A structured per-pool report: each pool's shape, usage, and high-water
1252    /// marks, in allocation-routing order.
1253    ///
1254    /// The read side of a measured memory plan — the cycle, and what the
1255    /// marks cover, is on [`MemoryReport`].
1256    pub fn memory_report(&self) -> MemoryReport {
1257        MemoryReport {
1258            dynamic: self.pools.iter().map(|pool| pool.report()).collect(),
1259            persistent: self.persistent.report(),
1260        }
1261    }
1262
1263    /// Print out a report of the current memory usage.
1264    pub fn print_memory_usage(&self) {
1265        #[cfg(feature = "std")]
1266        log::info!("{}", self.memory_usage());
1267    }
1268
1269    /// Binds the given [handle](HandleId) to a [`MemorySlot`].
1270    pub fn bind(
1271        &mut self,
1272        reserved: ManagedMemoryHandle,
1273        assigned: ManagedMemoryHandle,
1274        cursor: u64,
1275    ) -> Result<(), IoError> {
1276        let descriptor = reserved.descriptor();
1277
1278        if descriptor.location().init == 0 {
1279            return Err(IoError::NotFound {
1280                backtrace: BackTrace::capture(),
1281                reason: "Reserved memory isn't initialized".into(),
1282            });
1283        }
1284
1285        let pool_index = descriptor.location().pool as usize;
1286        if pool_index == PERSISTENT_POOL_POS as usize {
1287            // `bind` sets the slice's final identity to `assigned` (replacing the
1288            // throwaway reserved handle), so this — not the earlier `reserve` — is
1289            // the id a capture must track for a bound persistent buffer.
1290            self.capture_touch(&assigned);
1291            return self.persistent.bind(reserved, assigned, cursor);
1292        }
1293
1294        self.pools
1295            .get_mut(pool_index)
1296            .map(|p| p.bind(reserved, assigned, cursor))
1297            .ok_or_else(|| IoError::NotFound {
1298                backtrace: BackTrace::capture(),
1299                reason: format!("Memory pool {} doesn't exist", pool_index).into(),
1300            })?
1301    }
1302}
1303
1304impl<Storage: ComputeStorage> core::fmt::Display for MemoryManagement<Storage> {
1305    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1306        f.write_str("\n# MemoryManagement\n\n")?;
1307        f.write_fmt(format_args!(" - name: {:?}\n", self.name))?;
1308        f.write_fmt(format_args!("\n## Persistent\n\n{}", self.persistent))?;
1309        f.write_str("\n## Dynamic\n\n")?;
1310
1311        for pool in self.pools.iter() {
1312            f.write_fmt(format_args!("{pool}\n"))?;
1313        }
1314        let memory_usage = self.memory_usage();
1315        f.write_fmt(format_args!("\n## Summary\n\n{memory_usage}"))?;
1316
1317        Ok(())
1318    }
1319}
1320
1321impl<Storage> core::fmt::Debug for MemoryManagement<Storage> {
1322    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1323        f.write_str(
1324            alloc::format!(
1325                "DynamicMemoryManagement {:?}",
1326                core::any::type_name::<Storage>(),
1327            )
1328            .as_str(),
1329        )
1330    }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335    use super::*;
1336    use crate::{memory_management::MemoryManagement, storage::BytesStorage};
1337    use alloc::vec;
1338
1339    const DUMMY_MEM_PROPS: MemoryDeviceProperties = MemoryDeviceProperties {
1340        max_page_size: 128 * 1024 * 1024,
1341        alignment: 32,
1342    };
1343
1344    fn options() -> MemoryManagementOptions {
1345        MemoryManagementOptions {
1346            name: "test".into(),
1347            memory: MemoryAllocationOption::FromConfig,
1348        }
1349    }
1350
1351    // Test pools with slices.
1352    #[test_log::test]
1353    #[cfg(not(exclusive_memory_only))]
1354    fn test_handle_mutability() {
1355        let mut memory_management = MemoryManagement::from_configuration(
1356            BytesStorage::default(),
1357            &DUMMY_MEM_PROPS,
1358            MemoryConfiguration::SubSlices,
1359            Arc::new(ServerLogger::default()),
1360            options(),
1361        );
1362        let handle = memory_management.reserve(10).unwrap();
1363        let other_ref = handle.clone();
1364        assert!(!handle.can_mut(), "Handle can't be mut when multiple ref.");
1365        drop(other_ref);
1366        assert!(handle.can_mut(), "Handle should be mut when only one ref.");
1367    }
1368
1369    // Test pools with slices.
1370    #[test_log::test]
1371    #[cfg(not(exclusive_memory_only))]
1372    fn test_memory_usage() {
1373        let max_page_size = 512;
1374
1375        let mut memory_management = MemoryManagement::from_configuration(
1376            BytesStorage::default(),
1377            &DUMMY_MEM_PROPS,
1378            MemoryConfiguration::Custom {
1379                pool_options: vec![MemoryPoolOptions {
1380                    pool_type: PoolType::ExclusivePages {
1381                        max_alloc_size: max_page_size,
1382                    },
1383                    dealloc_period: None,
1384                }],
1385            },
1386            Arc::new(ServerLogger::default()),
1387            options(),
1388        );
1389        let handle = memory_management.reserve(100);
1390        let usage = memory_management.memory_usage();
1391
1392        assert_eq!(usage.bytes_in_use, 100);
1393        assert!(usage.bytes_reserved >= 100 && usage.bytes_reserved <= max_page_size);
1394
1395        // Drop and re-alloc.
1396        drop(handle);
1397        let _handle = memory_management.reserve(100);
1398        let usage_new = memory_management.memory_usage();
1399        assert_eq!(usage, usage_new);
1400    }
1401
1402    #[test_log::test]
1403    fn find_uninit_binding_returns_not_found() {
1404        let mut memory_management = MemoryManagement::from_configuration(
1405            BytesStorage::default(),
1406            &DUMMY_MEM_PROPS,
1407            MemoryConfiguration::Custom {
1408                pool_options: vec![MemoryPoolOptions {
1409                    pool_type: PoolType::SlicedPages {
1410                        page_size: 2048,
1411                        max_slice_size: 2048,
1412                        max_pool_size: None,
1413                    },
1414                    dealloc_period: None,
1415                }],
1416            },
1417            Arc::new(ServerLogger::default()),
1418            options(),
1419        );
1420
1421        // Even with a live page at index 0, a never-initialized descriptor must
1422        // not resolve to it.
1423        let _live = memory_management.reserve(512).unwrap();
1424
1425        let binding = ManagedMemoryHandle::new().binding();
1426        assert!(matches!(
1427            memory_management.get_cursor(binding),
1428            Err(IoError::NotFound { .. })
1429        ));
1430    }
1431
1432    #[test_log::test]
1433    fn find_stale_descriptor_returns_not_found() {
1434        let mut memory_management = MemoryManagement::from_configuration(
1435            BytesStorage::default(),
1436            &DUMMY_MEM_PROPS,
1437            MemoryConfiguration::Custom {
1438                pool_options: vec![MemoryPoolOptions {
1439                    pool_type: PoolType::SlicedPages {
1440                        page_size: 2048,
1441                        max_slice_size: 2048,
1442                        max_pool_size: None,
1443                    },
1444                    dealloc_period: None,
1445                }],
1446            },
1447            Arc::new(ServerLogger::default()),
1448            options(),
1449        );
1450
1451        let reserved = memory_management.reserve(512).unwrap();
1452        let stale = reserved.clone();
1453        let assigned = ManagedMemoryHandle::new();
1454        let assigned_binding = assigned.clone().binding();
1455
1456        memory_management.bind(reserved, assigned, 0).unwrap();
1457
1458        // The slice's identity is now `assigned`; the stale reserved descriptor
1459        // must surface as `NotFound`, not as the new allocation's data.
1460        assert!(matches!(
1461            memory_management.get_cursor(stale.binding()),
1462            Err(IoError::NotFound { .. })
1463        ));
1464        assert!(memory_management.get_cursor(assigned_binding).is_ok());
1465    }
1466
1467    #[test_log::test]
1468    fn held_binding_survives_explicit_cleanup_renumber() {
1469        let mut memory_management = MemoryManagement::from_configuration(
1470            BytesStorage::default(),
1471            &DUMMY_MEM_PROPS,
1472            MemoryConfiguration::Custom {
1473                pool_options: vec![MemoryPoolOptions {
1474                    pool_type: PoolType::ExclusivePages {
1475                        max_alloc_size: 1024,
1476                    },
1477                    dealloc_period: None,
1478                }],
1479            },
1480            Arc::new(ServerLogger::default()),
1481            options(),
1482        );
1483
1484        let handle_a = memory_management.reserve(1024).unwrap();
1485        let handle_b = memory_management.reserve(1024).unwrap();
1486        let handle_c = memory_management.reserve(1024).unwrap();
1487
1488        let binding_b = handle_b.binding();
1489        drop(handle_a);
1490        drop(handle_c);
1491
1492        // Deallocates the two free pages and renumbers the surviving one.
1493        memory_management.cleanup(true);
1494
1495        assert!(memory_management.get_cursor(binding_b.clone()).is_ok());
1496        assert!(memory_management.get_storage(binding_b).is_ok());
1497        assert_eq!(memory_management.memory_usage().bytes_reserved, 1024);
1498    }
1499
1500    fn capped_sliced_config(page_size: u64, max_pool_size: Option<u64>) -> MemoryConfiguration {
1501        MemoryConfiguration::Custom {
1502            pool_options: vec![MemoryPoolOptions {
1503                pool_type: PoolType::SlicedPages {
1504                    page_size,
1505                    max_slice_size: page_size,
1506                    max_pool_size,
1507                },
1508                dealloc_period: None,
1509            }],
1510        }
1511    }
1512
1513    #[test_log::test]
1514    fn capped_sliced_pool_errors_instead_of_growing() {
1515        let mut memory_management = MemoryManagement::from_configuration(
1516            BytesStorage::default(),
1517            &DUMMY_MEM_PROPS,
1518            capped_sliced_config(1024, Some(2048)),
1519            Arc::new(ServerLogger::default()),
1520            options(),
1521        );
1522
1523        let _a = memory_management.reserve(1024).unwrap();
1524        let _b = memory_management.reserve(1024).unwrap();
1525
1526        let result = memory_management.reserve(1024);
1527        assert!(matches!(
1528            result,
1529            Err(IoError::PoolCapacityExceeded { capacity: 2048, .. })
1530        ));
1531        assert_eq!(
1532            memory_management.memory_usage().bytes_reserved,
1533            2048,
1534            "a failed reservation must not grow the pool"
1535        );
1536    }
1537
1538    #[test_log::test]
1539    fn capped_sliced_pool_reuses_freed_memory() {
1540        let mut memory_management = MemoryManagement::from_configuration(
1541            BytesStorage::default(),
1542            &DUMMY_MEM_PROPS,
1543            capped_sliced_config(1024, Some(2048)),
1544            Arc::new(ServerLogger::default()),
1545            options(),
1546        );
1547
1548        let handle_a = memory_management.reserve(1024).unwrap();
1549        let _b = memory_management.reserve(1024).unwrap();
1550        drop(handle_a);
1551
1552        // The capacity error is transient: freeing makes the reservation fit
1553        // again without growing the pool.
1554        let _c = memory_management.reserve(1024).unwrap();
1555        assert_eq!(memory_management.memory_usage().bytes_reserved, 2048);
1556    }
1557
1558    #[test_log::test]
1559    fn capped_lazy_pool_cleanup_still_frees() {
1560        let mut memory_management = MemoryManagement::from_configuration(
1561            BytesStorage::default(),
1562            &DUMMY_MEM_PROPS,
1563            capped_sliced_config(1024, Some(2048)),
1564            Arc::new(ServerLogger::default()),
1565            options(),
1566        );
1567
1568        let handle_a = memory_management.reserve(1024).unwrap();
1569        let handle_b = memory_management.reserve(1024).unwrap();
1570        drop(handle_a);
1571        drop(handle_b);
1572        memory_management.cleanup(true);
1573        assert_eq!(memory_management.memory_usage().bytes_reserved, 0);
1574
1575        // The cap is still enforced after the pool shrank and regrew.
1576        let _a = memory_management.reserve(1024).unwrap();
1577        let _b = memory_management.reserve(1024).unwrap();
1578        assert!(matches!(
1579            memory_management.reserve(1024),
1580            Err(IoError::PoolCapacityExceeded { .. })
1581        ));
1582    }
1583
1584    #[test_log::test]
1585    fn max_pool_size_smaller_than_page_shrinks_page() {
1586        let mut memory_management = MemoryManagement::from_configuration(
1587            BytesStorage::default(),
1588            &DUMMY_MEM_PROPS,
1589            capped_sliced_config(2048, Some(512)),
1590            Arc::new(ServerLogger::default()),
1591            options(),
1592        );
1593
1594        let _small = memory_management.reserve(256).unwrap();
1595        assert!(memory_management.memory_usage().bytes_reserved <= 512);
1596
1597        // Larger than the (shrunk) page: rejected without growing the footprint.
1598        assert!(memory_management.reserve(1024).is_err());
1599        assert!(memory_management.memory_usage().bytes_reserved <= 512);
1600    }
1601
1602    #[test_log::test]
1603    fn max_pool_size_below_alignment_never_overshoots() {
1604        // A cap below the device alignment (32 in `DUMMY_MEM_PROPS`) can't fit
1605        // even the smallest page, so every reservation must error rather than
1606        // exceed the budget.
1607        let mut memory_management = MemoryManagement::from_configuration(
1608            BytesStorage::default(),
1609            &DUMMY_MEM_PROPS,
1610            capped_sliced_config(1024, Some(16)),
1611            Arc::new(ServerLogger::default()),
1612            options(),
1613        );
1614
1615        assert!(matches!(
1616            memory_management.reserve(8),
1617            Err(IoError::PoolCapacityExceeded { .. })
1618        ));
1619        assert_eq!(memory_management.memory_usage().bytes_reserved, 0);
1620    }
1621
1622    /// Persistent windows nest: a module load arms one window around the whole
1623    /// load while the parameter machinery arms one per parameter inside it —
1624    /// an inner window closing must not flip the rest of the outer one back to
1625    /// `Auto`, or most of the load's weights land in the dynamic pools (and
1626    /// block every later `install_pools`).
1627    #[test_log::test]
1628    fn persistent_windows_nest() {
1629        let mut memory_management = MemoryManagement::from_configuration(
1630            BytesStorage::default(),
1631            &DUMMY_MEM_PROPS,
1632            MemoryConfiguration::Custom {
1633                pool_options: vec![MemoryPoolOptions {
1634                    pool_type: PoolType::SlicedPages {
1635                        page_size: 4096,
1636                        max_slice_size: 4096,
1637                        max_pool_size: None,
1638                    },
1639                    dealloc_period: None,
1640                }],
1641            },
1642            Arc::new(ServerLogger::default()),
1643            options(),
1644        );
1645
1646        memory_management.mode(MemoryAllocationMode::Persistent); // the load's window
1647        memory_management.mode(MemoryAllocationMode::Persistent); // one parameter's window
1648        memory_management.mode(MemoryAllocationMode::Auto); // that parameter is done
1649
1650        // Still inside the load's window: the allocation must be persistent.
1651        let weight = memory_management.reserve(1024).unwrap();
1652        let report = memory_management.memory_report();
1653        assert_eq!(
1654            report.persistent.usage.bytes_in_use, 1024,
1655            "an allocation inside the outer window is persistent"
1656        );
1657        assert_eq!(
1658            report.dynamic[0].pages_peak, 0,
1659            "nothing leaked into the dynamic pools"
1660        );
1661
1662        // The outer window closes; ordinary allocations are dynamic again.
1663        memory_management.mode(MemoryAllocationMode::Auto);
1664        let _transient = memory_management.reserve(1024).unwrap();
1665        assert_eq!(memory_management.memory_report().dynamic[0].pages_peak, 1);
1666
1667        drop(weight);
1668    }
1669
1670    /// A full capped pool spills to the next accepting pool — with a warning,
1671    /// never silently — instead of failing the allocation.
1672    ///
1673    /// The cap is a measured plan, and a short plan should cost memory rather
1674    /// than kill the workload. When no later pool accepts the size the
1675    /// capacity error still surfaces (see
1676    /// `capped_sliced_pool_errors_instead_of_growing`), which is what keeps
1677    /// the budget-vs-device-OOM distinction schedulers rely on.
1678    #[test_log::test]
1679    fn capacity_overflow_falls_through_to_later_accepting_pool() {
1680        let mut memory_management = MemoryManagement::from_configuration(
1681            BytesStorage::default(),
1682            &DUMMY_MEM_PROPS,
1683            MemoryConfiguration::Custom {
1684                pool_options: vec![
1685                    MemoryPoolOptions {
1686                        pool_type: PoolType::SlicedPages {
1687                            page_size: 1024,
1688                            max_slice_size: 1024,
1689                            max_pool_size: Some(1024),
1690                        },
1691                        dealloc_period: None,
1692                    },
1693                    MemoryPoolOptions {
1694                        pool_type: PoolType::SlicedPages {
1695                            page_size: 1024,
1696                            max_slice_size: 1024,
1697                            max_pool_size: None,
1698                        },
1699                        dealloc_period: None,
1700                    },
1701                ],
1702            },
1703            Arc::new(ServerLogger::default()),
1704            options(),
1705        );
1706
1707        let _fill = memory_management.reserve(1024).unwrap();
1708        let _overflow = memory_management.reserve(1024).unwrap();
1709        assert_eq!(
1710            memory_management.memory_usage().bytes_reserved,
1711            2048,
1712            "the overflow landed in the later pool, not a second arena page"
1713        );
1714
1715        let report = memory_management.memory_report();
1716        assert_eq!(report.dynamic[0].pages_peak, 1, "the cap held");
1717        assert_eq!(report.dynamic[1].pages_peak, 1, "the tail caught the spill");
1718    }
1719
1720    #[test_log::test]
1721    fn alloc_two_chunks_on_one_page() {
1722        let page_size = 2048;
1723
1724        let mut memory_management = MemoryManagement::from_configuration(
1725            BytesStorage::default(),
1726            &DUMMY_MEM_PROPS,
1727            MemoryConfiguration::Custom {
1728                pool_options: vec![MemoryPoolOptions {
1729                    pool_type: PoolType::SlicedPages {
1730                        page_size,
1731                        max_slice_size: page_size,
1732                        max_pool_size: None,
1733                    },
1734                    dealloc_period: None,
1735                }],
1736            },
1737            Arc::new(ServerLogger::default()),
1738            options(),
1739        );
1740
1741        let alloc_size = 512;
1742        let _handle = memory_management.reserve(alloc_size);
1743        let _new_handle = memory_management.reserve(alloc_size);
1744
1745        let usage = memory_management.memory_usage();
1746        assert_eq!(usage.number_allocs, 2);
1747        assert_eq!(usage.bytes_in_use, alloc_size * 2);
1748        assert_eq!(usage.bytes_reserved, page_size);
1749    }
1750
1751    #[test_log::test]
1752    fn alloc_reuses_storage() {
1753        // If no storage is re-used, this will allocate two pages.
1754        let page_size = 512;
1755
1756        let mut memory_management = MemoryManagement::from_configuration(
1757            BytesStorage::default(),
1758            &DUMMY_MEM_PROPS,
1759            MemoryConfiguration::Custom {
1760                pool_options: vec![MemoryPoolOptions {
1761                    pool_type: PoolType::SlicedPages {
1762                        page_size,
1763                        max_slice_size: page_size,
1764                        max_pool_size: None,
1765                    },
1766                    dealloc_period: None,
1767                }],
1768            },
1769            Arc::new(ServerLogger::default()),
1770            options(),
1771        );
1772
1773        let alloc_size = 512;
1774        let _handle = memory_management.reserve(alloc_size);
1775        drop(_handle);
1776        let _new_handle = memory_management.reserve(alloc_size);
1777
1778        let usage = memory_management.memory_usage();
1779        assert_eq!(usage.number_allocs, 1);
1780        assert_eq!(usage.bytes_in_use, alloc_size);
1781        assert_eq!(usage.bytes_reserved, page_size);
1782    }
1783
1784    #[test_log::test]
1785    fn alloc_allocs_new_storage() {
1786        let page_size = 1024;
1787
1788        let mut memory_management = MemoryManagement::from_configuration(
1789            BytesStorage::default(),
1790            &DUMMY_MEM_PROPS,
1791            MemoryConfiguration::Custom {
1792                pool_options: vec![MemoryPoolOptions {
1793                    pool_type: PoolType::SlicedPages {
1794                        page_size,
1795                        max_slice_size: page_size,
1796                        max_pool_size: None,
1797                    },
1798                    dealloc_period: None,
1799                }],
1800            },
1801            Arc::new(ServerLogger::default()),
1802            options(),
1803        );
1804
1805        let alloc_size = 768;
1806        let _handle = memory_management.reserve(alloc_size);
1807        let _new_handle = memory_management.reserve(alloc_size);
1808
1809        let usage = memory_management.memory_usage();
1810        assert_eq!(usage.number_allocs, 2);
1811        assert_eq!(usage.bytes_in_use, alloc_size * 2);
1812        assert_eq!(usage.bytes_reserved, page_size * 2);
1813    }
1814
1815    #[test_log::test]
1816    fn alloc_respects_alignment_size() {
1817        let page_size = 500;
1818        let mut memory_management = MemoryManagement::from_configuration(
1819            BytesStorage::default(),
1820            &MemoryDeviceProperties {
1821                max_page_size: page_size,
1822                alignment: 50,
1823            },
1824            MemoryConfiguration::Custom {
1825                pool_options: vec![MemoryPoolOptions {
1826                    pool_type: PoolType::SlicedPages {
1827                        page_size,
1828                        max_slice_size: page_size,
1829                        max_pool_size: None,
1830                    },
1831                    dealloc_period: None,
1832                }],
1833            },
1834            Arc::new(ServerLogger::default()),
1835            options(),
1836        );
1837        let alloc_size = 40;
1838        let _handle = memory_management.reserve(alloc_size);
1839        let _new_handle = memory_management.reserve(alloc_size);
1840        let usage = memory_management.memory_usage();
1841        // Each slice should be aligned to 50 bytes, so 20 padding bytes.
1842        assert_eq!(usage.bytes_padding, 10 * 2);
1843    }
1844
1845    #[test_log::test]
1846    fn allocs_on_correct_page() {
1847        let sizes = [100, 200, 300, 400];
1848
1849        let pools = sizes
1850            .iter()
1851            .map(|size| MemoryPoolOptions {
1852                pool_type: PoolType::SlicedPages {
1853                    page_size: *size,
1854                    max_slice_size: *size,
1855                    max_pool_size: None,
1856                },
1857                dealloc_period: None,
1858            })
1859            .collect();
1860        let mut memory_management = MemoryManagement::from_configuration(
1861            BytesStorage::default(),
1862            &MemoryDeviceProperties {
1863                max_page_size: 128 * 1024 * 1024,
1864                alignment: 10,
1865            },
1866            MemoryConfiguration::Custom {
1867                pool_options: pools,
1868            },
1869            Arc::new(ServerLogger::default()),
1870            options(),
1871        );
1872        // Allocate one thing on each page.
1873        let alloc_sizes = [50, 150, 250, 350];
1874        let _handles = alloc_sizes.map(|s| memory_management.reserve(s));
1875
1876        let usage = memory_management.memory_usage();
1877
1878        // Total memory should be size of all pages, and no more.
1879        assert_eq!(usage.bytes_in_use, alloc_sizes.iter().sum::<u64>());
1880        assert!(usage.bytes_reserved >= sizes.iter().sum::<u64>());
1881    }
1882
1883    #[test_log::test]
1884    fn resolve_absent_pools_config_keeps_runtime_choice() {
1885        #[cfg(not(exclusive_memory_only))]
1886        assert!(matches!(
1887            MemoryConfiguration::SubSlices
1888                .resolve(None, &DUMMY_MEM_PROPS)
1889                .unwrap(),
1890            MemoryConfiguration::SubSlices
1891        ));
1892        assert!(matches!(
1893            MemoryConfiguration::ExclusivePages
1894                .resolve(None, &DUMMY_MEM_PROPS)
1895                .unwrap(),
1896            MemoryConfiguration::ExclusivePages
1897        ));
1898    }
1899
1900    #[test_log::test]
1901    fn resolve_preset_overrides_runtime_choice() {
1902        let preset = MemoryPoolsConfig::Preset(MemoryPoolsPreset::ExclusivePages);
1903        #[cfg(not(exclusive_memory_only))]
1904        let base = MemoryConfiguration::SubSlices;
1905        #[cfg(exclusive_memory_only)]
1906        let base = MemoryConfiguration::ExclusivePages;
1907
1908        assert!(matches!(
1909            base.resolve(Some(&preset), &DUMMY_MEM_PROPS).unwrap(),
1910            MemoryConfiguration::ExclusivePages
1911        ));
1912    }
1913
1914    #[test_log::test]
1915    #[cfg(exclusive_memory_only)]
1916    fn resolve_rejects_sliced_pools_when_exclusive_only() {
1917        use crate::config::size::MemorySize;
1918
1919        let pools = MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
1920            page_size: MemorySize(1024),
1921            max_slice_size: None,
1922            max_pool_size: None,
1923            dealloc_period: None,
1924        }]);
1925        assert_eq!(
1926            MemoryConfiguration::default()
1927                .resolve(Some(&pools), &DUMMY_MEM_PROPS)
1928                .unwrap_err(),
1929            PoolConfigError::SlicedPoolsUnavailable
1930        );
1931    }
1932
1933    #[test_log::test]
1934    #[cfg(not(exclusive_memory_only))]
1935    fn resolve_explicit_list_aligns_and_defaults() {
1936        use crate::config::size::MemorySize;
1937
1938        let pools = MemoryPoolsConfig::Explicit(vec![
1939            MemoryPoolConfig::Exclusive {
1940                max_alloc_size: MemorySize(8 * 1024),
1941                dealloc_period: Some(10000),
1942            },
1943            MemoryPoolConfig::Sliced {
1944                // Rounded up to the 32-byte alignment.
1945                page_size: MemorySize(1000),
1946                max_slice_size: None,
1947                max_pool_size: Some(MemorySize(4096)),
1948                dealloc_period: None,
1949            },
1950        ]);
1951
1952        let resolved = MemoryConfiguration::default()
1953            .resolve(Some(&pools), &DUMMY_MEM_PROPS)
1954            .unwrap();
1955        let MemoryConfiguration::Custom { pool_options } = resolved else {
1956            panic!("expected a custom configuration");
1957        };
1958
1959        assert_eq!(pool_options.len(), 2);
1960        assert!(matches!(
1961            pool_options[0].pool_type,
1962            PoolType::ExclusivePages {
1963                max_alloc_size: 8192
1964            }
1965        ));
1966        assert_eq!(pool_options[0].dealloc_period, Some(10000));
1967        assert!(matches!(
1968            pool_options[1].pool_type,
1969            PoolType::SlicedPages {
1970                page_size: 1024,
1971                // Defaults to the aligned page size.
1972                max_slice_size: 1024,
1973                max_pool_size: Some(4096),
1974            }
1975        ));
1976    }
1977
1978    #[test_log::test]
1979    #[cfg(not(exclusive_memory_only))]
1980    fn resolve_invalid_pool_configs_fail() {
1981        use crate::config::size::MemorySize;
1982
1983        let cases = [
1984            (
1985                MemoryPoolsConfig::Explicit(vec![]),
1986                PoolConfigError::EmptyPoolList,
1987            ),
1988            (
1989                MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
1990                    page_size: MemorySize(0),
1991                    max_slice_size: None,
1992                    max_pool_size: None,
1993                    dealloc_period: None,
1994                }]),
1995                PoolConfigError::ZeroSize { field: "page_size" },
1996            ),
1997            (
1998                MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
1999                    page_size: MemorySize(1024),
2000                    max_slice_size: Some(MemorySize(2048)),
2001                    max_pool_size: None,
2002                    dealloc_period: None,
2003                }]),
2004                PoolConfigError::SliceLargerThanPage {
2005                    page_size: 1024,
2006                    max_slice_size: 2048,
2007                },
2008            ),
2009            (
2010                MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
2011                    page_size: MemorySize(2048),
2012                    max_slice_size: None,
2013                    max_pool_size: Some(MemorySize(1024)),
2014                    dealloc_period: None,
2015                }]),
2016                PoolConfigError::CapSmallerThanPage {
2017                    page_size: 2048,
2018                    max_pool_size: 1024,
2019                },
2020            ),
2021            (
2022                MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
2023                    page_size: MemorySize(1024),
2024                    max_slice_size: None,
2025                    // 2^26 pages of 1 KiB: far beyond the u16 page index.
2026                    max_pool_size: Some(MemorySize(64 * 1024 * 1024 * 1024)),
2027                    dealloc_period: None,
2028                }]),
2029                PoolConfigError::TooManyPages {
2030                    pages: 64 * 1024 * 1024,
2031                },
2032            ),
2033            (
2034                MemoryPoolsConfig::Explicit(vec![
2035                    MemoryPoolConfig::Exclusive {
2036                        max_alloc_size: MemorySize(1024),
2037                        dealloc_period: None,
2038                    };
2039                    PERSISTENT_POOL_POS as usize
2040                ]),
2041                PoolConfigError::TooManyPools {
2042                    count: PERSISTENT_POOL_POS as usize,
2043                },
2044            ),
2045        ];
2046
2047        for (pools, expected) in cases {
2048            let result = MemoryConfiguration::default().resolve(Some(&pools), &DUMMY_MEM_PROPS);
2049            assert_eq!(result.unwrap_err(), expected);
2050        }
2051    }
2052
2053    // The motivating use case: allocations from different "sequence-length
2054    // ranges" reuse the same arena instead of each landing in its own
2055    // size-bucketed pool that keeps a separate reservation.
2056    #[test_log::test]
2057    #[cfg(not(exclusive_memory_only))]
2058    fn resolved_single_arena_reuses_across_sizes() {
2059        use crate::config::size::MemorySize;
2060
2061        let page = 1024 * 1024; // 1 MiB arena.
2062        let pools = MemoryPoolsConfig::Explicit(vec![MemoryPoolConfig::Sliced {
2063            page_size: MemorySize(page),
2064            max_slice_size: None,
2065            max_pool_size: None,
2066            dealloc_period: None,
2067        }]);
2068        let config = MemoryConfiguration::default()
2069            .resolve(Some(&pools), &DUMMY_MEM_PROPS)
2070            .unwrap();
2071
2072        let mut memory_management = MemoryManagement::from_configuration(
2073            BytesStorage::default(),
2074            &DUMMY_MEM_PROPS,
2075            config,
2076            Arc::new(ServerLogger::default()),
2077            options(),
2078        );
2079
2080        // A "small seq" allocation, then freed.
2081        let small = memory_management.reserve(4 * 1024).unwrap();
2082        drop(small);
2083        // A "large seq" allocation must reuse the same arena page.
2084        let large = memory_management.reserve(512 * 1024).unwrap();
2085
2086        let usage = memory_management.memory_usage();
2087        assert_eq!(
2088            usage.bytes_reserved, page,
2089            "both sizes must share a single arena page"
2090        );
2091        assert_eq!(usage.number_allocs, 1);
2092        drop(large);
2093    }
2094
2095    #[test_log::test]
2096    #[cfg(not(exclusive_memory_only))]
2097    fn allocate_deallocate_reallocate() {
2098        let mut memory_management = MemoryManagement::from_configuration(
2099            BytesStorage::default(),
2100            &MemoryDeviceProperties {
2101                max_page_size: 128 * 1024 * 1024,
2102                alignment: 32,
2103            },
2104            MemoryConfiguration::SubSlices,
2105            Arc::new(ServerLogger::default()),
2106            options(),
2107        );
2108        // Allocate a bunch
2109        let handles: Vec<_> = (0..5)
2110            .map(|i| memory_management.reserve(1000 * (i + 1)))
2111            .collect();
2112        let usage_before = memory_management.memory_usage();
2113        // Deallocate
2114        drop(handles);
2115        // Reallocate
2116        let _new_handles: Vec<_> = (0..5)
2117            .map(|i| memory_management.reserve(1000 * (i + 1)))
2118            .collect();
2119        let usage_after = memory_management.memory_usage();
2120        assert_eq!(usage_before.number_allocs, usage_after.number_allocs);
2121        assert_eq!(usage_before.bytes_in_use, usage_after.bytes_in_use);
2122        // Usage after can actually be _less_ because of defragging.
2123        assert!(usage_before.bytes_reserved >= usage_after.bytes_reserved);
2124    }
2125
2126    #[test_log::test]
2127    #[cfg(not(exclusive_memory_only))]
2128    fn test_fragmentation_resistance() {
2129        let mut memory_management = MemoryManagement::from_configuration(
2130            BytesStorage::default(),
2131            &MemoryDeviceProperties {
2132                max_page_size: 128 * 1024 * 1024,
2133                alignment: 32,
2134            },
2135            MemoryConfiguration::SubSlices,
2136            Arc::new(ServerLogger::default()),
2137            options(),
2138        );
2139        // Allocate a mix of small and large chunks
2140        let sizes = [50, 1000, 100, 5000, 200, 10000, 300];
2141        let handles: Vec<_> = sizes
2142            .iter()
2143            .map(|&size| memory_management.reserve(size).unwrap())
2144            .collect();
2145        let usage_before = memory_management.memory_usage();
2146        // Deallocate every other allocation
2147        for i in (0..handles.len()).step_by(2) {
2148            drop(handles[i].clone());
2149        }
2150        // Reallocate similar sizes
2151        for &size in &sizes[0..sizes.len() / 2] {
2152            memory_management.reserve(size).unwrap();
2153        }
2154        let usage_after = memory_management.memory_usage();
2155        // Check that we haven't increased our memory usage significantly
2156        assert!(usage_after.bytes_reserved <= (usage_before.bytes_reserved as f64 * 1.1) as u64);
2157    }
2158
2159    // Test pools without slices. More or less same as tests above.
2160    #[test_log::test]
2161    fn noslice_test_handle_mutability() {
2162        let mut memory_management = MemoryManagement::from_configuration(
2163            BytesStorage::default(),
2164            &(MemoryDeviceProperties {
2165                max_page_size: 128 * 1024 * 1024,
2166                alignment: 32,
2167            }),
2168            MemoryConfiguration::ExclusivePages,
2169            Arc::new(ServerLogger::default()),
2170            options(),
2171        );
2172        let handle = memory_management.reserve(10).unwrap();
2173        let other_ref = handle.clone();
2174        assert!(!handle.can_mut(), "Handle can't be mut when multiple ref.");
2175        drop(other_ref);
2176        assert!(handle.can_mut(), "Handle should be mut when only one ref.");
2177    }
2178
2179    #[test_log::test]
2180    fn noslice_alloc_two_chunk() {
2181        let mut memory_management = MemoryManagement::from_configuration(
2182            BytesStorage::default(),
2183            &DUMMY_MEM_PROPS,
2184            MemoryConfiguration::Custom {
2185                pool_options: vec![MemoryPoolOptions {
2186                    pool_type: PoolType::ExclusivePages {
2187                        max_alloc_size: 1024,
2188                    },
2189                    dealloc_period: None,
2190                }],
2191            },
2192            Arc::new(ServerLogger::default()),
2193            options(),
2194        );
2195
2196        let alloc_size = 512;
2197        let _handle = memory_management.reserve(alloc_size);
2198        let _new_handle = memory_management.reserve(alloc_size);
2199
2200        let usage = memory_management.memory_usage();
2201        assert_eq!(usage.number_allocs, 2);
2202        assert_eq!(usage.bytes_in_use, alloc_size * 2);
2203        assert!(usage.bytes_reserved >= alloc_size * 2);
2204    }
2205
2206    #[test_log::test]
2207    fn noslice_alloc_reuses_storage() {
2208        // If no storage is re-used, this will allocate two pages.
2209        let mut memory_management = MemoryManagement::from_configuration(
2210            BytesStorage::default(),
2211            &DUMMY_MEM_PROPS,
2212            MemoryConfiguration::Custom {
2213                pool_options: vec![MemoryPoolOptions {
2214                    pool_type: PoolType::ExclusivePages {
2215                        max_alloc_size: 1024,
2216                    },
2217                    dealloc_period: None,
2218                }],
2219            },
2220            Arc::new(ServerLogger::default()),
2221            options(),
2222        );
2223
2224        let alloc_size = 512;
2225        let _handle = memory_management.reserve(alloc_size);
2226        drop(_handle);
2227        let _new_handle = memory_management.reserve(alloc_size);
2228
2229        let usage = memory_management.memory_usage();
2230        assert_eq!(usage.number_allocs, 1);
2231        assert_eq!(usage.bytes_in_use, alloc_size);
2232        assert!(usage.bytes_reserved >= alloc_size);
2233    }
2234
2235    #[test_log::test]
2236    fn noslice_alloc_allocs_new_storage() {
2237        let mut memory_management = MemoryManagement::from_configuration(
2238            BytesStorage::default(),
2239            &DUMMY_MEM_PROPS,
2240            MemoryConfiguration::Custom {
2241                pool_options: vec![MemoryPoolOptions {
2242                    pool_type: PoolType::ExclusivePages {
2243                        max_alloc_size: 1024,
2244                    },
2245                    dealloc_period: None,
2246                }],
2247            },
2248            Arc::new(ServerLogger::default()),
2249            options(),
2250        );
2251
2252        let alloc_size = 768;
2253        let _handle = memory_management.reserve(alloc_size);
2254        let _new_handle = memory_management.reserve(alloc_size);
2255        let usage = memory_management.memory_usage();
2256        assert_eq!(usage.number_allocs, 2);
2257        assert_eq!(usage.bytes_in_use, alloc_size * 2);
2258        assert!(usage.bytes_reserved >= alloc_size * 2);
2259    }
2260
2261    #[test_log::test]
2262    fn noslice_alloc_respects_alignment_size() {
2263        let mut memory_management = MemoryManagement::from_configuration(
2264            BytesStorage::default(),
2265            &MemoryDeviceProperties {
2266                max_page_size: DUMMY_MEM_PROPS.max_page_size,
2267                alignment: 50,
2268            },
2269            MemoryConfiguration::Custom {
2270                pool_options: vec![MemoryPoolOptions {
2271                    pool_type: PoolType::ExclusivePages {
2272                        max_alloc_size: 50 * 20,
2273                    },
2274                    dealloc_period: None,
2275                }],
2276            },
2277            Arc::new(ServerLogger::default()),
2278            options(),
2279        );
2280        let alloc_size = 40;
2281        let _handle = memory_management.reserve(alloc_size);
2282        let _new_handle = memory_management.reserve(alloc_size);
2283        let usage = memory_management.memory_usage();
2284        // Each slice should be aligned to 60 bytes, so 20 padding bytes.
2285        assert_eq!(usage.bytes_padding, 10 * 2);
2286    }
2287
2288    #[test_log::test]
2289    fn noslice_allocs_on_correct_page() {
2290        let pools = [100, 200, 300, 400]
2291            .iter()
2292            .map(|&size| MemoryPoolOptions {
2293                pool_type: PoolType::SlicedPages {
2294                    page_size: size,
2295                    max_slice_size: size,
2296                    max_pool_size: None,
2297                },
2298                dealloc_period: None,
2299            })
2300            .collect();
2301        let mut memory_management = MemoryManagement::from_configuration(
2302            BytesStorage::default(),
2303            &MemoryDeviceProperties {
2304                max_page_size: DUMMY_MEM_PROPS.max_page_size,
2305                alignment: 10,
2306            },
2307            MemoryConfiguration::Custom {
2308                pool_options: pools,
2309            },
2310            Arc::new(ServerLogger::default()),
2311            options(),
2312        );
2313        // Allocate one thing on each page.
2314        let alloc_sizes = [50, 150, 250, 350];
2315        let _handles = alloc_sizes.map(|s| memory_management.reserve(s));
2316        let usage = memory_management.memory_usage();
2317        // Total memory should be size of all pages, and no more.
2318        assert_eq!(usage.bytes_in_use, alloc_sizes.iter().sum::<u64>());
2319    }
2320
2321    #[test_log::test]
2322    fn capture_pins_reused_persistent_slice() {
2323        let mut memory_management = MemoryManagement::from_configuration(
2324            BytesStorage::default(),
2325            &DUMMY_MEM_PROPS,
2326            MemoryConfiguration::ExclusivePages,
2327            Arc::new(ServerLogger::default()),
2328            options(),
2329        );
2330
2331        // First capture allocates a persistent slice, then everything is freed.
2332        memory_management.capture_begin();
2333        let first = memory_management.reserve(1024).unwrap();
2334        drop(first);
2335        drop(memory_management.capture_end());
2336
2337        // A second capture reuses that now-free slice: the reuse must be pinned
2338        // even though the slice predates the capture.
2339        memory_management.capture_begin();
2340        let second = memory_management.reserve(1024).unwrap();
2341        drop(second);
2342        let pins = memory_management.capture_end();
2343        assert_eq!(pins.len(), 1, "the reused slice must be retained");
2344
2345        // While pinned, the pool must not hand the slice to a later allocation.
2346        let before = memory_management.memory_usage();
2347        let _other = memory_management.reserve(1024).unwrap();
2348        let after = memory_management.memory_usage();
2349        assert!(
2350            after.bytes_reserved > before.bytes_reserved,
2351            "a pinned slice was handed to a later allocation"
2352        );
2353    }
2354
2355    #[test_log::test]
2356    fn capture_pins_preexisting_slice_freed_and_reused_midwindow() {
2357        let mut memory_management = MemoryManagement::from_configuration(
2358            BytesStorage::default(),
2359            &DUMMY_MEM_PROPS,
2360            MemoryConfiguration::ExclusivePages,
2361            Arc::new(ServerLogger::default()),
2362            options(),
2363        );
2364
2365        // A persistent slice that is live (in use) when the next window opens.
2366        memory_management.capture_begin();
2367        let live = memory_management.reserve(1024).unwrap();
2368        drop(memory_management.capture_end()); // release the pin; `live` still holds the slice.
2369
2370        // The window opens with `live`'s slice in use, then frees it mid-window
2371        // and reuses that exact slice for a window allocation the graph records
2372        // against. The old snapshot-of-in-use heuristic excluded it (it was in
2373        // use at begin); reservation-tracking pins it because the window touched
2374        // it — the whole point of the redesign.
2375        memory_management.capture_begin();
2376        drop(live);
2377        let reused = memory_management.reserve(1024).unwrap();
2378        drop(reused);
2379        let pins = memory_management.capture_end();
2380        assert_eq!(
2381            pins.len(),
2382            1,
2383            "a pre-existing slice freed and reused mid-window must be pinned"
2384        );
2385    }
2386
2387    #[test_log::test]
2388    fn capture_does_not_retain_untouched_free_slices() {
2389        let mut memory_management = MemoryManagement::from_configuration(
2390            BytesStorage::default(),
2391            &DUMMY_MEM_PROPS,
2392            MemoryConfiguration::ExclusivePages,
2393            Arc::new(ServerLogger::default()),
2394            options(),
2395        );
2396
2397        // Leave an idle free slice in the pool from an earlier capture.
2398        memory_management.capture_begin();
2399        let earlier = memory_management.reserve(1024).unwrap();
2400        drop(earlier);
2401        drop(memory_management.capture_end());
2402
2403        // A new capture that only ever touches a different size must not retain
2404        // that leftover idle slice — reservation-tracking pins exactly what the
2405        // window used, so no free-slice cleanup at `capture_begin` is needed.
2406        memory_management.capture_begin();
2407        let window = memory_management.reserve(2048).unwrap();
2408        drop(window);
2409        let pins = memory_management.capture_end();
2410        assert_eq!(
2411            pins.len(),
2412            1,
2413            "only the touched slice is retained, not the idle leftover"
2414        );
2415    }
2416
2417    #[test_log::test]
2418    fn capture_survives_explicit_cleanup() {
2419        let mut memory_management = MemoryManagement::from_configuration(
2420            BytesStorage::default(),
2421            &DUMMY_MEM_PROPS,
2422            MemoryConfiguration::ExclusivePages,
2423            Arc::new(ServerLogger::default()),
2424            options(),
2425        );
2426
2427        memory_management.capture_begin();
2428        let handle = memory_management.reserve(1024).unwrap();
2429        drop(handle);
2430        // An explicit cleanup mid-capture compacts the persistent pool; the
2431        // capture must keep its pins through the rebuild.
2432        memory_management.cleanup(true);
2433        let pins = memory_management.capture_end();
2434        assert_eq!(pins.len(), 1, "pin lost across an explicit cleanup");
2435    }
2436
2437    #[test_log::test]
2438    fn capture_begin_is_reentrant() {
2439        let mut memory_management = MemoryManagement::from_configuration(
2440            BytesStorage::default(),
2441            &DUMMY_MEM_PROPS,
2442            MemoryConfiguration::ExclusivePages,
2443            Arc::new(ServerLogger::default()),
2444            options(),
2445        );
2446
2447        memory_management.capture_begin();
2448        let first = memory_management.reserve(1024).unwrap();
2449        // A second begin (defensive: callers arm a capture exactly once) must
2450        // not discard the pins or the saved mode of the capture already in flight.
2451        memory_management.capture_begin();
2452        let second = memory_management.reserve(2048).unwrap();
2453        drop(first);
2454        drop(second);
2455        let pins = memory_management.capture_end();
2456        assert_eq!(pins.len(), 2, "pins from before the re-entrant begin lost");
2457        assert!(
2458            memory_management.capture_end().is_empty(),
2459            "capture must be fully disarmed"
2460        );
2461    }
2462
2463    #[test_log::test]
2464    fn capture_leaves_preexisting_buffers_alone() {
2465        let mut memory_management = MemoryManagement::from_configuration(
2466            BytesStorage::default(),
2467            &DUMMY_MEM_PROPS,
2468            MemoryConfiguration::ExclusivePages,
2469            Arc::new(ServerLogger::default()),
2470            options(),
2471        );
2472
2473        // A persistent buffer that predates the capture and stays alive
2474        // through it (weights, a graph input created earlier).
2475        memory_management.capture_begin();
2476        let preexisting = memory_management.reserve(1024).unwrap();
2477        drop(memory_management.capture_end());
2478
2479        memory_management.capture_begin();
2480        let window = memory_management.reserve(2048).unwrap();
2481        drop(window);
2482        let pins = memory_management.capture_end();
2483
2484        // Only the window's slice is claimed; the pre-existing buffer keeps a
2485        // single user reference, so in-place ops on it keep working.
2486        assert_eq!(
2487            pins.len(),
2488            1,
2489            "only the window's slice belongs to the graph"
2490        );
2491        assert!(
2492            preexisting.can_mut(),
2493            "a capture must not claim pre-existing live buffers"
2494        );
2495    }
2496
2497    /// Warmup must leave the pool holding its full *distinct working set*, not
2498    /// its transient peak.
2499    ///
2500    /// This is the property the whole priming phase exists for. If warmup is
2501    /// allowed to recycle its own slices, the pool only ever grows to the peak
2502    /// number of slices live *at any one instant* during the pass — and that
2503    /// peak depends on how far the host runs ahead of the device, so it can
2504    /// land below what the recorded run asks for. The window then has to
2505    /// allocate, which a capture records as a memory node, and CUDA refuses to
2506    /// relaunch a graph holding one: the first launch succeeds and every replay
2507    /// after it fails.
2508    ///
2509    /// Here warmup reserves the same size three times *sequentially*, so its
2510    /// instantaneous peak is one slice while its working set is three. The
2511    /// recorded run then holds three at once. Without retention the pool ends
2512    /// warmup with one slice and the window allocates two more.
2513    #[test_log::test]
2514    fn capture_priming_leaves_the_working_set_not_the_peak() {
2515        let mut memory_management = MemoryManagement::from_configuration(
2516            BytesStorage::default(),
2517            &DUMMY_MEM_PROPS,
2518            MemoryConfiguration::ExclusivePages,
2519            Arc::new(ServerLogger::default()),
2520            options(),
2521        );
2522
2523        // Warmup: three sequential reserve/drop cycles. Each drop would hand
2524        // the slice straight back to the next reserve if priming did not retain
2525        // it, leaving a one-slice pool.
2526        memory_management.capture_begin();
2527        for _ in 0..3 {
2528            let scratch = memory_management.reserve(1024).unwrap();
2529            drop(scratch);
2530        }
2531        // Warmup is over: release the retained slices. They stay in the pool,
2532        // now free, which is the entire point.
2533        memory_management.capture_priming_end();
2534        let after_warmup = memory_management.memory_usage();
2535
2536        // The recorded run holds three slices of that size simultaneously —
2537        // more than warmup's instantaneous peak of one. Every one of them must
2538        // come from the pool.
2539        let recorded: Vec<_> = (0..3)
2540            .map(|_| memory_management.reserve(1024).unwrap())
2541            .collect();
2542        let after_window = memory_management.memory_usage();
2543
2544        assert_eq!(
2545            after_window.bytes_reserved, after_warmup.bytes_reserved,
2546            "the capture window grew the pool: warmup left only its transient \
2547             peak, so the recorded run had to allocate — which a capture records \
2548             as a memory node and makes the graph un-relaunchable"
2549        );
2550
2551        drop(recorded);
2552        drop(memory_management.capture_end());
2553    }
2554
2555    /// The mechanism behind [`capture_priming_leaves_the_working_set_not_the_peak`]:
2556    /// a handle dropped *during* priming must not return its slice to the free
2557    /// list, and `capture_priming_end` must give every one of them back.
2558    #[test_log::test]
2559    fn capture_priming_holds_dropped_slices_until_priming_ends() {
2560        let mut memory_management = MemoryManagement::from_configuration(
2561            BytesStorage::default(),
2562            &DUMMY_MEM_PROPS,
2563            MemoryConfiguration::ExclusivePages,
2564            Arc::new(ServerLogger::default()),
2565            options(),
2566        );
2567
2568        memory_management.capture_begin();
2569        let first = memory_management.reserve(1024).unwrap();
2570        drop(first);
2571
2572        // Still priming: the dropped slice is retained, so this reserve cannot
2573        // recycle it and the pool has to grow.
2574        let before_second = memory_management.memory_usage();
2575        let second = memory_management.reserve(1024).unwrap();
2576        let after_second = memory_management.memory_usage();
2577        assert!(
2578            after_second.bytes_reserved > before_second.bytes_reserved,
2579            "priming must retain a dropped slice instead of recycling it"
2580        );
2581        drop(second);
2582
2583        // Priming over: both slices are free again and must now be reused.
2584        memory_management.capture_priming_end();
2585        let before_reuse = memory_management.memory_usage();
2586        let reused = memory_management.reserve(1024).unwrap();
2587        let after_reuse = memory_management.memory_usage();
2588        assert_eq!(
2589            after_reuse.bytes_reserved, before_reuse.bytes_reserved,
2590            "capture_priming_end must release the retained slices for reuse"
2591        );
2592
2593        drop(reused);
2594        drop(memory_management.capture_end());
2595    }
2596
2597    /// A backend that never calls `capture_priming_end` (HIP did not, before the
2598    /// call was added to both) must not leak warmup's slices past the capture.
2599    #[test_log::test]
2600    fn capture_end_releases_primed_slices_when_priming_never_ended() {
2601        let mut memory_management = MemoryManagement::from_configuration(
2602            BytesStorage::default(),
2603            &DUMMY_MEM_PROPS,
2604            MemoryConfiguration::ExclusivePages,
2605            Arc::new(ServerLogger::default()),
2606            options(),
2607        );
2608
2609        memory_management.capture_begin();
2610        let scratch = memory_management.reserve(1024).unwrap();
2611        drop(scratch);
2612        // The caller let its handle go, but priming is still holding the slice.
2613        assert!(
2614            memory_management.memory_usage().bytes_in_use > 0,
2615            "priming should still be retaining the dropped slice"
2616        );
2617
2618        // No `capture_priming_end` — `capture_end` drops the `CaptureState`,
2619        // and with it every handle priming retained.
2620        drop(memory_management.capture_end());
2621        assert_eq!(
2622            memory_management.memory_usage().bytes_in_use,
2623            0,
2624            "primed slices outlived the capture"
2625        );
2626    }
2627
2628    #[test_log::test]
2629    fn noslice_allocate_deallocate_reallocate() {
2630        let mut memory_management = MemoryManagement::from_configuration(
2631            BytesStorage::default(),
2632            &MemoryDeviceProperties {
2633                max_page_size: 128 * 1024 * 1024,
2634                alignment: 32,
2635            },
2636            MemoryConfiguration::ExclusivePages,
2637            Arc::new(ServerLogger::default()),
2638            options(),
2639        );
2640        // Allocate a bunch
2641        let handles: Vec<_> = (0..5)
2642            .map(|i| memory_management.reserve(1000 * (i + 1)))
2643            .collect();
2644        let usage_before = memory_management.memory_usage();
2645        // Deallocate
2646        drop(handles);
2647        // Reallocate
2648        let _new_handles: Vec<_> = (0..5)
2649            .map(|i| memory_management.reserve(1000 * (i + 1)))
2650            .collect();
2651        let usage_after = memory_management.memory_usage();
2652        assert_eq!(usage_before.number_allocs, usage_after.number_allocs);
2653        assert_eq!(usage_before.bytes_in_use, usage_after.bytes_in_use);
2654        assert_eq!(usage_before.bytes_reserved, usage_after.bytes_reserved);
2655    }
2656}