Skip to main content

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}