Skip to main content

oxicuda_driver/
stream_ordered_model.rs

1//! Faithful CPU model of CUDA stream-ordered memory allocation.
2//!
3//! This module implements the *semantics* of the CUDA stream-ordered
4//! allocator (`cuMemAllocAsync` / `cuMemFreeAsync` / the `cuMemPool*` family)
5//! without any GPU.  It is a self-contained, deterministic simulation that the
6//! [`StreamMemoryPool`](crate::stream_ordered_alloc::StreamMemoryPool) drives
7//! on every platform so that the allocator's behaviour can be exercised and
8//! reasoned about on a plain CPU.
9//!
10//! # What "stream-ordered" means
11//!
12//! In CUDA, `cuMemAllocAsync(ptr, size, stream)` returns a pointer
13//! *immediately* to the host, but the memory is only **valid on the GPU once
14//! the stream reaches the allocation point**.  Symmetrically,
15//! `cuMemFreeAsync(ptr, stream)` records a free that only takes effect once
16//! the stream reaches the free point — until then the memory is still in use
17//! by earlier work on that stream.  A pointer freed on stream `A` may be
18//! reused by a later allocation, and that reuse is only safe with respect to
19//! work on another stream `B` if `B` has been ordered after the free (e.g. via
20//! an event).
21//!
22//! # The model
23//!
24//! Each stream is modelled by a `StreamClock`: a monotonically increasing
25//! *submit* counter (the position at which the next operation is enqueued) and
26//! a *reached* counter (how far the stream has actually executed).  Submitting
27//! an operation returns its sequence number; the operation is **complete** once
28//! `reached >= seq`.  Advancing a stream (the model's analogue of
29//! `cuStreamSynchronize`) sets `reached = submit`, retiring every pending
30//! operation in FIFO order — exactly the in-order guarantee CUDA gives.
31//!
32//! Memory is modelled by a flat virtual address space.  Live allocations own a
33//! `Block`; a stream-ordered free moves the block onto a *pending-free*
34//! queue tagged with the freeing stream and the free's sequence number.  When
35//! that stream reaches the free point the block is returned to the pool's free
36//! list and becomes eligible for reuse by a later same-or-larger request
37//! (first-fit over the free list, preferring exact size).  `reserved` bytes
38//! count everything the pool has carved from the (virtual) device, whereas
39//! `used` bytes count only currently-live allocations; trimming releases
40//! free-list bytes above the release threshold back to the device.
41
42use std::collections::HashMap;
43
44use crate::error::{CudaError, CudaResult};
45
46/// Identifier of a stream within the model.
47///
48/// Derived from a real [`Stream`](crate::stream::Stream)'s raw handle (or any
49/// stable `u64` token), this lets the CPU model order operations per stream
50/// without owning the stream itself.  The reserved value [`StreamOrderId::NULL`]
51/// models the default (NULL) stream.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
53pub struct StreamOrderId(pub u64);
54
55impl StreamOrderId {
56    /// The default (NULL) stream identifier.
57    pub const NULL: StreamOrderId = StreamOrderId(0);
58
59    /// Returns the raw token backing this identifier.
60    #[inline]
61    pub fn raw(self) -> u64 {
62        self.0
63    }
64}
65
66impl From<u64> for StreamOrderId {
67    #[inline]
68    fn from(value: u64) -> Self {
69        StreamOrderId(value)
70    }
71}
72
73/// Per-stream logical clock modelling in-order execution.
74///
75/// `submit` is the sequence number that will be handed to the *next* operation
76/// enqueued on the stream; `reached` is how far the stream has executed.  An
77/// operation with sequence `s` is complete once `reached >= s`.
78#[derive(Debug, Clone, Copy, Default)]
79struct StreamClock {
80    /// Sequence number of the next operation to be submitted.
81    submit: u64,
82    /// Sequence number the stream has executed up to (inclusive of all
83    /// operations with a strictly smaller sequence number).
84    reached: u64,
85}
86
87impl StreamClock {
88    /// Enqueue an operation and return its sequence number.
89    fn enqueue(&mut self) -> u64 {
90        let seq = self.submit;
91        self.submit = self.submit.saturating_add(1);
92        seq
93    }
94
95    /// Advance the stream to the head (model of `cuStreamSynchronize`):
96    /// every submitted operation is now complete.
97    fn advance_to_head(&mut self) {
98        self.reached = self.submit;
99    }
100
101    /// Returns `true` once the operation with sequence `seq` has executed.
102    fn has_reached(&self, seq: u64) -> bool {
103        self.reached > seq
104    }
105}
106
107/// A contiguous virtual memory block tracked by the model.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109struct Block {
110    /// Virtual device address (non-zero).
111    ptr: u64,
112    /// Capacity of the block in bytes (may exceed the request that owns it
113    /// when the block was reused from the free list).
114    capacity: usize,
115}
116
117/// A stream-ordered free awaiting completion of its stream.
118#[derive(Debug, Clone, Copy)]
119struct PendingFree {
120    /// The block being freed.
121    block: Block,
122    /// The stream the free was ordered on.
123    stream: StreamOrderId,
124    /// The free's sequence number on `stream`.
125    seq: u64,
126}
127
128/// Record of a live allocation produced by the model.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct ModelAllocation {
131    /// Virtual device pointer (non-zero).
132    pub ptr: u64,
133    /// Bytes requested by the caller (`<= capacity`).
134    pub size: usize,
135    /// Capacity of the backing block.
136    pub capacity: usize,
137    /// Stream the allocation was ordered on.
138    pub stream: StreamOrderId,
139    /// Sequence number at which the allocation becomes valid on `stream`.
140    pub ready_seq: u64,
141}
142
143/// Configuration knobs the model needs from the pool.
144#[derive(Debug, Clone, Copy)]
145pub struct ModelLimits {
146    /// Hard cap on `reserved` bytes (`0` == unlimited).
147    pub max_pool_size: usize,
148    /// Bytes kept reserved on an implicit trim / release.
149    pub release_threshold: usize,
150}
151
152/// The stream-ordered allocation engine.
153///
154/// All accounting is in *virtual* bytes; no host or device memory is actually
155/// reserved.  The engine is deterministic: identical operation sequences
156/// produce identical pointers and statistics.
157#[derive(Debug)]
158pub struct StreamOrderModel {
159    /// Per-stream logical clocks.
160    clocks: HashMap<StreamOrderId, StreamClock>,
161    /// Free blocks available for reuse (returned by completed frees).
162    free_list: Vec<Block>,
163    /// Frees whose stream has not yet reached the free point.
164    pending_frees: Vec<PendingFree>,
165    /// Set of pointers that are currently live (alloc'd, not yet freed).
166    live: HashMap<u64, Block>,
167    /// Next virtual address to hand out for a brand-new block.
168    next_addr: u64,
169    /// Bytes carved from the (virtual) device — live + free-list + pending.
170    reserved: usize,
171    /// Bytes belonging to currently-live allocations.
172    used: usize,
173    /// Peak `reserved` since creation or last reset.
174    reserved_high: usize,
175    /// Peak `used` since creation or last reset.
176    used_high: usize,
177    /// Number of currently-live allocations.
178    active: usize,
179    /// Peak number of concurrent live allocations.
180    peak_active: usize,
181    /// Pool limits mirrored from the configuration.
182    limits: ModelLimits,
183}
184
185/// Base of the model's virtual address space (avoids null and small ints).
186const VIRTUAL_BASE: u64 = 0x0000_7F00_0000_0000;
187/// Allocation granularity — every block is rounded up to this, matching the
188/// 512-byte minimum granularity CUDA's stream-ordered allocator uses.
189const GRANULARITY: usize = 512;
190
191impl StreamOrderModel {
192    /// Create a fresh model with the given pool limits.
193    pub fn new(limits: ModelLimits) -> Self {
194        Self {
195            clocks: HashMap::new(),
196            free_list: Vec::new(),
197            pending_frees: Vec::new(),
198            live: HashMap::new(),
199            next_addr: VIRTUAL_BASE,
200            reserved: 0,
201            used: 0,
202            reserved_high: 0,
203            used_high: 0,
204            active: 0,
205            peak_active: 0,
206            limits,
207        }
208    }
209
210    /// Round a request up to the allocation granularity.
211    fn align(size: usize) -> usize {
212        // size is always >= 1 here; round up to the next multiple of GRANULARITY.
213        size.saturating_add(GRANULARITY - 1) / GRANULARITY * GRANULARITY
214    }
215
216    /// Mirror an updated release threshold from the pool configuration.
217    pub fn set_release_threshold(&mut self, threshold: usize) {
218        self.limits.release_threshold = threshold;
219    }
220
221    /// Allocate `size` bytes ordered on `stream`.
222    ///
223    /// Returns a [`ModelAllocation`] describing the (possibly reused) block and
224    /// the sequence number at which it becomes valid on the stream.
225    ///
226    /// # Errors
227    ///
228    /// * [`CudaError::InvalidValue`] if `size == 0`.
229    /// * [`CudaError::OutOfMemory`] if satisfying the request would push
230    ///   `reserved` past `max_pool_size`.
231    pub fn alloc(&mut self, size: usize, stream: StreamOrderId) -> CudaResult<ModelAllocation> {
232        if size == 0 {
233            return Err(CudaError::InvalidValue);
234        }
235
236        // Opportunistically retire any frees whose streams have already
237        // advanced, so their blocks are reuse-eligible for this request.
238        self.collect_ready_frees();
239
240        let want = Self::align(size);
241
242        // First-fit over the free list, preferring an exact-capacity match so
243        // that small requests don't permanently consume large reclaimed blocks.
244        let block = if let Some(idx) = self.pick_free_block(want) {
245            self.free_list.swap_remove(idx)
246        } else {
247            // No reusable block — carve a fresh one from the virtual device.
248            // This grows `reserved`, so enforce the pool ceiling here.
249            if self.limits.max_pool_size > 0
250                && self.reserved.saturating_add(want) > self.limits.max_pool_size
251            {
252                return Err(CudaError::OutOfMemory);
253            }
254            let ptr = self.next_addr;
255            self.next_addr = self.next_addr.saturating_add(want as u64);
256            self.reserved = self.reserved.saturating_add(want);
257            if self.reserved > self.reserved_high {
258                self.reserved_high = self.reserved;
259            }
260            Block {
261                ptr,
262                capacity: want,
263            }
264        };
265
266        // Order the allocation on the stream and mark the block live.
267        let ready_seq = self.clock_mut(stream).enqueue();
268        self.live.insert(block.ptr, block);
269        self.used = self.used.saturating_add(block.capacity);
270        if self.used > self.used_high {
271            self.used_high = self.used;
272        }
273        self.active = self.active.saturating_add(1);
274        if self.active > self.peak_active {
275            self.peak_active = self.active;
276        }
277
278        Ok(ModelAllocation {
279            ptr: block.ptr,
280            size,
281            capacity: block.capacity,
282            stream,
283            ready_seq,
284        })
285    }
286
287    /// Pick a reusable free block of at least `want` bytes.
288    ///
289    /// Prefers an exact-capacity match; otherwise returns the first block large
290    /// enough (first-fit).  Returns the index into [`Self::free_list`].
291    fn pick_free_block(&self, want: usize) -> Option<usize> {
292        let mut first_fit: Option<usize> = None;
293        for (idx, block) in self.free_list.iter().enumerate() {
294            if block.capacity == want {
295                return Some(idx);
296            }
297            if block.capacity > want && first_fit.is_none() {
298                first_fit = Some(idx);
299            }
300        }
301        first_fit
302    }
303
304    /// Record a stream-ordered free of `ptr` on `stream`.
305    ///
306    /// The block is not returned to the free list until `stream` reaches the
307    /// free point; until then it remains counted in `reserved` but no longer in
308    /// `used`.
309    ///
310    /// # Errors
311    ///
312    /// * [`CudaError::InvalidValue`] if `ptr` is not a live allocation
313    ///   (covers double-free and free of a foreign pointer).
314    pub fn free(&mut self, ptr: u64, stream: StreamOrderId) -> CudaResult<()> {
315        let block = self.live.remove(&ptr).ok_or(CudaError::InvalidValue)?;
316
317        self.used = self.used.saturating_sub(block.capacity);
318        self.active = self.active.saturating_sub(1);
319
320        let seq = self.clock_mut(stream).enqueue();
321        self.pending_frees.push(PendingFree { block, stream, seq });
322
323        // A free that is already ordered-complete (its stream has been advanced
324        // past this point) can be reclaimed straight away.
325        self.collect_ready_frees();
326        Ok(())
327    }
328
329    /// Returns `true` if `ptr` is a currently-live allocation in this model.
330    pub fn is_live(&self, ptr: u64) -> bool {
331        self.live.contains_key(&ptr)
332    }
333
334    /// Advance a stream to its head: every operation submitted so far is now
335    /// complete (model of `cuStreamSynchronize`).  Completed frees are
336    /// reclaimed into the free list.
337    pub fn synchronize(&mut self, stream: StreamOrderId) {
338        self.clock_mut(stream).advance_to_head();
339        self.collect_ready_frees();
340    }
341
342    /// Returns `true` if the allocation `alloc` is valid for use on its own
343    /// ordering stream (i.e. the stream has executed past the allocation
344    /// point).  This is the same-stream visibility rule.
345    pub fn is_ready_same_stream(&self, alloc: &ModelAllocation) -> bool {
346        self.clocks
347            .get(&alloc.stream)
348            .is_some_and(|c| c.has_reached(alloc.ready_seq))
349    }
350
351    /// Returns `true` if `alloc` (made on stream `A`) is safe to use on
352    /// `consumer` (stream `B`) given that `B` has been ordered after sequence
353    /// `wait_seq` on `A` (the sequence captured by an event `B` waited on).
354    ///
355    /// Cross-stream use is only safe when the event was recorded **after** the
356    /// allocation became ready (`wait_seq > ready_seq`) and `consumer != A`
357    /// degenerates to the same-stream rule when they are equal.
358    pub fn is_ready_cross_stream(
359        &self,
360        alloc: &ModelAllocation,
361        consumer: StreamOrderId,
362        wait_seq: u64,
363    ) -> bool {
364        if consumer == alloc.stream {
365            return self.is_ready_same_stream(alloc);
366        }
367        // The consumer stream observes the allocation only if the event it
368        // waited on was recorded at or after the allocation point.
369        wait_seq > alloc.ready_seq
370    }
371
372    /// Record an event on `stream`, returning the sequence number it captures.
373    ///
374    /// A later `cuStreamWaitEvent` on another stream is ordered after every
375    /// operation submitted on `stream` before this point — modelled by handing
376    /// back the stream's current submit position.
377    pub fn record_event(&mut self, stream: StreamOrderId) -> u64 {
378        self.clock_mut(stream).submit
379    }
380
381    /// Reclaim every pending free whose stream has reached the free point.
382    fn collect_ready_frees(&mut self) {
383        let mut still_pending = Vec::with_capacity(self.pending_frees.len());
384        // Take ownership to avoid borrowing `self` across the closure.
385        let drained = std::mem::take(&mut self.pending_frees);
386        for pf in drained {
387            let reached = self
388                .clocks
389                .get(&pf.stream)
390                .is_some_and(|c| c.has_reached(pf.seq));
391            if reached {
392                self.free_list.push(pf.block);
393            } else {
394                still_pending.push(pf);
395            }
396        }
397        self.pending_frees = still_pending;
398        // After reclaiming, optionally shrink the free list down to the
399        // release threshold (implicit release, mirroring driver behaviour).
400        self.release_excess();
401    }
402
403    /// Release free-list bytes that exceed the release threshold back to the
404    /// (virtual) device, lowering `reserved`.
405    fn release_excess(&mut self) {
406        // Bytes that must stay reserved regardless: live + pending-free bytes.
407        let pending_bytes: usize = self.pending_frees.iter().map(|p| p.block.capacity).sum();
408        let pinned = self.used.saturating_add(pending_bytes);
409        let keep_floor = self.limits.release_threshold.max(pinned);
410
411        while self.reserved > keep_floor {
412            let Some(block) = self.free_list.pop() else {
413                break;
414            };
415            // Dropping the block from the free list returns its bytes to the
416            // device.  We do not recycle the virtual address — fresh blocks
417            // always take a new address, which keeps pointers unambiguous.
418            self.reserved = self.reserved.saturating_sub(block.capacity);
419        }
420    }
421
422    /// Explicit trim (model of `cuMemPoolTrimTo`): keep at least
423    /// `min_bytes_to_keep` reserved, releasing free-list blocks above that.
424    pub fn trim_to(&mut self, min_bytes_to_keep: usize) {
425        // First make sure completed frees are on the free list.
426        self.collect_ready_frees();
427
428        let pending_bytes: usize = self.pending_frees.iter().map(|p| p.block.capacity).sum();
429        let pinned = self.used.saturating_add(pending_bytes);
430        let keep_floor = min_bytes_to_keep.max(pinned);
431
432        while self.reserved > keep_floor {
433            let Some(block) = self.free_list.pop() else {
434                break;
435            };
436            self.reserved = self.reserved.saturating_sub(block.capacity);
437        }
438    }
439
440    /// Reset the peak (`reserved_high` / `used_high`) statistics to the current
441    /// values.
442    pub fn reset_peaks(&mut self) {
443        self.reserved_high = self.reserved;
444        self.used_high = self.used;
445        self.peak_active = self.active;
446    }
447
448    /// Current `reserved` byte count.
449    #[inline]
450    pub fn reserved(&self) -> usize {
451        self.reserved
452    }
453
454    /// Current `used` byte count.
455    #[inline]
456    pub fn used(&self) -> usize {
457        self.used
458    }
459
460    /// Peak `reserved` byte count.
461    #[inline]
462    pub fn reserved_high(&self) -> usize {
463        self.reserved_high
464    }
465
466    /// Peak `used` byte count.
467    #[inline]
468    pub fn used_high(&self) -> usize {
469        self.used_high
470    }
471
472    /// Number of currently-live allocations.
473    #[inline]
474    pub fn active(&self) -> usize {
475        self.active
476    }
477
478    /// Peak number of concurrent live allocations.
479    #[inline]
480    pub fn peak_active(&self) -> usize {
481        self.peak_active
482    }
483
484    /// Number of reuse-eligible free blocks currently held.
485    #[inline]
486    pub fn free_block_count(&self) -> usize {
487        self.free_list.len()
488    }
489
490    /// Number of pending (not-yet-complete) stream-ordered frees.
491    #[inline]
492    pub fn pending_free_count(&self) -> usize {
493        self.pending_frees.len()
494    }
495
496    /// Get a mutable handle to a stream's clock, creating it on first use.
497    fn clock_mut(&mut self, stream: StreamOrderId) -> &mut StreamClock {
498        self.clocks.entry(stream).or_default()
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn limits(max: usize, release: usize) -> ModelLimits {
507        ModelLimits {
508            max_pool_size: max,
509            release_threshold: release,
510        }
511    }
512
513    #[test]
514    fn alloc_rounds_up_to_granularity() {
515        let mut m = StreamOrderModel::new(limits(0, 0));
516        let a = m.alloc(1, StreamOrderId::NULL).expect("alloc");
517        assert_eq!(a.size, 1);
518        assert_eq!(a.capacity, GRANULARITY);
519        assert_eq!(m.reserved(), GRANULARITY);
520        assert_eq!(m.used(), GRANULARITY);
521        assert_ne!(a.ptr, 0);
522    }
523
524    #[test]
525    fn zero_size_rejected() {
526        let mut m = StreamOrderModel::new(limits(0, 0));
527        assert_eq!(
528            m.alloc(0, StreamOrderId::NULL),
529            Err(CudaError::InvalidValue)
530        );
531    }
532
533    #[test]
534    fn free_of_foreign_pointer_rejected() {
535        let mut m = StreamOrderModel::new(limits(0, 0));
536        assert_eq!(
537            m.free(0xDEAD_BEEF, StreamOrderId::NULL),
538            Err(CudaError::InvalidValue)
539        );
540    }
541
542    #[test]
543    fn double_free_rejected() {
544        let mut m = StreamOrderModel::new(limits(0, 0));
545        let s = StreamOrderId(7);
546        let a = m.alloc(256, s).expect("alloc");
547        assert!(m.free(a.ptr, s).is_ok());
548        assert_eq!(m.free(a.ptr, s), Err(CudaError::InvalidValue));
549    }
550
551    #[test]
552    fn pending_free_holds_block_until_stream_advances() {
553        // Keep everything reserved so the released bytes don't vanish.
554        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
555        let s = StreamOrderId(1);
556        let a = m.alloc(512, s).expect("alloc");
557        // Submit one more op so the free does not sit at the stream head with
558        // reached already past it.
559        let _b = m.alloc(512, s).expect("alloc2");
560        m.free(a.ptr, s).expect("free");
561        // The freeing op is pending: stream has not been advanced.
562        assert_eq!(m.pending_free_count(), 1);
563        assert_eq!(m.free_block_count(), 0);
564        // Advancing the stream retires the free → block becomes reusable.
565        m.synchronize(s);
566        assert_eq!(m.pending_free_count(), 0);
567        assert_eq!(m.free_block_count(), 1);
568    }
569
570    #[test]
571    fn freed_block_is_reused_by_same_size_alloc() {
572        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
573        let s = StreamOrderId(2);
574        let a = m.alloc(1024, s).expect("alloc");
575        let ptr_a = a.ptr;
576        let reserved_after_first = m.reserved();
577        m.free(a.ptr, s).expect("free");
578        m.synchronize(s); // retire the free
579        // A same-size allocation must reuse the freed block (same pointer) and
580        // must not grow reserved.
581        let b = m.alloc(1024, s).expect("alloc reuse");
582        assert_eq!(b.ptr, ptr_a, "freed block should be reused");
583        assert_eq!(m.reserved(), reserved_after_first, "no new reservation");
584        assert_eq!(m.free_block_count(), 0);
585    }
586
587    #[test]
588    fn same_stream_visibility_rule() {
589        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
590        let s = StreamOrderId(3);
591        let a = m.alloc(64, s).expect("alloc");
592        // Not yet executed: stream has not advanced past the alloc point.
593        assert!(!m.is_ready_same_stream(&a));
594        m.synchronize(s);
595        assert!(m.is_ready_same_stream(&a));
596    }
597
598    #[test]
599    fn cross_stream_requires_event_after_alloc() {
600        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
601        let producer = StreamOrderId(10);
602        let consumer = StreamOrderId(20);
603        let a = m.alloc(128, producer).expect("alloc");
604        // Event recorded BEFORE the alloc would have captured seq 0.
605        let early_wait = 0u64;
606        assert!(!m.is_ready_cross_stream(&a, consumer, early_wait));
607        // Event recorded AFTER the alloc captures the post-alloc submit pos.
608        let late_wait = m.record_event(producer);
609        assert!(late_wait > a.ready_seq);
610        assert!(m.is_ready_cross_stream(&a, consumer, late_wait));
611    }
612
613    #[test]
614    fn reserved_vs_used_accounting_consistent() {
615        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
616        let s = StreamOrderId(4);
617        let a = m.alloc(1000, s).expect("a");
618        let b = m.alloc(2000, s).expect("b");
619        let total = a.capacity + b.capacity;
620        assert_eq!(m.used(), total);
621        assert_eq!(m.reserved(), total);
622        m.free(a.ptr, s).expect("free a");
623        // used drops immediately; reserved stays until trimmed.
624        assert_eq!(m.used(), b.capacity);
625        assert_eq!(m.reserved(), total);
626        m.synchronize(s);
627        // Block is on the free list but threshold keeps it reserved.
628        assert_eq!(m.reserved(), total);
629        assert_eq!(m.free_block_count(), 1);
630    }
631
632    #[test]
633    fn trim_releases_free_blocks_above_threshold() {
634        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
635        let s = StreamOrderId(5);
636        let a = m.alloc(4096, s).expect("a");
637        let reserved_full = m.reserved();
638        m.free(a.ptr, s).expect("free");
639        m.synchronize(s);
640        assert_eq!(m.free_block_count(), 1);
641        assert_eq!(m.reserved(), reserved_full);
642        // Trim to zero releases the free block.
643        m.trim_to(0);
644        assert_eq!(m.free_block_count(), 0);
645        assert_eq!(m.reserved(), 0);
646    }
647
648    #[test]
649    fn release_threshold_keeps_some_reserved() {
650        let mut m = StreamOrderModel::new(limits(0, 4096));
651        let s = StreamOrderId(6);
652        let a = m.alloc(4096, s).expect("a");
653        m.free(a.ptr, s).expect("free");
654        m.synchronize(s);
655        // Threshold == 4096 keeps the block reserved on the implicit release.
656        assert_eq!(m.reserved(), 4096);
657        assert_eq!(m.free_block_count(), 1);
658    }
659
660    #[test]
661    fn max_pool_size_enforced_on_fresh_blocks() {
662        let mut m = StreamOrderModel::new(limits(1024, 0));
663        let s = StreamOrderId::NULL;
664        assert!(m.alloc(1024, s).is_ok());
665        // A second fresh block would exceed the 1024-byte ceiling.
666        assert_eq!(m.alloc(1, s), Err(CudaError::OutOfMemory));
667    }
668
669    #[test]
670    fn larger_request_reuses_oversized_free_block() {
671        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
672        let s = StreamOrderId(8);
673        // Free a 2048-byte block, then request 1024 — first-fit reuse.
674        let big = m.alloc(2048, s).expect("big");
675        let big_ptr = big.ptr;
676        m.free(big.ptr, s).expect("free big");
677        m.synchronize(s);
678        let small = m.alloc(1024, s).expect("small reuse");
679        assert_eq!(small.ptr, big_ptr, "oversized block reused");
680        assert_eq!(small.capacity, 2048, "capacity retained from block");
681    }
682
683    #[test]
684    fn peak_stats_track_and_reset() {
685        let mut m = StreamOrderModel::new(limits(0, usize::MAX));
686        let s = StreamOrderId(9);
687        let a = m.alloc(1024, s).expect("a");
688        let _b = m.alloc(2048, s).expect("b");
689        assert_eq!(m.peak_active(), 2);
690        assert_eq!(m.used_high(), a.capacity + 2048);
691        m.free(a.ptr, s).expect("free a");
692        m.synchronize(s);
693        m.reset_peaks();
694        assert_eq!(m.peak_active(), 1);
695        assert_eq!(m.used_high(), m.used());
696    }
697}