tensor_wasm_wasi_gpu/device_mem.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! [`DeviceMemRegistry`] — instance-scoped store of explicit device-memory
5//! allocations.
6//!
7//! The `wasi:cuda` host surface historically relied on CUDA Unified Memory:
8//! a guest pointer argument was bounds-checked against linear memory and
9//! handed to `cuLaunchKernel` verbatim, the UVM driver migrating pages on
10//! demand. That limits portability to UVM-capable setups. The explicit
11//! device-buffer surface (`alloc` / `free` / `memcpy-h2d` / `memcpy-d2h`)
12//! lets a guest manage discrete device buffers that work on any CUDA host.
13//!
14//! This registry is the host-side bookkeeping for those buffers. It mirrors
15//! [`crate::registry::KernelRegistry`] exactly:
16//!
17//! * every allocation is tagged with its owning [`InstanceId`], and
18//! [`DeviceMemRegistry::lookup`] / [`DeviceMemRegistry::free`] refuse a
19//! handle that belongs to a different instance (`AbiError::InvalidHandle`)
20//! — a guest cannot forge another instance's handle;
21//! * an aggregate-bytes cap ([`MAX_TOTAL_DEVICE_BYTES`]) is enforced with a
22//! compare-and-swap loop so one instance cannot pin unbounded device
23//! memory before tripping the per-instance count cap
24//! ([`MAX_DEVICE_ALLOCS_PER_INSTANCE`]).
25//!
26//! ## Process-wide aggregate ceiling (M-3)
27//!
28//! The per-instance caps above are defence-in-depth but do not bound the
29//! *aggregate* across instances: each [`WasiCudaContext`] historically owns
30//! its own registry, so N instances could each admit up to
31//! [`MAX_TOTAL_DEVICE_BYTES`] with no global ceiling. Every registry therefore
32//! also charges a shared [`DeviceMemBudget`] — a process-wide counter of live
33//! device bytes and allocations — and refuses an `insert` that would push the
34//! shared total over [`MAX_PROCESS_DEVICE_BYTES`] (or the shared count over
35//! [`MAX_PROCESS_DEVICE_ALLOCS`]). By default every [`DeviceMemRegistry::new`]
36//! attaches to the same [`process_device_budget`] singleton, so the ceiling is
37//! genuinely process-wide regardless of how many contexts are created. This
38//! mirrors the intent of the shared [`crate::async_dispatch::BackPressure`]
39//! cap (one process-wide ceiling shared by every instance), but does not
40//! depend on the executor remembering to thread a clone: the singleton is the
41//! default. Tests that need an isolated budget use
42//! [`DeviceMemRegistry::with_budget`].
43//!
44//! [`WasiCudaContext`]: crate::host::WasiCudaContext
45//! On no-CUDA builds the registry records the *requested* size and a synthetic
46//! handle (no real `cuMemAlloc` runs); the host functions validate arguments
47//! and then return [`AbiError::NotAvailable`]. On CUDA builds the registry
48//! additionally carries the real device pointer (`CUdeviceptr`) so the
49//! `memcpy` paths can drive `cuMemcpyHtoD` / `cuMemcpyDtoH`.
50
51use std::sync::atomic::{AtomicU64, Ordering};
52use std::sync::{Arc, OnceLock};
53
54use dashmap::DashMap;
55use tensor_wasm_core::types::InstanceId;
56
57use crate::abi::AbiError;
58
59/// Maximum size of a single `alloc` request, in bytes (256 MiB).
60///
61/// A request above this cap is rejected with [`AbiError::QuotaExceeded`]
62/// before any driver call. Sized to comfortably hold a large tensor tile
63/// while bounding the damage a single hostile `alloc` can do.
64pub const MAX_DEVICE_ALLOC_BYTES: u64 = 256 * 1024 * 1024;
65
66/// Maximum number of live device allocations a single instance may hold.
67pub const MAX_DEVICE_ALLOCS_PER_INSTANCE: usize = 4096;
68
69/// Soft cap on aggregate retained device bytes per instance (sum of live
70/// allocation sizes). Set to 16x the per-call cap (4 GiB) so a single
71/// instance cannot pin unbounded device memory below the per-call ceiling
72/// before tripping [`MAX_DEVICE_ALLOCS_PER_INSTANCE`].
73pub const MAX_TOTAL_DEVICE_BYTES: u64 = 16 * MAX_DEVICE_ALLOC_BYTES;
74
75/// Process-wide ceiling on aggregate live device bytes across *every*
76/// instance's registry (M-3).
77///
78/// The per-instance [`MAX_TOTAL_DEVICE_BYTES`] cap bounds one registry, but
79/// without a shared ceiling N instances could each pin that much, so the true
80/// process exposure scales with the instance count. This constant is the
81/// shared upper bound that the [`DeviceMemBudget`] enforces in addition to the
82/// per-instance caps.
83///
84/// Sized at 4x the per-instance aggregate cap (64 GiB): generous enough that a
85/// handful of well-behaved instances are never throttled by it, while still
86/// putting a finite ceiling on total pinned device memory so a fan-out of
87/// instances cannot collectively exhaust the device. The multiple (rather than
88/// equality with the per-instance cap) keeps the per-instance cap meaningful
89/// as the *first* line of defence — a single runaway instance trips its own
90/// cap long before it can monopolise the process budget. Hosts with smaller
91/// GPUs should treat this as an upper bound and rely on the per-instance cap
92/// plus the real `cuMemAlloc` failure for tighter physical limits.
93pub const MAX_PROCESS_DEVICE_BYTES: u64 = 4 * MAX_TOTAL_DEVICE_BYTES;
94
95/// Process-wide ceiling on the number of live device allocations across every
96/// instance's registry (M-3).
97///
98/// Mirrors [`MAX_PROCESS_DEVICE_BYTES`] for the count axis: 4x the per-instance
99/// count cap, so the per-instance count cap remains the first tripwire while a
100/// process-wide ceiling still bounds total live handles (each of which costs a
101/// driver allocation and registry slot).
102pub const MAX_PROCESS_DEVICE_ALLOCS: usize = 4 * MAX_DEVICE_ALLOCS_PER_INSTANCE;
103
104/// Process-wide aggregate device-memory budget shared by every
105/// [`DeviceMemRegistry`].
106///
107/// Tracks the sum of live allocation sizes and the count of live allocations
108/// across all instances. A registry charges this budget on `insert` (refusing
109/// the allocation with [`AbiError::QuotaExceeded`] when it would push the
110/// shared total over [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`])
111/// and credits it back on `free` / drop. All arithmetic is checked /
112/// saturating so a bookkeeping slip can never wrap the counters.
113///
114/// This is the device-memory analogue of the shared
115/// [`crate::async_dispatch::BackPressure`] cap: one process-wide ceiling shared
116/// by every instance. Unlike `BackPressure`, the default registry constructor
117/// attaches to the [`process_device_budget`] singleton automatically, so the
118/// ceiling holds even if an embedder constructs contexts without explicitly
119/// threading shared state.
120#[derive(Debug, Default)]
121pub struct DeviceMemBudget {
122 /// Sum of live allocation sizes across all attached registries.
123 total_bytes: AtomicU64,
124 /// Count of live allocations across all attached registries.
125 total_allocs: AtomicU64,
126}
127
128impl DeviceMemBudget {
129 /// Construct an empty budget. Most callers want the shared
130 /// [`process_device_budget`] singleton instead; this is for tests that
131 /// need an isolated budget.
132 pub fn new() -> Self {
133 Self {
134 total_bytes: AtomicU64::new(0),
135 total_allocs: AtomicU64::new(0),
136 }
137 }
138
139 /// Reserve `size` bytes (one allocation) against the process-wide budget.
140 ///
141 /// Uses a compare-and-swap loop on the byte counter so the check + add is
142 /// atomic against concurrent allocations from other instances. On success
143 /// the count is bumped and `Ok(())` returned; if the reservation would
144 /// breach either process cap, nothing is mutated and
145 /// [`AbiError::QuotaExceeded`] is returned.
146 fn try_reserve(&self, size: u64) -> Result<(), AbiError> {
147 // Count axis first: a single fetch_add we roll back if it overshoots,
148 // mirroring the byte CAS below. Doing the count check up front keeps
149 // the (rarer) byte CAS from spinning when the count cap is the gate.
150 let prev_allocs = self.total_allocs.fetch_add(1, Ordering::AcqRel);
151 if prev_allocs >= MAX_PROCESS_DEVICE_ALLOCS as u64 {
152 // Roll back the speculative bump and refuse.
153 self.total_allocs.fetch_sub(1, Ordering::AcqRel);
154 return Err(AbiError::QuotaExceeded);
155 }
156 let mut current = self.total_bytes.load(Ordering::Acquire);
157 loop {
158 let next = current.saturating_add(size);
159 if next > MAX_PROCESS_DEVICE_BYTES {
160 // Byte cap tripped — release the count reservation we took.
161 self.total_allocs.fetch_sub(1, Ordering::AcqRel);
162 return Err(AbiError::QuotaExceeded);
163 }
164 match self.total_bytes.compare_exchange_weak(
165 current,
166 next,
167 Ordering::AcqRel,
168 Ordering::Acquire,
169 ) {
170 Ok(_) => return Ok(()),
171 Err(observed) => current = observed,
172 }
173 }
174 }
175
176 /// Credit `size` bytes (one allocation) back to the budget on free / drop.
177 /// Saturating so a double-credit can never wrap below zero.
178 fn release(&self, size: u64) {
179 let _ = self
180 .total_bytes
181 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
182 Some(cur.saturating_sub(size))
183 });
184 let _ = self
185 .total_allocs
186 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
187 Some(cur.saturating_sub(1))
188 });
189 }
190
191 /// Aggregate live device bytes across every attached registry. Visible for
192 /// metrics and tests.
193 pub fn total_bytes(&self) -> u64 {
194 self.total_bytes.load(Ordering::Acquire)
195 }
196
197 /// Aggregate live allocation count across every attached registry. Visible
198 /// for metrics and tests.
199 pub fn total_allocs(&self) -> u64 {
200 self.total_allocs.load(Ordering::Acquire)
201 }
202}
203
204/// The process-wide [`DeviceMemBudget`] singleton.
205///
206/// Every [`DeviceMemRegistry::new`] attaches to this, so the
207/// [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`] ceiling is
208/// enforced across all instances in the process without the embedder having to
209/// thread shared state explicitly. Tests that need isolation construct their
210/// own budget via [`DeviceMemRegistry::with_budget`].
211pub fn process_device_budget() -> &'static Arc<DeviceMemBudget> {
212 static BUDGET: OnceLock<Arc<DeviceMemBudget>> = OnceLock::new();
213 BUDGET.get_or_init(|| Arc::new(DeviceMemBudget::new()))
214}
215
216/// Metadata about a single device-memory allocation.
217#[derive(Debug)]
218pub struct DeviceMemEntry {
219 /// Owning instance (used to authorise `free` / `memcpy` calls).
220 pub owner: InstanceId,
221 /// Requested allocation size in bytes.
222 pub size: u64,
223 /// CUDA device pointer; only meaningful when the `cuda` feature is
224 /// enabled. On no-CUDA builds the field is absent so the registry can
225 /// still be exercised by tests.
226 #[cfg(feature = "cuda")]
227 pub device_ptr: cust::sys::CUdeviceptr,
228}
229
230/// Cheap handle to a device-memory entry's stable fields.
231///
232/// Returned by [`DeviceMemRegistry::lookup`]. Carries the device pointer on
233/// CUDA builds so the `memcpy` paths can drive the driver without holding the
234/// `dashmap` entry borrow across the copy.
235#[derive(Clone, Debug)]
236pub struct DeviceMemHandle {
237 /// Owning instance.
238 pub owner: InstanceId,
239 /// Allocation size in bytes.
240 pub size: u64,
241 /// Device pointer on CUDA builds.
242 #[cfg(feature = "cuda")]
243 pub device_ptr: cust::sys::CUdeviceptr,
244}
245
246/// Instance-scoped device-memory registry.
247pub struct DeviceMemRegistry {
248 next_handle: AtomicU64,
249 entries: DashMap<u64, DeviceMemEntry>,
250 /// Sum of live allocation sizes. Tracked separately so `insert` can
251 /// reject above [`MAX_TOTAL_DEVICE_BYTES`] without scanning the map.
252 total_device_bytes: AtomicU64,
253 /// Process-wide aggregate budget this registry charges in addition to its
254 /// own per-instance caps (M-3). Shared by every registry created via
255 /// [`Self::new`] (the [`process_device_budget`] singleton); tests may
256 /// supply an isolated budget via [`Self::with_budget`].
257 budget: Arc<DeviceMemBudget>,
258}
259
260impl Default for DeviceMemRegistry {
261 fn default() -> Self {
262 Self::new()
263 }
264}
265
266impl DeviceMemRegistry {
267 /// Construct an empty registry.
268 ///
269 /// Handles start at 1 so callers may reserve 0 as a sentinel; they are
270 /// otherwise sequential. Cross-instance forgery is prevented by the
271 /// owner-`InstanceId` check in [`Self::lookup`] / [`Self::free`], so the
272 /// handle space does not need the randomised-seed treatment the kernel
273 /// registry uses for its ids.
274 pub fn new() -> Self {
275 Self::with_budget(Arc::clone(process_device_budget()))
276 }
277
278 /// Construct an empty registry charging the given process-wide
279 /// [`DeviceMemBudget`] instead of the shared [`process_device_budget`]
280 /// singleton.
281 ///
282 /// Intended for tests that need an isolated aggregate so the process-wide
283 /// caps can be exercised without the global singleton bleeding state
284 /// across test cases (which run in the same process). Production code
285 /// should use [`Self::new`] so every instance shares the one ceiling.
286 pub fn with_budget(budget: Arc<DeviceMemBudget>) -> Self {
287 Self {
288 next_handle: AtomicU64::new(1),
289 entries: DashMap::new(),
290 total_device_bytes: AtomicU64::new(0),
291 budget,
292 }
293 }
294
295 /// Reserve aggregate-bytes budget for a new allocation, returning the
296 /// freshly-assigned handle on success.
297 ///
298 /// Enforces [`MAX_DEVICE_ALLOCS_PER_INSTANCE`] and
299 /// [`MAX_TOTAL_DEVICE_BYTES`] (the per-instance caps) **and** the
300 /// process-wide [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`]
301 /// caps via the shared [`DeviceMemBudget`] (all returning
302 /// [`AbiError::QuotaExceeded`]) before inserting the entry. The per-call
303 /// [`MAX_DEVICE_ALLOC_BYTES`] cap is the caller's responsibility (the host
304 /// function checks it before any driver call) — this method only enforces
305 /// the aggregate caps so the check + add stays atomic against concurrent
306 /// allocations.
307 ///
308 /// The per-instance caps are checked first (defence in depth: a single
309 /// runaway instance trips its own cap before it can charge the shared
310 /// budget), then the process-wide budget. If the process budget refuses,
311 /// the per-instance byte reservation is rolled back so the two counters
312 /// never drift.
313 pub fn insert(&self, entry: DeviceMemEntry) -> Result<u64, AbiError> {
314 if self.entries.len() >= MAX_DEVICE_ALLOCS_PER_INSTANCE {
315 return Err(AbiError::QuotaExceeded);
316 }
317 let add = entry.size;
318 // Compare-and-swap loop so the check + add is atomic against
319 // concurrent allocations — mirrors `KernelRegistry::register`.
320 let mut current = self.total_device_bytes.load(Ordering::Acquire);
321 loop {
322 let next = current.saturating_add(add);
323 if next > MAX_TOTAL_DEVICE_BYTES {
324 return Err(AbiError::QuotaExceeded);
325 }
326 match self.total_device_bytes.compare_exchange_weak(
327 current,
328 next,
329 Ordering::AcqRel,
330 Ordering::Acquire,
331 ) {
332 Ok(_) => break,
333 Err(observed) => current = observed,
334 }
335 }
336 // Charge the process-wide budget. On refusal, release the
337 // per-instance reservation we just took so the counters stay in sync.
338 if let Err(e) = self.budget.try_reserve(add) {
339 let _ =
340 self.total_device_bytes
341 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
342 Some(cur.saturating_sub(add))
343 });
344 return Err(e);
345 }
346 let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
347 self.entries.insert(handle, entry);
348 Ok(handle)
349 }
350
351 /// Look up an allocation by handle, returning an independent handle copy.
352 ///
353 /// Returns `Err(AbiError::InvalidHandle)` if the handle is unknown or
354 /// belongs to a different instance.
355 pub fn lookup(&self, handle: u64, owner: InstanceId) -> Result<DeviceMemHandle, AbiError> {
356 let r = self.entries.get(&handle).ok_or(AbiError::InvalidHandle)?;
357 if r.owner != owner {
358 return Err(AbiError::InvalidHandle);
359 }
360 Ok(DeviceMemHandle {
361 owner: r.owner,
362 size: r.size,
363 #[cfg(feature = "cuda")]
364 device_ptr: r.device_ptr,
365 })
366 }
367
368 /// Remove an allocation owned by `owner`, returning its entry.
369 ///
370 /// Returns `Err(AbiError::InvalidHandle)` when the handle is unknown or
371 /// belongs to another instance — a guest cannot free a buffer it does not
372 /// own. On success both the per-instance aggregate-bytes counter and the
373 /// shared process-wide [`DeviceMemBudget`] are credited back.
374 pub fn free(&self, handle: u64, owner: InstanceId) -> Result<DeviceMemEntry, AbiError> {
375 // Authorise the owner before removing so a cross-owner `free` cannot
376 // even observe whether the handle exists (it always sees
377 // `InvalidHandle`, matching the `lookup` discrimination).
378 {
379 let r = self.entries.get(&handle).ok_or(AbiError::InvalidHandle)?;
380 if r.owner != owner {
381 return Err(AbiError::InvalidHandle);
382 }
383 }
384 let (_, entry) = self
385 .entries
386 .remove(&handle)
387 .ok_or(AbiError::InvalidHandle)?;
388 let _ = self
389 .total_device_bytes
390 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
391 Some(cur.saturating_sub(entry.size))
392 });
393 self.budget.release(entry.size);
394 Ok(entry)
395 }
396
397 /// Number of currently-live allocations.
398 pub fn len(&self) -> usize {
399 self.entries.len()
400 }
401
402 /// True if there are no live allocations.
403 pub fn is_empty(&self) -> bool {
404 self.entries.is_empty()
405 }
406
407 /// Aggregate device bytes currently retained. Visible for metrics and
408 /// tests.
409 pub fn total_device_bytes(&self) -> u64 {
410 self.total_device_bytes.load(Ordering::Acquire)
411 }
412
413 /// Borrow the process-wide [`DeviceMemBudget`] this registry charges.
414 /// Visible for metrics and tests.
415 pub fn budget(&self) -> &Arc<DeviceMemBudget> {
416 &self.budget
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn entry(owner: InstanceId, size: u64) -> DeviceMemEntry {
425 DeviceMemEntry {
426 owner,
427 size,
428 #[cfg(feature = "cuda")]
429 device_ptr: 0,
430 }
431 }
432
433 #[test]
434 fn insert_then_lookup() {
435 let reg = DeviceMemRegistry::new();
436 let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
437 let found = reg.lookup(h, InstanceId(1)).unwrap();
438 assert_eq!(found.owner, InstanceId(1));
439 assert_eq!(found.size, 4096);
440 }
441
442 #[test]
443 fn lookup_wrong_owner_rejected() {
444 let reg = DeviceMemRegistry::new();
445 let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
446 assert_eq!(
447 reg.lookup(h, InstanceId(2)).unwrap_err(),
448 AbiError::InvalidHandle
449 );
450 }
451
452 #[test]
453 fn free_wrong_owner_rejected() {
454 let reg = DeviceMemRegistry::new();
455 let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
456 assert_eq!(
457 reg.free(h, InstanceId(2)).unwrap_err(),
458 AbiError::InvalidHandle
459 );
460 // The entry is still present and still owned by instance 1.
461 assert!(reg.lookup(h, InstanceId(1)).is_ok());
462 }
463
464 #[test]
465 fn free_unknown_rejected() {
466 let reg = DeviceMemRegistry::new();
467 assert_eq!(
468 reg.free(999, InstanceId(1)).unwrap_err(),
469 AbiError::InvalidHandle
470 );
471 }
472
473 #[test]
474 fn alloc_free_lifecycle_tracks_bytes() {
475 let reg = DeviceMemRegistry::new();
476 assert!(reg.is_empty());
477 let h = reg.insert(entry(InstanceId(1), 8192)).unwrap();
478 assert_eq!(reg.len(), 1);
479 assert_eq!(reg.total_device_bytes(), 8192);
480 let freed = reg.free(h, InstanceId(1)).unwrap();
481 assert_eq!(freed.size, 8192);
482 assert!(reg.is_empty());
483 assert_eq!(reg.total_device_bytes(), 0);
484 // The handle is gone now; a second free fails.
485 assert_eq!(
486 reg.free(h, InstanceId(1)).unwrap_err(),
487 AbiError::InvalidHandle
488 );
489 }
490
491 /// A registry charging an isolated [`DeviceMemBudget`] so process-wide
492 /// counters cannot bleed across tests (all unit tests share one process,
493 /// hence one [`process_device_budget`] singleton).
494 fn iso_reg() -> DeviceMemRegistry {
495 DeviceMemRegistry::with_budget(Arc::new(DeviceMemBudget::new()))
496 }
497
498 #[test]
499 fn aggregate_byte_cap_enforced() {
500 let reg = iso_reg();
501 let per = MAX_DEVICE_ALLOC_BYTES; // 256 MiB
502 let cap_count = (MAX_TOTAL_DEVICE_BYTES / per) as usize; // 16
503 for _ in 0..cap_count {
504 reg.insert(entry(InstanceId(1), per)).expect("under cap");
505 }
506 // The next allocation at the per-call max trips the aggregate cap.
507 assert_eq!(
508 reg.insert(entry(InstanceId(1), per)).unwrap_err(),
509 AbiError::QuotaExceeded
510 );
511 }
512
513 #[test]
514 fn per_instance_count_cap_enforced() {
515 let reg = iso_reg();
516 // Tiny allocations so the count cap (not the byte cap) gates.
517 for _ in 0..MAX_DEVICE_ALLOCS_PER_INSTANCE {
518 reg.insert(entry(InstanceId(1), 1))
519 .expect("under count cap");
520 }
521 assert_eq!(
522 reg.insert(entry(InstanceId(1), 1)).unwrap_err(),
523 AbiError::QuotaExceeded
524 );
525 }
526
527 /// Two registries sharing one [`DeviceMemBudget`] (the production setup,
528 /// where every instance's registry charges the same process-wide budget)
529 /// cannot collectively exceed [`MAX_PROCESS_DEVICE_BYTES`], even though
530 /// neither has tripped its own per-instance [`MAX_TOTAL_DEVICE_BYTES`] cap.
531 #[test]
532 fn process_byte_cap_enforced_across_registries() {
533 let budget = Arc::new(DeviceMemBudget::new());
534 let per = MAX_DEVICE_ALLOC_BYTES; // 256 MiB
535 // Per-instance aggregate cap holds this many per-call-max allocations.
536 let per_reg = (MAX_TOTAL_DEVICE_BYTES / per) as usize; // 16
537 // Process cap is 4x the per-instance cap, so it takes 4 saturated
538 // registries to reach it.
539 let regs: Vec<DeviceMemRegistry> = (0..4)
540 .map(|_| DeviceMemRegistry::with_budget(Arc::clone(&budget)))
541 .collect();
542 for (i, reg) in regs.iter().enumerate() {
543 for _ in 0..per_reg {
544 reg.insert(entry(InstanceId(i as u128 + 1), per))
545 .expect("each registry stays under its own per-instance cap");
546 }
547 }
548 // Every registry is at its per-instance cap and the shared budget is
549 // at the process ceiling. A fresh registry — well under its own
550 // per-instance cap — is refused on the process budget.
551 assert_eq!(budget.total_bytes(), MAX_PROCESS_DEVICE_BYTES);
552 let extra = DeviceMemRegistry::with_budget(Arc::clone(&budget));
553 assert_eq!(
554 extra.insert(entry(InstanceId(99), per)).unwrap_err(),
555 AbiError::QuotaExceeded
556 );
557 // Freeing one allocation credits the shared budget back, re-admitting
558 // exactly one more allocation.
559 let freed_handle = 1u64; // first handle in regs[0]
560 regs[0]
561 .free(freed_handle, InstanceId(1))
562 .expect("free a live allocation");
563 assert_eq!(budget.total_bytes(), MAX_PROCESS_DEVICE_BYTES - per);
564 let h = extra
565 .insert(entry(InstanceId(99), per))
566 .expect("re-admitted after a free freed shared headroom");
567 assert!(extra.lookup(h, InstanceId(99)).is_ok());
568 }
569
570 /// The process-wide *count* cap ([`MAX_PROCESS_DEVICE_ALLOCS`]) is enforced
571 /// across registries sharing one budget, independent of the byte cap.
572 #[test]
573 fn process_alloc_count_cap_enforced_across_registries() {
574 let budget = Arc::new(DeviceMemBudget::new());
575 // Tiny 1-byte allocations so the count cap (not the byte cap) gates.
576 // Spread across enough registries that no single one trips its own
577 // per-instance count cap first.
578 let regs: Vec<DeviceMemRegistry> = (0..4)
579 .map(|_| DeviceMemRegistry::with_budget(Arc::clone(&budget)))
580 .collect();
581 for reg in ®s {
582 for _ in 0..MAX_DEVICE_ALLOCS_PER_INSTANCE {
583 reg.insert(entry(InstanceId(1), 1))
584 .expect("under count cap");
585 }
586 }
587 assert_eq!(budget.total_allocs(), MAX_PROCESS_DEVICE_ALLOCS as u64);
588 let extra = DeviceMemRegistry::with_budget(Arc::clone(&budget));
589 assert_eq!(
590 extra.insert(entry(InstanceId(1), 1)).unwrap_err(),
591 AbiError::QuotaExceeded
592 );
593 }
594
595 /// A `free` credits the shared budget back so the process counters track
596 /// *live* bytes / allocations, not cumulative.
597 #[test]
598 fn free_credits_shared_budget() {
599 let budget = Arc::new(DeviceMemBudget::new());
600 let reg = DeviceMemRegistry::with_budget(Arc::clone(&budget));
601 let h = reg.insert(entry(InstanceId(1), 8192)).unwrap();
602 assert_eq!(budget.total_bytes(), 8192);
603 assert_eq!(budget.total_allocs(), 1);
604 reg.free(h, InstanceId(1)).unwrap();
605 assert_eq!(budget.total_bytes(), 0);
606 assert_eq!(budget.total_allocs(), 0);
607 }
608
609 #[test]
610 fn handles_are_unique_and_increasing() {
611 let reg = DeviceMemRegistry::new();
612 let a = reg.insert(entry(InstanceId(1), 1)).unwrap();
613 let b = reg.insert(entry(InstanceId(1), 1)).unwrap();
614 assert_ne!(a, b);
615 assert_eq!(a + 1, b);
616 }
617}