Skip to main content

xlog_cuda/device_runtime/
budget.rs

1//! [`GlobalDeviceBudget`] — per-runtime byte-limit decorator.
2//!
3//! Wraps a [`DeviceMemoryResource`] and enforces a single byte limit
4//! across all allocations that flow through it. Designed to be the
5//! per-runtime singleton replacement for the v0.5 per-provider
6//! `GpuMemoryManager` (which had no way to enforce a coherent budget
7//! across parallel tests, multiple providers, or Python callers
8//! sharing one physical GPU).
9//!
10//! # Accounting model
11//!
12//! `GlobalDeviceBudget` keeps `reserved_bytes` strictly equal to
13//! `inner.bytes_outstanding()` at every quiescent moment. This is the
14//! "live + retired-but-not-yet-freed" view from the trait — exactly
15//! the bytes the budget should be guarding.
16//!
17//! To keep that invariant under both synchronous and stream-ordered
18//! async inners, every public method is serialized through a single
19//! `Mutex<BudgetState>` and the inner call is invoked **inside** the
20//! lock. The lock window is bounded by the inner's CUDA call, which
21//! is in any case the dominant cost — the budget decorator does not
22//! add hot-path overhead beyond what the inner already imposes.
23//!
24//! ## Allocate
25//!
26//!   1. Lock state.
27//!   2. If `reserved + bytes > limit`: return
28//!      `ResourceError::OutOfBudget` with exact current, requested,
29//!      remaining, and configured-limit bytes.
30//!   3. Optimistically reserve: `reserved += bytes`.
31//!   4. Call `inner.allocate(bytes, ..)` under the lock. The inner's
32//!      own bookkeeping moves `bytes` from "free" to "live".
33//!   5. If inner returned `Err`, roll back the reservation:
34//!      `reserved -= bytes`. Forward the error.
35//!
36//! ## Deallocate / Reap
37//!
38//! For both methods we sample `inner.bytes_outstanding()` before and
39//! after the inner call (under the lock), and decrement `reserved`
40//! by the observed delta. The pattern handles both backends without
41//! branching:
42//!
43//!   * Synchronous inner (`DirectCudaResource`): `bytes_outstanding`
44//!     drops by the block's bytes on `deallocate`, so the delta is
45//!     `block.bytes`. `reap_pending` is a no-op (delta zero).
46//!   * Stream-ordered async inner (`AsyncCudaResource`): `deallocate`
47//!     moves bytes from "live" to "pending"; `bytes_outstanding`
48//!     stays the same, so the delta is zero — the budget is *not*
49//!     released yet. `reap_pending` drains the pending bytes whose
50//!     queued `cuMemFreeAsync` has completed; `bytes_outstanding`
51//!     drops by the drained total and the budget releases that
52//!     same total.
53//!
54//! Because the inner call and the before/after samples happen under
55//! the same lock, no concurrent budget op can perturb the inner's
56//! `bytes_outstanding` between our reads — the delta strictly
57//! reflects this call's effect on the inner.
58//!
59//! # Composition
60//!
61//! `GlobalDeviceBudget` is a normal `DeviceMemoryResource`, so it
62//! plugs into [`XlogDeviceRuntime::with_resource`] and stacks under
63//! / over [`LoggingResource`]. Recommended ordering for production:
64//! `GlobalDeviceBudget(LoggingResource(AsyncCudaResource))`. That
65//! gives the budget atomic accounting, the logger sees the
66//! eventually-applied call (so `OutOfBudget` errors do not get
67//! double-logged), and the underlying allocator is reached last.
68//! Tests can stack either way.
69
70use std::sync::Mutex;
71
72use super::resource::{
73    Access, AllocTag, BlockId, DeviceBlock, DeviceMemoryResource, ResourceBudgetSnapshot,
74    ResourceError, ResourceResult, StreamId,
75};
76
77/// Internal state guarded by the budget mutex. Kept in its own
78/// struct so the lock guard syntactically scopes all updates.
79struct BudgetState {
80    reserved: usize,
81}
82
83/// Per-runtime byte-limit decorator.
84pub struct GlobalDeviceBudget {
85    inner: Box<dyn DeviceMemoryResource + Send + Sync>,
86    limit: usize,
87    state: Mutex<BudgetState>,
88}
89
90impl GlobalDeviceBudget {
91    /// Wrap `inner` with a hard `limit` in bytes. The initial
92    /// reserved tally is sampled from `inner.bytes_outstanding()`
93    /// so callers may compose around an inner that already has live
94    /// allocations — though in practice the decorator is installed
95    /// before any allocation flows through it.
96    pub fn new(inner: Box<dyn DeviceMemoryResource + Send + Sync>, limit: usize) -> Self {
97        let initial = inner.bytes_outstanding();
98        Self {
99            inner,
100            limit,
101            state: Mutex::new(BudgetState { reserved: initial }),
102        }
103    }
104
105    /// Hard byte limit. Set at construction; not adjustable.
106    pub fn limit(&self) -> usize {
107        self.limit
108    }
109
110    /// Bytes currently reserved against the budget (live + pending
111    /// async free). Matches `inner.bytes_outstanding()` at every
112    /// quiescent moment.
113    pub fn reserved_bytes(&self) -> usize {
114        self.state
115            .lock()
116            .expect("GlobalDeviceBudget poisoned")
117            .reserved
118    }
119
120    /// Headroom in bytes for the next allocation. Equal to
121    /// `limit - reserved_bytes`, saturating at zero.
122    pub fn remaining(&self) -> usize {
123        let state = self.state.lock().expect("GlobalDeviceBudget poisoned");
124        self.limit.saturating_sub(state.reserved)
125    }
126}
127
128impl DeviceMemoryResource for GlobalDeviceBudget {
129    fn allocate(
130        &self,
131        bytes: usize,
132        stream: StreamId,
133        tag: AllocTag,
134    ) -> ResourceResult<DeviceBlock> {
135        // First-pass reservation attempt under the budget lock.
136        // If the request fits, reserve and forward to the inner
137        // immediately.
138        {
139            let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
140            let remaining = self.limit.saturating_sub(state.reserved);
141            if bytes <= remaining {
142                state.reserved = state.reserved.saturating_add(bytes);
143                drop(state);
144                return match self.inner.allocate(bytes, stream, tag) {
145                    Ok(block) => Ok(block),
146                    Err(e) => {
147                        let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
148                        state.reserved = state.reserved.saturating_sub(bytes);
149                        Err(e)
150                    }
151                };
152            }
153            // Genuinely oversized requests can never fit even
154            // after a reap. Short-circuit before touching the
155            // inner stack so the rejection stays cheap and does
156            // not emit a reap log record.
157            if bytes > self.limit {
158                return Err(ResourceError::OutOfBudget {
159                    requested: bytes,
160                    current: state.reserved,
161                    remaining,
162                    limit: self.limit,
163                });
164            }
165        }
166
167        // Second pass: reservation didn't fit. With the
168        // stream-ordered async backend, dropped buffers transit
169        // through `pending_per_stream` until `reap_pending` runs;
170        // their bytes still count against `state.reserved`. Tight
171        // allocate-then-drop loops (cert hardware sustained
172        // tests; recursive Datalog inner loops without explicit
173        // reap) hit this even when the GPU has plenty of free
174        // memory. Drain pending frees once and retry. If the
175        // retry still fails, the budget is genuinely exhausted
176        // and the caller should see `OutOfBudget` as before.
177        //
178        // Reap is performed WITHOUT holding `state` so the inner
179        // resource's own locks can run; reap itself updates
180        // `state.reserved` via `Self::reap_pending` (which takes
181        // the lock) so a concurrent racing allocate sees the
182        // freed bytes.
183        let _ = self.reap_pending();
184
185        let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
186        let remaining = self.limit.saturating_sub(state.reserved);
187        if bytes > remaining {
188            return Err(ResourceError::OutOfBudget {
189                requested: bytes,
190                current: state.reserved,
191                remaining,
192                limit: self.limit,
193            });
194        }
195        state.reserved = state.reserved.saturating_add(bytes);
196        drop(state);
197
198        match self.inner.allocate(bytes, stream, tag) {
199            Ok(block) => Ok(block),
200            Err(e) => {
201                let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
202                state.reserved = state.reserved.saturating_sub(bytes);
203                Err(e)
204            }
205        }
206    }
207
208    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
209        let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
210
211        let before = self.inner.bytes_outstanding();
212        let result = self.inner.deallocate(block);
213        let after = self.inner.bytes_outstanding();
214        let freed = before.saturating_sub(after);
215        if freed > 0 {
216            state.reserved = state.reserved.saturating_sub(freed);
217        }
218        result
219    }
220
221    fn device_ordinal(&self) -> u32 {
222        self.inner.device_ordinal()
223    }
224
225    fn bytes_outstanding(&self) -> usize {
226        // Authoritative view is the inner's. We could return our
227        // own `reserved` instead, but matching the inner sidesteps
228        // any transient skew during error rollback.
229        self.inner.bytes_outstanding()
230    }
231
232    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
233        let state = self.state.lock().expect("GlobalDeviceBudget poisoned");
234        Some(ResourceBudgetSnapshot {
235            limit: self.limit,
236            reserved: state.reserved,
237        })
238    }
239
240    fn reap_pending(&self) -> ResourceResult<()> {
241        let mut state = self.state.lock().expect("GlobalDeviceBudget poisoned");
242
243        let before = self.inner.bytes_outstanding();
244        let result = self.inner.reap_pending();
245        let after = self.inner.bytes_outstanding();
246        let freed = before.saturating_sub(after);
247        if freed > 0 {
248            state.reserved = state.reserved.saturating_sub(freed);
249        }
250        result
251    }
252
253    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
254        // Pass-through: budget enforcement does not affect
255        // cross-stream lifetime tracking; the inner resource (the
256        // stream-ordered backend) is the only layer that owns
257        // last-use events.
258        self.inner.record_block_use(block, use_stream)
259    }
260
261    fn supports_block_use_tracking(&self) -> bool {
262        self.inner.supports_block_use_tracking()
263    }
264
265    fn prepare_block_use(
266        &self,
267        block: BlockId,
268        use_stream: StreamId,
269        access: Access,
270    ) -> ResourceResult<()> {
271        // Pass-through: cross-stream waits live in the
272        // stream-ordered backend; budget accounting is unaffected.
273        self.inner.prepare_block_use(block, use_stream, access)
274    }
275
276    fn finish_block_use(
277        &self,
278        block: BlockId,
279        use_stream: StreamId,
280        access: Access,
281    ) -> ResourceResult<()> {
282        // Pass-through: see prepare_block_use rationale above.
283        self.inner.finish_block_use(block, use_stream, access)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::super::async_resource::AsyncCudaResource;
290    use super::super::direct::DirectCudaResource;
291    use super::super::resource::{BlockState, Generation};
292    use super::super::stream_pool::StreamPool;
293    use super::*;
294    use std::sync::Arc;
295
296    use crate::CudaDevice;
297
298    fn try_device() -> Option<Arc<CudaDevice>> {
299        match CudaDevice::new(0) {
300            Ok(device) => Some(Arc::new(device)),
301            Err(error) if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") => {
302                panic!("XLOG_REQUIRE_CUDA=1 but CUDA initialization failed: {error}")
303            }
304            Err(error) => {
305                eprintln!("Skipping test: CUDA runtime unavailable: {error}");
306                None
307            }
308        }
309    }
310
311    /// Test fixture that always fails `allocate` so we can exercise
312    /// the rollback path without touching CUDA. `deallocate` and
313    /// `reap_pending` are no-ops; `bytes_outstanding` reflects an
314    /// internally tracked tally so the budget's delta-sampling logic
315    /// is also exercised.
316    struct AlwaysFailAllocResource {
317        ord: u32,
318        outstanding: std::sync::atomic::AtomicUsize,
319    }
320
321    impl AlwaysFailAllocResource {
322        fn new(ord: u32) -> Self {
323            Self {
324                ord,
325                outstanding: std::sync::atomic::AtomicUsize::new(0),
326            }
327        }
328    }
329
330    impl DeviceMemoryResource for AlwaysFailAllocResource {
331        fn allocate(
332            &self,
333            _bytes: usize,
334            _stream: StreamId,
335            _tag: AllocTag,
336        ) -> ResourceResult<DeviceBlock> {
337            Err(ResourceError::Driver("inner always fails".into()))
338        }
339        fn deallocate(&self, _block: DeviceBlock) -> ResourceResult<()> {
340            Ok(())
341        }
342        fn device_ordinal(&self) -> u32 {
343            self.ord
344        }
345        fn bytes_outstanding(&self) -> usize {
346            self.outstanding.load(std::sync::atomic::Ordering::Relaxed)
347        }
348    }
349
350    #[test]
351    fn allocate_within_limit_succeeds_and_updates_reserved() {
352        let Some(device) = try_device() else {
353            return;
354        };
355        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
356        let budget = GlobalDeviceBudget::new(inner, 64 * 1024);
357
358        let block = budget
359            .allocate(2048, StreamId::DEFAULT, AllocTag("budget-success"))
360            .expect("alloc within limit");
361        assert_eq!(budget.reserved_bytes(), 2048);
362        assert_eq!(budget.remaining(), 64 * 1024 - 2048);
363        assert_eq!(budget.bytes_outstanding(), 2048);
364
365        budget.deallocate(block).expect("dealloc");
366        assert_eq!(budget.reserved_bytes(), 0);
367        assert_eq!(budget.bytes_outstanding(), 0);
368    }
369
370    #[test]
371    fn allocate_at_exact_limit_succeeds_then_next_byte_rejected() {
372        let Some(device) = try_device() else {
373            return;
374        };
375        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
376        let budget = GlobalDeviceBudget::new(inner, 4096);
377
378        let block = budget
379            .allocate(4096, StreamId::DEFAULT, AllocTag::UNTAGGED)
380            .expect("alloc at exact limit");
381        assert_eq!(budget.reserved_bytes(), 4096);
382        assert_eq!(budget.remaining(), 0);
383
384        let err = budget.allocate(1, StreamId::DEFAULT, AllocTag::UNTAGGED);
385        assert!(
386            matches!(
387                err,
388                Err(ResourceError::OutOfBudget {
389                    requested: 1,
390                    current: 4096,
391                    remaining: 0,
392                    limit: 4096,
393                })
394            ),
395            "expected OutOfBudget {{1,0}}, got {:?}",
396            err
397        );
398        // Failed alloc must not perturb reserved.
399        assert_eq!(budget.reserved_bytes(), 4096);
400
401        budget.deallocate(block).expect("dealloc");
402        assert_eq!(budget.reserved_bytes(), 0);
403    }
404
405    #[test]
406    fn over_limit_alloc_returns_out_of_budget_with_correct_remaining() {
407        let Some(device) = try_device() else {
408            return;
409        };
410        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
411        let budget = GlobalDeviceBudget::new(inner, 1024);
412
413        // First alloc takes 768 bytes → 256 remaining.
414        let block = budget
415            .allocate(768, StreamId::DEFAULT, AllocTag::UNTAGGED)
416            .expect("first alloc");
417        assert_eq!(budget.remaining(), 256);
418
419        let err = budget.allocate(512, StreamId::DEFAULT, AllocTag::UNTAGGED);
420        assert!(
421            matches!(
422                err,
423                Err(ResourceError::OutOfBudget {
424                    requested: 512,
425                    current: 768,
426                    remaining: 256,
427                    limit: 1024,
428                })
429            ),
430            "expected OutOfBudget {{512,256}}, got {:?}",
431            err
432        );
433
434        budget.deallocate(block).expect("dealloc");
435    }
436
437    #[test]
438    fn memory_pressure_runtime_budget_reports_exact_limit() {
439        let Some(device) = try_device() else {
440            return;
441        };
442        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
443        let budget = GlobalDeviceBudget::new(inner, 1024);
444        let block = budget
445            .allocate(768, StreamId::DEFAULT, AllocTag::UNTAGGED)
446            .expect("baseline allocation");
447
448        let error = budget
449            .allocate(512, StreamId::DEFAULT, AllocTag::UNTAGGED)
450            .expect_err("cumulative allocation must exceed the runtime budget");
451
452        assert_eq!(
453            format!("{error:?}"),
454            "OutOfBudget { requested: 512, current: 768, remaining: 256, limit: 1024 }"
455        );
456        assert_eq!(budget.reserved_bytes(), 768);
457        budget.deallocate(block).expect("dealloc");
458    }
459
460    #[test]
461    fn failed_inner_allocation_rolls_back_reservation() {
462        // No CUDA dependency — the fake inner always errors.
463        let inner = Box::new(AlwaysFailAllocResource::new(0));
464        let budget = GlobalDeviceBudget::new(inner, 1024 * 1024);
465        assert_eq!(budget.reserved_bytes(), 0);
466
467        let err = budget.allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED);
468        assert!(matches!(err, Err(ResourceError::Driver(_))));
469        // Reservation must be rolled back: no live or pending bytes
470        // landed on the inner, so reserved stays at the pre-call
471        // value (0).
472        assert_eq!(budget.reserved_bytes(), 0);
473        assert_eq!(budget.remaining(), 1024 * 1024);
474    }
475
476    #[test]
477    fn deallocate_releases_budget_immediately_for_synchronous_inner() {
478        // DirectCudaResource is treated as synchronous from the
479        // budget's perspective: bytes_outstanding drops at
480        // deallocate time, so the delta-based release fires there.
481        let Some(device) = try_device() else {
482            return;
483        };
484        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
485        let budget = GlobalDeviceBudget::new(inner, 16 * 1024);
486
487        let block = budget
488            .allocate(8 * 1024, StreamId::DEFAULT, AllocTag::UNTAGGED)
489            .expect("alloc");
490        assert_eq!(budget.reserved_bytes(), 8 * 1024);
491        budget.deallocate(block).expect("dealloc");
492        assert_eq!(
493            budget.reserved_bytes(),
494            0,
495            "synchronous inner releases budget at deallocate"
496        );
497        // reap is a no-op for sync inners; budget unchanged.
498        budget.reap_pending().expect("reap noop");
499        assert_eq!(budget.reserved_bytes(), 0);
500    }
501
502    #[test]
503    fn deallocate_holds_budget_for_async_inner_until_reap_pending() {
504        let Some(device) = try_device() else {
505            return;
506        };
507        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
508        let inner = Box::new(AsyncCudaResource::new(
509            Arc::clone(&device),
510            0,
511            Arc::clone(&pool),
512        ));
513        let budget = GlobalDeviceBudget::new(inner, 32 * 1024);
514
515        let block = budget
516            .allocate(4096, StreamId::DEFAULT, AllocTag("budget-async"))
517            .expect("alloc");
518        assert_eq!(budget.reserved_bytes(), 4096);
519
520        // After deallocate the cuMemFreeAsync is queued but not
521        // drained; bytes_outstanding still shows 4096 (live → pending),
522        // so the budget MUST NOT release yet.
523        budget.deallocate(block).expect("dealloc");
524        assert_eq!(
525            budget.reserved_bytes(),
526            4096,
527            "async inner: budget must stay reserved until reap_pending drains pending free"
528        );
529        assert_eq!(budget.bytes_outstanding(), 4096);
530
531        budget.reap_pending().expect("reap");
532        assert_eq!(
533            budget.reserved_bytes(),
534            0,
535            "async inner: reap_pending releases the pending bytes"
536        );
537        assert_eq!(budget.bytes_outstanding(), 0);
538    }
539
540    #[test]
541    fn deallocate_unknown_block_does_not_release_budget() {
542        let Some(device) = try_device() else {
543            return;
544        };
545        let inner = Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
546        let budget = GlobalDeviceBudget::new(inner, 16 * 1024);
547
548        let block = budget
549            .allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED)
550            .expect("alloc");
551        assert_eq!(budget.reserved_bytes(), 2048);
552
553        // Bogus block — inner returns UseAfterFree without freeing
554        // anything; budget must not move.
555        let bogus = DeviceBlock {
556            ptr: 0xfeed_face,
557            device_ordinal: 0,
558            alloc_stream: StreamId::DEFAULT,
559            bytes: 1024,
560            align: 1,
561            tag: AllocTag::UNTAGGED,
562            generation: Generation::next(),
563            state: BlockState::Live,
564        };
565        let res = budget.deallocate(bogus);
566        assert!(matches!(res, Err(ResourceError::UseAfterFree { .. })));
567        assert_eq!(
568            budget.reserved_bytes(),
569            2048,
570            "bogus dealloc must not release budget"
571        );
572
573        budget.deallocate(block).expect("real dealloc");
574        assert_eq!(budget.reserved_bytes(), 0);
575    }
576
577    #[test]
578    fn forwards_device_ordinal() {
579        let inner = Box::new(AlwaysFailAllocResource::new(7));
580        let budget = GlobalDeviceBudget::new(inner, 1024);
581        assert_eq!(budget.device_ordinal(), 7);
582    }
583}