tensor_wasm_mem/unified.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! `UnifiedBuffer` — a region of memory that is addressable from both CPU and
5//! GPU when a CUDA backing feature is enabled.
6//!
7//! Three backings, selected at compile time:
8//! - With `unified-memory`: `cudaMallocManaged` via the `cust` crate. Pages
9//! migrate on demand between host and device. This is the v0.3 default for
10//! any host that opts into a CUDA backing and remains the cust-backed path
11//! the audit-closed test matrix exercises.
12//! - With `cudarc-backend` (and `unified-memory` OFF): `cuMemAllocManaged`
13//! via the W1.2 `cudarc_backend::CudarcUnifiedBuffer` spike.
14//! This is the v0.5 cust-successor fallback per
15//! [RFC 0001](../../../rfcs/0001-cuda-oxide-integration.md): once
16//! cuda-oxide v0.2 ships its managed-memory wrapper this path can be
17//! swapped for `CudaOxideUnifiedBuffer`; until then `cudarc-backend`
18//! is the v0.5 default-flip candidate.
19//! - Without either: a heap-allocated `Box<[u8]>`. This compiles on non-CUDA
20//! hosts and is what CI's no-feature build uses. It exposes the same API
21//! but the `prefetch_to_device` / `prefetch_to_host` methods become no-ops.
22//!
23//! # Feature precedence
24//!
25//! When both `unified-memory` and `cudarc-backend` are enabled simultaneously,
26//! the cust path wins. This preserves the v0.3 default (every existing
27//! production deployment built with `--features unified-memory` keeps the
28//! exact same allocator under its feet) and lets dev boxes that have both
29//! features turned on opt to cust without a separate feature gate. To force
30//! the cudarc backing on such a host, build with
31//! `--no-default-features --features cudarc-backend` (or otherwise omit
32//! `unified-memory` from the active feature set).
33//!
34//! Precedence table:
35//!
36//! | `unified-memory` | `cudarc-backend` | Active backing | `is_uvm_backed()` |
37//! |------------------|-------------------|----------------------------|-------------------|
38//! | on | off | cust `UnifiedBuffer<u8>` | `true` |
39//! | on | on | cust `UnifiedBuffer<u8>` | `true` |
40//! | off | on | `CudarcUnifiedBuffer` | `true` |
41//! | off | off | `Box<[u8]>` | `false` |
42
43use std::fmt;
44use std::ptr::NonNull;
45use std::sync::Arc;
46
47use tensor_wasm_tenant::TenantContext;
48
49/// Errors raised by `UnifiedBuffer` operations.
50#[derive(Debug, thiserror::Error)]
51pub enum UnifiedError {
52 /// The underlying allocator failed.
53 #[error("allocation failed: {0}")]
54 Allocation(String),
55 /// A CUDA API call failed.
56 #[error("CUDA call failed: {0}")]
57 Cuda(String),
58 /// Zero-byte allocation requested (not supported).
59 #[error("cannot allocate a zero-byte buffer")]
60 ZeroSize,
61 /// Requested allocation exceeds the configured / hard-coded cap.
62 ///
63 /// Distinct from [`UnifiedError::Allocation`] so callers can plumb
64 /// structured `requested` / `limit` figures all the way through to
65 /// `tensor_wasm_core::error::TensorWasmError::MemoryExhausted` without
66 /// resorting to substring-matching on a message.
67 #[error("requested {requested} bytes exceeds hard cap {limit}")]
68 TooLarge {
69 /// Bytes the caller asked for.
70 requested: u64,
71 /// Hard cap enforced by the host (bytes).
72 limit: u64,
73 },
74 /// A [`UnifiedBacking`] method is not available on the active backing.
75 ///
76 /// Surfaced by trait methods (e.g. `prefetch_to_device` on the cudarc
77 /// stub or the `Box<[u8]>` host fallback) when the underlying backing
78 /// has no implementation. Carries the method name and the backing tag
79 /// so operator tooling and tests can match on the exact gap without
80 /// scraping driver error strings.
81 #[error("{feature:?} not supported by backing {backing:?}")]
82 NotSupported {
83 /// Stable identifier for the method that has no implementation
84 /// on this backing (e.g. `"prefetch_to_device"`).
85 feature: &'static str,
86 /// Stable identifier for the backing that lacks the feature
87 /// (e.g. `"cudarc-stub"`, `"host-box"`).
88 backing: &'static str,
89 },
90}
91
92/// Memory-residency hint passed through [`UnifiedBacking::apply_advice`].
93///
94/// Mirrors the `cuMemAdvise` flags already used by the three concrete
95/// backings ([`crate::advise::Advice`] on the cust path,
96/// `cudarc_backend::apply_advice` on the cudarc path, and the
97/// `CudaOxideAdvice` enum on the cuda-oxide path). This trait-facing enum
98/// is declared in the common `unified` module so downstream code can
99/// target a single shape across every backing.
100///
101/// Variants intentionally hold a bare `u32` device ordinal rather than
102/// [`DeviceId`] so the enum has zero non-trivial dependencies and a
103/// future port (e.g. a Vulkan / ROCm backing) can implement
104/// [`UnifiedBacking`] without pulling the CUDA-tagged [`DeviceId`] into
105/// its interface.
106#[non_exhaustive]
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum UvmAdvice {
109 /// `CU_MEM_ADVISE_SET_READ_MOSTLY`. Pages are read by many devices
110 /// but rarely written; the runtime may duplicate them.
111 SetReadMostly,
112 /// `CU_MEM_ADVISE_UNSET_READ_MOSTLY`. Reverse the read-mostly hint.
113 UnsetReadMostly,
114 /// `CU_MEM_ADVISE_SET_PREFERRED_LOCATION` for the given device
115 /// ordinal — pages should live primarily on that device.
116 SetPreferredLocation(u32),
117 /// `CU_MEM_ADVISE_UNSET_PREFERRED_LOCATION`. Reverse the preferred
118 /// location hint.
119 UnsetPreferredLocation,
120 /// `CU_MEM_ADVISE_SET_ACCESSED_BY` for the given device ordinal —
121 /// the device will access the region, so the runtime should map it
122 /// without migrating.
123 SetAccessedBy(u32),
124 /// `CU_MEM_ADVISE_UNSET_ACCESSED_BY` for the given device ordinal.
125 UnsetAccessedBy(u32),
126}
127
128/// Common surface for unified-memory backings (cust, cudarc, cuda-oxide).
129///
130/// The three concrete buffer types in this crate hand-mirror the same API.
131/// This trait pins the contract; v0.4 will migrate the concrete types to
132/// implement it and the public `UnifiedBuffer` may become an enum or a
133/// `Box<dyn UnifiedBacking>` shell. For v0.3.6 the trait is documentation-
134/// shaped: a wire-stable description of what every backing must support so
135/// downstream code (and future ports) can target it.
136///
137/// # Method semantics
138///
139/// Implementations that lack support for a particular driver call (the
140/// cudarc stub, the `Box<[u8]>` host fallback, or a port that simply
141/// has not wired the entry point yet) MUST return
142/// [`UnifiedError::NotSupported`] from the corresponding method rather
143/// than silently succeeding with a no-op. The legacy `UnifiedBuffer`
144/// path keeps its historical no-op behaviour for `prefetch_to_*` to
145/// preserve back-compat with v0.3 callers; that exemption is documented
146/// per-method below.
147pub trait UnifiedBacking: Send + Sync {
148 /// Number of bytes in this allocation.
149 fn len(&self) -> usize;
150
151 /// True iff [`Self::len`] is zero.
152 fn is_empty(&self) -> bool {
153 self.len() == 0
154 }
155
156 /// Borrow the host-visible slice. UVM means the bytes are accessible
157 /// to both host and device; reads after a device write may need a
158 /// `prefetch_to_host` first depending on the backing.
159 fn as_slice(&self) -> &[u8];
160
161 /// Mutably borrow the host-visible slice. See [`Self::as_slice`].
162 fn as_mut_slice(&mut self) -> &mut [u8];
163
164 /// Apply a CUDA `cuMemAdvise` hint. No-op on backings that don't
165 /// support it (host-only fallback returns `Ok(())`; the cudarc /
166 /// cuda-oxide stubs return [`UnifiedError::NotSupported`] until the
167 /// real wrapper lands).
168 fn apply_advice(&self, hint: UvmAdvice) -> Result<(), UnifiedError>;
169
170 /// Prefetch to a device. May be a no-op on backings without UVM
171 /// prefetch (documented per-backing).
172 fn prefetch_to_device(&self, device_ord: u32) -> Result<(), UnifiedError>;
173
174 /// Prefetch back to the host CPU.
175 fn prefetch_to_host(&self) -> Result<(), UnifiedError>;
176}
177
178/// Identifies a CUDA device. On non-CUDA hosts this is a free-form tag.
179#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
180pub struct DeviceId(pub u32);
181
182impl fmt::Display for DeviceId {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(f, "cuda:{}", self.0)
185 }
186}
187
188/// A contiguous memory region addressable from both CPU and GPU.
189///
190/// Safety invariant: `ptr` is non-null and points to `size` valid bytes
191/// allocated by the active backing. Dropping the buffer frees the allocation.
192pub struct UnifiedBuffer {
193 ptr: NonNull<u8>,
194 size: usize,
195 device_id: DeviceId,
196 // Holds the underlying storage so it is freed on drop. Which concrete
197 // variant `Backing` resolves to is determined at compile time by the
198 // feature flags: cust `UnifiedBuffer<u8>` under `unified-memory`,
199 // `CudarcUnifiedBuffer` under `cudarc-backend` (when `unified-memory`
200 // is off), or a plain `Box<[u8]>` on the default no-feature build.
201 // See the module-level precedence table for the full matrix.
202 //
203 // ALIASING: `backing` and `ptr` observe the same allocation. The
204 // sealed `Backing` enum (declared `pub(super)` inside `mod backing`)
205 // exists only to give that allocation a typed `Drop`; its variants
206 // MUST NOT be pattern-matched and its wrapped storage MUST NOT be
207 // re-borrowed via the inner type's `as_mut_slice` / `as_ptr`. See
208 // the "Aliasing invariant" doc on `mod backing` for the full
209 // contract. The audit-T5 fix sealed this by making variants
210 // unreachable from outside the `unified` module.
211 #[allow(dead_code)]
212 backing: Backing,
213 /// Audit H4: whether the bytes at `ptr` are dereferenceable from the
214 /// host CPU. `true` for managed/unified allocations (cust
215 /// `cuMemAllocManaged`, cudarc `cuMemAllocManaged`) and the host
216 /// `Box<[u8]>` fallback — all of which are page-migratable / plain
217 /// host memory and so safe to view as a `&[u8]`. `false` for the T39
218 /// `new_in_tenant_pool` path, whose `cuMemAllocFromPoolAsync` memory
219 /// is DEVICE-ONLY (see the "Bytes layout" doc on
220 /// `new_in_tenant_pool`); calling `from_raw_parts` over it
221 /// would be host-side UB. `as_slice` / `as_mut_slice` consult this
222 /// flag and panic rather than fabricating a host slice over device
223 /// memory. Every constructor MUST set this correctly.
224 host_addressable: bool,
225 /// Audit (LOW, Drop-time zeroization): whether the backing is plain
226 /// host memory (`Box<[u8]>`) whose bytes can be safely overwritten
227 /// from the CPU in `Drop`. Only the no-CUDA `Box<[u8]>` fallback sets
228 /// this `true`; the CUDA device paths leave it `false` because
229 /// zeroizing them needs a live CUDA context (not available in a bare
230 /// `Drop`). Distinct from `host_addressable`: a future host-pinned
231 /// CUDA allocation could be host-addressable yet still need a context
232 /// to free, so the two concerns are tracked separately.
233 host_zeroize_on_drop: bool,
234 /// Physical byte capacity of the underlying allocation on the HOST
235 /// (`Box<[u8]>`) backing.
236 ///
237 /// `size` is the *logical* (currently-used) length exposed by
238 /// [`Self::len`] / [`Self::as_slice`] / [`Self::as_mut_slice`];
239 /// `host_capacity` is how many bytes the backing allocation actually
240 /// owns. The invariant `size <= host_capacity` is upheld by every
241 /// constructor and by [`Self::try_grow_in_place`], which is what makes
242 /// it sound for the slice/`Drop` paths to keep bounding their
243 /// `from_raw_parts` over `self.ptr` with `self.size` — they only ever
244 /// view a prefix of the real allocation.
245 ///
246 /// For the standard host constructors (`new`, `new_on`,
247 /// `new_with_visible_window_on`, …) this equals `size`: the
248 /// `vec![0u8; size]` backing is exactly sized, so there is no spare
249 /// room and an in-place grow request necessarily falls back to the
250 /// realloc path. [`Self::new_host_with_capacity_on`] is the entry
251 /// point that allocates `capacity > size` physical bytes so that
252 /// subsequent [`Self::try_grow_in_place`] calls up to `capacity`
253 /// succeed without a realloc+copy — the per-spawn Wasm-linear-memory
254 /// win this field exists to enable.
255 ///
256 /// On the CUDA backings this field is bookkeeping only (set equal to
257 /// `size`); the CUDA in-place grow path is still deferred (see
258 /// [`Self::try_grow_in_place`]).
259 host_capacity: usize,
260 /// Tenant context for GPU memory accounting. When `Some`, the
261 /// buffer's `Drop` impl calls
262 /// [`TenantContext::release_gpu_bytes`] for `size` bytes. Set only
263 /// by [`UnifiedBuffer::new_on_with_tenant_context`] — the legacy
264 /// [`UnifiedBuffer::new`] / [`UnifiedBuffer::new_on`] constructors
265 /// leave this `None` so existing call sites (tests, benches,
266 /// untenanted use of the pool) are unaffected.
267 ///
268 /// Held in an `Arc` so the buffer can outlive whichever
269 /// `TensorWasmMemoryCreator` constructed it — the creator's clone
270 /// chain ends when the last `UnifiedBuffer` (or Wasm linear
271 /// memory) finishes its `release_gpu_bytes` decrement.
272 tenant_ctx: Option<Arc<TenantContext>>,
273}
274
275// SAFETY: the inner pointer is owned by this struct and not shared without
276// explicit synchronisation. The wrapper type itself can be sent across threads;
277// concurrent access to the underlying bytes requires external synchronisation
278// (same contract as `Vec<u8>` once you have a `&mut [u8]`).
279unsafe impl Send for UnifiedBuffer {}
280unsafe impl Sync for UnifiedBuffer {}
281
282/// # Aliasing invariant
283///
284/// Each [`Backing`] variant wraps an owning allocation whose first byte
285/// is ALSO observed via the parent [`UnifiedBuffer`]'s `NonNull<u8>`.
286/// The owning storage (e.g. [`cust::memory::UnifiedBuffer<u8>`],
287/// [`crate::cudarc_backend::CudarcUnifiedBuffer`], or `Box<[u8]>`)
288/// exposes its own `as_mut_slice()` / `as_ptr()` accessors that would
289/// hand out a `&mut [u8]` to exactly the same bytes Rust already has a
290/// `&mut [u8]` to via `UnifiedBuffer::as_mut_slice` — producing two
291/// live mutable references to the same allocation, instant UB.
292///
293/// The variants are SEALED — declared `pub(super)` so no code outside
294/// the `unified` module can pattern-match or destructure them. The
295/// ONLY sound operations on a `Backing` value are:
296///
297/// 1. Leave it in place inside [`UnifiedBuffer`] (the by-design case).
298/// 2. Drop it (freeing the allocation; runs automatically on
299/// `UnifiedBuffer::drop`).
300/// 3. Replace the entire [`UnifiedBuffer`] (which moves + drops the
301/// old `Backing` as a whole; the `NonNull<u8>` is replaced in lock
302/// step).
303///
304/// Specifically forbidden, even inside the `unified` module:
305///
306/// - `match` / `if let` on `Backing` variants to call `as_mut_slice`,
307/// `as_ptr`, `as_unified_ptr`, or any other method that hands out a
308/// borrow or pointer overlapping the parent struct's
309/// [`UnifiedBuffer::ptr`]. Use `self.ptr` directly.
310/// - `mem::replace` / `mem::take` / `into_inner` style moves that
311/// extract the wrapped allocation while the parent's `ptr` is still
312/// considered live.
313///
314/// Future contributors adding a new variant MUST add a `# Safety`
315/// section to its doc comment that explains how the new variant
316/// preserves these rules. The `#[deny(missing_docs)]` attribute on
317/// the enum enforces the per-variant doc requirement at compile time.
318#[cfg(feature = "unified-memory")]
319mod backing {
320 use super::*;
321
322 /// Compile-time constant exposed by [`super::UnifiedBuffer::is_uvm_backed`].
323 ///
324 /// `true` here: the cust path routes through `cuMemAllocManaged`. By the
325 /// module-level precedence rule (see [`super`] doc), this branch wins
326 /// whenever `unified-memory` is enabled even if `cudarc-backend` is also
327 /// on, so dev hosts that toggle both features keep the v0.3 cust
328 /// allocator. Setting this constant to `true` is part of the three-way
329 /// gating documented at the module head: `unified-memory` OR
330 /// `cudarc-backend` ⇒ `true`; only the default `Box<[u8]>` path ⇒ `false`.
331 pub(super) const IS_UVM_BACKED: bool = true;
332
333 /// Audit (LOW, Drop-time zeroization): `false` here — the cust managed
334 /// allocation is freed by cust's typed `Drop`, which needs a live CUDA
335 /// context, so [`super::UnifiedBuffer::drop`] must NOT attempt a
336 /// host-side memset over it. Only the no-CUDA `Box<[u8]>` build sets
337 /// this `true`.
338 pub(super) const IS_HOST_BACKED: bool = false;
339
340 /// Owning storage for a [`super::UnifiedBuffer`] under the
341 /// `unified-memory` feature.
342 ///
343 /// SEALED: declared `pub(super)`, so neither this enum nor its
344 /// variants can be named outside the `unified` module. See the
345 /// "Aliasing invariant" section on the parent module for the full
346 /// safety contract. The variants are documentation-grade only —
347 /// they exist to give the wrapped allocation a typed `Drop`; they
348 /// must not be pattern-matched.
349 #[deny(missing_docs)]
350 #[allow(dead_code)]
351 pub(super) enum Backing {
352 /// cust-managed UVM allocation (`cuMemAllocManaged` via cust 0.3).
353 ///
354 /// # Safety
355 ///
356 /// The wrapped [`cust::memory::UnifiedBuffer<u8>`] aliases the
357 /// same bytes as the parent [`super::UnifiedBuffer`]'s
358 /// `NonNull<u8>`. Do NOT call `as_mut_slice` / `as_ptr` /
359 /// `as_unified_ptr` on the wrapped value once it has been
360 /// moved into this variant — those accessors are reserved for
361 /// the pre-aliasing construction path inside
362 /// [`Backing::allocate`].
363 Cuda(cust::memory::UnifiedBuffer<u8>),
364
365 /// T39 driver-enforced tenant pool allocation
366 /// (`cuMemAllocFromPoolAsync` against a `TenantMemPool`).
367 ///
368 /// Distinct from [`Backing::Cuda`] because the free path goes
369 /// through `cuMemFreeAsync` on the tenant's pool handle rather
370 /// than cust's `cuMemFree_v2`. The held `Arc<TenantMemPool>`
371 /// keeps the pool (and its `cuMemPoolDestroy` deadline) alive
372 /// for the buffer's lifetime.
373 ///
374 /// # Safety
375 ///
376 /// Same aliasing rule as [`Backing::Cuda`]: the wrapped
377 /// `NonNull<u8>` aliases the parent
378 /// [`super::UnifiedBuffer`]'s `ptr` field; do NOT
379 /// pattern-match this variant from outside the construction
380 /// + drop paths in this module.
381 #[cfg(feature = "gpu-mem-pool")]
382 TenantPool(super::TenantPoolBacking),
383 }
384
385 /// Process-wide CUDA context init via cust::quick_init. cust 0.3
386 /// uses an implicit primary-context model -- without this, any
387 /// allocation returns `CUDA_ERROR_NOT_INITIALIZED` because `cuInit(0)`
388 /// was never called. The result is held in a `OnceLock` so subsequent
389 /// allocations are init-free. We only need to keep the `Context` alive
390 /// for the rest of the process; nothing here ever drops it.
391 fn ensure_cuda_init() -> Result<(), UnifiedError> {
392 use std::sync::OnceLock;
393 static CTX: OnceLock<Result<cust::context::Context, String>> = OnceLock::new();
394 let r =
395 CTX.get_or_init(|| cust::quick_init().map_err(|e| format!("cust::quick_init: {e:?}")));
396 match r {
397 Ok(_) => Ok(()),
398 Err(msg) => Err(UnifiedError::Cuda(msg.clone())),
399 }
400 }
401
402 impl Backing {
403 pub(super) fn allocate(
404 size: usize,
405 init_zero_bytes: usize,
406 ) -> Result<(NonNull<u8>, Self), UnifiedError> {
407 // Ensure cuInit(0) + a primary context have run before the first
408 // cuMemAllocManaged. Idempotent and cheap on subsequent calls.
409 ensure_cuda_init()?;
410 // Allocate the managed region WITHOUT touching every byte. cust's
411 // `UnifiedBuffer::new(&0u8, size)` runs a per-element write loop
412 // over the whole allocation, which under `TensorWasmLinearMemory`'s
413 // option-(a) preallocate-at-max strategy costs a full
414 // `DEFAULT_MAX_BYTES` (256 MiB by default) memset on every Wasm
415 // spawn. Wasm semantics only require the *visible* window to be
416 // zero at instantiation; `memory.grow` bytes are zeroed by
417 // Wasmtime itself. So we ask cust for an uninitialised allocation
418 // and zero only `init_zero_bytes` ourselves.
419 //
420 // SAFETY: `uninitialized` only requires that the caller treats the
421 // returned bytes as uninitialised memory until written. We do
422 // exactly that — the slice below is the first write into the
423 // region, and we cap it at `init_zero_bytes <= size`.
424 let mut buf = unsafe { cust::memory::UnifiedBuffer::<u8>::uninitialized(size) }
425 .map_err(|e| UnifiedError::Cuda(format!("{e:?}")))?;
426 let init = init_zero_bytes.min(size);
427 if init > 0 {
428 buf.as_mut_slice()[..init].fill(0);
429 }
430 // SAFETY: cust returns a non-null aligned pointer to the allocation.
431 let ptr = NonNull::new(buf.as_unified_ptr().as_raw_mut() as *mut u8)
432 .ok_or_else(|| UnifiedError::Allocation("cust returned null".into()))?;
433 Ok((ptr, Backing::Cuda(buf)))
434 }
435 }
436}
437
438#[cfg(all(not(feature = "unified-memory"), feature = "cudarc-backend"))]
439mod backing {
440 //! Sealed owning storage for [`super::UnifiedBuffer`]. The
441 //! `Backing` enum aliases the same allocation as
442 //! `super::UnifiedBuffer::ptr` (a `NonNull<u8>`), so its variants
443 //! are declared `pub(super)` and MUST NOT be pattern-matched from
444 //! outside this module. See the matching "Aliasing invariant"
445 //! comment on the `feature = "unified-memory"` build of this
446 //! module for the full safety contract.
447 use super::*;
448 use crate::cudarc_backend::CudarcUnifiedBuffer;
449
450 /// Compile-time constant exposed by [`super::UnifiedBuffer::is_uvm_backed`].
451 ///
452 /// `true` here: the cudarc path routes through `cuMemAllocManaged` via
453 /// `cudarc::driver::sys::lib().cuMemAllocManaged` (see the W1.2 spike in
454 /// [`crate::cudarc_backend`]). This branch only fires when
455 /// `unified-memory` is OFF and `cudarc-backend` is ON — the module-level
456 /// precedence rule lets cust win whenever both are enabled. Three-way
457 /// gating recap: `unified-memory` OR `cudarc-backend` ⇒ `true`; only the
458 /// default `Box<[u8]>` fallback ⇒ `false`.
459 pub(super) const IS_UVM_BACKED: bool = true;
460
461 /// Audit (LOW, Drop-time zeroization): `false` here — the cudarc
462 /// managed allocation is freed via `cuMemFree_v2`, which needs a live
463 /// CUDA context, so [`super::UnifiedBuffer::drop`] must NOT attempt a
464 /// host-side memset over it. Only the no-CUDA `Box<[u8]>` build sets
465 /// this `true`.
466 pub(super) const IS_HOST_BACKED: bool = false;
467
468 /// Owning storage for a [`super::UnifiedBuffer`] under the
469 /// `cudarc-backend` feature.
470 ///
471 /// SEALED: declared `pub(super)`, so neither this enum nor its
472 /// variants can be named outside the `unified` module. See the
473 /// "Aliasing invariant" section on the parent module for the full
474 /// safety contract.
475 #[deny(missing_docs)]
476 #[allow(dead_code)]
477 pub(super) enum Backing {
478 /// cudarc-managed UVM allocation (`cuMemAllocManaged` via cudarc).
479 ///
480 /// # Safety
481 ///
482 /// The wrapped [`CudarcUnifiedBuffer`] aliases the same bytes
483 /// as the parent [`super::UnifiedBuffer`]'s `NonNull<u8>`. Do
484 /// NOT call `as_mut_slice` / `as_ptr` on the wrapped value
485 /// once it has been moved into this variant — those accessors
486 /// are reserved for the pre-aliasing construction path inside
487 /// [`Backing::allocate`].
488 Cudarc(CudarcUnifiedBuffer),
489
490 /// T39 driver-enforced tenant pool allocation
491 /// (`cuMemAllocFromPoolAsync` against a `TenantMemPool`).
492 ///
493 /// Distinct from [`Backing::Cudarc`] because the free path
494 /// goes through `cuMemFreeAsync` on the tenant's pool handle
495 /// rather than `cuMemFree_v2`. The held `Arc<TenantMemPool>`
496 /// keeps the pool (and its `cuMemPoolDestroy` deadline) alive
497 /// for the buffer's lifetime.
498 ///
499 /// # Safety
500 ///
501 /// Same aliasing rule as [`Backing::Cudarc`]: the wrapped
502 /// `NonNull<u8>` aliases the parent
503 /// [`super::UnifiedBuffer`]'s `ptr` field; do NOT
504 /// pattern-match this variant from outside the construction
505 /// + drop paths in this module.
506 #[cfg(feature = "gpu-mem-pool")]
507 TenantPool(super::TenantPoolBacking),
508 }
509
510 impl Backing {
511 /// Allocate `size` bytes of CUDA Unified Memory via cudarc.
512 ///
513 /// Zeroes only the visible window (`init_zero_bytes`, clamped to
514 /// `size`). Bytes outside `[0, init_zero_bytes)` are NOT zeroed and
515 /// contain undefined data until the caller writes them. This matches
516 /// the cust path's behaviour and restores the `visible_bytes`
517 /// optimisation that motivates
518 /// [`super::UnifiedBuffer::new_with_visible_window_on`]: a 256 MiB
519 /// Wasm linear-memory spawn pays only a `minimum_bytes` memset
520 /// (typically one 64 KiB Wasm page) instead of a full `cap`-sized
521 /// fill.
522 ///
523 /// # Audit H2 invariant (no cross-tenant data leak)
524 ///
525 /// Cross-tenant safety is upheld at the layer above this one:
526 ///
527 /// - The pool path
528 /// ([`crate::pool::UnifiedMemoryPool::allocate`]) zeroes every
529 /// carved `[offset, offset + size)` region via `ptr::write_bytes`
530 /// before returning the [`crate::pool::PoolAllocation`] to the
531 /// tenant. That defends slabs that are recycled across tenants
532 /// (audit H1, regression-pinned by
533 /// `pool::tests::recycled_allocation_reads_as_zero`).
534 /// - The direct linear-memory path
535 /// ([`crate::wasm_memory::TensorWasmLinearMemory`]) zeroes the
536 /// visible window at construction time (via this function's
537 /// `init_zero_bytes` fill) and zeroes bytes freshly exposed by
538 /// `memory.grow` in
539 /// [`crate::wasm_memory::TensorWasmLinearMemory::grow_to`]
540 /// (regression-pinned by the H2 comment block at that call site).
541 ///
542 /// Removing the redundant full-allocation memset that previously
543 /// ran here restores the visible-window optimisation; the H2
544 /// invariant is preserved by the layer-above defences listed above.
545 pub(super) fn allocate(
546 size: usize,
547 init_zero_bytes: usize,
548 ) -> Result<(NonNull<u8>, Self), UnifiedError> {
549 // Route through the W1.2 cudarc spike. `CudarcUnifiedBuffer::new`
550 // already handles `cuInit` + primary-context retention via the
551 // cached `Arc<CudaDevice>` in `cudarc_backend.rs`, so there is no
552 // analogue of the cust path's `ensure_cuda_init()` helper here.
553 let mut buf = CudarcUnifiedBuffer::new(size)?;
554 // cuMemAllocManaged does NOT zero-initialise the returned region,
555 // so we zero the Wasm visible window ourselves to match the
556 // initial-zero contract of `memory 1` instantiation. Bytes beyond
557 // `init_zero_bytes` stay uninitialised; Wasmtime separately zeros
558 // any bytes exposed by `memory.grow`. The audit-T14 perf fix
559 // dropped a redundant full-allocation memset that previously
560 // followed this fill — see the doc comment above for the H2
561 // safety rationale.
562 let init = init_zero_bytes.min(size);
563 if init > 0 {
564 buf.as_mut_slice()[..init].fill(0);
565 }
566 // SAFETY: cudarc returns a non-null aligned pointer on success;
567 // `CudarcUnifiedBuffer::new` already verified this and would have
568 // returned `UnifiedError::Allocation` otherwise.
569 let ptr = NonNull::new(buf.as_ptr() as *mut u8)
570 .ok_or_else(|| UnifiedError::Allocation("cudarc returned null".into()))?;
571 Ok((ptr, Backing::Cudarc(buf)))
572 }
573 }
574}
575
576#[cfg(all(not(feature = "unified-memory"), not(feature = "cudarc-backend")))]
577mod backing {
578 //! Sealed owning storage for [`super::UnifiedBuffer`]. The
579 //! `Backing` enum aliases the same allocation as
580 //! `super::UnifiedBuffer::ptr` (a `NonNull<u8>`), so its variants
581 //! are declared `pub(super)` and MUST NOT be pattern-matched from
582 //! outside this module. See the matching "Aliasing invariant"
583 //! comment on the `feature = "unified-memory"` build of this
584 //! module for the full safety contract.
585 use super::*;
586
587 /// Compile-time constant exposed by [`super::UnifiedBuffer::is_uvm_backed`].
588 ///
589 /// `false` on the no-CUDA default build: [`Backing::allocate`] returns a
590 /// heap `Box<[u8]>` and the prefetch/advise helpers are no-ops. This
591 /// branch fires only when BOTH `unified-memory` and `cudarc-backend` are
592 /// off — enabling either of the two CUDA-backing features flips the
593 /// constant to `true`. See the module-level precedence table for the
594 /// three-way gating.
595 pub(super) const IS_UVM_BACKED: bool = false;
596
597 /// Audit (LOW, Drop-time zeroization): `true` here — the backing is a
598 /// plain heap `Box<[u8]>`, so [`super::UnifiedBuffer::drop`] can
599 /// volatile-zero the bytes before the box is freed (no CUDA context
600 /// required). This is the ONLY build where the constant is `true`.
601 pub(super) const IS_HOST_BACKED: bool = true;
602
603 /// Owning storage for a [`super::UnifiedBuffer`] on the no-CUDA
604 /// default build.
605 ///
606 /// SEALED: declared `pub(super)`, so neither this enum nor its
607 /// variants can be named outside the `unified` module. See the
608 /// "Aliasing invariant" section on the parent module for the full
609 /// safety contract.
610 #[deny(missing_docs)]
611 #[allow(dead_code)]
612 pub(super) enum Backing {
613 /// Heap-backed fallback (`Box<[u8]>`).
614 ///
615 /// # Safety
616 ///
617 /// The wrapped `Box<[u8]>` aliases the same bytes as the
618 /// parent [`super::UnifiedBuffer`]'s `NonNull<u8>`. Do NOT
619 /// call `as_mut_ptr` / `as_mut` / index the slice once the box
620 /// has been moved into this variant — those accessors are
621 /// reserved for the pre-aliasing construction path inside
622 /// [`Backing::allocate`].
623 Host(Box<[u8]>),
624 }
625
626 impl Backing {
627 pub(super) fn allocate(
628 size: usize,
629 _init_zero_bytes: usize,
630 ) -> Result<(NonNull<u8>, Self), UnifiedError> {
631 // Allocate a zeroed boxed slice; this serves the no-CUDA path.
632 // `vec![0u8; size]` already zeroes the entire allocation in one
633 // `memset`-equivalent call (and on this branch there is no GPU
634 // page-fault round trip to amortise against), so the
635 // `_init_zero_bytes` distinction is irrelevant: we keep the
636 // whole-slab zero-init regardless. The parameter is accepted to
637 // match the cust/cudarc signatures.
638 let mut boxed: Box<[u8]> = vec![0u8; size].into_boxed_slice();
639 let ptr = NonNull::new(boxed.as_mut_ptr())
640 .ok_or_else(|| UnifiedError::Allocation("Box returned null".into()))?;
641 Ok((ptr, Backing::Host(boxed)))
642 }
643 }
644}
645
646// Private re-export: pulls the SEALED `Backing` type and its associated
647// constant into the `unified` module's name space. The use statement is
648// intentionally non-`pub` — `Backing` itself is `pub(super)` inside its
649// `mod backing` block, so neither this re-export nor the type can be
650// named from any other module in the crate. Combined with the
651// per-variant `# Safety` invariant on each `Backing` arm (see the
652// "Aliasing invariant" doc on the `mod backing` blocks above), this
653// closes the audit-T5 finding that `Backing::Cuda` aliased the parent
654// struct's `NonNull<u8>`.
655use backing::{Backing, IS_HOST_BACKED, IS_UVM_BACKED};
656
657/// T39 owning storage for a [`UnifiedBuffer`] allocated through a
658/// tenant-scoped `cuMemPool`.
659///
660/// Wraps the raw `NonNull<u8>` returned by
661/// `cuMemAllocFromPoolAsync` plus the `Arc<TenantMemPool>` whose
662/// release-threshold cap the driver is now enforcing. The `Drop` impl
663/// frees through `pool.deallocate` (`cuMemFreeAsync`) before the held
664/// `Arc` itself drops, so the pool's `cuMemPoolDestroy` cannot run
665/// while any of its allocations are still live.
666///
667/// Lives at parent-module scope so the per-feature `mod backing`
668/// blocks above can both name the same type — the cudarc-only build
669/// and the `unified-memory`-plus-`gpu-mem-pool` build share this one
670/// definition rather than each carrying their own copy.
671///
672/// # Safety
673///
674/// `ptr` aliases the parent [`UnifiedBuffer`]'s `ptr` field. Do NOT
675/// hand the inner pointer out via any accessor here — the pre-aliasing
676/// construction path inside [`UnifiedBuffer::new_in_tenant_pool`]
677/// captures the pointer, and from then on only the parent struct's
678/// `as_ptr` / `as_mut_ptr` are the legal access paths.
679#[cfg(feature = "gpu-mem-pool")]
680pub(crate) struct TenantPoolBacking {
681 ptr: NonNull<u8>,
682 /// Allocation size in bytes. Held so [`Drop`] can return the host-side cap
683 /// reservation (`TenantMemPool::live_bytes`) that
684 /// [`TenantMemPool::allocate`] took — `deallocate` only carries the
685 /// pointer, not the size. See fix #1.
686 size: usize,
687 pool: Arc<crate::cuda_mem_pool::TenantMemPool>,
688}
689
690// Audit (LOW — ASLR-leak via logs): hand-written `Debug` that REDACTS the
691// raw `ptr` address rather than the `#[derive(Debug)]` default (which would
692// print the allocation pointer). Logs/backtraces that render this struct
693// then cannot be used to defeat ASLR.
694#[cfg(feature = "gpu-mem-pool")]
695impl fmt::Debug for TenantPoolBacking {
696 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
697 f.debug_struct("TenantPoolBacking")
698 .field("ptr", &"<redacted>")
699 .field("pool_cap_bytes", &self.pool.cap_bytes())
700 .finish()
701 }
702}
703
704#[cfg(feature = "gpu-mem-pool")]
705impl Drop for TenantPoolBacking {
706 fn drop(&mut self) {
707 // Free through the tenant pool. Mirrors the failure-handling
708 // discipline of [`crate::cudarc_backend::CudarcUnifiedBuffer::drop`]:
709 // drop cannot return an error, so a `cuMemFreeAsync` failure is
710 // logged at `error!` and the pointer is left orphaned. The
711 // tenant pool's release-threshold cap means a leaked
712 // allocation is still bounded by the cap; the v0.5 leak-audit
713 // story will unify this with `LEAKED_CUDA_ALLOCATIONS`.
714 if let Err(e) = self.pool.deallocate(self.ptr) {
715 tracing::error!(
716 target: "tensor_wasm_mem::cuda_mem_pool",
717 error = ?e,
718 pool_cap_bytes = self.pool.cap_bytes(),
719 "cuMemFreeAsync failed in TenantPoolBacking::drop -- \
720 allocation leaked, bounded by pool cap",
721 );
722 }
723 // Fix #1: return the host-side cap reservation regardless of whether
724 // the driver free succeeded. The reservation is admission control for
725 // *future* allocations; holding a phantom reservation for a buffer the
726 // tenant has dropped would slowly starve the tenant on repeated driver
727 // hiccups. A genuine driver-free failure is already logged loudly above.
728 self.pool.release_bytes(self.size);
729 }
730}
731
732// SAFETY: the inner pointer is owned by this struct and not shared
733// without explicit synchronisation; the `Arc<TenantMemPool>` is itself
734// `Send + Sync` (see `TenantMemPool`'s `unsafe impl`).
735#[cfg(feature = "gpu-mem-pool")]
736unsafe impl Send for TenantPoolBacking {}
737#[cfg(feature = "gpu-mem-pool")]
738unsafe impl Sync for TenantPoolBacking {}
739
740impl UnifiedBuffer {
741 /// Allocate a new unified buffer of `size` bytes on the default device.
742 ///
743 /// The full allocation is zero-initialised. For large allocations where
744 /// only a subset of bytes is observed before being written by the caller,
745 /// prefer [`Self::new_with_visible_window_on`], which limits the
746 /// zero-fill to a caller-supplied prefix and leaves the rest uninitialised
747 /// (skipping a per-element memset on the cust path).
748 pub fn new(size: usize) -> Result<Self, UnifiedError> {
749 Self::new_on(size, DeviceId::default())
750 }
751
752 /// Allocate a new unified buffer of `size` bytes on the named device.
753 ///
754 /// The full allocation is zero-initialised — see [`Self::new`] for the
755 /// rationale and the partial-zero alternative.
756 pub fn new_on(size: usize, device_id: DeviceId) -> Result<Self, UnifiedError> {
757 // Zero the whole allocation to preserve historical semantics. Callers
758 // that can scope the zero-fill to a smaller prefix should use
759 // `new_with_visible_window_on` directly.
760 Self::new_with_visible_window_on(size, size, device_id)
761 }
762
763 /// Allocate `size` bytes on the named device, zeroing only the first
764 /// `visible_bytes` (clamped to `size`).
765 ///
766 /// This is the per-Wasm-spawn optimisation path: under
767 /// `TensorWasmLinearMemory`'s option-(a) preallocate-at-max strategy the
768 /// total allocation can reach hundreds of megabytes
769 /// ([`crate::wasm_memory::DEFAULT_MAX_BYTES`] is 256 MiB), but the Wasm
770 /// spec only requires the initial *minimum* window to read as zero —
771 /// Wasmtime separately zero-fills any bytes later exposed by
772 /// `memory.grow`. Restricting the up-front memset to `visible_bytes`
773 /// drops the per-spawn cost from O(cap) to O(minimum).
774 ///
775 /// The cust path (`unified-memory`) routes through
776 /// `cust::memory::UnifiedBuffer::uninitialized` + a bounded `fill(0)`;
777 /// the cudarc path zeros the same window after `cuMemAllocManaged` (which
778 /// does not zero-init); the host `Box<[u8]>` fallback ignores
779 /// `visible_bytes` and zero-fills via `vec![0u8; size]` because the
780 /// no-CUDA build has no large-allocation cost concern.
781 pub fn new_with_visible_window_on(
782 size: usize,
783 visible_bytes: usize,
784 device_id: DeviceId,
785 ) -> Result<Self, UnifiedError> {
786 if size == 0 {
787 return Err(UnifiedError::ZeroSize);
788 }
789 let (ptr, backing) = Backing::allocate(size, visible_bytes)?;
790 Ok(Self {
791 ptr,
792 size,
793 device_id,
794 backing,
795 // Audit H4: the standard backings (cust/cudarc managed memory
796 // and the host `Box<[u8]>`) are all host-dereferenceable.
797 host_addressable: true,
798 // Audit (LOW): only the no-CUDA `Box<[u8]>` build can be
799 // zeroized in `Drop` without a live CUDA context.
800 host_zeroize_on_drop: IS_HOST_BACKED,
801 // Exactly-sized backing: no spare capacity, so logical ==
802 // physical and `try_grow_in_place` cannot grow without a
803 // realloc. See `new_host_with_capacity_on` for the spare-cap
804 // path.
805 host_capacity: size,
806 tenant_ctx: None,
807 })
808 }
809
810 /// Allocate a HOST (`Box<[u8]>`) buffer with `capacity` physical bytes
811 /// but only `size` bytes of *logical* length exposed.
812 ///
813 /// This is the entry point that makes [`Self::try_grow_in_place`]
814 /// genuinely grow in place: the backing owns `capacity` zeroed bytes,
815 /// [`Self::len`] / [`Self::as_slice`] / [`Self::as_mut_slice`] report
816 /// the `size`-byte prefix, and a later `try_grow_in_place(new_size)`
817 /// with `new_size <= capacity` bumps the logical length (zero-filling
818 /// the newly-exposed region per the H2 guarantee) without a
819 /// realloc+copy. The motivating case is
820 /// [`crate::wasm_memory::TensorWasmLinearMemory`]: reserve at
821 /// `max-pages` once, then satisfy each `memory.grow` as an in-place
822 /// logical bump instead of forcing the B5 option-(a) up-front
823 /// max-preallocate on every spawn.
824 ///
825 /// On the CUDA backings there is no host capacity concept, so this
826 /// degrades to an exactly-`size` allocation (`capacity` is clamped to
827 /// `size`) and `try_grow_in_place` remains deferred there. The whole
828 /// `capacity`-region is zero-initialised on the host path, so the H2
829 /// zero-on-grow guarantee holds for every byte that any future
830 /// in-place grow can expose.
831 ///
832 /// `capacity` is clamped up to `size` (a `capacity < size` request is
833 /// a caller bug; we never expose more than we own). `size == 0` is
834 /// rejected as [`UnifiedError::ZeroSize`], matching the other
835 /// constructors.
836 pub fn new_host_with_capacity_on(
837 size: usize,
838 capacity: usize,
839 device_id: DeviceId,
840 ) -> Result<Self, UnifiedError> {
841 if size == 0 {
842 return Err(UnifiedError::ZeroSize);
843 }
844 // Never expose more than the physical allocation owns.
845 let capacity = capacity.max(size);
846 // Allocate the full physical capacity. On the host path
847 // `Backing::allocate` zero-fills the entire `Box<[u8]>` regardless
848 // of the visible-window argument, so every byte the buffer can
849 // ever expose via an in-place grow is already zero (H2). On the
850 // CUDA paths the allocation is `capacity` bytes too, but the
851 // capacity is bookkeeping-only there because CUDA in-place grow is
852 // still deferred.
853 let (ptr, backing) = Backing::allocate(capacity, capacity)?;
854 Ok(Self {
855 ptr,
856 // Logical length starts at `size`; the rest of `capacity` is
857 // reserved spare that `try_grow_in_place` can later expose.
858 size,
859 device_id,
860 backing,
861 host_addressable: true,
862 host_zeroize_on_drop: IS_HOST_BACKED,
863 host_capacity: capacity,
864 tenant_ctx: None,
865 })
866 }
867
868 /// Allocate `size` bytes on the named device, consulting the
869 /// tenant's GPU memory cap before touching the underlying CUDA
870 /// driver.
871 ///
872 /// Roadmap feature #8 path: this is the tenant-aware analogue of
873 /// [`Self::new_on`]. The lifecycle is:
874 ///
875 /// 1. Call [`TenantContext::consume_gpu_bytes`] for `size` bytes.
876 /// On `Err(GpuMemoryExhausted)` return the structured error
877 /// untouched so the caller can convert it into a `4xx` response
878 /// body without scraping a message string. No CUDA driver call
879 /// happens on the rejection path — important because the only
880 /// realistic in-process recovery is to fail fast.
881 /// 2. Allocate the underlying `Backing`. If the driver itself
882 /// fails (OOM at the cuMemAllocManaged level, ZeroSize, etc.),
883 /// we **must** undo the `consume_gpu_bytes` so the counter does
884 /// not drift above true utilisation. Failure to release here
885 /// would let a tenant's `gpu_bytes_in_use` ratchet up past the
886 /// cap on repeated driver-OOM and the cap would deny later
887 /// legitimate allocations.
888 /// 3. Stash the `Arc<TenantContext>` on the buffer so `Drop` can
889 /// call [`TenantContext::release_gpu_bytes`]. This is the
890 /// release half of the accounting; the CAS-loop in
891 /// `release_gpu_bytes` saturates on underflow, so a Drop after
892 /// an extraordinary release path (e.g. process shutdown) is
893 /// bookkeeping-safe.
894 ///
895 /// v0.3.7 record-only contract: the CUDA driver itself never sees
896 /// the cap until v0.4 wires `cuMemPoolSetAttribute`. See
897 /// `docs/GPU-QUOTAS.md`.
898 pub fn new_on_with_tenant_context(
899 size: usize,
900 device_id: DeviceId,
901 tenant_ctx: Arc<TenantContext>,
902 ) -> Result<Self, tensor_wasm_core::error::TensorWasmError> {
903 Self::new_with_visible_window_on_with_tenant_context(size, size, device_id, tenant_ctx)
904 }
905
906 /// Tenant-aware variant of [`Self::new_with_visible_window_on`].
907 ///
908 /// Consults the tenant's GPU memory cap before allocating; on a cap
909 /// violation returns
910 /// [`tensor_wasm_core::error::TensorWasmError::GpuMemoryExhausted`]
911 /// with the requested-vs-limit-vs-current triple, with no driver
912 /// call performed. On a successful allocation the resulting
913 /// [`UnifiedBuffer`]'s `Drop` returns `size` bytes to the tenant
914 /// via [`TenantContext::release_gpu_bytes`].
915 pub fn new_with_visible_window_on_with_tenant_context(
916 size: usize,
917 visible_bytes: usize,
918 device_id: DeviceId,
919 tenant_ctx: Arc<TenantContext>,
920 ) -> Result<Self, tensor_wasm_core::error::TensorWasmError> {
921 // Caller-bug guard: zero-byte allocations are rejected upstream
922 // by `Backing::allocate`, but we also do not want to bump the
923 // tenant counter for a request we are about to refuse.
924 if size == 0 {
925 return Err(UnifiedError::ZeroSize.into());
926 }
927 // Step 1: reserve against the cap (or counter-only when no cap).
928 tenant_ctx.consume_gpu_bytes(size as u64)?;
929 // Step 2: hand off to the legacy constructor. On driver failure
930 // we must roll back the `consume_gpu_bytes` step, otherwise the
931 // counter drifts above the real utilisation. Mapping
932 // `UnifiedError` → `TensorWasmError` is the existing
933 // `impl From<UnifiedError>` at the bottom of this module.
934 match Backing::allocate(size, visible_bytes) {
935 Ok((ptr, backing)) => Ok(Self {
936 ptr,
937 size,
938 device_id,
939 backing,
940 // Audit H4: same standard host-addressable backings as
941 // `new_with_visible_window_on`.
942 host_addressable: true,
943 // Audit (LOW): only the no-CUDA `Box<[u8]>` build is
944 // zeroized on drop.
945 host_zeroize_on_drop: IS_HOST_BACKED,
946 // Exactly-sized backing: logical == physical, no spare
947 // capacity for an in-place grow.
948 host_capacity: size,
949 tenant_ctx: Some(tenant_ctx),
950 }),
951 Err(e) => {
952 tenant_ctx.release_gpu_bytes(size as u64);
953 Err(e.into())
954 }
955 }
956 }
957
958 /// T39 — allocate `size` bytes routed through the tenant's
959 /// driver-enforced [`TenantMemPool`].
960 ///
961 /// This is the bypass-resistant complement to
962 /// [`Self::new_on_with_tenant_context`]: instead of (or rather,
963 /// in addition to) the in-process `consume_gpu_bytes` counter,
964 /// the CUDA driver itself enforces the
965 /// `CU_MEMPOOL_ATTR_RELEASE_THRESHOLD` cap configured on the
966 /// pool. Over-cap allocations fail with
967 /// `CUDA_ERROR_OUT_OF_MEMORY` at the driver level, which this
968 /// constructor surfaces as
969 /// [`tensor_wasm_core::error::TensorWasmError::CudaError`] via
970 /// the [`UnifiedError`] → [`tensor_wasm_core::error::TensorWasmError`]
971 /// conversion.
972 ///
973 /// # Bytes layout
974 ///
975 /// `cuMemAllocFromPoolAsync` returns device-located uninitialised
976 /// memory. Unlike the unified-memory (`cuMemAllocManaged`) path,
977 /// this allocation is NOT host-addressable through normal
978 /// dereferencing — accessing the bytes from the CPU requires an
979 /// explicit copy or an event-synchronised stream. The Wasm
980 /// linear-memory path therefore CANNOT route through this
981 /// constructor today; the v0.5 cutover that splits managed-memory
982 /// from pooled-device-memory will introduce a separate
983 /// linear-memory variant for tenants that opt into the
984 /// driver-enforced path. For v0.4 this entry point is intended
985 /// for scratch/working-set buffers that live entirely on the
986 /// device.
987 ///
988 /// Gated behind `#[cfg(feature = "gpu-mem-pool")]`. The pool
989 /// handle MUST have been constructed for the same `device_id` you
990 /// pass here; the constructor does not double-check (cudarc's
991 /// `cuMemAllocFromPoolAsync` returns
992 /// `CUDA_ERROR_INVALID_DEVICE` on a mismatch, which we surface as
993 /// `UnifiedError::Cuda`).
994 #[cfg(feature = "gpu-mem-pool")]
995 pub fn new_in_tenant_pool(
996 pool: Arc<crate::cuda_mem_pool::TenantMemPool>,
997 size: usize,
998 device_id: DeviceId,
999 ) -> Result<Self, UnifiedError> {
1000 if size == 0 {
1001 return Err(UnifiedError::ZeroSize);
1002 }
1003 // Bypass the per-feature `Backing::allocate` machinery: the
1004 // pool path is the *only* allocator here, and it does not
1005 // zero-init (device-located memory is uninitialised at
1006 // allocation). We deliberately do NOT pay for a
1007 // full-allocation memset here because the T39 use cases
1008 // (working-set scratch buffers) overwrite the region
1009 // immediately.
1010 //
1011 // Audit (MEDIUM — intra-tenant residue): because this path
1012 // skips the device memset, the freshly-allocated region may
1013 // contain bytes left over from a PRIOR allocation that the
1014 // SAME tenant's pool recycled (`cuMemAllocFromPoolAsync` draws
1015 // from the pool's released-but-not-returned arena). Cross-
1016 // tenant safety is unaffected — each tenant owns a distinct
1017 // pool — but a tenant could observe its own freed residue.
1018 //
1019 // CONTRACT: callers MUST fully overwrite every byte they will
1020 // subsequently read before reading it. This buffer is returned
1021 // UNINITIALISED. There is no host-side memset available on this
1022 // path (the bytes are device-only — see "Bytes layout" above —
1023 // so `ptr::write_bytes` from the CPU would be UB), and a device
1024 // memset helper would require threading a `&Stream` through
1025 // `TenantMemPool`, which lives in another module. If a
1026 // zero-on-allocate option is wanted it should be added to
1027 // `TenantMemPool::allocate` (a stream-synchronised `cuMemsetD8`)
1028 // rather than faked here; deferred — see the report.
1029 let ptr = pool.allocate(size)?;
1030 let tp = TenantPoolBacking {
1031 ptr,
1032 size,
1033 pool: Arc::clone(&pool),
1034 };
1035 Ok(Self {
1036 ptr,
1037 size,
1038 device_id,
1039 backing: Backing::TenantPool(tp),
1040 // Audit H4: `cuMemAllocFromPoolAsync` returns DEVICE-ONLY
1041 // memory (see the "Bytes layout" doc above). It is NOT
1042 // host-dereferenceable, so flag it accordingly — `as_slice`
1043 // / `as_mut_slice` will panic rather than fabricate a host
1044 // `&[u8]` over device memory (which would be UB).
1045 host_addressable: false,
1046 // Audit (LOW): device-only memory cannot be zeroized from the
1047 // CPU in `Drop` (needs a live CUDA context); leave it to the
1048 // pool's free path.
1049 host_zeroize_on_drop: false,
1050 // Device-only allocation: no host capacity concept and no
1051 // in-place grow on this path. Bookkeeping-equal to `size`.
1052 host_capacity: size,
1053 // No `tenant_ctx`: this constructor is the *driver*-pin
1054 // entry point. The caller chooses whether to also stash
1055 // an `Arc<TenantContext>` for in-process accounting via a
1056 // wrapping path; conflating the two layers here would
1057 // make the cap appear to be enforced twice on a single
1058 // allocation and confuse `gpu_bytes_in_use` reporting.
1059 tenant_ctx: None,
1060 })
1061 }
1062
1063 /// Length in bytes.
1064 ///
1065 /// This is the *logical* (currently-used) length — the number of bytes
1066 /// exposed by [`Self::as_slice`] / [`Self::as_mut_slice`]. On the host
1067 /// backing it may be smaller than the physical allocation; see
1068 /// [`Self::capacity`].
1069 pub fn len(&self) -> usize {
1070 self.size
1071 }
1072
1073 /// Physical byte capacity of the underlying allocation.
1074 ///
1075 /// Equals [`Self::len`] for every buffer except those built via
1076 /// [`Self::new_host_with_capacity_on`], where it reports the reserved
1077 /// spare into which [`Self::try_grow_in_place`] can grow without a
1078 /// realloc+copy. Always `>= len()`.
1079 pub fn capacity(&self) -> usize {
1080 self.host_capacity
1081 }
1082
1083 /// True if zero-length. Always false for a successfully constructed buffer.
1084 pub fn is_empty(&self) -> bool {
1085 self.size == 0
1086 }
1087
1088 /// Raw pointer to the first byte. Used by FFI/`MemoryCreator` in S5.
1089 pub fn as_ptr(&self) -> *const u8 {
1090 self.ptr.as_ptr() as *const u8
1091 }
1092
1093 /// Mutable raw pointer to the first byte.
1094 pub fn as_mut_ptr(&mut self) -> *mut u8 {
1095 self.ptr.as_ptr()
1096 }
1097
1098 /// Borrow the buffer as a shared byte slice.
1099 ///
1100 /// # Panics
1101 ///
1102 /// Audit H4: panics if the buffer is NOT host-addressable (i.e. it
1103 /// was created via `new_in_tenant_pool`, whose
1104 /// `cuMemAllocFromPoolAsync` memory is device-only). Fabricating a
1105 /// host `&[u8]` over device memory is undefined behaviour, so this
1106 /// fails loudly rather than handing out a slice the CPU cannot
1107 /// legally dereference. The signature is unchanged (returns `&[u8]`,
1108 /// not `Result`) because every in-tree caller —
1109 /// `TensorWasmLinearMemory` in `wasm_memory.rs`, the pool/tests —
1110 /// only ever calls this on host-addressable managed/heap buffers;
1111 /// see the `host_addressable` field.
1112 pub fn as_slice(&self) -> &[u8] {
1113 assert!(
1114 self.host_addressable,
1115 "UnifiedBuffer::as_slice on a device-only buffer (audit H4): \
1116 this allocation came from new_in_tenant_pool \
1117 (cuMemAllocFromPoolAsync) and is NOT host-dereferenceable; \
1118 copy it to host memory via a stream instead"
1119 );
1120 // SAFETY: ptr is non-null and points to `size` valid bytes by the
1121 // type invariant, and the assert above proves the bytes are
1122 // host-addressable.
1123 unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.size) }
1124 }
1125
1126 /// Borrow the buffer as a mutable byte slice.
1127 ///
1128 /// # Panics
1129 ///
1130 /// Audit H4: panics if the buffer is NOT host-addressable. See
1131 /// [`Self::as_slice`] for the rationale.
1132 pub fn as_mut_slice(&mut self) -> &mut [u8] {
1133 assert!(
1134 self.host_addressable,
1135 "UnifiedBuffer::as_mut_slice on a device-only buffer (audit H4): \
1136 this allocation came from new_in_tenant_pool \
1137 (cuMemAllocFromPoolAsync) and is NOT host-dereferenceable; \
1138 copy it to/from host memory via a stream instead"
1139 );
1140 // SAFETY: ptr is non-null, points to `size` valid bytes, `&mut self`
1141 // proves uniqueness, and the assert above proves the bytes are
1142 // host-addressable.
1143 unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.size) }
1144 }
1145
1146 /// Which device this buffer is anchored to.
1147 pub fn device_id(&self) -> DeviceId {
1148 self.device_id
1149 }
1150
1151 /// Audit (LOW — ASLR-leak via logs): an opaque, non-reversible token
1152 /// derived from the real pointer for use in `Debug` / log output. It
1153 /// lets two renders of the same buffer be correlated WITHOUT
1154 /// disclosing the actual allocation address (which would leak the
1155 /// memory layout and defeat ASLR). A plain multiplicative hash of the
1156 /// address bits — not cryptographic, just enough to scramble the
1157 /// address so it cannot be read back.
1158 fn opaque_handle(&self) -> u32 {
1159 let bits = self.ptr.as_ptr() as usize as u64;
1160 // FNV-1a-ish fold down to 32 bits; one-way for logging purposes.
1161 let mixed = bits.wrapping_mul(0x9E37_79B9_7F4A_7C15);
1162 ((mixed >> 32) ^ (mixed & 0xFFFF_FFFF)) as u32
1163 }
1164
1165 /// Attempt to grow the buffer's logical length in place to `new_size`
1166 /// bytes, without a realloc+copy.
1167 ///
1168 /// # Behaviour by backing
1169 ///
1170 /// **HOST (`Box<[u8]>`, the no-CUDA default build) — implemented.**
1171 /// Succeeds iff `new_size <= self.capacity()` (the physical bytes the
1172 /// backing already owns). On success the logical length
1173 /// ([`Self::len`]) is bumped to `new_size` and the newly-exposed region
1174 /// `[old_len, new_size)` is zero-filled to uphold the H2 zero-on-grow
1175 /// guarantee, then `Ok(())` is returned. A shrink (`new_size <=
1176 /// old_len`) just lowers the logical length and zero-fills nothing.
1177 /// When `new_size` exceeds the physical capacity there is no spare room
1178 /// to grow into, so the call returns
1179 /// [`UnifiedError::NotSupported`] (`feature = "try_grow_in_place"`,
1180 /// `backing = "host-box"`) — a typed, NON-CUDA "would require realloc"
1181 /// signal the caller uses to fall back to its realloc+copy path. The
1182 /// exactly-sized constructors (`new`, `new_on`,
1183 /// `new_with_visible_window_on`, …) leave no spare capacity, so a real
1184 /// grow against them always takes that fallback;
1185 /// [`Self::new_host_with_capacity_on`] is the constructor that reserves
1186 /// spare capacity so the in-place path actually fires (the per-spawn
1187 /// Wasm-linear-memory win — reserve at max once, then grow logically).
1188 ///
1189 /// **CUDA (`unified-memory` cust / `cudarc-backend`) — still deferred.**
1190 /// Returns [`UnifiedError::Cuda`] with the documented
1191 /// `"in-place grow not yet wired"` sentinel. `cuMemAllocManaged`
1192 /// returns a fixed-size allocation with no in-place grow; the Driver
1193 /// API alternative (`cuMemAddressReserve` + `cuMemCreate` + `cuMemMap` +
1194 /// `cuMemSetAccess`, see the `TODO(v0.4)` below) is the remaining
1195 /// work. We deliberately do NOT report a CUDA error on the host build:
1196 /// the host path has no CUDA at all, so misclassifying its "no spare
1197 /// capacity" outcome as a CUDA failure (the previous stub's bug) would
1198 /// send callers down a driver-error branch that does not apply.
1199 ///
1200 /// # H2 / safety invariants
1201 ///
1202 /// `new_size` is never allowed to exceed the physical allocation:
1203 /// success requires `new_size <= host_capacity`, preserving the
1204 /// `size <= host_capacity` struct invariant that lets the slice and
1205 /// `Drop` paths soundly bound their `from_raw_parts` over `self.ptr`
1206 /// with `self.size`. The grown region is zeroed through the same
1207 /// host-addressable `&mut [u8]` the caller would see, so no UB and no
1208 /// stale residue is exposed. The grow is rejected on a non-host-
1209 /// addressable buffer (device-only `new_in_tenant_pool` memory) because
1210 /// it would need a host write into device memory — that path is CUDA
1211 /// and so already returns the deferred sentinel.
1212 ///
1213 /// # TODO(v0.4): CUDA in-place grow via `cuMemAddressReserve`+`cuMemMap`
1214 ///
1215 /// Implemented: the HOST `Box<[u8]>` spare-capacity path above.
1216 /// Deferred: the CUDA virtual-memory path:
1217 ///
1218 /// 1. `cuMemAddressReserve(size = max-pages, align)` — reserve a
1219 /// virtual address window large enough for any future grow.
1220 /// 2. `cuMemCreate(handle, initial_size, prop)` — allocate the
1221 /// initial physical backing.
1222 /// 3. `cuMemMap(va, initial_size, handle)` — map physical to
1223 /// virtual.
1224 /// 4. `cuMemSetAccess(va, initial_size, ReadWrite)` — grant the
1225 /// current device permission.
1226 /// 5. To grow: `cuMemCreate(handle_more, delta, prop)` +
1227 /// `cuMemMap(va + initial_size, delta, handle_more)` +
1228 /// `cuMemSetAccess(va, new_size, ReadWrite)`.
1229 ///
1230 /// Still a follow-up because:
1231 ///
1232 /// - The `cuMemAddressReserve` family is in `cust::sys` / cudarc
1233 /// `sys::lib()` but neither crate has a safe wrapper, so the
1234 /// implementation is ~300-500 LOC of careful `unsafe`.
1235 /// - The path requires the GPU's
1236 /// [`concurrentManagedAccess`](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#um-concurrent-access)
1237 /// device attribute. Consumer Turing/Ampere cards in Windows
1238 /// WDDM mode do NOT expose this — same limitation that makes
1239 /// the W5.9 `cuMemPrefetchAsync` smoke test the one failure in
1240 /// the cudarc set. The v0.4 implementation needs a Linux
1241 /// datacenter GPU (the S22 self-hosted runner from C1) to
1242 /// verify, which doesn't exist yet.
1243 /// - cuda-oxide v0.2 may ship a higher-level virtual-memory
1244 /// wrapper that obviates the bare driver API entirely, per
1245 /// RFC 0001's "cuda-oxide host crates" inventory; waiting one
1246 /// release cycle may save us the work.
1247 pub fn try_grow_in_place(&mut self, new_size: usize) -> Result<(), UnifiedError> {
1248 // CUDA backings: still deferred. Keep the documented sentinel so
1249 // the v0.4 caller match site is stable. Reported as a CUDA error
1250 // ONLY because the active backing IS CUDA here.
1251 if IS_UVM_BACKED {
1252 return Err(UnifiedError::Cuda(
1253 "in-place grow not yet wired -- see UnifiedBuffer::try_grow_in_place doc + \
1254 RFC 0001 v0.4 follow-up. Until then TensorWasmLinearMemory uses the B5 \
1255 option-(a) preallocate-at-max strategy."
1256 .into(),
1257 ));
1258 }
1259
1260 // HOST (`Box<[u8]>`) path. A device-only (non-host-addressable)
1261 // buffer cannot be grown by a host write; reject it the same way
1262 // the slice accessors do, but as a typed error rather than a panic.
1263 if !self.host_addressable {
1264 return Err(UnifiedError::NotSupported {
1265 feature: "try_grow_in_place",
1266 backing: "host-box",
1267 });
1268 }
1269
1270 // No spare physical capacity to grow into: signal the caller to
1271 // fall back to its realloc+copy path. NOT a CUDA error — there is
1272 // no CUDA on this build.
1273 if new_size > self.host_capacity {
1274 return Err(UnifiedError::NotSupported {
1275 feature: "try_grow_in_place",
1276 backing: "host-box",
1277 });
1278 }
1279
1280 let old_size = self.size;
1281 // Bump the logical length first so `as_mut_slice` exposes the
1282 // newly-claimed bytes; the `size <= host_capacity` invariant is
1283 // upheld by the capacity check above.
1284 self.size = new_size;
1285 if new_size > old_size {
1286 // H2 zero-on-grow: zero exactly the freshly-exposed region
1287 // `[old_size, new_size)`. These bytes were already zero-filled
1288 // at allocation time (the host `Backing::allocate` /
1289 // `new_host_with_capacity_on` zero the whole physical
1290 // allocation), but a buffer may have been grown, shrunk, and
1291 // re-grown, so re-zero unconditionally to keep the guarantee
1292 // independent of prior history.
1293 self.as_mut_slice()[old_size..new_size].fill(0);
1294 }
1295 Ok(())
1296 }
1297
1298 /// Whether [`Self::try_grow_in_place`] is implemented on this build.
1299 ///
1300 /// `true` on the no-CUDA `Box<[u8]>` host build, where
1301 /// [`Self::try_grow_in_place`] grows the logical length into reserved
1302 /// physical capacity without a realloc (see
1303 /// [`Self::new_host_with_capacity_on`]). `false` on the CUDA backings,
1304 /// where the `cuMemAddressReserve` + `cuMemMap` path is still the v0.4
1305 /// follow-up documented on `try_grow_in_place`.
1306 ///
1307 /// Callers (mainly `TensorWasmLinearMemory::grow_to`) probe this to
1308 /// pick between the in-place-grow and max-preallocate strategies
1309 /// without scraping the error string. Note that a `true` here means the
1310 /// mechanism exists; an individual grow can still return
1311 /// [`UnifiedError::NotSupported`] when the specific buffer has no spare
1312 /// capacity, at which point the caller takes its realloc fallback.
1313 pub const fn supports_in_place_grow() -> bool {
1314 // HOST build supports it; CUDA builds do not (yet).
1315 !IS_UVM_BACKED
1316 }
1317
1318 /// Whether this buffer is backed by CUDA Unified Memory (`cuMemAllocManaged`).
1319 ///
1320 /// Returns `true` when the crate was compiled with EITHER
1321 /// `--features unified-memory` (cust path, the v0.3 default) OR
1322 /// `--features cudarc-backend` (the W1.2 cudarc spike, used as the
1323 /// `Backing::Cudarc` variant when `unified-memory` is off). Returns
1324 /// `false` only on the bare default build where the backing is a heap
1325 /// `Box<[u8]>`. This is a compile-time property of the active backing
1326 /// (it does not probe the driver at runtime), and is exposed as a public
1327 /// probe so callers — including [`crate::wasm_memory::TensorWasmLinearMemory`]
1328 /// — can assert in tests that the audit-flagged "wasm linear memory not
1329 /// UVM-backed" gap is actually closed at build configuration time.
1330 ///
1331 /// See the module-level precedence table for the full feature-combination
1332 /// matrix.
1333 pub fn is_uvm_backed(&self) -> bool {
1334 IS_UVM_BACKED
1335 }
1336
1337 /// Suggest to the runtime that the buffer should be migrated to the device.
1338 /// No-op when `unified-memory` is disabled.
1339 ///
1340 /// **Implementation status:** under `--features unified-memory`, this is
1341 /// currently an advisory no-op. cust 0.3.2's `MemoryAdvise::prefetch_to_device`
1342 /// requires a `&Stream` + `&Device` (rather than the bare `i32` ordinal this
1343 /// signature accepts), and the unified-memory subsystem does not yet thread
1344 /// a `Stream` through the public surface. On Windows WDDM the equivalent
1345 /// `cuMemPrefetchAsync` call returns `CUDA_ERROR_INVALID_DEVICE` anyway
1346 /// because consumer Turing cards don't expose `concurrentManagedAccess`,
1347 /// so the user-visible behavior is the same. The `cudarc_backend` path
1348 /// (see `cudarc_backend.rs`) does call the driver fn directly via
1349 /// `cudarc::driver::sys::lib().cuMemPrefetchAsync`, where the
1350 /// platform support story is the same.
1351 ///
1352 /// TODO(v0.4): thread a `Stream` through `UnifiedBuffer` and wire this
1353 /// against `cust::memory::MemoryAdvise::prefetch_to_device(&stream, &device)`.
1354 pub fn prefetch_to_device(&self) -> Result<(), UnifiedError> {
1355 Ok(())
1356 }
1357
1358 /// Suggest to the runtime that the buffer should be migrated back to host
1359 /// memory. Currently an advisory no-op for the same reasons as
1360 /// [`Self::prefetch_to_device`].
1361 pub fn prefetch_to_host(&self) -> Result<(), UnifiedError> {
1362 Ok(())
1363 }
1364}
1365
1366impl UnifiedBacking for UnifiedBuffer {
1367 fn len(&self) -> usize {
1368 UnifiedBuffer::len(self)
1369 }
1370
1371 fn as_slice(&self) -> &[u8] {
1372 UnifiedBuffer::as_slice(self)
1373 }
1374
1375 fn as_mut_slice(&mut self) -> &mut [u8] {
1376 UnifiedBuffer::as_mut_slice(self)
1377 }
1378
1379 fn apply_advice(&self, hint: UvmAdvice) -> Result<(), UnifiedError> {
1380 // The legacy `UnifiedBuffer` path delegates advice through the
1381 // [`crate::advise`] module on cust builds; on other builds the
1382 // module is a documented no-op (`Ok(())`). To stay back-compat
1383 // with v0.3 we preserve that no-op shape rather than escalating
1384 // to `NotSupported`. The cust path is the only one whose
1385 // upstream `crate::advise::Advice` enum is wired to the real
1386 // driver call today.
1387 #[cfg(feature = "unified-memory")]
1388 {
1389 let advice = match hint {
1390 UvmAdvice::SetReadMostly => crate::advise::Advice::ReadMostly,
1391 UvmAdvice::UnsetReadMostly => {
1392 // The cust path's [`crate::advise::Advice`] enum has
1393 // no `UnsetReadMostly` variant yet (v0.3 never wired
1394 // it). Surface as `NotSupported` so callers can
1395 // detect the gap without scraping a driver error
1396 // string; the v0.4 cutover will add the variant.
1397 return Err(UnifiedError::NotSupported {
1398 feature: "apply_advice(UnsetReadMostly)",
1399 backing: "cust",
1400 });
1401 }
1402 UvmAdvice::SetPreferredLocation(d) => {
1403 crate::advise::Advice::PreferredLocation(DeviceId(d))
1404 }
1405 UvmAdvice::UnsetPreferredLocation => crate::advise::Advice::UnsetPreferredLocation,
1406 UvmAdvice::SetAccessedBy(d) => crate::advise::Advice::AccessedBy(DeviceId(d)),
1407 UvmAdvice::UnsetAccessedBy(d) => {
1408 crate::advise::Advice::UnsetAccessedBy(DeviceId(d))
1409 }
1410 };
1411 crate::advise::apply(self, advice)
1412 }
1413 #[cfg(not(feature = "unified-memory"))]
1414 {
1415 // No-CUDA and cudarc-only paths: the legacy `UnifiedBuffer`
1416 // hand-mirror returned `Ok(())` for advise calls (the
1417 // `crate::advise::apply` function is itself a no-op here),
1418 // so the trait surface keeps that contract for back-compat.
1419 // Callers that want a hard error on a missing backing should
1420 // use the per-backend types directly until v0.4 routes
1421 // advice through `UnifiedBacking` everywhere.
1422 let _ = hint;
1423 Ok(())
1424 }
1425 }
1426
1427 fn prefetch_to_device(&self, device_ord: u32) -> Result<(), UnifiedError> {
1428 // The legacy method signature on `UnifiedBuffer` takes no
1429 // ordinal (the cust path infers it from the buffer's owning
1430 // device). The trait surface accepts an ordinal so future
1431 // backings can target a non-owning device. cust 0.3's safe
1432 // surface cannot retarget mid-flight, so we can only honor a
1433 // prefetch aimed at the owning device; for any other ordinal we
1434 // surface `NotSupported` rather than silently servicing it
1435 // against the wrong device (matching the cudarc path — see
1436 // `cudarc_backend.rs`). The owning-device case stays an advisory
1437 // no-op `Ok(())` per `UnifiedBuffer::prefetch_to_device`.
1438 if device_ord == self.device_id().0 {
1439 UnifiedBuffer::prefetch_to_device(self)
1440 } else {
1441 Err(UnifiedError::NotSupported {
1442 feature: "prefetch_to_device(non-owning-ordinal)",
1443 backing: "cust",
1444 })
1445 }
1446 }
1447
1448 fn prefetch_to_host(&self) -> Result<(), UnifiedError> {
1449 UnifiedBuffer::prefetch_to_host(self)
1450 }
1451}
1452
1453impl fmt::Debug for UnifiedBuffer {
1454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1455 // Audit (LOW — ASLR-leak via logs): do NOT print the real `ptr`
1456 // address. A raw allocation address in a `Debug` render (which
1457 // routinely lands in logs / panic backtraces / error reports)
1458 // leaks the heap/UVM layout and defeats ASLR for an attacker who
1459 // can read them. We surface only the size + device, plus an
1460 // opaque, non-reversible handle so two `Debug` lines for the same
1461 // buffer can still be correlated without disclosing the address.
1462 f.debug_struct("UnifiedBuffer")
1463 .field("ptr", &"<redacted>")
1464 .field("handle", &format_args!("{:#010x}", self.opaque_handle()))
1465 .field("size", &self.size)
1466 .field("device_id", &self.device_id)
1467 .field("host_addressable", &self.host_addressable)
1468 .finish()
1469 }
1470}
1471
1472impl Drop for UnifiedBuffer {
1473 fn drop(&mut self) {
1474 // Tenant-accounting release. Only runs for buffers constructed
1475 // through [`Self::new_on_with_tenant_context`] (or the
1476 // visible-window variant); the legacy `new` / `new_on` paths
1477 // leave `tenant_ctx == None` so this is a single `Option` check
1478 // on the drop hot path — no atomic, no allocation. The
1479 // underlying CUDA / heap free runs unconditionally via the
1480 // `backing` field's own drop.
1481 //
1482 // Drop-ordering w.r.t. the `Backing` aliasing invariant: this
1483 // `drop` body only touches the tenant counter; it does NOT
1484 // read or write through `self.ptr`. After the body returns,
1485 // Rust runs field-drop in declaration order (`ptr`, `size`,
1486 // `device_id`, `backing`, `tenant_ctx`). `NonNull<u8>` is
1487 // `Copy`-shaped — its drop is a no-op — and only the
1488 // `backing` field's `Drop` actually frees the allocation. So
1489 // no in-flight `as_mut_slice` borrow can race a free here:
1490 // `&mut self` in `drop` precludes any outstanding borrow, and
1491 // the wrapped allocation is freed exactly once via the typed
1492 // `Backing` drop. See the `Backing` "Aliasing invariant" doc.
1493 if let Some(ctx) = self.tenant_ctx.as_ref() {
1494 ctx.release_gpu_bytes(self.size as u64);
1495 }
1496
1497 // Audit (LOW — no Drop-time zeroization of sensitive buffers):
1498 // overwrite the bytes before the backing's own `Drop` frees them,
1499 // so freed memory does not linger with potentially-sensitive
1500 // residue. Gated to the HOST `Box<[u8]>` path only
1501 // (`host_zeroize_on_drop`, set from `IS_HOST_BACKED`): the CUDA
1502 // device/managed paths need a live CUDA context to touch their
1503 // bytes and so are deliberately excluded — zeroing them here would
1504 // be UB / a fault. We use `write_bytes` through the volatile
1505 // wrapper so the compiler cannot elide the store as a dead write
1506 // into about-to-be-freed memory. This crate does not depend on the
1507 // `zeroize` crate (not in its Cargo.toml), so a manual volatile
1508 // memset is used instead.
1509 if self.host_zeroize_on_drop && self.host_capacity > 0 {
1510 // SAFETY: on the host-backed build `ptr` points to
1511 // `host_capacity` valid, host-addressable, uniquely-owned bytes
1512 // (proved by `&mut self` in `drop`). We zero the FULL physical
1513 // capacity, not just the logical `size`: a buffer built via
1514 // `new_host_with_capacity_on` (or grown-then-shrunk) owns spare
1515 // bytes in `[size, host_capacity)` that may hold prior residue,
1516 // and those bytes are about to be freed too. The backing
1517 // `Box<[u8]>` is still alive — field drop runs only after this
1518 // body returns — so the write targets live memory.
1519 // `write_volatile` prevents the store from being optimised away.
1520 unsafe {
1521 std::ptr::write_bytes(self.ptr.as_ptr(), 0u8, self.host_capacity);
1522 // A volatile re-read defeats dead-store elimination: it
1523 // forces the zeroing store above to be observable.
1524 let _ = std::ptr::read_volatile(self.ptr.as_ptr());
1525 }
1526 }
1527 }
1528}
1529
1530/// Cross-crate conversion so `tensor-wasm-mem` errors flow into the workspace's
1531/// unified [`TensorWasmError`] type without manual mapping at every call site.
1532///
1533/// - `ZeroSize` → `TensorWasmError::Serialization` with a descriptive message
1534/// (zero-byte allocations are caller bugs, not memory exhaustion).
1535/// - `Allocation { .. }` → `TensorWasmError::Serialization` carrying the
1536/// detail string. Exhaustion is NOT routed through this variant — pool /
1537/// buffer exhaustion is reported as the structured
1538/// `UnifiedError::TooLarge { requested, limit }` instead, which maps
1539/// directly to `MemoryExhausted` below. Any remaining `Allocation` payload
1540/// reaching this conversion is a caller bug (e.g. `minimum > maximum`,
1541/// bad alignment) and is surfaced as `Serialization` accordingly.
1542/// - `TooLarge { requested, limit }` → `TensorWasmError::MemoryExhausted {
1543/// requested, limit }` (1:1, structured).
1544/// - `Cuda { .. }` → `TensorWasmError::CudaError` (1:1 mapping).
1545///
1546/// [`TensorWasmError`]: tensor_wasm_core::error::TensorWasmError
1547impl From<UnifiedError> for tensor_wasm_core::error::TensorWasmError {
1548 fn from(e: UnifiedError) -> Self {
1549 match e {
1550 UnifiedError::ZeroSize => tensor_wasm_core::error::TensorWasmError::Serialization(
1551 "unified buffer: zero-byte allocation rejected".into(),
1552 ),
1553 UnifiedError::Allocation(msg) => {
1554 tensor_wasm_core::error::TensorWasmError::Serialization(
1555 format!("unified buffer allocation failed: {msg}").into(),
1556 )
1557 }
1558 UnifiedError::Cuda(msg) => {
1559 tensor_wasm_core::error::TensorWasmError::CudaError(msg.into())
1560 }
1561 UnifiedError::TooLarge { requested, limit } => {
1562 tensor_wasm_core::error::TensorWasmError::MemoryExhausted { requested, limit }
1563 }
1564 // `NotSupported` is the v0.3.6 B4.4 trait-surface error
1565 // variant: a [`UnifiedBacking`] method that has no
1566 // implementation on the active backing. We surface it as
1567 // `Serialization` (a "the call shape is wrong for what's
1568 // available" bucket) carrying the {feature, backing} pair
1569 // so downstream logs preserve the gap shape. A future
1570 // workspace error refactor may give this a first-class
1571 // `BackendUnsupported` variant; for v0.3.6 we keep the
1572 // mapping body-only.
1573 UnifiedError::NotSupported { feature, backing } => {
1574 tensor_wasm_core::error::TensorWasmError::Serialization(
1575 format!("unified backing {backing:?} does not support feature {feature:?}")
1576 .into(),
1577 )
1578 }
1579 }
1580 }
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585 use super::*;
1586
1587 #[test]
1588 fn allocate_and_round_trip() {
1589 let mut b = UnifiedBuffer::new(64).expect("alloc");
1590 assert_eq!(b.len(), 64);
1591 assert!(!b.is_empty());
1592 b.as_mut_slice().copy_from_slice(&[7u8; 64]);
1593 assert!(b.as_slice().iter().all(|&v| v == 7));
1594 }
1595
1596 #[test]
1597 fn zero_size_rejected() {
1598 let err = UnifiedBuffer::new(0).expect_err("zero should be rejected");
1599 assert!(matches!(err, UnifiedError::ZeroSize));
1600 }
1601
1602 #[test]
1603 fn try_grow_in_place_classifies_per_backing() {
1604 // CUDA backings are still scaffolded: until v0.4 lands the
1605 // cuMemAddressReserve + cuMemMap path they return the documented
1606 // sentinel and supports_in_place_grow() is false. The HOST build
1607 // implements in-place grow, so supports_in_place_grow() is true and
1608 // an over-capacity grow returns the typed NON-CUDA NotSupported
1609 // signal rather than a CUDA error.
1610 assert_eq!(UnifiedBuffer::supports_in_place_grow(), !IS_UVM_BACKED);
1611 // `new(64)` is exactly-sized (capacity == 64), so growing to 128 has
1612 // no spare room and must fall back.
1613 let mut b = UnifiedBuffer::new(64).expect("alloc");
1614 let err = b.try_grow_in_place(128).expect_err("over-cap must error");
1615 if IS_UVM_BACKED {
1616 // CUDA path: documented sentinel string, contract for the v0.4
1617 // TensorWasmLinearMemory::grow_to caller match site.
1618 let msg = format!("{err}");
1619 assert!(
1620 msg.contains("in-place grow not yet wired"),
1621 "sentinel string changed; v0.4 caller match site must be updated: {msg}",
1622 );
1623 } else {
1624 // HOST path: typed realloc-fallback signal, NOT a CUDA error.
1625 assert!(
1626 matches!(
1627 err,
1628 UnifiedError::NotSupported {
1629 feature: "try_grow_in_place",
1630 backing: "host-box",
1631 }
1632 ),
1633 "host over-cap grow must return typed NotSupported, got {err:?}",
1634 );
1635 }
1636 }
1637
1638 // ---- HOST (`Box<[u8]>`) in-place grow regression tests ----
1639 //
1640 // Gated to the no-CUDA default build: these exercise the
1641 // spare-capacity path that only exists on the host backing.
1642 #[cfg(all(not(feature = "unified-memory"), not(feature = "cudarc-backend")))]
1643 mod host_in_place_grow {
1644 use super::*;
1645
1646 #[test]
1647 fn host_grow_into_reserved_capacity_succeeds_and_zero_fills() {
1648 // Reserve 256 bytes physical, expose 64 logical.
1649 let mut b = UnifiedBuffer::new_host_with_capacity_on(64, 256, DeviceId::default())
1650 .expect("alloc with capacity");
1651 assert_eq!(b.len(), 64);
1652 assert_eq!(b.capacity(), 256);
1653 // Write a non-zero sentinel into the visible window so we can
1654 // prove the grow does NOT clobber existing bytes.
1655 b.as_mut_slice().fill(0xAB);
1656 // Grow in place to 200 (<= capacity): must succeed without realloc.
1657 b.try_grow_in_place(200)
1658 .expect("in-place grow within capacity");
1659 assert_eq!(b.len(), 200);
1660 assert_eq!(b.capacity(), 256, "capacity unchanged by in-place grow");
1661 let s = b.as_slice();
1662 // Pre-existing bytes preserved.
1663 assert!(
1664 s[..64].iter().all(|&x| x == 0xAB),
1665 "existing bytes clobbered"
1666 );
1667 // H2: freshly-exposed region reads as zero.
1668 assert!(
1669 s[64..200].iter().all(|&x| x == 0),
1670 "grown region not zeroed"
1671 );
1672 }
1673
1674 #[test]
1675 fn host_grow_beyond_capacity_returns_not_supported() {
1676 let mut b = UnifiedBuffer::new_host_with_capacity_on(64, 128, DeviceId::default())
1677 .expect("alloc with capacity");
1678 let err = b
1679 .try_grow_in_place(129)
1680 .expect_err("over-capacity must fall back");
1681 assert!(
1682 matches!(
1683 err,
1684 UnifiedError::NotSupported {
1685 feature: "try_grow_in_place",
1686 backing: "host-box",
1687 }
1688 ),
1689 "expected typed realloc-fallback signal, got {err:?}",
1690 );
1691 // The failed grow must not mutate the logical length.
1692 assert_eq!(b.len(), 64);
1693 }
1694
1695 #[test]
1696 fn host_regrow_after_shrink_rezeros_exposed_region() {
1697 let mut b = UnifiedBuffer::new_host_with_capacity_on(8, 64, DeviceId::default())
1698 .expect("alloc with capacity");
1699 // Grow to 64, dirty the whole window, then shrink back to 8.
1700 b.try_grow_in_place(64).expect("grow to cap");
1701 b.as_mut_slice().fill(0xFF);
1702 b.try_grow_in_place(8).expect("shrink");
1703 assert_eq!(b.len(), 8);
1704 // Re-grow: the re-exposed [8, 64) region must read as zero even
1705 // though it held 0xFF before the shrink (H2 holds independent of
1706 // prior history).
1707 b.try_grow_in_place(64).expect("re-grow to cap");
1708 assert!(
1709 b.as_slice()[8..64].iter().all(|&x| x == 0),
1710 "re-grown region must be re-zeroed",
1711 );
1712 }
1713
1714 #[test]
1715 fn host_exactly_sized_buffer_has_no_spare_capacity() {
1716 // Sanity: the standard constructor leaves capacity == len, so a
1717 // real grow always falls back.
1718 let mut b = UnifiedBuffer::new(64).expect("alloc");
1719 assert_eq!(b.capacity(), b.len());
1720 assert!(b.try_grow_in_place(65).is_err());
1721 // Growing to exactly the current size is a no-op success.
1722 b.try_grow_in_place(64)
1723 .expect("grow to current len is a no-op");
1724 assert_eq!(b.len(), 64);
1725 }
1726 }
1727
1728 #[test]
1729 fn device_id_default_is_zero() {
1730 let b = UnifiedBuffer::new(8).unwrap();
1731 assert_eq!(b.device_id(), DeviceId(0));
1732 }
1733
1734 #[test]
1735 fn device_id_display_format() {
1736 assert_eq!(DeviceId(3).to_string(), "cuda:3");
1737 }
1738
1739 #[test]
1740 fn prefetch_no_op_without_cuda() {
1741 // Calling prefetch should be safe even without the unified-memory feature.
1742 let b = UnifiedBuffer::new(32).unwrap();
1743 b.prefetch_to_device().expect("no-op should succeed");
1744 b.prefetch_to_host().expect("no-op should succeed");
1745 }
1746
1747 #[test]
1748 fn pointers_are_non_null_and_consistent() {
1749 let mut b = UnifiedBuffer::new(16).unwrap();
1750 let p1 = b.as_ptr();
1751 let p2 = b.as_mut_ptr() as *const u8;
1752 assert_eq!(p1, p2);
1753 assert!(!p1.is_null());
1754 }
1755
1756 #[test]
1757 fn from_unified_error_to_tensor_wasm_error_zero_size() {
1758 let e = UnifiedError::ZeroSize;
1759 let b: tensor_wasm_core::error::TensorWasmError = e.into();
1760 assert!(matches!(
1761 b,
1762 tensor_wasm_core::error::TensorWasmError::Serialization(_)
1763 ));
1764 // `TensorWasmError`'s Display is sanitised and omits the inner detail;
1765 // the forwarded context is reachable via `inner()`.
1766 assert!(b.inner().unwrap_or("").contains("zero-byte"));
1767 }
1768
1769 #[test]
1770 fn from_unified_error_to_tensor_wasm_error_cuda() {
1771 let e = UnifiedError::Cuda("ctx not current".into());
1772 let b: tensor_wasm_core::error::TensorWasmError = e.into();
1773 match b {
1774 tensor_wasm_core::error::TensorWasmError::CudaError(s) => {
1775 assert_eq!(&*s, "ctx not current")
1776 }
1777 other => panic!("expected CudaError, got {other:?}"),
1778 }
1779 }
1780
1781 #[test]
1782 fn from_unified_error_too_large_maps_to_memory_exhausted_with_figures() {
1783 // Pool / buffer exhaustion is reported as the structured `TooLarge`
1784 // variant; the `From` impl plumbs the `requested` / `limit` fields
1785 // straight through to `MemoryExhausted` (no string parsing).
1786 let e = UnifiedError::TooLarge {
1787 requested: 4096,
1788 limit: 1024,
1789 };
1790 let b: tensor_wasm_core::error::TensorWasmError = e.into();
1791 match b {
1792 tensor_wasm_core::error::TensorWasmError::MemoryExhausted { requested, limit } => {
1793 assert_eq!(requested, 4096);
1794 assert_eq!(limit, 1024);
1795 }
1796 other => panic!("expected MemoryExhausted, got {other:?}"),
1797 }
1798 }
1799
1800 #[test]
1801 #[cfg(feature = "unified-memory")]
1802 fn is_uvm_backed_true_under_feature() {
1803 // Closes the v0.3.2 audit gap (Problem #5): when the `unified-memory`
1804 // Cargo feature is on, `UnifiedBuffer` must report it routes through
1805 // `cuMemAllocManaged`. This is the compile-time guarantee that the
1806 // `TensorWasmLinearMemory` zero-copy promise rests on.
1807 let b = UnifiedBuffer::new(64).expect("alloc under feature");
1808 assert!(
1809 b.is_uvm_backed(),
1810 "unified-memory build must use UVM backing"
1811 );
1812 }
1813
1814 #[test]
1815 #[cfg(all(not(feature = "unified-memory"), not(feature = "cudarc-backend")))]
1816 fn is_uvm_backed_false_without_feature() {
1817 // Without either CUDA backing feature, the backing is a heap
1818 // `Box<[u8]>`. This test pins the inverse half of the contract so a
1819 // future regression that accidentally turns the probe into a
1820 // runtime-always-true cannot sneak past CI's no-feature build.
1821 let b = UnifiedBuffer::new(64).expect("alloc without feature");
1822 assert!(!b.is_uvm_backed(), "no-feature build must use heap backing");
1823 }
1824
1825 #[test]
1826 #[cfg(all(not(feature = "unified-memory"), feature = "cudarc-backend"))]
1827 fn is_uvm_backed_true_under_cudarc_backend() {
1828 // Mirrors `is_uvm_backed_true_under_feature` for the cudarc path.
1829 // When `--features cudarc-backend` is on (and `unified-memory` is
1830 // off), `UnifiedBuffer` routes through `cuMemAllocManaged` via
1831 // cudarc per the module-level precedence rule, so the probe must
1832 // report `true`. NOTE: this test allocates a real CUDA buffer and
1833 // therefore requires a working driver at test time. Use the smoke
1834 // test under `tests/cudarc_unified_buffer_smoke.rs` for the same
1835 // contract at integration-test scope.
1836 let b = UnifiedBuffer::new(64).expect("alloc under cudarc-backend");
1837 assert!(
1838 b.is_uvm_backed(),
1839 "cudarc-backend build must use UVM backing"
1840 );
1841 }
1842
1843 #[test]
1844 fn backing_aliasing_sealed_allocate_use_drop_round_trip() {
1845 // Audit T5 regression: the `Backing` enum aliases the same
1846 // allocation as the parent `UnifiedBuffer`'s `NonNull<u8>`.
1847 // We have sealed the enum (`pub(super)` inside a private
1848 // `mod backing { ... }`) so no caller can pattern-match a
1849 // variant and call `as_mut_slice` on the inner storage in
1850 // parallel with `UnifiedBuffer::as_mut_slice`. This test
1851 // exercises the only sound lifecycle — allocate, observe
1852 // through the parent struct's slice accessor, drop — and
1853 // asserts that the bytes round-trip without observable
1854 // aliasing fallout. The compile-time guarantee that no
1855 // external code can name `Backing::Cuda(...)` etc. is
1856 // enforced by the `pub(super)` declaration and verified at
1857 // build time; this runtime test exists for behavioural
1858 // regression coverage.
1859 let mut b = UnifiedBuffer::new(128).expect("alloc");
1860 // Write through the parent struct's `as_mut_slice` — the
1861 // only sound path. The inner `Backing` storage MUST NOT be
1862 // touched concurrently.
1863 {
1864 let s = b.as_mut_slice();
1865 for (i, byte) in s.iter_mut().enumerate() {
1866 *byte = (i & 0xFF) as u8;
1867 }
1868 }
1869 // Re-borrow read-only and confirm the writes landed.
1870 {
1871 let s = b.as_slice();
1872 for (i, byte) in s.iter().enumerate() {
1873 assert_eq!(
1874 *byte,
1875 (i & 0xFF) as u8,
1876 "byte {i} mismatch — aliasing regression?"
1877 );
1878 }
1879 }
1880 // Drop the buffer at end of scope. The `Drop` impl must free
1881 // the underlying allocation exactly once via `Backing`'s
1882 // typed drop; ASan / Valgrind under CI would surface a
1883 // double-free if anything outside the sealed module had
1884 // reached in and called `into_inner` on the wrapped storage.
1885 drop(b);
1886 }
1887
1888 #[test]
1889 fn debug_redacts_raw_pointer_address() {
1890 // Audit (LOW — ASLR-leak via logs): the `Debug` render must NOT
1891 // contain the real allocation address. We assert the redaction
1892 // marker is present and that the literal pointer (formatted the
1893 // way `#[derive(Debug)]` would) does not appear.
1894 let b = UnifiedBuffer::new(32).expect("alloc");
1895 let dbg = format!("{b:?}");
1896 assert!(dbg.contains("<redacted>"), "ptr not redacted: {dbg}");
1897 let raw = format!("{:p}", b.as_ptr());
1898 assert!(
1899 !dbg.contains(&raw),
1900 "Debug leaked the raw pointer address {raw}: {dbg}"
1901 );
1902 }
1903
1904 #[test]
1905 #[cfg(all(not(feature = "unified-memory"), not(feature = "cudarc-backend")))]
1906 fn host_backed_buffer_is_zeroized_on_drop() {
1907 // Audit (LOW — Drop-time zeroization): on the host `Box<[u8]>`
1908 // build, dropping a buffer must overwrite its bytes. We cannot
1909 // legally read freed memory, so instead we verify the buffer is
1910 // flagged for drop-time zeroization and that the volatile memset
1911 // path runs without fault on a written-then-dropped buffer.
1912 let mut b = UnifiedBuffer::new(256).expect("alloc");
1913 b.as_mut_slice().fill(0xAB);
1914 assert!(
1915 b.host_zeroize_on_drop,
1916 "host build must flag drop-time zeroization"
1917 );
1918 // Dropping triggers the volatile zero memset; a fault here (e.g.
1919 // a regression that enabled zeroization on a device path) would
1920 // surface as a crash under this no-feature CI build.
1921 drop(b);
1922 }
1923
1924 #[test]
1925 fn host_addressable_buffers_expose_slices() {
1926 // Audit H4: the standard constructors produce host-addressable
1927 // buffers, so `as_slice` / `as_mut_slice` must NOT panic.
1928 let mut b = UnifiedBuffer::new(16).expect("alloc");
1929 assert!(b.host_addressable);
1930 b.as_mut_slice().fill(1);
1931 assert!(b.as_slice().iter().all(|&v| v == 1));
1932 }
1933
1934 #[test]
1935 fn from_unified_error_allocation_maps_to_serialization() {
1936 // Any `Allocation` payload reaching this conversion is a caller bug
1937 // (bad alignment, `minimum > maximum`, etc.) — exhaustion is now
1938 // routed through the structured `TooLarge` variant. The conversion
1939 // simply forwards the detail string into `Serialization`.
1940 let e = UnifiedError::Allocation("minimum 1024 > maximum 512".into());
1941 let b: tensor_wasm_core::error::TensorWasmError = e.into();
1942 assert!(matches!(
1943 b,
1944 tensor_wasm_core::error::TensorWasmError::Serialization(_)
1945 ));
1946 // Display is sanitised; the forwarded detail is in `inner()`.
1947 assert!(b.inner().unwrap_or("").contains("minimum 1024"));
1948 }
1949}