Skip to main content

xlog_cuda/device_runtime/
budget.rs

1//! Per-runtime budget over the canonical memory resource.
2//!
3//! Admission reserves bytes before allocation. Definite refusal and pre-malloc
4//! unwind roll back. Acquired storage carries its exact release ticket through
5//! ordinary and cold reclamation. Cumulative proven-release events restore
6//! capacity even when concurrent allocations or out-of-band reaping occur.
7
8use std::sync::{Arc, Mutex};
9
10#[cfg(test)]
11use super::resource::AllocTag;
12use super::resource::{
13    Access, AllocationAccounting, AllocationRequest, BlockId, DeviceBlock, DeviceMemoryResource,
14    ResourceBudgetSnapshot, ResourceError, ResourceResult, StreamId,
15};
16
17/// Internal state guarded by the budget mutex. Kept in its own
18/// struct so the lock guard syntactically scopes all updates.
19struct BudgetState {
20    admitted_total: u128,
21    baseline_reclaimed: u128,
22}
23
24impl BudgetState {
25    fn reserved(&self, total_reclaimed: u128) -> ResourceResult<usize> {
26        let reclaimed = total_reclaimed
27            .checked_sub(self.baseline_reclaimed)
28            .ok_or_else(|| {
29                ResourceError::Driver("resource reclamation total precedes budget baseline".into())
30            })?;
31        let reserved = self.admitted_total.checked_sub(reclaimed).ok_or_else(|| {
32            ResourceError::Driver("resource releases exceed budget admissions".into())
33        })?;
34        usize::try_from(reserved)
35            .map_err(|_| ResourceError::Driver("global budget accounting overflow".into()))
36    }
37}
38
39struct BudgetReservation<'a> {
40    state: &'a Mutex<BudgetState>,
41    bytes: usize,
42    reclamation: Arc<crate::memory::AllocationReclamation>,
43}
44
45impl Drop for BudgetReservation<'_> {
46    fn drop(&mut self) {
47        if !self.reclamation.was_acquired() {
48            let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
49            state.admitted_total = state
50                .admitted_total
51                .checked_sub(self.bytes as u128)
52                .expect("budget admission includes the refused allocation");
53        }
54    }
55}
56
57/// Per-runtime byte-limit decorator.
58pub struct GlobalDeviceBudget {
59    inner: Box<dyn DeviceMemoryResource + Send + Sync>,
60    limit: usize,
61    state: Mutex<BudgetState>,
62    accounting: Arc<AllocationAccounting>,
63}
64
65impl GlobalDeviceBudget {
66    /// Wrap `inner` with a hard `limit` in bytes. The initial
67    /// reserved tally and release baseline are sampled atomically from the
68    /// inner resource's canonical accounting ledger,
69    /// so callers may compose around an inner that already has live
70    /// allocations — though in practice the decorator is installed
71    /// before any allocation flows through it.
72    pub fn new(inner: Box<dyn DeviceMemoryResource + Send + Sync>, limit: usize) -> Self {
73        let accounting = inner.allocation_accounting();
74        let (initial, baseline_reclaimed) = accounting.snapshot();
75        Self {
76            inner,
77            limit,
78            state: Mutex::new(BudgetState {
79                admitted_total: initial as u128,
80                baseline_reclaimed,
81            }),
82            accounting,
83        }
84    }
85
86    /// Hard byte limit. Set at construction; not adjustable.
87    pub fn limit(&self) -> usize {
88        self.limit
89    }
90
91    /// Bytes currently reserved against the budget (live + pending
92    /// async free). Matches `inner.bytes_outstanding()` at every
93    /// quiescent moment.
94    pub fn reserved_bytes(&self) -> usize {
95        let state = self.state.lock().expect("GlobalDeviceBudget poisoned");
96        state
97            .reserved(self.accounting.snapshot().1)
98            .expect("canonical budget release accounting is consistent")
99    }
100
101    /// Headroom in bytes for the next allocation. Equal to
102    /// `limit - reserved_bytes`, saturating at zero.
103    pub fn remaining(&self) -> usize {
104        self.limit.saturating_sub(self.reserved_bytes())
105    }
106
107    fn allocate_with_pressure(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
108        let bytes = request.bytes;
109        let reservation_pressure_bytes = request.reservation_pressure_bytes;
110        // Admit against both materialized allocations and runtime promises.
111        // Only materialized bytes are added to this decorator's own tally.
112        {
113            let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
114            let current = state
115                .reserved(self.accounting.snapshot().1)?
116                .checked_add(reservation_pressure_bytes)
117                .ok_or_else(|| {
118                    ResourceError::Driver(
119                        "global budget reservation accounting overflow".to_string(),
120                    )
121                })?;
122            let remaining = self.limit.saturating_sub(current);
123            if bytes <= remaining {
124                state.admitted_total =
125                    state
126                        .admitted_total
127                        .checked_add(bytes as u128)
128                        .ok_or_else(|| {
129                            ResourceError::Driver("global budget accounting overflow".to_string())
130                        })?;
131                drop(state);
132                return self.materialize_reserved(request);
133            }
134            if bytes > self.limit.saturating_sub(reservation_pressure_bytes) {
135                return Err(ResourceError::OutOfBudget {
136                    requested: bytes,
137                    current,
138                    remaining,
139                    limit: self.limit,
140                });
141            }
142        }
143
144        // A request that could fit after retired asynchronous allocations are
145        // reclaimed gets one reap-and-retry before it is rejected.
146        let _ = self.reap_pending();
147
148        let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
149        let current = state
150            .reserved(self.accounting.snapshot().1)?
151            .checked_add(reservation_pressure_bytes)
152            .ok_or_else(|| {
153                ResourceError::Driver("global budget reservation accounting overflow".to_string())
154            })?;
155        let remaining = self.limit.saturating_sub(current);
156        if bytes > remaining {
157            return Err(ResourceError::OutOfBudget {
158                requested: bytes,
159                current,
160                remaining,
161                limit: self.limit,
162            });
163        }
164        state.admitted_total =
165            state
166                .admitted_total
167                .checked_add(bytes as u128)
168                .ok_or_else(|| {
169                    ResourceError::Driver("global budget accounting overflow".to_string())
170                })?;
171        drop(state);
172
173        self.materialize_reserved(request)
174    }
175
176    fn materialize_reserved(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
177        let _reservation = BudgetReservation {
178            state: &self.state,
179            bytes: request.bytes,
180            reclamation: request.reclamation(),
181        };
182        self.inner.materialize(request)
183    }
184}
185
186impl DeviceMemoryResource for GlobalDeviceBudget {
187    fn materialize(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
188        self.allocate_with_pressure(request)
189    }
190
191    fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
192        Arc::clone(&self.accounting)
193    }
194
195    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
196        self.inner.deallocate(block)
197    }
198
199    fn device_ordinal(&self) -> u32 {
200        self.inner.device_ordinal()
201    }
202
203    fn bytes_outstanding(&self) -> usize {
204        self.accounting.snapshot().0
205    }
206
207    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
208        Some(ResourceBudgetSnapshot {
209            limit: self.limit,
210            reserved: self.reserved_bytes(),
211        })
212    }
213
214    fn reap_pending(&self) -> ResourceResult<()> {
215        self.inner.reap_pending()
216    }
217
218    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
219        // Pass-through: budget enforcement does not affect
220        // cross-stream lifetime tracking; the inner resource (the
221        // stream-ordered backend) is the only layer that owns
222        // last-use events.
223        self.inner.record_block_use(block, use_stream)
224    }
225
226    fn supports_block_use_tracking(&self) -> bool {
227        self.inner.supports_block_use_tracking()
228    }
229
230    fn access_dependencies(
231        &self,
232        block: BlockId,
233        bytes: usize,
234    ) -> ResourceResult<Option<std::sync::Arc<super::resource::DeviceAccessDependencies>>> {
235        self.inner.access_dependencies(block, bytes)
236    }
237
238    fn prepare_block_use(
239        &self,
240        block: BlockId,
241        use_stream: StreamId,
242        access: Access,
243    ) -> ResourceResult<()> {
244        // Pass-through: cross-stream waits live in the
245        // stream-ordered backend; budget accounting is unaffected.
246        self.inner.prepare_block_use(block, use_stream, access)
247    }
248
249    fn finish_block_use(
250        &self,
251        block: BlockId,
252        use_stream: StreamId,
253        access: Access,
254    ) -> ResourceResult<()> {
255        // Pass-through: see prepare_block_use rationale above.
256        self.inner.finish_block_use(block, use_stream, access)
257    }
258}
259
260#[cfg(test)]
261pub(crate) mod tests {
262    use super::super::async_resource::AsyncCudaResource;
263    use super::super::direct::DirectCudaResource;
264    use super::super::resource::{BlockState, Generation};
265    use super::super::stream_pool::StreamPool;
266    use super::*;
267    use std::sync::Arc;
268
269    use crate::CudaDevice;
270
271    fn try_device() -> Option<Arc<CudaDevice>> {
272        match CudaDevice::new(0) {
273            Ok(device) => Some(Arc::new(device)),
274            Err(error) if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") => {
275                panic!("XLOG_REQUIRE_CUDA=1 but CUDA initialization failed: {error}")
276            }
277            Err(error) => {
278                eprintln!("Skipping test: CUDA runtime unavailable: {error}");
279                None
280            }
281        }
282    }
283
284    /// Test fixture that always fails `allocate` so we can exercise
285    /// the rollback path without touching CUDA. `deallocate` and
286    /// `reap_pending` are no-ops; `bytes_outstanding` reflects an
287    /// shared physical ledger, independently of admission rollback.
288    struct AlwaysFailAllocResource {
289        ord: u32,
290        accounting: Arc<AllocationAccounting>,
291    }
292
293    impl AlwaysFailAllocResource {
294        fn new(ord: u32) -> Self {
295            Self {
296                ord,
297                accounting: Arc::default(),
298            }
299        }
300    }
301
302    impl DeviceMemoryResource for AlwaysFailAllocResource {
303        fn materialize(&self, _request: AllocationRequest) -> ResourceResult<DeviceBlock> {
304            Err(ResourceError::Driver("inner always fails".into()))
305        }
306        fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
307            Arc::clone(&self.accounting)
308        }
309        fn deallocate(&self, _block: DeviceBlock) -> ResourceResult<()> {
310            Ok(())
311        }
312        fn device_ordinal(&self) -> u32 {
313            self.ord
314        }
315        fn bytes_outstanding(&self) -> usize {
316            self.accounting.snapshot().0
317        }
318    }
319
320    pub(crate) struct HostAllocation {
321        storage: Option<Box<[u8]>>,
322        reclamation: Arc<crate::memory::AllocationReclamation>,
323    }
324
325    impl Drop for HostAllocation {
326        fn drop(&mut self) {
327            drop(self.storage.take());
328            self.reclamation
329                .complete()
330                .expect("host allocation physically freed");
331        }
332    }
333
334    #[derive(Default)]
335    pub(crate) struct DeferredHostResource {
336        pub(crate) owners: Arc<Mutex<std::collections::HashMap<u64, HostAllocation>>>,
337        pub(crate) accounting: Arc<AllocationAccounting>,
338        pub(crate) release_on_detach: bool,
339    }
340
341    impl DeviceMemoryResource for DeferredHostResource {
342        fn materialize(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
343            let AllocationRequest {
344                bytes,
345                stream,
346                tag,
347                reclamation,
348                ..
349            } = request;
350            reclamation.attach_resource(Arc::clone(&self.accounting), bytes)?;
351            let storage = vec![0_u8; bytes].into_boxed_slice();
352            let ptr = storage.as_ptr() as u64;
353            let owner = HostAllocation {
354                storage: Some(storage),
355                reclamation,
356            };
357            owner.reclamation.acquired()?;
358            self.owners.lock().unwrap().insert(ptr, owner);
359            Ok(DeviceBlock {
360                ptr,
361                device_ordinal: 0,
362                alloc_stream: stream,
363                bytes,
364                align: 1,
365                tag,
366                generation: Generation::next(),
367                state: BlockState::Live,
368            })
369        }
370
371        fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
372            assert!(self.owners.lock().unwrap().contains_key(&block.ptr));
373            if self.release_on_detach {
374                let owner = self.owners.lock().unwrap().remove(&block.ptr);
375                drop(owner);
376            }
377            Ok(())
378        }
379
380        fn device_ordinal(&self) -> u32 {
381            0
382        }
383
384        fn bytes_outstanding(&self) -> usize {
385            self.accounting.snapshot().0
386        }
387        fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
388            Arc::clone(&self.accounting)
389        }
390    }
391
392    #[test]
393    fn host_release_outside_resource_calls_restores_budget_capacity() {
394        let resource = DeferredHostResource::default();
395        let owners = Arc::clone(&resource.owners);
396        let budget = GlobalDeviceBudget::new(Box::new(resource), 64);
397        let block = budget
398            .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
399            .unwrap();
400        let ptr = block.ptr;
401        budget.deallocate(block).unwrap();
402        assert_eq!(budget.reserved_bytes(), 64);
403
404        let owner = owners.lock().unwrap().remove(&ptr).unwrap();
405        drop(owner);
406
407        assert_eq!(budget.bytes_outstanding(), 0);
408        assert_eq!(budget.reserved_bytes(), 0);
409        assert_eq!(budget.remaining(), 64);
410        let replacement = budget
411            .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
412            .expect("physical release restores admission capacity");
413        budget.deallocate(replacement).unwrap();
414    }
415
416    #[test]
417    fn host_budget_construction_preserves_live_bytes_and_prior_reclamation() {
418        let resource = DeferredHostResource::default();
419        let owners = Arc::clone(&resource.owners);
420        let old = resource
421            .allocate(16, StreamId::DEFAULT, AllocTag::UNTAGGED)
422            .unwrap();
423        drop(owners.lock().unwrap().remove(&old.ptr));
424        let live = resource
425            .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
426            .unwrap();
427        let inner = GlobalDeviceBudget::new(Box::new(resource), 96);
428        let outer = GlobalDeviceBudget::new(Box::new(inner), 96);
429        assert_eq!(outer.reserved_bytes(), 64);
430        assert!(outer
431            .allocate_with_reservation_pressure(1, 32, StreamId::DEFAULT, AllocTag::UNTAGGED)
432            .is_err());
433        assert_eq!(outer.reserved_bytes(), 64);
434        drop(owners.lock().unwrap().remove(&live.ptr));
435        assert_eq!(outer.reserved_bytes(), 0);
436        let next = outer
437            .allocate(96, StreamId::DEFAULT, AllocTag::UNTAGGED)
438            .unwrap();
439        assert_eq!(outer.reserved_bytes(), 96);
440        let owner = owners.lock().unwrap().remove(&next.ptr).unwrap();
441        std::thread::spawn(move || drop(owner)).join().unwrap();
442        assert_eq!(outer.remaining(), 96);
443    }
444
445    #[test]
446    fn host_budget_new_admission_does_not_mask_concurrent_physical_release() {
447        struct PausingResource {
448            inner: DeferredHostResource,
449            entered: Arc<std::sync::Barrier>,
450            proceed: Arc<std::sync::Barrier>,
451        }
452        impl DeviceMemoryResource for PausingResource {
453            fn materialize(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
454                self.entered.wait();
455                self.proceed.wait();
456                self.inner.materialize(request)
457            }
458            fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
459                self.inner.allocation_accounting()
460            }
461            fn bytes_outstanding(&self) -> usize {
462                self.inner.bytes_outstanding()
463            }
464            fn device_ordinal(&self) -> u32 {
465                0
466            }
467            fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
468                self.inner.deallocate(block)
469            }
470        }
471        let inner = DeferredHostResource::default();
472        let old = inner
473            .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
474            .unwrap();
475        let owners = Arc::clone(&inner.owners);
476        let entered = Arc::new(std::sync::Barrier::new(2));
477        let proceed = Arc::new(std::sync::Barrier::new(2));
478        let budget = Arc::new(GlobalDeviceBudget::new(
479            Box::new(PausingResource {
480                inner,
481                entered: Arc::clone(&entered),
482                proceed: Arc::clone(&proceed),
483            }),
484            128,
485        ));
486        let worker = Arc::clone(&budget);
487        let admission = std::thread::spawn(move || {
488            worker
489                .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
490                .unwrap()
491        });
492        entered.wait();
493        assert_eq!(budget.reserved_bytes(), 128);
494        drop(owners.lock().unwrap().remove(&old.ptr));
495        assert_eq!(budget.reserved_bytes(), 64);
496        proceed.wait();
497        let new = admission.join().unwrap();
498        assert_eq!(budget.bytes_outstanding(), 64);
499        assert_eq!(budget.reserved_bytes(), 64);
500        drop(owners.lock().unwrap().remove(&new.ptr));
501        assert_eq!(budget.remaining(), 128);
502    }
503
504    #[test]
505    fn allocate_within_limit_succeeds_and_updates_reserved() {
506        let Some(device) = try_device() else {
507            return;
508        };
509        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
510        let budget = GlobalDeviceBudget::new(inner, 64 * 1024);
511
512        let block = budget
513            .allocate(2048, StreamId::DEFAULT, AllocTag("budget-success"))
514            .expect("alloc within limit");
515        assert_eq!(budget.reserved_bytes(), 2048);
516        assert_eq!(budget.remaining(), 64 * 1024 - 2048);
517        assert_eq!(budget.bytes_outstanding(), 2048);
518
519        budget.deallocate(block).expect("dealloc");
520        assert_eq!(budget.reserved_bytes(), 0);
521        assert_eq!(budget.bytes_outstanding(), 0);
522    }
523
524    #[test]
525    fn failed_initialization_refunds_only_after_physical_release() {
526        struct AllocatedThenFailed {
527            inner: DeferredHostResource,
528            unwind: bool,
529            release_in_frame: bool,
530        }
531        impl DeviceMemoryResource for AllocatedThenFailed {
532            fn materialize(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
533                let reclamation = request.reclamation();
534                let block = self.inner.materialize(request)?;
535                if self.release_in_frame {
536                    let owner = self.inner.owners.lock().unwrap().remove(&block.ptr);
537                    drop(owner);
538                }
539                if self.unwind {
540                    panic!("initialization interrupted after acquisition");
541                }
542                Err(ResourceError::Driver("ready event failed".into())
543                    .retaining(block.bytes, reclamation))
544            }
545            fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
546                self.inner.allocation_accounting()
547            }
548            fn deallocate(&self, _: DeviceBlock) -> ResourceResult<()> {
549                unreachable!()
550            }
551            fn device_ordinal(&self) -> u32 {
552                0
553            }
554            fn bytes_outstanding(&self) -> usize {
555                self.inner.bytes_outstanding()
556            }
557        }
558        for unwind in [false, true] {
559            for release_in_frame in [false, true] {
560                let inner = DeferredHostResource::default();
561                let owners = Arc::clone(&inner.owners);
562                let budget = GlobalDeviceBudget::new(
563                    Box::new(AllocatedThenFailed {
564                        inner,
565                        unwind,
566                        release_in_frame,
567                    }),
568                    64,
569                );
570                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
571                    assert!(budget
572                        .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
573                        .is_err());
574                }));
575                assert_eq!(outcome.is_err(), unwind);
576                assert_eq!(
577                    budget.reserved_bytes(),
578                    if release_in_frame { 0 } else { 64 }
579                );
580                let pending = std::mem::take(&mut *owners.lock().unwrap());
581                std::thread::spawn(move || drop(pending)).join().unwrap();
582                assert_eq!(budget.reserved_bytes(), 0);
583                budget.reap_pending().unwrap();
584                assert_eq!(budget.remaining(), 64);
585            }
586        }
587    }
588
589    #[test]
590    fn allocation_unwind_before_acquisition_restores_budget_charge() {
591        struct UnwindingResource(Arc<AllocationAccounting>);
592        impl DeviceMemoryResource for UnwindingResource {
593            fn materialize(&self, _: AllocationRequest) -> ResourceResult<DeviceBlock> {
594                panic!("allocator refused before acquisition")
595            }
596            fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
597                Arc::clone(&self.0)
598            }
599            fn deallocate(&self, _: DeviceBlock) -> ResourceResult<()> {
600                unreachable!()
601            }
602            fn device_ordinal(&self) -> u32 {
603                0
604            }
605            fn bytes_outstanding(&self) -> usize {
606                0
607            }
608        }
609        let budget = GlobalDeviceBudget::new(Box::new(UnwindingResource(Arc::default())), 64);
610        assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
611            let _ = budget.allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED);
612        }))
613        .is_err());
614        assert_eq!(budget.reserved_bytes(), 0);
615        assert_eq!(budget.remaining(), 64);
616    }
617
618    #[test]
619    fn allocate_at_exact_limit_succeeds_then_next_byte_rejected() {
620        let Some(device) = try_device() else {
621            return;
622        };
623        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
624        let budget = GlobalDeviceBudget::new(inner, 4096);
625
626        let block = budget
627            .allocate(4096, StreamId::DEFAULT, AllocTag::UNTAGGED)
628            .expect("alloc at exact limit");
629        assert_eq!(budget.reserved_bytes(), 4096);
630        assert_eq!(budget.remaining(), 0);
631
632        let err = budget.allocate(1, StreamId::DEFAULT, AllocTag::UNTAGGED);
633        assert!(
634            matches!(
635                err,
636                Err(ResourceError::OutOfBudget {
637                    requested: 1,
638                    current: 4096,
639                    remaining: 0,
640                    limit: 4096,
641                })
642            ),
643            "expected OutOfBudget {{1,0}}, got {:?}",
644            err
645        );
646        // Failed alloc must not perturb reserved.
647        assert_eq!(budget.reserved_bytes(), 4096);
648
649        budget.deallocate(block).expect("dealloc");
650        assert_eq!(budget.reserved_bytes(), 0);
651    }
652
653    #[test]
654    fn over_limit_alloc_returns_out_of_budget_with_correct_remaining() {
655        let Some(device) = try_device() else {
656            return;
657        };
658        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
659        let budget = GlobalDeviceBudget::new(inner, 1024);
660
661        // First alloc takes 768 bytes → 256 remaining.
662        let block = budget
663            .allocate(768, StreamId::DEFAULT, AllocTag::UNTAGGED)
664            .expect("first alloc");
665        assert_eq!(budget.remaining(), 256);
666
667        let err = budget.allocate(512, StreamId::DEFAULT, AllocTag::UNTAGGED);
668        assert!(
669            matches!(
670                err,
671                Err(ResourceError::OutOfBudget {
672                    requested: 512,
673                    current: 768,
674                    remaining: 256,
675                    limit: 1024,
676                })
677            ),
678            "expected OutOfBudget {{512,256}}, got {:?}",
679            err
680        );
681
682        budget.deallocate(block).expect("dealloc");
683    }
684
685    #[test]
686    fn memory_pressure_runtime_budget_reports_exact_limit() {
687        let Some(device) = try_device() else {
688            return;
689        };
690        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
691        let budget = GlobalDeviceBudget::new(inner, 1024);
692        let block = budget
693            .allocate(768, StreamId::DEFAULT, AllocTag::UNTAGGED)
694            .expect("baseline allocation");
695
696        let error = budget
697            .allocate(512, StreamId::DEFAULT, AllocTag::UNTAGGED)
698            .expect_err("cumulative allocation must exceed the runtime budget");
699
700        assert_eq!(
701            format!("{error:?}"),
702            "OutOfBudget { requested: 512, current: 768, remaining: 256, limit: 1024 }"
703        );
704        assert_eq!(budget.reserved_bytes(), 768);
705        budget.deallocate(block).expect("dealloc");
706    }
707
708    #[test]
709    fn failed_inner_allocation_rolls_back_reservation() {
710        // No CUDA dependency — the fake inner always errors.
711        let inner = Box::new(AlwaysFailAllocResource::new(0));
712        let budget = GlobalDeviceBudget::new(inner, 1024 * 1024);
713        assert_eq!(budget.reserved_bytes(), 0);
714
715        let err = budget.allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED);
716        assert!(matches!(err, Err(ResourceError::Driver(_))));
717        // Reservation must be rolled back: no live or pending bytes
718        // landed on the inner, so reserved stays at the pre-call
719        // value (0).
720        assert_eq!(budget.reserved_bytes(), 0);
721        assert_eq!(budget.remaining(), 1024 * 1024);
722    }
723
724    #[test]
725    fn reservation_pressure_is_included_before_inner_allocation() {
726        let inner = Box::new(AlwaysFailAllocResource::new(0));
727        let budget = GlobalDeviceBudget::new(inner, 1024);
728
729        let error = budget
730            .allocate_with_reservation_pressure(600, 500, StreamId::DEFAULT, AllocTag::UNTAGGED)
731            .expect_err("runtime promises must reduce ordinary allocation headroom");
732        assert!(matches!(
733            error,
734            ResourceError::OutOfBudget {
735                requested: 600,
736                current: 500,
737                remaining: 524,
738                limit: 1024,
739            }
740        ));
741        assert_eq!(budget.reserved_bytes(), 0);
742    }
743
744    #[test]
745    fn deallocate_releases_budget_immediately_for_synchronous_inner() {
746        // DirectCudaResource is treated as synchronous from the
747        // budget's perspective: physical release during deallocate updates
748        // the shared ledger before the next admission snapshot.
749        let Some(device) = try_device() else {
750            return;
751        };
752        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
753        let budget = GlobalDeviceBudget::new(inner, 16 * 1024);
754
755        let block = budget
756            .allocate(8 * 1024, StreamId::DEFAULT, AllocTag::UNTAGGED)
757            .expect("alloc");
758        assert_eq!(budget.reserved_bytes(), 8 * 1024);
759        budget.deallocate(block).expect("dealloc");
760        assert_eq!(
761            budget.reserved_bytes(),
762            0,
763            "synchronous inner releases budget at deallocate"
764        );
765        // reap is a no-op for sync inners; budget unchanged.
766        budget.reap_pending().expect("reap noop");
767        assert_eq!(budget.reserved_bytes(), 0);
768    }
769
770    #[test]
771    fn deallocate_holds_budget_for_async_inner_until_reap_pending() {
772        let Some(device) = try_device() else {
773            return;
774        };
775        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
776        let inner = Box::new(AsyncCudaResource::new(
777            Arc::clone(&device),
778            0,
779            Arc::clone(&pool),
780        ));
781        let budget = GlobalDeviceBudget::new(inner, 32 * 1024);
782
783        let block = budget
784            .allocate(4096, StreamId::DEFAULT, AllocTag("budget-async"))
785            .expect("alloc");
786        assert_eq!(budget.reserved_bytes(), 4096);
787
788        // After deallocate the cuMemFreeAsync is queued but not
789        // drained; bytes_outstanding still shows 4096 (live → pending),
790        // so the budget MUST NOT release yet.
791        budget.deallocate(block).expect("dealloc");
792        assert_eq!(
793            budget.reserved_bytes(),
794            4096,
795            "async inner: budget must stay reserved until reap_pending drains pending free"
796        );
797        assert_eq!(budget.bytes_outstanding(), 4096);
798
799        budget.reap_pending().expect("reap");
800        assert_eq!(
801            budget.reserved_bytes(),
802            0,
803            "async inner: reap_pending releases the pending bytes"
804        );
805        assert_eq!(budget.bytes_outstanding(), 0);
806    }
807
808    #[test]
809    fn deallocate_unknown_block_does_not_release_budget() {
810        let Some(device) = try_device() else {
811            return;
812        };
813        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
814        let budget = GlobalDeviceBudget::new(inner, 16 * 1024);
815
816        let block = budget
817            .allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED)
818            .expect("alloc");
819        assert_eq!(budget.reserved_bytes(), 2048);
820
821        // Bogus block — inner returns UseAfterFree without freeing
822        // anything; budget must not move.
823        let bogus = DeviceBlock {
824            ptr: 0xfeed_face,
825            device_ordinal: 0,
826            alloc_stream: StreamId::DEFAULT,
827            bytes: 1024,
828            align: 1,
829            tag: AllocTag::UNTAGGED,
830            generation: Generation::next(),
831            state: BlockState::Live,
832        };
833        let res = budget.deallocate(bogus);
834        assert!(matches!(res, Err(ResourceError::UseAfterFree { .. })));
835        assert_eq!(
836            budget.reserved_bytes(),
837            2048,
838            "bogus dealloc must not release budget"
839        );
840
841        budget.deallocate(block).expect("real dealloc");
842        assert_eq!(budget.reserved_bytes(), 0);
843    }
844
845    #[test]
846    fn forwards_device_ordinal() {
847        let inner = Box::new(AlwaysFailAllocResource::new(7));
848        let budget = GlobalDeviceBudget::new(inner, 1024);
849        assert_eq!(budget.device_ordinal(), 7);
850    }
851}