tensor_wasm_mem/pool.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! `UnifiedMemoryPool` — a bump allocator that amortises the cost of CUDA
5//! Unified Memory allocations.
6//!
7//! `cudaMallocManaged` is expensive (tens to hundreds of microseconds per call
8//! on busy systems). For Wasm linear memory we want sub-microsecond allocations
9//! at instance-spawn time. The pool pre-allocates one large [`UnifiedBuffer`]
10//! and hands out aligned sub-slices via a simple bump pointer.
11
12use std::fmt;
13use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
14
15use crate::unified::{DeviceId, UnifiedBuffer, UnifiedError};
16
17/// A bump-allocated pool carved from a single underlying [`UnifiedBuffer`].
18///
19/// Allocations succeed until the slab is exhausted; the pool does not reclaim
20/// freed regions (callers maintain their own life cycle by dropping
21/// [`PoolAllocation`]s, which mark the bump pointer as logically released only
22/// in the *all-released-at-end* discipline used by ephemeral Wasm instances).
23///
24/// # Pool teardown contract
25///
26/// Slabs handed out as Wasm linear memory (via
27/// [`crate::wasm_memory::TensorWasmMemoryCreator`]) preserve the
28/// *monotonic bump* invariant for the lifetime of the pool: the
29/// `PoolAllocation` drop guard returned by [`Self::allocate`] is intentionally
30/// `std::mem::forget`-ed by the linear-memory creator, so the bump pointer
31/// never rewinds while pooled instances are alive. The bump pointer only
32/// rewinds via an explicit [`Self::reset`] call, which requires `&mut self`
33/// (every other `Arc<UnifiedMemoryPool>` clone dropped) AND `live == 0`
34/// (every issued `PoolAllocation` AND every pool-backed Wasm linear memory
35/// dropped).
36///
37/// As of audit T6, `crate::wasm_memory::PooledLinearMemory`'s own `Drop`
38/// mirrors the `mem::forget(alloc)` effect by calling `Self::release`
39/// from its destructor: the bump pointer is *not* rewound (so any
40/// `base_ptr` held by other pool consumers stays valid), but the live
41/// counter is decremented so a future `reset()` can run once every issued
42/// memory has been dropped. Before T6 the counter stayed elevated forever
43/// once any pool-backed Wasm memory was issued, permanently blocking
44/// `reset()`; that left operators with no path to recycle a slab across
45/// tenants short of dropping the pool itself.
46///
47/// Operators that want per-tenant resets must still satisfy both gates
48/// (drop every `Arc<UnifiedMemoryPool>` clone except their own, and drop
49/// every issued memory) — alternately they can continue to use one pool
50/// per tenant and drop the pool wholesale. The pinned behaviour lives in
51/// `crates/tensor-wasm-mem/tests/pool_teardown_contract.rs` and the
52/// call-site rationale is recorded inline above the `std::mem::forget(alloc)`
53/// line in `TensorWasmMemoryCreator::new_memory`.
54pub struct UnifiedMemoryPool {
55 slab: UnifiedBuffer,
56 /// Next free byte offset within the slab.
57 ///
58 /// Audit T26: this counter was previously a field on a `Mutex<PoolState>`
59 /// shared with `live` and `issued_total`. Every `allocate` call therefore
60 /// took the same mutex, serialising tenants that were otherwise touching
61 /// disjoint byte ranges. Moving the bump to an `AtomicUsize` with a CAS
62 /// loop in [`Self::allocate`] removes that contention point while
63 /// preserving the disjoint-allocation invariant (a successful CAS proves
64 /// `[old_bump, new_bump)` was reserved exclusively by this caller).
65 ///
66 /// The CAS must NOT race with [`Self::reset`], which would let `allocate`
67 /// hand out a region overlapping a tenant's still-live `base_ptr`. The
68 /// `&mut self` signature on `reset` (T4) provides exactly that mutual
69 /// exclusion: while reset holds `&mut self`, the type system forbids any
70 /// concurrent `&self` call into `allocate`. Inside reset we therefore use
71 /// `*self.bump.get_mut() = 0` (a non-atomic write through the unique
72 /// reference) rather than `store(Release)`, because there is provably no
73 /// other thread observing the atomic at that point.
74 bump: AtomicUsize,
75 /// Outstanding allocations counter; the slab is "reset-eligible" when
76 /// this hits zero. Incremented atomically in [`Self::allocate`] AFTER
77 /// the bump reservation succeeds, decremented in [`Self::release`] (the
78 /// safe `PoolAllocation::Drop` path and the T6 `PooledLinearMemory::Drop`
79 /// mirror path).
80 live: AtomicUsize,
81 /// Total bytes ever issued (sticky counter for metrics). Saturates at
82 /// `u64::MAX`. Never decremented; survives [`Self::reset`] intact.
83 issued_total: AtomicU64,
84}
85
86/// A region of memory carved from a pool. Drops decrement the pool's live count.
87pub struct PoolAllocation<'p> {
88 pool: &'p UnifiedMemoryPool,
89 offset: usize,
90 size: usize,
91}
92
93impl UnifiedMemoryPool {
94 /// Create a pool that owns `capacity` bytes on the default device.
95 pub fn new(capacity: usize) -> Result<Self, UnifiedError> {
96 Self::new_on(capacity, DeviceId::default())
97 }
98
99 /// Create a pool that owns `capacity` bytes on the named device.
100 pub fn new_on(capacity: usize, device_id: DeviceId) -> Result<Self, UnifiedError> {
101 let slab = UnifiedBuffer::new_on(capacity, device_id)?;
102 Ok(Self {
103 slab,
104 bump: AtomicUsize::new(0),
105 live: AtomicUsize::new(0),
106 issued_total: AtomicU64::new(0),
107 })
108 }
109
110 /// Slab capacity in bytes.
111 pub fn capacity(&self) -> usize {
112 self.slab.len()
113 }
114
115 /// Device that the underlying slab is anchored to.
116 pub fn device_id(&self) -> DeviceId {
117 self.slab.device_id()
118 }
119
120 /// Bytes still available for new allocations.
121 pub fn remaining(&self) -> usize {
122 // `Acquire` pairs with the `Release` CAS in `allocate`: any caller
123 // reading `remaining` after observing a successful `allocate` on
124 // another thread sees the bumped value.
125 self.slab
126 .len()
127 .saturating_sub(self.bump.load(Ordering::Acquire))
128 }
129
130 /// Outstanding allocation count.
131 pub fn live_allocations(&self) -> usize {
132 // `Acquire` so a thread that observes `live == 0` and goes on to
133 // `reset()` is guaranteed to have happens-before with every prior
134 // `release` increment-then-decrement.
135 self.live.load(Ordering::Acquire)
136 }
137
138 /// Total bytes issued since the pool was created (or last reset).
139 pub fn issued_total(&self) -> u64 {
140 // Metrics path; `Relaxed` is sufficient — operators only read this for
141 // alerting, not to reason about happens-before with allocation.
142 self.issued_total.load(Ordering::Relaxed)
143 }
144
145 /// Allocate `size` bytes aligned to `align` (must be a power of two).
146 ///
147 /// Returns `Err(UnifiedError::TooLarge { requested, limit })` when the
148 /// slab is exhausted (carrying the requested size and the pool capacity
149 /// so downstream layers can plumb the figures into
150 /// `TensorWasmError::MemoryExhausted` without parsing strings), or
151 /// `Err(UnifiedError::Allocation(...))` when `align` is zero / not a
152 /// power of two / exceeds the maximum alignment cap.
153 pub fn allocate(&self, size: usize, align: usize) -> Result<PoolAllocation<'_>, UnifiedError> {
154 if size == 0 {
155 return Err(UnifiedError::ZeroSize);
156 }
157 if align == 0 || !align.is_power_of_two() {
158 return Err(UnifiedError::Allocation(format!(
159 "alignment {align} is not a non-zero power of two"
160 )));
161 }
162 // Cap alignment at 1 GiB. Larger values would dwarf any realistic slab
163 // size and only existed in the API surface to satisfy `is_power_of_two`
164 // (which accepts `1 << 63`). Rejecting them early prevents
165 // `(bump + align - 1)` from silently wrapping into a tiny positive
166 // value that then bypasses the exhaustion check below.
167 const MAX_ALIGN: usize = 1 << 30;
168 if align > MAX_ALIGN {
169 return Err(UnifiedError::Allocation(format!(
170 "alignment {align} exceeds maximum {MAX_ALIGN}"
171 )));
172 }
173
174 // Audit T26: CAS loop on the bump pointer. Previously this critical
175 // section took a `parking_lot::Mutex<PoolState>` to update `bump`,
176 // `live`, and `issued_total` atomically. The mutex serialised
177 // unrelated tenants on the hot allocation path; the atomic-bump
178 // version lets disjoint allocations proceed in parallel without
179 // changing any externally visible behaviour.
180 //
181 // Disjoint-region invariant: a CAS success on `bump` proves nothing
182 // else updated `bump` between our `load` and `compare_exchange_weak`.
183 // Therefore the byte range `[aligned_bump, end)` was unreserved at
184 // CAS time and is now reserved exclusively by this caller. The zero-
185 // fill below targets that range and cannot race another allocator
186 // because the bump pointer has already moved past `end`.
187 //
188 // Reset interaction: `reset` requires `&mut self` (T4), so no
189 // concurrent `&self` allocate call can be live during reset, which
190 // means a reset will never race with this CAS.
191 let (aligned_bump, _end) = loop {
192 // `Relaxed` is sufficient here: the only ordering we care about
193 // is on the CAS itself, where `AcqRel` ties the bump update to
194 // the subsequent zero-fill (`Acquire`) and to any other thread
195 // that later observes the new bump (`Release`).
196 let current_bump = self.bump.load(Ordering::Relaxed);
197 // Compute `(bump + align - 1) & !(align - 1)` with overflow
198 // checks so a pathological `bump` near `usize::MAX` cannot wrap.
199 let aligned = current_bump
200 .checked_add(align - 1)
201 .map(|v| v & !(align - 1))
202 .ok_or_else(|| UnifiedError::Allocation("alignment overflow".into()))?;
203 let new_end = aligned
204 .checked_add(size)
205 .ok_or_else(|| UnifiedError::Allocation("offset overflow".into()))?;
206 if new_end > self.slab.len() {
207 // Pool exhaustion is reported as the structured `TooLarge`
208 // variant so the `From<UnifiedError> for TensorWasmError`
209 // impl can route exhaustion to
210 // `MemoryExhausted { requested, limit }` with the real
211 // figures, not zeroed placeholders. The `requested` field
212 // reflects the caller-visible size; `limit` is the slab
213 // capacity so operators can size pools from telemetry.
214 return Err(UnifiedError::TooLarge {
215 requested: size as u64,
216 limit: self.slab.len() as u64,
217 });
218 }
219 // `compare_exchange_weak` is fine on this hot path: spurious
220 // failures simply re-iterate the loop. `AcqRel` on success
221 // synchronises with the zero-fill below and with later
222 // observers; `Relaxed` on failure is sufficient because we
223 // re-read `bump` at the top of the next iteration.
224 match self.bump.compare_exchange_weak(
225 current_bump,
226 new_end,
227 Ordering::AcqRel,
228 Ordering::Relaxed,
229 ) {
230 Ok(_) => break (aligned, new_end),
231 Err(_) => continue,
232 }
233 };
234 // The CAS succeeded; we now own `[aligned_bump, end)`.
235 // `live` and `issued_total` are independent atomic counters — no
236 // ordering dependency on each other. `Release` on `live` so a
237 // reader that later observes `live > 0` also observes the bump
238 // update via the same fence chain.
239 self.live.fetch_add(1, Ordering::Release);
240 // `Relaxed` for the sticky metrics counter — operators read this
241 // for alerting, not synchronisation. `saturating_add` is performed
242 // by a CAS loop to preserve the saturating-at-u64::MAX behaviour
243 // of the original `st.issued_total.saturating_add(...)` call.
244 let size_u64 = size as u64;
245 let mut issued = self.issued_total.load(Ordering::Relaxed);
246 loop {
247 let next = issued.saturating_add(size_u64);
248 match self.issued_total.compare_exchange_weak(
249 issued,
250 next,
251 Ordering::Relaxed,
252 Ordering::Relaxed,
253 ) {
254 Ok(_) => break,
255 Err(observed) => issued = observed,
256 }
257 }
258 // Cross-tenant data-leak mitigation (audit H1):
259 // -------------------------------------------------------------------
260 // The slab is recycled across tenants via `reset()`, which only resets
261 // the bump pointer — recycled bytes still carry the previous tenant's
262 // data. Zero the freshly-carved region before we hand the
263 // `PoolAllocation` to the caller so a guest cannot observe a peer's
264 // memory through an uninitialised read.
265 //
266 // We use `ptr::write_bytes` rather than `slice::fill(0)` for two
267 // reasons: (1) it lowers to a single `memset` intrinsic on every
268 // backend LLVM cares about, where `.fill(0)` historically optimises
269 // less reliably; (2) it sidesteps the need to construct a `&mut [u8]`
270 // alias over the slab, which is awkward to do soundly while another
271 // thread may hold a different `&mut [u8]` slice into a disjoint
272 // region of the same slab.
273 //
274 // Finding (PERF, allocate whole-region memset defeats the
275 // sub-microsecond pool goal for large slabs):
276 // -------------------------------------------------------------------
277 // Cost: O(size) per allocation. For a 256 MiB Wasm linear memory this
278 // is on the order of tens of milliseconds — large enough that callers
279 // doing many small allocations should batch where possible, but
280 // unavoidable for correctness given the recycle discipline. Skipping
281 // it would re-open the H1 cross-tenant disclosure window.
282 //
283 // DECISION: the memset is REQUIRED and is kept at full `size`; the
284 // "zero only the visible window" narrowing CANNOT be applied at this
285 // layer. `allocate` takes a single `size` and has no `minimum` /
286 // visible-window parameter, and the `PoolAllocation` it returns hands
287 // the caller the FULL `[0, size)` range immediately via
288 // `as_slice()` / `as_mut_slice()` (see the public accessors below).
289 // There is no `grow_to` step at the pool layer that would zero a tail
290 // before first read, and several callers — including the H1 regression
291 // tests `recycled_allocation_reads_as_zero` and
292 // `first_allocation_reads_as_zero`, plus the cross-tenant
293 // `tests/cudarc_visible_window_only.rs` — rely on every byte of the
294 // carved region reading zero on hand-out. Narrowing the zero-fill to a
295 // prefix here would leave the recycled tail observable and re-open the
296 // H1 cross-tenant disclosure window: a soundness regression, not a
297 // safe optimization. (The wasm path's tail IS additionally re-zeroed
298 // by `PooledLinearMemory::grow_to`, but the pool API contract is wider
299 // than that single consumer, so the conservative full memset is the
300 // only sound choice at this layer.)
301 //
302 // PERF note (considered, intentionally NOT applied here): the
303 // `UnifiedBuffer` visible-window path can zero only `[0, minimum)` and
304 // lean on `grow_to`'s tail zero-fill because the bytes past `minimum`
305 // are not host-visible until a later `grow_to` zeroes them on the way
306 // in. That optimization does NOT transfer to the pool layer: a
307 // `PoolAllocation` exposes the FULL carved `[0, size)` range to the
308 // caller immediately (there is no `minimum`/visible-window narrowing
309 // and no `grow_to` step that would zero a tail before first read), so
310 // every byte we hand out is reachable right away. Zeroing only a
311 // prefix here would leave the recycled tail observable and re-open the
312 // H1 cross-tenant disclosure window — a soundness regression. The
313 // whole-region `memset` is therefore the correct conservative choice.
314 //
315 // SAFETY: `aligned_bump + size <= self.slab.len()` (checked above);
316 // the slab's pointer is non-null and points to `len()` valid bytes
317 // for the lifetime of `&self`; the bump allocator guarantees the
318 // `[aligned_bump, aligned_bump + size)` byte range is disjoint from
319 // every other live `PoolAllocation`, so this write cannot race
320 // another thread's `as_mut_slice()`.
321 unsafe {
322 let base = self.slab.as_ptr().add(aligned_bump) as *mut u8;
323 std::ptr::write_bytes(base, 0u8, size);
324 }
325
326 Ok(PoolAllocation {
327 pool: self,
328 offset: aligned_bump,
329 size,
330 })
331 }
332
333 /// Reset the bump pointer back to zero. Safe to call only when there are
334 /// no outstanding [`PoolAllocation`]s; returns an error otherwise.
335 ///
336 /// # Why `&mut self` (audit T4)
337 ///
338 /// `reset` rewinds the bump pointer so the next `allocate` will hand out
339 /// byte ranges that overlap with regions previously issued. If `reset`
340 /// took `&self`, a caller could legitimately hold a `&[u8]` derived from
341 /// an earlier allocation (or a raw pointer obtained via the slab base) at
342 /// the moment of reset, and a subsequent `allocate` would alias that
343 /// borrow with freshly-issued memory — a use-after-rewind UB hazard that
344 /// is invisible to the borrow checker because the bump pointer move only
345 /// requires a shared borrow of the interior `Mutex`. Requiring `&mut
346 /// self` reflects the actual aliasing contract: no other borrow of
347 /// `self` (and therefore no live `PoolAllocation`, no `slab_ptr`-derived
348 /// raw pointer that has been turned into a slice, etc.) may coexist with
349 /// a reset. Callers holding the pool through `Arc<UnifiedMemoryPool>`
350 /// must either drop every clone but one (so `Arc::get_mut` succeeds) or
351 /// switch to a per-tenant pool — see the Wasm-backing teardown contract
352 /// below.
353 ///
354 /// # Teardown contract for Wasm-backing pools (audit T6)
355 ///
356 /// Pool slabs that served a
357 /// [`crate::wasm_memory::TensorWasmMemoryCreator`]-issued pool-backed Wasm
358 /// linear memory (`PooledLinearMemory`) remain resettable: although
359 /// `PoolAllocation` drop guards are intentionally leaked at carve time
360 /// to keep the bump pointer monotonic (see the `std::mem::forget(alloc)`
361 /// site in
362 /// `TensorWasmMemoryCreator::new_memory`),
363 /// `PooledLinearMemory`'s own `Drop` impl now mirrors the leak by calling
364 /// `Self::release` when the linear memory itself is torn down. The
365 /// [`live_allocations`](Self::live_allocations) counter therefore returns
366 /// to zero once every issued pool-backed Wasm memory has been dropped, at
367 /// which point `reset` succeeds (the existing `live > 0` guard below is
368 /// unchanged; what changed is that `live` is now actually allowed to
369 /// reach zero again). The bump pointer is NOT rewound by `Drop` —
370 /// monotonic-bump semantics are preserved between explicit `reset` calls,
371 /// so any in-flight reads through a `base_ptr` carved out earlier remain
372 /// valid until reset is *explicitly* invoked (and reset is gated by
373 /// `&mut self`, which the type system refuses while any other
374 /// `Arc<UnifiedMemoryPool>` keepalive exists).
375 ///
376 /// Operators wanting per-tenant resets can therefore either:
377 /// (a) drop every issued memory then call `reset` on a uniquely-owned
378 /// pool, or
379 /// (b) continue to use one pool per tenant and drop the pool wholesale
380 /// at tenant teardown.
381 pub fn reset(&mut self) -> Result<(), UnifiedError> {
382 // `&mut self` guarantees no other thread holds *any* reference to
383 // `self`, so a non-atomic `get_mut().clone()` would be sound here.
384 // We still go through the atomic accessor for ergonomic symmetry
385 // with the rest of the module; `Ordering::Acquire` ensures any
386 // `release` decrement on another thread that this one has not yet
387 // synchronised with is visible (it must be, given the `&mut self`
388 // contract, but the explicit acquire keeps the code self-documenting).
389 let live_now = *self.live.get_mut();
390 if live_now != 0 {
391 return Err(UnifiedError::Allocation(format!(
392 "cannot reset: {live_now} live allocations outstanding",
393 )));
394 }
395 // Direct non-atomic writes through `get_mut()` — `&mut self` proves
396 // uniqueness, so this cannot race with any concurrent CAS in
397 // `allocate` (the type system forbids `&self` calls while `reset`
398 // is in flight). `issued_total` is sticky on purpose — it reflects
399 // lifetime activity.
400 *self.bump.get_mut() = 0;
401 Ok(())
402 }
403
404 /// Decrement the `live` counter for an allocation at `[offset, offset+size)`.
405 ///
406 /// Does NOT rewind the bump pointer — monotonic-bump semantics are
407 /// preserved (the carved region remains logically claimed against the
408 /// slab's lifetime; only [`Self::reset`] rewinds, and only when
409 /// `live == 0`). The `offset` and `size` parameters are accepted so
410 /// future per-region debug-assertions can match them against a
411 /// shadow free-list; today they are only validated by the live-count
412 /// underflow assert.
413 ///
414 /// Called from:
415 ///
416 /// * [`PoolAllocation::drop`] — the safe borrow-based release path.
417 /// * [`crate::wasm_memory::PooledLinearMemory::drop`] — the
418 /// `mem::forget`-mirror release path (audit T6: previously the
419 /// counter was held elevated forever once any pool-backed Wasm
420 /// memory was issued, permanently blocking [`Self::reset`]; now
421 /// the Wasm memory's own `Drop` decrements the counter so a
422 /// subsequent reset can run once every issued memory has been
423 /// dropped).
424 ///
425 /// Calling this more times than [`Self::allocate`] was called is a
426 /// logic bug — the `debug_assert!` below catches it in debug builds.
427 /// In release builds the counter saturates at zero (defensive: an
428 /// under-count is still safer than wrapping `usize`).
429 pub(crate) fn release(&self, _offset: usize, _size: usize) {
430 // Audit T26: `live` is an `AtomicUsize` (was previously behind the
431 // shared `Mutex<PoolState>`). We do the underflow-safe decrement
432 // in a single CAS loop so a buggy double-release saturates at zero
433 // in release builds rather than wrapping to `usize::MAX` —
434 // identical defensive behaviour to the original
435 // `saturating_sub(1)` under the mutex.
436 //
437 // `AcqRel` so a thread that later observes `live == 0` and proceeds
438 // to `reset()` has a happens-before chain to every prior release.
439 let mut current = self.live.load(Ordering::Acquire);
440 loop {
441 debug_assert!(
442 current > 0,
443 "UnifiedMemoryPool::release called more times than allocate \
444 (would underflow live counter)"
445 );
446 let next = current.saturating_sub(1);
447 match self.live.compare_exchange_weak(
448 current,
449 next,
450 Ordering::AcqRel,
451 Ordering::Acquire,
452 ) {
453 Ok(_) => break,
454 Err(observed) => current = observed,
455 }
456 }
457 }
458
459 /// Raw pointer to the start of the slab (intended for tests / FFI).
460 ///
461 /// # Safety
462 ///
463 /// The returned pointer is only valid:
464 ///
465 /// 1. While `self` (or any borrow of it, including `Arc` keepalives) is
466 /// alive — the slab is freed when the pool is dropped.
467 /// 2. While no `&mut self` method on the pool executes — notably
468 /// [`Self::reset`], which rewinds the bump pointer and would let a
469 /// subsequent [`Self::allocate`] hand out a `PoolAllocation` whose
470 /// `&mut [u8]` aliases any slice the caller may have constructed
471 /// from this pointer.
472 /// 3. Reads/writes through the pointer must not overlap any currently
473 /// live [`PoolAllocation`]'s `[offset, offset + size)` byte range,
474 /// since `PoolAllocation::as_mut_slice` proves uniqueness over that
475 /// range and a parallel raw-pointer write would form an illegal
476 /// `&mut` / `*mut` alias.
477 ///
478 /// The accessor is `pub(crate)` so external code cannot derive a slice
479 /// that outlives a future `reset()`; in-crate callers (tests, FFI
480 /// glue) must still satisfy the conditions above at every use site.
481 #[cfg(test)]
482 #[allow(dead_code)]
483 pub(crate) unsafe fn slab_ptr(&self) -> *const u8 {
484 self.slab.as_ptr()
485 }
486}
487
488impl<'p> PoolAllocation<'p> {
489 /// Byte length of this region.
490 pub fn len(&self) -> usize {
491 self.size
492 }
493
494 /// True if zero-length (never for a successfully created allocation).
495 pub fn is_empty(&self) -> bool {
496 self.size == 0
497 }
498
499 /// Offset within the underlying slab.
500 pub fn offset(&self) -> usize {
501 self.offset
502 }
503
504 /// Borrow as a shared byte slice.
505 pub fn as_slice(&self) -> &[u8] {
506 // SAFETY: the pool's slab is alive (we borrow `&'p UnifiedMemoryPool`)
507 // and we carved [offset, offset+size) out of it during allocation.
508 unsafe {
509 let base = self.pool.slab.as_ptr().add(self.offset);
510 std::slice::from_raw_parts(base, self.size)
511 }
512 }
513
514 /// Borrow as a mutable byte slice.
515 ///
516 /// `&mut self` and the disjoint-region invariant of bump allocation
517 /// together prove no other live alias points at `[offset, offset+size)`.
518 pub fn as_mut_slice(&mut self) -> &mut [u8] {
519 // SAFETY: see above; `&mut self` proves uniqueness of this PoolAllocation
520 // and the bump allocator guarantees disjoint regions across allocations.
521 unsafe {
522 let base = self.pool.slab.as_ptr().add(self.offset) as *mut u8;
523 std::slice::from_raw_parts_mut(base, self.size)
524 }
525 }
526}
527
528impl fmt::Debug for PoolAllocation<'_> {
529 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530 f.debug_struct("PoolAllocation")
531 .field("offset", &self.offset)
532 .field("size", &self.size)
533 .finish()
534 }
535}
536
537impl Drop for PoolAllocation<'_> {
538 fn drop(&mut self) {
539 self.pool.release(self.offset, self.size);
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn allocate_within_capacity() {
549 let pool = UnifiedMemoryPool::new(1024).unwrap();
550 assert_eq!(pool.capacity(), 1024);
551 let a = pool.allocate(100, 16).unwrap();
552 assert_eq!(a.len(), 100);
553 assert_eq!(a.offset() % 16, 0);
554 assert_eq!(pool.live_allocations(), 1);
555 }
556
557 #[test]
558 fn allocations_are_disjoint_and_aligned() {
559 let pool = UnifiedMemoryPool::new(4096).unwrap();
560 let a = pool.allocate(100, 64).unwrap();
561 let b = pool.allocate(200, 64).unwrap();
562 assert!(b.offset() >= a.offset() + a.len());
563 assert_eq!(a.offset() % 64, 0);
564 assert_eq!(b.offset() % 64, 0);
565 }
566
567 #[test]
568 fn exhaustion_returns_too_large_with_figures() {
569 // Exhaustion is now reported via the structured `TooLarge` variant
570 // (carrying the requested size and the slab capacity) so the
571 // `From<UnifiedError> for TensorWasmError` impl can route the
572 // failure to `MemoryExhausted { requested, limit }` without
573 // substring-matching on a message.
574 let pool = UnifiedMemoryPool::new(128).unwrap();
575 let _a = pool.allocate(64, 16).unwrap();
576 let err = pool.allocate(128, 16).expect_err("should exhaust");
577 match err {
578 UnifiedError::TooLarge { requested, limit } => {
579 assert_eq!(requested, 128);
580 assert_eq!(limit, 128);
581 }
582 other => panic!("expected TooLarge, got {other:?}"),
583 }
584 }
585
586 #[test]
587 fn drop_decrements_live() {
588 let pool = UnifiedMemoryPool::new(256).unwrap();
589 {
590 let _a = pool.allocate(32, 1).unwrap();
591 assert_eq!(pool.live_allocations(), 1);
592 }
593 assert_eq!(pool.live_allocations(), 0);
594 }
595
596 #[test]
597 fn reset_only_when_empty() {
598 // `reset` now takes `&mut self` (audit T4). The type system already
599 // refuses to reset while a `PoolAllocation` borrow is live — there is
600 // no longer a way to call `reset` with `&self`-derived aliases alive
601 // through ordinary safe code. But the runtime live-count guard inside
602 // `reset` still matters for the `wasm_memory::TensorWasmMemoryCreator`
603 // path that intentionally `mem::forget`s the `PoolAllocation` to keep
604 // the bump pointer monotonic across pooled Wasm instances; for that
605 // caller the borrow is gone but the live counter is incremented, so the
606 // runtime check is the last line of defence. Simulate that scenario
607 // here by forgetting an allocation to bump the live count without
608 // holding a borrow that would conflict with `&mut pool`.
609 //
610 // (Audit T6: in production, `PooledLinearMemory::Drop` calls
611 // `release()` to mirror this walk-down automatically, so a `reset`
612 // after every issued Wasm memory has been dropped will succeed.
613 // This unit test reproduces that walk-down by hand to keep the
614 // pool-module test self-contained.)
615 let mut pool = UnifiedMemoryPool::new(256).unwrap();
616 let a = pool.allocate(64, 1).unwrap();
617 // `mem::forget` releases the borrow without running `Drop`, so the
618 // live counter stays at 1 while `&mut pool` becomes obtainable.
619 std::mem::forget(a);
620 assert!(
621 pool.reset().is_err(),
622 "reset must fail while the live-allocations counter is non-zero"
623 );
624 // Walk the counter back down by hand (we forgot the drop guard) so
625 // we can exercise the success path. In production, this is exactly
626 // what `PooledLinearMemory::Drop` does for pool-backed Wasm memories.
627 pool.release(0, 64);
628 pool.reset().expect("reset must succeed when empty");
629 assert_eq!(pool.remaining(), pool.capacity());
630 }
631
632 #[test]
633 fn issued_total_is_sticky_across_reset() {
634 // See `reset_only_when_empty` for why this binding is `mut`.
635 let mut pool = UnifiedMemoryPool::new(1024).unwrap();
636 {
637 let _a = pool.allocate(64, 1).unwrap();
638 let _b = pool.allocate(64, 1).unwrap();
639 }
640 assert_eq!(pool.issued_total(), 128);
641 pool.reset().unwrap();
642 assert_eq!(pool.issued_total(), 128); // sticky
643 }
644
645 #[test]
646 fn invalid_alignment_rejected() {
647 let pool = UnifiedMemoryPool::new(256).unwrap();
648 assert!(pool.allocate(8, 0).is_err());
649 assert!(pool.allocate(8, 3).is_err()); // not a power of two
650 assert!(pool.allocate(0, 8).is_err());
651 }
652
653 #[test]
654 fn excessive_alignment_rejected() {
655 let pool = UnifiedMemoryPool::new(4096).unwrap();
656 // Power-of-two alignment beyond the 1 GiB cap must be rejected
657 // (would otherwise overflow when computing the aligned bump).
658 let huge = (1usize << 31) + 1; // > 2 GiB
659 // Use a clean power-of-two above the cap.
660 let too_big = 1usize << 31;
661 assert!(pool.allocate(8, too_big).is_err());
662 let _ = huge; // silence unused
663 }
664
665 #[test]
666 fn writes_visible_in_slice() {
667 let pool = UnifiedMemoryPool::new(256).unwrap();
668 let mut a = pool.allocate(16, 8).unwrap();
669 for (i, byte) in a.as_mut_slice().iter_mut().enumerate() {
670 *byte = i as u8;
671 }
672 for (i, &byte) in a.as_slice().iter().enumerate() {
673 assert_eq!(byte, i as u8);
674 }
675 }
676
677 #[test]
678 fn recycled_allocation_reads_as_zero() {
679 // Cross-tenant data-leak regression test (audit H1):
680 // -------------------------------------------------------------------
681 // Simulates the recycle path: tenant A writes 0xAB into a pool
682 // allocation, releases it, the pool is reset, and tenant B requests
683 // an allocation that lands on the same bytes. The recycled bytes
684 // MUST read as zero — otherwise tenant B can observe tenant A's
685 // private data. This pins the `ptr::write_bytes` zero-fill added in
686 // `UnifiedMemoryPool::allocate`.
687 const SIZE: usize = 4 * 1024; // 4 KiB — large enough to detect any
688 // off-by-one in the memset bounds.
689 // `reset` now takes `&mut self` (audit T4); see `reset_only_when_empty`.
690 let mut pool = UnifiedMemoryPool::new(64 * 1024).unwrap();
691
692 // Tenant A: poison every byte with the sentinel.
693 {
694 let mut a = pool.allocate(SIZE, 64).unwrap();
695 a.as_mut_slice().fill(0xAB);
696 // sanity: poison actually went in
697 assert!(a.as_slice().iter().all(|&b| b == 0xAB));
698 }
699 // Reset the bump pointer so the same byte range is reachable again.
700 pool.reset().expect("reset after tenant A drops");
701
702 // Tenant B: ask for the same shape and assert every byte reads zero.
703 let b = pool.allocate(SIZE, 64).unwrap();
704 let leaked: Vec<usize> = b
705 .as_slice()
706 .iter()
707 .enumerate()
708 .filter_map(|(i, &v)| if v != 0 { Some(i) } else { None })
709 .collect();
710 assert!(
711 leaked.is_empty(),
712 "recycled pool allocation leaked tenant-A data at {} byte offsets (first: {:?})",
713 leaked.len(),
714 leaked.first(),
715 );
716 }
717
718 #[test]
719 fn pool_concurrent_allocate_cas() {
720 // Audit T26: the CAS-bump replacement must keep the disjoint-region
721 // invariant intact under concurrent pressure. Spawn 16 threads, each
722 // performing N allocations of 1000 bytes (alignment 1 so the
723 // `aligned_bump` arithmetic is a no-op and the total consumed equals
724 // exactly `threads * iters * 1000`), collect every offset, and assert:
725 //
726 // (a) the final `bump` equals `threads * iters * 1000` — no CAS
727 // loss, no double-bump;
728 // (b) `live_allocations` equals `threads * iters` — every allocation
729 // incremented `live` exactly once;
730 // (c) the set of offsets is pairwise non-overlapping — disjoint
731 // allocation invariant survived the mutex removal.
732 //
733 // We intentionally `std::mem::forget` every allocation so the live
734 // counter is held high; the test owns the pool exclusively so it can
735 // drive the counter back to zero at the end via `release`.
736 use std::sync::Arc;
737 use std::thread;
738
739 const THREADS: usize = 16;
740 const ITERS: usize = 1000;
741 const SIZE: usize = 1000;
742
743 let pool = Arc::new(UnifiedMemoryPool::new(THREADS * ITERS * SIZE + 4096).expect("pool"));
744
745 let mut handles = Vec::with_capacity(THREADS);
746 for _ in 0..THREADS {
747 let pool = Arc::clone(&pool);
748 handles.push(thread::spawn(move || {
749 let mut offsets = Vec::with_capacity(ITERS);
750 for _ in 0..ITERS {
751 let a = pool.allocate(SIZE, 1).expect("alloc");
752 offsets.push((a.offset(), a.len()));
753 // Forget the drop guard so the live counter stays
754 // elevated; we walk it back down at the end. This also
755 // means the bump pointer cannot accidentally rewind via
756 // `reset` between iterations.
757 std::mem::forget(a);
758 }
759 offsets
760 }));
761 }
762
763 let mut all_offsets: Vec<(usize, usize)> = Vec::with_capacity(THREADS * ITERS);
764 for h in handles {
765 all_offsets.extend(h.join().expect("thread join"));
766 }
767
768 // (a) total bump consumed.
769 assert_eq!(
770 pool.capacity() - pool.remaining(),
771 THREADS * ITERS * SIZE,
772 "total consumed bytes must equal threads * iters * size; \
773 a discrepancy means a CAS update was lost or doubled"
774 );
775
776 // (b) live count.
777 assert_eq!(
778 pool.live_allocations(),
779 THREADS * ITERS,
780 "every allocation must increment `live` exactly once"
781 );
782
783 // (c) pairwise disjoint regions. Sort by offset; then walk and
784 // assert each region starts at or after the previous one ended.
785 all_offsets.sort_unstable_by_key(|&(off, _)| off);
786 for w in all_offsets.windows(2) {
787 let (off_a, len_a) = w[0];
788 let (off_b, _len_b) = w[1];
789 assert!(
790 off_a + len_a <= off_b,
791 "concurrent allocations overlap: [{off_a}, {}) vs [{off_b}, ...) \
792 — CAS bump did not preserve the disjoint-region invariant",
793 off_a + len_a,
794 );
795 }
796
797 // Drain the live counter by hand so the pool's Drop is clean.
798 for (off, len) in all_offsets {
799 pool.release(off, len);
800 }
801 assert_eq!(pool.live_allocations(), 0);
802 }
803
804 #[test]
805 fn first_allocation_reads_as_zero() {
806 // Companion to `recycled_allocation_reads_as_zero`: even the *first*
807 // allocation out of a fresh pool must read as zero. On the heap
808 // backing (`Box<[u8]>`) this is trivially true because `vec![0u8; n]`
809 // zeroes. On the cust path, `cust::memory::UnifiedBuffer::new(&0u8,
810 // size)` seeds with zero. On the cudarc path, `cuMemAllocManaged`
811 // does NOT zero — the `unified.rs` change closes that hole. Either
812 // way the contract surfaced by `UnifiedMemoryPool::allocate` must be
813 // identical across all three backings, so we test it here.
814 let pool = UnifiedMemoryPool::new(4 * 1024).unwrap();
815 let a = pool.allocate(1024, 16).unwrap();
816 assert!(
817 a.as_slice().iter().all(|&b| b == 0),
818 "first allocation out of fresh pool must read as zero"
819 );
820 }
821}