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}