Skip to main content

xlog_cuda/
memory.rs

1//! CUDA memory management
2//!
3//! This module provides GPU memory management with budget enforcement.
4//! It wraps cudarc's allocation functions and tracks total allocated memory.
5
6use std::mem::ManuallyDrop;
7use std::ops::{Deref, DerefMut};
8use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
9use std::sync::Arc;
10
11use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, DevicePtrMut, DeviceSlice, SyncOnDrop};
12use xlog_core::{MemoryBudget, Result, Schema, XlogError};
13
14use crate::arrow_device::ArrowDeviceImport;
15use crate::cuda_compat::{AsKernelParam, DeviceParamStorage, IntoKernelParamStorage};
16use crate::device_runtime::{
17    AllocTag, BlockId, BlockState, DeviceBlock, ResourceError, RuntimeMemoryReservation, StreamId,
18    XlogDeviceRuntime,
19};
20use crate::dlpack::DlpackManagedTensor;
21use crate::CudaDevice;
22
23#[cfg(test)]
24type AfterLocalReservationHook = std::sync::Mutex<Option<Arc<dyn Fn(u64) + Send + Sync + 'static>>>;
25
26/// GPU memory manager with budget enforcement
27///
28/// Tracks allocated GPU memory and enforces a memory budget.
29/// When the budget would be exceeded, returns `XlogError::ResourceExhausted`.
30///
31/// # v0.6 device-runtime routing (opt-in)
32///
33/// Constructing via [`GpuMemoryManager::with_runtime`] attaches an
34/// [`XlogDeviceRuntime`] that mediates allocations through the v0.6
35/// resource stack (e.g., `GlobalDeviceBudget` → `LoggingResource` →
36/// `AsyncCudaResource`). When attached:
37///   * [`GpuMemoryManager::alloc::<T>`] routes the underlying
38///     allocation through the runtime and produces a typed view via
39///     cudarc's `upgrade_device_ptr::<T>`. The returned
40///     [`TrackedCudaSlice`] frees through the runtime on drop.
41///   * [`GpuMemoryManager::alloc_raw`] is the explicit raw-bytes
42///     entry point (no typed view), also runtime-routed.
43///
44/// Both budgets apply: the manager's local `MemoryBudget` AND any
45/// `GlobalDeviceBudget` stacked above the runtime's underlying
46/// resource.
47///
48/// When the manager is constructed via [`GpuMemoryManager::new`]
49/// (no runtime attached), `alloc::<T>` and the rest of the public
50/// API behave bit-for-bit identically to pre-migration: cudarc's
51/// `device.alloc::<T>(len)` allocates and `cudarc` frees on drop.
52/// `alloc_raw` returns `XlogError::Kernel` when no runtime is
53/// attached (no silent fallback). `CudaKernelProvider::new`
54/// continues to construct the manager via `new` for now;
55/// runtime-routed providers are an opt-in through `with_runtime`
56/// at construction sites that need it.
57pub struct GpuMemoryManager {
58    /// The CUDA device for memory operations
59    device: Arc<CudaDevice>,
60    /// Memory budget configuration
61    budget: MemoryBudget,
62    /// Accounting shared by every allocation view over this budget.
63    accounting: Arc<GpuMemoryAccounting>,
64    /// Optional v0.6 device runtime. When set, [`alloc_raw`]
65    /// reserves through the runtime's resource stack in addition
66    /// to enforcing the local budget; both must accept for the
67    /// allocation to proceed.
68    runtime: Option<Arc<XlogDeviceRuntime>>,
69    /// Unit-test seam used to pause a request after local reservation but
70    /// before runtime admission. Production builds contain no hook.
71    #[cfg(test)]
72    after_local_reservation_hook: AfterLocalReservationHook,
73}
74
75#[derive(Default)]
76struct GpuMemoryAccounting {
77    /// Serializes accounting mutations that must validate multiple counters
78    /// before changing any of them.
79    mutation_lock: std::sync::Mutex<()>,
80    /// Bytes reserved against the local budget, including requests that have
81    /// passed the local guard but are still awaiting allocator admission.
82    /// This counter is intentionally conservative so concurrent requests
83    /// cannot oversubscribe the configured budget.
84    budget_reserved: AtomicU64,
85    /// Currently admitted bytes (tracked atomically for thread safety).
86    /// Unlike `budget_reserved`, this excludes provisional and refused
87    /// requests and is the value exposed by [`allocated_bytes`](Self::allocated_bytes).
88    allocated: AtomicU64,
89    /// High-water mark of successful manager-accounted reservations since
90    /// construction or the last [`reset_peak`](Self::reset_peak). This is a
91    /// reservation-lifetime metric, not a physical-memory measurement.
92    peak: AtomicU64,
93    /// Count of `alloc` calls (device allocation requests). Resettable; used by
94    /// the GPU-resident MC engine's no-host gate to prove that **zero** device
95    /// allocations happen inside the measured region (all arenas are allocated
96    /// before it). Distinct from `allocated` (bytes).
97    alloc_count: AtomicU64,
98    /// Runtime deallocations that returned an error. Their bytes remain
99    /// charged locally because physical release was not proven.
100    deallocation_failure_count: AtomicU64,
101    deallocation_failure_bytes: AtomicU64,
102}
103
104/// One atomic claim on a [`GpuMemoryManager`] budget.
105///
106/// The claim protects a complete multi-allocation request from competing
107/// callers. Bytes remain reserved until they are transferred to returned
108/// allocation owners or released when this token is dropped.
109#[must_use = "dropping the reservation immediately releases its unused budget"]
110pub struct GpuMemoryReservation {
111    manager: Arc<GpuMemoryManager>,
112    runtime_reservation: Option<RuntimeMemoryReservation>,
113    total_bytes: u64,
114    remaining_bytes: u64,
115}
116
117impl std::fmt::Debug for GpuMemoryReservation {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("GpuMemoryReservation")
120            .field("total_bytes", &self.total_bytes)
121            .field("remaining_bytes", &self.remaining_bytes)
122            .finish()
123    }
124}
125
126impl GpuMemoryReservation {
127    /// Stable address of the memory manager that admitted this reservation.
128    pub fn memory_manager_ptr_value(&self) -> usize {
129        Arc::as_ptr(&self.manager) as usize
130    }
131
132    /// Complete byte claim made when this reservation was created.
133    pub fn total_bytes(&self) -> u64 {
134        self.total_bytes
135    }
136
137    /// Bytes still available for materialization through this token.
138    pub fn remaining_bytes(&self) -> u64 {
139        self.remaining_bytes
140    }
141
142    /// Bytes already transferred to allocation owners.
143    pub fn used_bytes(&self) -> u64 {
144        self.total_bytes - self.remaining_bytes
145    }
146
147    /// Allocate typed device memory from this reservation.
148    pub fn alloc<T: cudarc::driver::DeviceRepr>(
149        &mut self,
150        len: usize,
151    ) -> Result<TrackedCudaSlice<T>> {
152        self.manager
153            .accounting
154            .alloc_count
155            .fetch_add(1, Ordering::Relaxed);
156        let bytes = (len as u64)
157            .checked_mul(std::mem::size_of::<T>() as u64)
158            .ok_or_else(|| XlogError::Kernel("Allocation size overflow".to_string()))?;
159        let used = self.used_bytes();
160        if bytes > self.remaining_bytes {
161            return Err(MemoryPressure {
162                layer: "manager_reservation_alloc",
163                current_bytes: used as u128,
164                requested_bytes: bytes as u128,
165                budget_bytes: self.total_bytes,
166                prior_peak_bytes: self.manager.accounting.peak.load(Ordering::SeqCst),
167            }
168            .into_error());
169        }
170
171        self.remaining_bytes -= bytes;
172        let manager = Arc::clone(&self.manager);
173        let runtime_reservation = self.runtime_reservation.as_mut();
174        match manager.alloc_after_local_reservation::<T>(len, bytes, runtime_reservation) {
175            Ok(allocation) => Ok(allocation),
176            Err(error) => {
177                self.remaining_bytes = self
178                    .remaining_bytes
179                    .checked_add(bytes)
180                    .expect("reservation rollback overflow");
181                Err(error)
182            }
183        }
184    }
185
186    /// Allocate raw device bytes from this reservation through the attached
187    /// runtime resource stack.
188    pub fn alloc_raw(&mut self, bytes: usize, tag: AllocTag) -> Result<RuntimeAllocBlock> {
189        let bytes_u64 = u64::try_from(bytes)
190            .map_err(|_| XlogError::Kernel("Allocation size overflow".to_string()))?;
191        let used = self.used_bytes();
192        if bytes_u64 > self.remaining_bytes {
193            return Err(MemoryPressure {
194                layer: "manager_reservation_alloc_raw",
195                current_bytes: used as u128,
196                requested_bytes: bytes_u64 as u128,
197                budget_bytes: self.total_bytes,
198                prior_peak_bytes: self.manager.accounting.peak.load(Ordering::SeqCst),
199            }
200            .into_error());
201        }
202
203        self.remaining_bytes -= bytes_u64;
204        let manager = Arc::clone(&self.manager);
205        let runtime_reservation = self.runtime_reservation.as_mut();
206        match manager.alloc_raw_after_local_reservation(bytes, bytes_u64, tag, runtime_reservation)
207        {
208            Ok(allocation) => Ok(allocation),
209            Err(error) => {
210                self.remaining_bytes = self
211                    .remaining_bytes
212                    .checked_add(bytes_u64)
213                    .expect("reservation rollback overflow");
214                Err(error)
215            }
216        }
217    }
218}
219
220impl Drop for GpuMemoryReservation {
221    fn drop(&mut self) {
222        let unused = std::mem::take(&mut self.remaining_bytes);
223        let release = self.manager.rollback_local_reservation(unused);
224        debug_assert!(release.is_ok(), "reservation release must be balanced");
225    }
226}
227
228struct MemoryPressure {
229    layer: &'static str,
230    current_bytes: u128,
231    requested_bytes: u128,
232    budget_bytes: u64,
233    prior_peak_bytes: u64,
234}
235
236impl MemoryPressure {
237    fn required_bytes(&self) -> u128 {
238        self.current_bytes + self.requested_bytes
239    }
240
241    fn into_error(self) -> XlogError {
242        let required_bytes = self.required_bytes();
243        let required_u64_overflow = required_bytes > u64::MAX as u128;
244        XlogError::ResourceExhausted {
245            context: format!(
246                "GPU memory pressure: layer={} current_bytes={} requested_bytes={} required_bytes={} required_u64_overflow={} budget_bytes={} prior_peak_bytes={}",
247                self.layer,
248                self.current_bytes,
249                self.requested_bytes,
250                required_bytes,
251                required_u64_overflow,
252                self.budget_bytes,
253                self.prior_peak_bytes,
254            ),
255            estimated_bytes: u64::try_from(required_bytes).unwrap_or(u64::MAX),
256            budget_bytes: self.budget_bytes,
257        }
258    }
259}
260
261/// Selects which allocator owns the underlying device memory of a
262/// [`TrackedCudaSlice`]. Internal — surfaced only via the methods
263/// on `TrackedCudaSlice`. Migrated allocations carry `Runtime`
264/// backing; legacy allocations stay on `Cudarc`.
265enum Backing {
266    /// Legacy: cudarc owns the slice. The inner `CudaSlice<T>` is
267    /// the actual handle returned by `device.alloc::<T>(..)`, and
268    /// dropping it invokes cudarc's free path. The
269    /// `TrackedCudaSlice` `Drop` impl runs that drop explicitly so
270    /// the timing is identical to pre-migration behavior.
271    Cudarc,
272    /// v0.6 runtime-routed: the [`XlogDeviceRuntime`] owns the
273    /// allocation via its resource stack, and the inner
274    /// `CudaSlice<T>` is a typed view created by
275    /// `upgrade_device_ptr::<T>` over the runtime's raw pointer.
276    /// On drop, the inner view must be **forgotten** (cudarc must
277    /// not free) and the runtime must be told to deallocate the
278    /// `DeviceBlock`. Order of operations matters: deallocate the
279    /// block first, then forget the view, so the runtime sees the
280    /// block in its `live` map.
281    Runtime {
282        runtime: Arc<XlogDeviceRuntime>,
283        block: Option<DeviceBlock>,
284    },
285}
286
287/// Debug probe: poison legacy allocations with 0xDD at drop so any
288/// live alias of freed memory becomes visually distinct. Gated on
289/// `XLOG_DEBUG_POISON_FREE=1`, read once per process.
290fn poison_free_enabled() -> bool {
291    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
292    *ENABLED.get_or_init(|| std::env::var("XLOG_DEBUG_POISON_FREE").map(|v| v == "1") == Ok(true))
293}
294
295/// Debug probe: poison fresh legacy allocations with 0xDD so reads of
296/// unwritten contents surface deterministically. Gated on
297/// `XLOG_DEBUG_POISON_ALLOC=1`, read once per process.
298fn poison_alloc_enabled() -> bool {
299    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
300    *ENABLED.get_or_init(|| std::env::var("XLOG_DEBUG_POISON_ALLOC").map(|v| v == "1") == Ok(true))
301}
302
303/// Debug probe: track live legacy allocation ranges and panic if the
304/// allocator ever hands out a region overlapping one that is still
305/// live (double-hand-out / use-after-free detector, timing
306/// independent). Gated on `XLOG_DEBUG_ALLOC_GUARD=1`.
307fn alloc_guard() -> Option<&'static std::sync::Mutex<std::collections::BTreeMap<u64, u64>>> {
308    static GUARD: std::sync::OnceLock<
309        Option<std::sync::Mutex<std::collections::BTreeMap<u64, u64>>>,
310    > = std::sync::OnceLock::new();
311    GUARD
312        .get_or_init(|| {
313            if std::env::var("XLOG_DEBUG_ALLOC_GUARD").map(|v| v == "1") == Ok(true) {
314                Some(std::sync::Mutex::new(std::collections::BTreeMap::new()))
315            } else {
316                None
317            }
318        })
319        .as_ref()
320}
321
322fn alloc_guard_insert(ptr: u64, bytes: u64) {
323    let Some(guard) = alloc_guard() else { return };
324    if bytes == 0 {
325        return;
326    }
327    let mut live = guard.lock().unwrap();
328    // Overlap check against the nearest live range at or below ptr and
329    // the first live range above it.
330    if let Some((&p, &b)) = live.range(..=ptr).next_back() {
331        if p + b > ptr {
332            panic!(
333                "ALLOC GUARD: new allocation [{:#x}, {:#x}) overlaps live [{:#x}, {:#x})",
334                ptr,
335                ptr + bytes,
336                p,
337                p + b
338            );
339        }
340    }
341    if let Some((&p, _)) = live.range(ptr + 1..).next() {
342        if ptr + bytes > p {
343            panic!(
344                "ALLOC GUARD: new allocation [{:#x}, {:#x}) overlaps live starting at {:#x}",
345                ptr,
346                ptr + bytes,
347                p
348            );
349        }
350    }
351    live.insert(ptr, bytes);
352}
353
354fn alloc_guard_remove(ptr: u64) {
355    let Some(guard) = alloc_guard() else { return };
356    guard.lock().unwrap().remove(&ptr);
357}
358
359/// A `CudaSlice` that automatically updates `GpuMemoryManager`
360/// allocation tracking on drop. Inner slice is wrapped in
361/// `ManuallyDrop` so the [`Backing`] enum can choose between
362/// cudarc-side free (legacy) and runtime-side deallocate (migrated)
363/// without producing a double-free.
364pub struct TrackedCudaSlice<T: cudarc::driver::DeviceRepr> {
365    bytes: u64,
366    manager: Arc<GpuMemoryManager>,
367    inner: ManuallyDrop<CudaSlice<T>>,
368    raw_ptr: cudarc::driver::sys::CUdeviceptr,
369    backing: Backing,
370}
371
372#[derive(Clone)]
373pub(crate) struct RuntimeAllocationIdentity {
374    pub(crate) manager_id: usize,
375    pub(crate) allocation_ptr: u64,
376    pub(crate) allocation_bytes: usize,
377    pub(crate) block_id: BlockId,
378    pub(crate) block_bytes: usize,
379    pub(crate) block_state: BlockState,
380    pub(crate) context: Arc<cudarc::driver::CudaContext>,
381}
382
383impl<T: cudarc::driver::DeviceRepr> Deref for TrackedCudaSlice<T> {
384    type Target = CudaSlice<T>;
385
386    fn deref(&self) -> &Self::Target {
387        &self.inner
388    }
389}
390
391impl<T: cudarc::driver::DeviceRepr> DerefMut for TrackedCudaSlice<T> {
392    fn deref_mut(&mut self) -> &mut Self::Target {
393        &mut self.inner
394    }
395}
396
397impl<T: cudarc::driver::DeviceRepr> DeviceSlice<T> for TrackedCudaSlice<T> {
398    fn len(&self) -> usize {
399        self.inner.len()
400    }
401
402    fn stream(&self) -> &Arc<CudaStream> {
403        self.inner.stream()
404    }
405}
406
407impl<T: cudarc::driver::DeviceRepr> DevicePtr<T> for TrackedCudaSlice<T> {
408    fn device_ptr<'a>(
409        &'a self,
410        stream: &'a CudaStream,
411    ) -> (cudarc::driver::sys::CUdeviceptr, SyncOnDrop<'a>) {
412        // Explicit `&*` deref through ManuallyDrop — the trait
413        // method is not auto-resolved through the wrapper.
414        DevicePtr::device_ptr(&*self.inner, stream)
415    }
416}
417
418impl<T: cudarc::driver::DeviceRepr> DevicePtrMut<T> for TrackedCudaSlice<T> {
419    fn device_ptr_mut<'a>(
420        &'a mut self,
421        stream: &'a CudaStream,
422    ) -> (cudarc::driver::sys::CUdeviceptr, SyncOnDrop<'a>) {
423        DevicePtrMut::device_ptr_mut(&mut *self.inner, stream)
424    }
425}
426
427impl<T: cudarc::driver::DeviceRepr> TrackedCudaSlice<T> {
428    pub fn device_ptr(&self) -> &cudarc::driver::sys::CUdeviceptr {
429        &self.raw_ptr
430    }
431
432    pub fn device_ptr_value(&self) -> cudarc::driver::sys::CUdeviceptr {
433        self.raw_ptr
434    }
435
436    /// Stable address of the memory manager that owns this allocation.
437    pub fn memory_manager_ptr_value(&self) -> usize {
438        Arc::as_ptr(&self.manager) as usize
439    }
440
441    pub(crate) fn runtime_allocation_identity(&self) -> Result<Option<RuntimeAllocationIdentity>> {
442        let Some(block) = self.runtime_block() else {
443            return Ok(None);
444        };
445        let allocation_bytes = self
446            .len()
447            .checked_mul(std::mem::size_of::<T>())
448            .ok_or_else(|| XlogError::Kernel("runtime allocation byte size overflow".into()))?;
449        Ok(Some(RuntimeAllocationIdentity {
450            manager_id: self.memory_manager_ptr_value(),
451            allocation_ptr: self.device_ptr_value(),
452            allocation_bytes,
453            block_id: BlockId::from_block(block),
454            block_bytes: block.bytes,
455            block_state: block.state,
456            context: Arc::clone(DeviceSlice::stream(self).context()),
457        }))
458    }
459
460    /// Borrow the underlying [`DeviceBlock`] for runtime-backed
461    /// allocations. Returns `None` for legacy cudarc-backed
462    /// slices ([`Backing::Cudarc`]) — those are not tracked by
463    /// the v0.6 device runtime and therefore have no
464    /// runtime-side block to record uses against.
465    ///
466    /// Callers (notably [`crate::launch::LaunchRecorder`]) use
467    /// this to attach cross-stream uses via
468    /// [`crate::device_runtime::XlogDeviceRuntime::record_block_use`].
469    /// A `None` return signals that the slice is on the legacy
470    /// path and the recorder cannot track its lifetime — callers
471    /// must either route the allocation through
472    /// [`GpuMemoryManager::with_runtime`] or accept that no
473    /// cross-stream safety applies to this buffer.
474    pub fn runtime_block(&self) -> Option<&crate::device_runtime::DeviceBlock> {
475        match &self.backing {
476            Backing::Cudarc => None,
477            Backing::Runtime { block, .. } => block.as_ref(),
478        }
479    }
480
481    /// Reinterpret this typed allocation as a raw byte allocation.
482    ///
483    /// This is a zero-copy conversion used by XLOG's columnar
484    /// `CudaBuffer` representation, which stores device memory as
485    /// untyped bytes + a schema. The conversion preserves the
486    /// underlying [`Backing`] — runtime-routed slices remain
487    /// runtime-routed, legacy cudarc slices remain cudarc-routed —
488    /// so deallocation continues to match the original allocator.
489    pub fn into_bytes(self) -> TrackedCudaSlice<u8> {
490        // Wrap `self` in `ManuallyDrop` so its `Drop` impl never
491        // runs — we are doing the cleanup manually below by either
492        // (a) leaving the original `inner` forgotten and reusing
493        // its `backing` (Runtime mode), or (b) leaving the original
494        // `inner` forgotten while the new u8 view takes ownership
495        // via `upgrade_device_ptr` (Cudarc mode — same dance as
496        // the pre-migration code).
497        let this = ManuallyDrop::new(self);
498        let bytes = this.bytes;
499        let manager = Arc::clone(&this.manager);
500        let ptr = this.raw_ptr;
501
502        let len_bytes: usize = bytes
503            .try_into()
504            .expect("TrackedCudaSlice byte size must fit into usize");
505
506        // SAFETY: `this` is `ManuallyDrop`, so its destructor will
507        // not run. We bit-copy `backing` out of the original; the
508        // original location is forgotten along with the rest of
509        // `this`. This is sound because each field is owned and not
510        // touched again.
511        let backing: Backing = unsafe { std::ptr::read(&this.backing) };
512
513        // SAFETY: the runtime / cudarc-side memory is still live —
514        // the original `inner` ManuallyDrop never had its
515        // destructor called, so cudarc has not freed. The new
516        // `CudaSlice<u8>` is a typed view over the same bytes.
517        // For Cudarc backing the new view will free on drop (one
518        // alloc, one free, balanced — same as pre-migration).
519        // For Runtime backing the new view will be `mem::forget`
520        // -ed by the new `Drop` impl, and the runtime's
521        // `deallocate(block)` (carried in `backing`) is the sole
522        // free path.
523        let new_inner = unsafe {
524            manager
525                .device
526                .inner()
527                .upgrade_device_ptr::<u8>(ptr, len_bytes)
528        };
529
530        TrackedCudaSlice {
531            bytes,
532            manager,
533            inner: ManuallyDrop::new(new_inner),
534            raw_ptr: ptr,
535            backing,
536        }
537    }
538}
539
540impl<T: cudarc::driver::DeviceRepr> AsKernelParam for &TrackedCudaSlice<T> {
541    fn as_kernel_param(&self) -> *mut std::ffi::c_void {
542        ((*self).device_ptr() as *const cudarc::driver::sys::CUdeviceptr)
543            .cast_mut()
544            .cast()
545    }
546}
547
548impl<T: cudarc::driver::DeviceRepr> AsKernelParam for &mut TrackedCudaSlice<T> {
549    fn as_kernel_param(&self) -> *mut std::ffi::c_void {
550        ((self.device_ptr()) as *const cudarc::driver::sys::CUdeviceptr)
551            .cast_mut()
552            .cast()
553    }
554}
555
556impl<'a, T: cudarc::driver::DeviceRepr> IntoKernelParamStorage for &'a TrackedCudaSlice<T> {
557    type Storage = DeviceParamStorage<'a>;
558
559    fn into_kernel_param_storage(self) -> Self::Storage {
560        let (ptr, sync) = DevicePtr::device_ptr(&*self.inner, self.inner.stream());
561        DeviceParamStorage::synced(ptr, sync)
562    }
563}
564
565impl<T: cudarc::driver::DeviceRepr> IntoKernelParamStorage for &mut TrackedCudaSlice<T> {
566    type Storage = DeviceParamStorage<'static>;
567
568    fn into_kernel_param_storage(self) -> Self::Storage {
569        let stream = self.inner.stream().clone();
570        let (ptr, sync) = DevicePtrMut::device_ptr_mut(&mut *self.inner, &stream);
571        std::mem::forget(sync);
572        DeviceParamStorage::unsynced(ptr)
573    }
574}
575
576impl<T: cudarc::driver::DeviceRepr> Drop for TrackedCudaSlice<T> {
577    fn drop(&mut self) {
578        let released = match &mut self.backing {
579            Backing::Cudarc => {
580                // Debug probe (XLOG_DEBUG_POISON_FREE=1): overwrite the
581                // allocation with 0xDD before cudarc frees it, so any
582                // still-live alias of this memory reads the poison
583                // pattern instead of recycled contents. Diagnostic only;
584                // off unless the env var is set.
585                if poison_free_enabled() && self.bytes > 0 {
586                    unsafe {
587                        let _ = cudarc::driver::sys::cuMemsetD8_v2(
588                            self.raw_ptr,
589                            0xDD,
590                            self.bytes as usize,
591                        );
592                    }
593                }
594                alloc_guard_remove(self.raw_ptr);
595                // SAFETY: drop runs at most once per slice, and the
596                // inner CudaSlice<T> has not been moved out by any
597                // method (`into_bytes` consumes `self` by value and
598                // leaves the original ManuallyDrop forgotten).
599                unsafe { ManuallyDrop::drop(&mut self.inner) };
600                true
601            }
602            Backing::Runtime { runtime, block } => {
603                // Runtime owns the underlying memory. Tell it to
604                // deallocate the block; the inner `CudaSlice<T>` is
605                // a typed view that must NOT free on its own,
606                // which `ManuallyDrop` ensures by simply not
607                // calling its destructor here.
608                match block.take() {
609                    Some(block) => match runtime.deallocate(block) {
610                        Ok(()) => true,
611                        Err(_) => {
612                            self.manager.record_deallocation_failure(self.bytes);
613                            false
614                        }
615                    },
616                    None => false,
617                }
618            }
619        };
620        if released {
621            let release = self.manager.release_owned_allocation(self.bytes);
622            debug_assert!(release.is_ok(), "allocation release must be balanced");
623        }
624    }
625}
626
627impl GpuMemoryManager {
628    /// Create a new GPU memory manager
629    ///
630    /// # Arguments
631    /// * `device` - The CUDA device to allocate memory on
632    /// * `budget` - Memory budget configuration
633    pub fn new(device: Arc<CudaDevice>, budget: MemoryBudget) -> Self {
634        Self {
635            device,
636            budget,
637            accounting: Arc::new(GpuMemoryAccounting::default()),
638            runtime: None,
639            #[cfg(test)]
640            after_local_reservation_hook: std::sync::Mutex::new(None),
641        }
642    }
643
644    /// Like [`new`], but additionally attaches a v0.6
645    /// [`XlogDeviceRuntime`]. The runtime mediates **both**
646    /// [`alloc::<T>`](Self::alloc) and [`alloc_raw`](Self::alloc_raw)
647    /// through the v0.6 resource stack: typed `alloc::<T>` returns a
648    /// [`TrackedCudaSlice<T>`] whose underlying memory is owned by
649    /// the runtime (typed view via cudarc's `upgrade_device_ptr::<T>`,
650    /// freed through the runtime on drop). The legacy cudarc path is
651    /// only used when the manager is built via [`new`] (no runtime
652    /// attached). Provider construction does not yet require the
653    /// runtime; callers that want runtime-routed allocations opt in
654    /// here.
655    pub fn with_runtime(
656        device: Arc<CudaDevice>,
657        budget: MemoryBudget,
658        runtime: Arc<XlogDeviceRuntime>,
659    ) -> Self {
660        Self {
661            device,
662            budget,
663            accounting: Arc::new(GpuMemoryAccounting::default()),
664            runtime: Some(runtime),
665            #[cfg(test)]
666            after_local_reservation_hook: std::sync::Mutex::new(None),
667        }
668    }
669
670    /// Attach a stream-safe runtime while preserving this manager's exact
671    /// device, total budget, and atomic accounting ledger.
672    ///
673    /// The overlay is an allocation view, not an independent budget. Parent
674    /// and overlay allocations therefore cannot oversubscribe the configured
675    /// limit, including when they race. Validation is completed before the
676    /// shared ledger is cloned.
677    pub fn with_runtime_overlay(
678        self: &Arc<Self>,
679        runtime: Arc<XlogDeviceRuntime>,
680    ) -> Result<Arc<Self>> {
681        if !Arc::ptr_eq(&self.device, runtime.device()) {
682            return Err(XlogError::Kernel(
683                "GpuMemoryManager::with_runtime_overlay requires the runtime to share the exact CUDA device handle"
684                    .to_string(),
685            ));
686        }
687        let device_ordinal = u32::try_from(self.device.ordinal()).map_err(|_| {
688            XlogError::Kernel(format!(
689                "CUDA device ordinal {} is not representable as u32",
690                self.device.ordinal()
691            ))
692        })?;
693        if runtime.device_ordinal() != device_ordinal {
694            return Err(XlogError::Kernel(format!(
695                "GpuMemoryManager::with_runtime_overlay device ordinal mismatch: manager={} runtime={}",
696                device_ordinal,
697                runtime.device_ordinal()
698            )));
699        }
700        if !runtime.supports_block_use_tracking() {
701            return Err(XlogError::Kernel(
702                "GpuMemoryManager::with_runtime_overlay requires a runtime with cross-stream block-use tracking"
703                    .to_string(),
704            ));
705        }
706
707        Ok(Arc::new(Self {
708            device: Arc::clone(&self.device),
709            budget: self.budget.clone(),
710            accounting: Arc::clone(&self.accounting),
711            runtime: Some(runtime),
712            #[cfg(test)]
713            after_local_reservation_hook: std::sync::Mutex::new(None),
714        }))
715    }
716
717    /// Atomically reserve `bytes` for one bounded multi-allocation request.
718    ///
719    /// The returned token owns the complete local-budget claim. A
720    /// runtime-backed manager also reserves the same bytes against the
721    /// runtime resource stack's finite global budget before touching local
722    /// accounting; runtimes without a reservable global budget are refused.
723    /// Creating the token performs no device allocation and does not increment
724    /// [`alloc_count`](Self::alloc_count).
725    pub fn reserve_bytes(self: &Arc<Self>, bytes: u64) -> Result<GpuMemoryReservation> {
726        let runtime_reservation = match &self.runtime {
727            Some(runtime) => {
728                let bytes_usize = usize::try_from(bytes).map_err(|_| {
729                    XlogError::Kernel(format!(
730                        "GPU reservation size {} bytes exceeds platform usize",
731                        bytes
732                    ))
733                })?;
734                Some(runtime.reserve_memory(bytes_usize).map_err(|error| {
735                    map_resource_error(error, self.accounting.peak.load(Ordering::SeqCst))
736                })?)
737            }
738            None => None,
739        };
740        self.reserve_local_bytes(bytes, "manager_reserve")?;
741        Ok(GpuMemoryReservation {
742            manager: Arc::clone(self),
743            runtime_reservation,
744            total_bytes: bytes,
745            remaining_bytes: bytes,
746        })
747    }
748
749    fn reserve_local_bytes(&self, bytes: u64, layer: &'static str) -> Result<()> {
750        let _mutation = self
751            .accounting
752            .mutation_lock
753            .lock()
754            .expect("GPU memory accounting poisoned");
755        loop {
756            let current = self.accounting.budget_reserved.load(Ordering::SeqCst);
757            let required = current as u128 + bytes as u128;
758            if required > self.budget.device_bytes as u128 {
759                return Err(MemoryPressure {
760                    layer,
761                    current_bytes: current as u128,
762                    requested_bytes: bytes as u128,
763                    budget_bytes: self.budget.device_bytes,
764                    prior_peak_bytes: self.accounting.peak.load(Ordering::SeqCst),
765                }
766                .into_error());
767            }
768            if self
769                .accounting
770                .budget_reserved
771                .compare_exchange(current, required as u64, Ordering::SeqCst, Ordering::SeqCst)
772                .is_ok()
773            {
774                return Ok(());
775            }
776        }
777    }
778
779    /// Borrow the attached device runtime, if any. `None` when the
780    /// manager was constructed via [`new`]. Test/diagnostic
781    /// accessor; production call sites that need the runtime own
782    /// it directly.
783    pub fn runtime(&self) -> Option<&Arc<XlogDeviceRuntime>> {
784        self.runtime.as_ref()
785    }
786
787    /// Complete stream-ordered frees that are pending in the attached device
788    /// runtime.
789    ///
790    /// Diagnostic transactions use this after dropping temporary buffers on
791    /// an error path so a later operation observes the restored byte budget.
792    pub fn reap_pending_deallocations(&self) -> Result<()> {
793        let Some(runtime) = self.runtime.as_ref() else {
794            return Ok(());
795        };
796        runtime
797            .reap_pending()
798            .map_err(|error| map_resource_error(error, self.peak_bytes()))
799    }
800
801    /// Release a local-budget reservation that was not admitted by the
802    /// underlying allocator. Admitted accounting is untouched because the
803    /// request was never published there.
804    fn rollback_local_reservation(&self, bytes: u64) -> Result<()> {
805        let _mutation = self
806            .accounting
807            .mutation_lock
808            .lock()
809            .expect("GPU memory accounting poisoned");
810        let previous = self.accounting.budget_reserved.load(Ordering::SeqCst);
811        let next = previous.checked_sub(bytes).ok_or_else(|| {
812            XlogError::Kernel(format!(
813                "GPU memory reservation release underflow: current_bytes={} requested_bytes={}",
814                previous, bytes
815            ))
816        })?;
817        self.accounting
818            .budget_reserved
819            .store(next, Ordering::SeqCst);
820        Ok(())
821    }
822
823    /// Publish a successful allocator admission. The local-budget reservation
824    /// already includes `bytes`; only admitted current and peak are updated.
825    fn publish_admission(&self, bytes: u64) {
826        let _mutation = self
827            .accounting
828            .mutation_lock
829            .lock()
830            .expect("GPU memory accounting poisoned");
831        let previous = self.accounting.allocated.load(Ordering::SeqCst);
832        let admitted = previous
833            .checked_add(bytes)
834            .expect("admitted allocation accounting overflow");
835        self.accounting.allocated.store(admitted, Ordering::SeqCst);
836        self.accounting.peak.fetch_max(admitted, Ordering::SeqCst);
837    }
838
839    /// Release bytes owned by a successfully destroyed tracked allocation.
840    /// Only allocation-owner drop paths may call this method.
841    fn release_owned_allocation(&self, bytes: u64) -> Result<()> {
842        let _mutation = self
843            .accounting
844            .mutation_lock
845            .lock()
846            .expect("GPU memory accounting poisoned");
847        let admitted = self.accounting.allocated.load(Ordering::SeqCst);
848        let reserved = self.accounting.budget_reserved.load(Ordering::SeqCst);
849        let next_admitted = admitted.checked_sub(bytes).ok_or_else(|| {
850            XlogError::Kernel(format!(
851                "GPU admitted allocation release underflow: current_bytes={} requested_bytes={}",
852                admitted, bytes
853            ))
854        })?;
855        let next_reserved = reserved.checked_sub(bytes).ok_or_else(|| {
856            XlogError::Kernel(format!(
857                "GPU local reservation release underflow: current_bytes={} requested_bytes={}",
858                reserved, bytes
859            ))
860        })?;
861        self.accounting
862            .allocated
863            .store(next_admitted, Ordering::SeqCst);
864        self.accounting
865            .budget_reserved
866            .store(next_reserved, Ordering::SeqCst);
867        Ok(())
868    }
869
870    fn record_deallocation_failure(&self, bytes: u64) {
871        let _ = self.accounting.deallocation_failure_count.fetch_update(
872            Ordering::SeqCst,
873            Ordering::SeqCst,
874            |current| Some(current.saturating_add(1)),
875        );
876        let _ = self.accounting.deallocation_failure_bytes.fetch_update(
877            Ordering::SeqCst,
878            Ordering::SeqCst,
879            |current| Some(current.saturating_add(bytes)),
880        );
881    }
882
883    /// Allocate GPU memory for `len` elements of type `T`
884    ///
885    /// # Arguments
886    /// * `len` - Number of elements to allocate
887    ///
888    /// # Returns
889    /// A tracked `CudaSlice<T>` containing the allocated memory
890    ///
891    /// # Errors
892    /// - `XlogError::ResourceExhausted` if allocation would exceed budget
893    /// - `XlogError::Kernel` if CUDA allocation fails
894    ///
895    /// # v0.6 routing
896    /// When the manager has an attached [`XlogDeviceRuntime`]
897    /// (constructed via [`with_runtime`]), the underlying allocation
898    /// is routed through the runtime's resource stack and a typed
899    /// view is created via cudarc's `upgrade_device_ptr::<T>` over
900    /// the runtime's raw pointer. The returned [`TrackedCudaSlice`]
901    /// frees through the runtime on drop. Without a runtime
902    /// attached, the legacy cudarc `alloc::<T>` path is used and
903    /// drop frees through cudarc — bit-for-bit identical to
904    /// pre-migration behavior.
905    pub fn alloc<T: cudarc::driver::DeviceRepr>(
906        self: &Arc<Self>,
907        len: usize,
908    ) -> Result<TrackedCudaSlice<T>> {
909        // Count every device allocation request (resettable no-host-gate counter).
910        self.accounting.alloc_count.fetch_add(1, Ordering::Relaxed);
911
912        // Fix Issue 2: Use checked_mul to prevent integer overflow before cast
913        let bytes = (len as u64)
914            .checked_mul(std::mem::size_of::<T>() as u64)
915            .ok_or_else(|| XlogError::Kernel("Allocation size overflow".to_string()))?;
916
917        self.reserve_local_bytes(bytes, "manager_alloc")?;
918        match self.alloc_after_local_reservation::<T>(len, bytes, None) {
919            Ok(allocation) => Ok(allocation),
920            Err(error) => {
921                self.rollback_local_reservation(bytes)?;
922                Err(error)
923            }
924        }
925    }
926
927    fn alloc_after_local_reservation<T: cudarc::driver::DeviceRepr>(
928        self: &Arc<Self>,
929        len: usize,
930        bytes: u64,
931        runtime_reservation: Option<&mut RuntimeMemoryReservation>,
932    ) -> Result<TrackedCudaSlice<T>> {
933        #[cfg(test)]
934        self.run_after_local_reservation_hook(bytes);
935
936        if let Some(runtime) = &self.runtime {
937            // Zero-byte allocations (empty Vec, empty buffer) are
938            // legitimate in production code. The v0.6 resource
939            // stack rejects zero-byte requests by contract
940            // (DirectCudaResource and AsyncCudaResource both error
941            // on `bytes == 0` because `cuMemAlloc(0)` is undefined
942            // behavior in the CUDA driver). Cudarc's `alloc::<T>(0)`
943            // does the right thing — returns an empty CudaSlice<T>
944            // without calling the driver — so route zero-byte
945            // requests through the legacy path even when a runtime
946            // is attached. The resulting slice carries
947            // `Backing::Cudarc`; its drop is a no-op against
948            // cudarc's empty handle.
949            //
950            // `len == 0` and `bytes == 0` are equivalent here only
951            // if `T` has nonzero size (the common case). For
952            // zero-sized types (rare but valid in Rust) `bytes`
953            // would also be 0 regardless of `len`; the cudarc empty
954            // path handles both consistently.
955            if bytes == 0 {
956                let slice = unsafe {
957                    self.device.inner().alloc::<T>(len).map_err(|e| {
958                        XlogError::Kernel(format!("GPU allocation failed (zero-byte): {}", e))
959                    })?
960                };
961                let (raw_ptr, sync) = DevicePtr::device_ptr(&slice, slice.stream());
962                std::mem::forget(sync);
963                self.publish_admission(bytes);
964                return Ok(TrackedCudaSlice {
965                    bytes,
966                    manager: Arc::clone(self),
967                    inner: ManuallyDrop::new(slice),
968                    raw_ptr,
969                    backing: Backing::Cudarc,
970                });
971            }
972
973            // v0.6 path: route through the runtime resource stack.
974            // Convert checked: `bytes` is u64 from
975            // `len * size_of::<T>()`, and the runtime trait surface
976            // uses `usize`. On 64-bit targets this is lossless; on
977            // 32-bit a stray `bytes as usize` would silently
978            // truncate and desync manager accounting (which still
979            // tracks the full u64) from the runtime's view. Surface
980            // the overflow as `XlogError::Kernel`; the caller retains or
981            // rolls back the local reservation as appropriate.
982            let bytes_usize = match usize::try_from(bytes) {
983                Ok(v) => v,
984                Err(_) => {
985                    return Err(XlogError::Kernel(format!(
986                        "GPU allocation size {} bytes exceeds platform usize",
987                        bytes
988                    )));
989                }
990            };
991            let block_result = match runtime_reservation {
992                Some(reservation) => {
993                    reservation.allocate(bytes_usize, StreamId::DEFAULT, AllocTag::UNTAGGED)
994                }
995                None => runtime.allocate(bytes_usize, StreamId::DEFAULT, AllocTag::UNTAGGED),
996            };
997            let block = match block_result {
998                Ok(b) => b,
999                Err(e) => {
1000                    let prior_peak = self.accounting.peak.load(Ordering::SeqCst);
1001                    return Err(map_resource_error(e, prior_peak));
1002                }
1003            };
1004            self.publish_admission(bytes);
1005            let raw_ptr = block.ptr;
1006            // SAFETY: `block.ptr` is a live device pointer of size
1007            // `bytes` returned by the runtime; `len * size_of::<T>()`
1008            // == `bytes` by construction. The resulting CudaSlice<T>
1009            // is a typed view; the `Backing::Runtime` Drop branch
1010            // forgets it (via ManuallyDrop + no destructor call) so
1011            // cudarc never frees — the runtime's deallocate is the
1012            // sole free path.
1013            let typed_view = unsafe { self.device.inner().upgrade_device_ptr::<T>(raw_ptr, len) };
1014            return Ok(TrackedCudaSlice {
1015                bytes,
1016                manager: Arc::clone(self),
1017                inner: ManuallyDrop::new(typed_view),
1018                raw_ptr,
1019                backing: Backing::Runtime {
1020                    runtime: Arc::clone(runtime),
1021                    block: Some(block),
1022                },
1023            });
1024        }
1025
1026        // Legacy path: cudarc allocator. SAFETY: budget reserved
1027        // atomically above and the device is valid; cudarc's
1028        // alloc returns properly aligned memory for type T.
1029        let slice = unsafe {
1030            self.device
1031                .inner()
1032                .alloc::<T>(len)
1033                .map_err(|e| XlogError::Kernel(format!("GPU allocation failed: {}", e)))?
1034        };
1035        let (raw_ptr, sync) = DevicePtr::device_ptr(&slice, slice.stream());
1036        std::mem::forget(sync);
1037        alloc_guard_insert(raw_ptr, bytes);
1038        self.publish_admission(bytes);
1039
1040        // Debug probe (XLOG_DEBUG_POISON_ALLOC=1): poison fresh legacy
1041        // allocations with 0xDD so any read of unwritten allocation
1042        // contents becomes a deterministic, recognizable pattern
1043        // instead of whatever the recycled memory held. Diagnostic
1044        // only; off unless the env var is set.
1045        if poison_alloc_enabled() && bytes > 0 {
1046            unsafe {
1047                let _ = cudarc::driver::sys::cuMemsetD8Async(
1048                    raw_ptr,
1049                    0xDD,
1050                    bytes as usize,
1051                    std::ptr::null_mut(),
1052                );
1053            }
1054        }
1055
1056        Ok(TrackedCudaSlice {
1057            bytes,
1058            manager: Arc::clone(self),
1059            inner: ManuallyDrop::new(slice),
1060            raw_ptr,
1061            backing: Backing::Cudarc,
1062        })
1063    }
1064
1065    /// Check if an allocation of `bytes` would exceed the budget
1066    ///
1067    /// # Arguments
1068    /// * `bytes` - Number of bytes to allocate
1069    ///
1070    /// # Returns
1071    /// `Ok(())` if allocation is within budget
1072    ///
1073    /// # Errors
1074    /// `XlogError::ResourceExhausted` if allocation would exceed budget
1075    pub fn check_budget(&self, bytes: u64) -> Result<()> {
1076        // Include provisional local reservations: this is a budget-admission
1077        // query, not a sample of already admitted allocations.
1078        let current = self.accounting.budget_reserved.load(Ordering::SeqCst);
1079        let required = current as u128 + bytes as u128;
1080
1081        if required > self.budget.device_bytes as u128 {
1082            return Err(MemoryPressure {
1083                layer: "manager_check_budget",
1084                current_bytes: current as u128,
1085                requested_bytes: bytes as u128,
1086                budget_bytes: self.budget.device_bytes,
1087                prior_peak_bytes: self.accounting.peak.load(Ordering::SeqCst),
1088            }
1089            .into_error());
1090        }
1091
1092        Ok(())
1093    }
1094
1095    /// Get the current allocated memory in bytes
1096    pub fn allocated_bytes(&self) -> u64 {
1097        self.accounting.allocated.load(Ordering::SeqCst)
1098    }
1099
1100    /// Number of runtime deallocations that returned an error. Their bytes
1101    /// remain charged locally because physical release was not proven.
1102    pub fn deallocation_failure_count(&self) -> u64 {
1103        self.accounting
1104            .deallocation_failure_count
1105            .load(Ordering::SeqCst)
1106    }
1107
1108    /// Total bytes retained in local accounting after runtime deallocation
1109    /// errors.
1110    pub fn deallocation_failure_bytes(&self) -> u64 {
1111        self.accounting
1112            .deallocation_failure_bytes
1113            .load(Ordering::SeqCst)
1114    }
1115
1116    /// High-water mark of successful manager-accounted reservations since
1117    /// construction or the last [`reset_peak`](Self::reset_peak). Direct CUDA
1118    /// allocations that bypass this manager are not included.
1119    pub fn peak_bytes(&self) -> u64 {
1120        self.accounting.peak.load(Ordering::SeqCst)
1121    }
1122
1123    /// Reset the peak high-water mark to the *current* allocated
1124    /// level, so a measurement window starts from live state rather
1125    /// than zero. Measurement-harness API.
1126    pub fn reset_peak(&self) {
1127        self.accounting.peak.store(
1128            self.accounting.allocated.load(Ordering::SeqCst),
1129            Ordering::SeqCst,
1130        );
1131    }
1132
1133    /// Number of `alloc` calls issued so far (device allocation requests).
1134    /// The GPU-resident MC engine snapshots this around the measured region to
1135    /// prove `per_operator_host_allocations == 0` (all arenas pre-allocated).
1136    pub fn alloc_count(&self) -> u64 {
1137        self.accounting.alloc_count.load(Ordering::Relaxed)
1138    }
1139
1140    /// Reset the allocation-request counter to zero.
1141    pub fn reset_alloc_count(&self) {
1142        self.accounting.alloc_count.store(0, Ordering::Relaxed);
1143    }
1144
1145    /// Get the memory budget
1146    pub fn budget(&self) -> &MemoryBudget {
1147        &self.budget
1148    }
1149
1150    /// Total local budget enforced jointly by this manager and any overlays.
1151    pub fn budget_limit_bytes(&self) -> u64 {
1152        self.budget.device_bytes
1153    }
1154
1155    /// Get the underlying CUDA device
1156    pub fn device(&self) -> &Arc<CudaDevice> {
1157        &self.device
1158    }
1159
1160    /// Validate a caller-requested manual accounting release.
1161    ///
1162    /// Tracked allocations release themselves through their private owner
1163    /// path. A public caller cannot authenticate ownership, so nonzero manual
1164    /// releases are refused while bytes are live and underflow is rejected
1165    /// without mutating either counter.
1166    pub fn record_free(&self, bytes: u64) -> Result<()> {
1167        let _mutation = self
1168            .accounting
1169            .mutation_lock
1170            .lock()
1171            .expect("GPU memory accounting poisoned");
1172        let admitted = self.accounting.allocated.load(Ordering::SeqCst);
1173        let reserved = self.accounting.budget_reserved.load(Ordering::SeqCst);
1174        if admitted != 0 || reserved != 0 {
1175            return Err(XlogError::Kernel(format!(
1176                "manual GPU accounting release refused with live tracked bytes: admitted_bytes={} reserved_bytes={}",
1177                admitted, reserved
1178            )));
1179        }
1180        if bytes != 0 {
1181            return Err(XlogError::Kernel(format!(
1182                "manual GPU accounting release underflow: current_bytes=0 requested_bytes={}",
1183                bytes
1184            )));
1185        }
1186        Ok(())
1187    }
1188
1189    /// v0.6 device-runtime entry point: allocate `bytes` raw bytes
1190    /// through the attached [`XlogDeviceRuntime`].
1191    ///
1192    /// Returns a [`RuntimeAllocBlock`] that owns the allocation. On
1193    /// drop, the block deallocates through the runtime and updates
1194    /// both the manager's local `allocated` counter and the
1195    /// runtime's bookkeeping.
1196    ///
1197    /// Both budgets apply: the manager's local
1198    /// `MemoryBudget::device_bytes` AND any `GlobalDeviceBudget`
1199    /// stacked above the runtime's underlying resource. Either
1200    /// rejecting the request returns an `XlogError`. On runtime
1201    /// rejection the local reservation is rolled back so subsequent
1202    /// allocations see consistent state.
1203    ///
1204    /// # Errors
1205    /// * `XlogError::Kernel` if no runtime is attached.
1206    /// * `XlogError::ResourceExhausted` if the local budget cannot
1207    ///   accommodate the request.
1208    /// * `XlogError::ResourceExhausted` if the runtime's budget rejects the
1209    ///   request. Other runtime errors are reported as `XlogError::Kernel`.
1210    pub fn alloc_raw(self: &Arc<Self>, bytes: usize, tag: AllocTag) -> Result<RuntimeAllocBlock> {
1211        if self.runtime.is_none() {
1212            return Err(XlogError::Kernel(
1213                "GpuMemoryManager::alloc_raw called without an attached XlogDeviceRuntime; \
1214                 construct via with_runtime to enable runtime routing"
1215                    .to_string(),
1216            ));
1217        }
1218        let bytes_u64 = u64::try_from(bytes)
1219            .map_err(|_| XlogError::Kernel("Allocation size overflow".to_string()))?;
1220        self.reserve_local_bytes(bytes_u64, "manager_alloc_raw")?;
1221        match self.alloc_raw_after_local_reservation(bytes, bytes_u64, tag, None) {
1222            Ok(allocation) => Ok(allocation),
1223            Err(error) => {
1224                self.rollback_local_reservation(bytes_u64)?;
1225                Err(error)
1226            }
1227        }
1228    }
1229
1230    fn alloc_raw_after_local_reservation(
1231        self: &Arc<Self>,
1232        bytes: usize,
1233        bytes_u64: u64,
1234        tag: AllocTag,
1235        runtime_reservation: Option<&mut RuntimeMemoryReservation>,
1236    ) -> Result<RuntimeAllocBlock> {
1237        let runtime = self.runtime.as_ref().ok_or_else(|| {
1238            XlogError::Kernel(
1239                "GpuMemoryManager::alloc_raw called without an attached XlogDeviceRuntime; \
1240                 construct via with_runtime to enable runtime routing"
1241                    .to_string(),
1242            )
1243        })?;
1244
1245        #[cfg(test)]
1246        self.run_after_local_reservation_hook(bytes_u64);
1247
1248        // Route through the runtime. Stream is the runtime's
1249        // default for now; once stream-aware kernel launches start
1250        // routing through alloc_raw the caller will pass an
1251        // explicit StreamId.
1252        let allocation = match runtime_reservation {
1253            Some(reservation) => reservation.allocate(bytes, StreamId::DEFAULT, tag),
1254            None => runtime.allocate(bytes, StreamId::DEFAULT, tag),
1255        };
1256        match allocation {
1257            Ok(block) => {
1258                self.publish_admission(bytes_u64);
1259                Ok(RuntimeAllocBlock {
1260                    bytes: bytes_u64,
1261                    manager: Arc::clone(self),
1262                    runtime: Arc::clone(runtime),
1263                    block: Some(block),
1264                })
1265            }
1266            Err(e) => {
1267                let prior_peak = self.accounting.peak.load(Ordering::SeqCst);
1268                Err(map_resource_error(e, prior_peak))
1269            }
1270        }
1271    }
1272
1273    /// Get remaining budget in bytes
1274    pub fn remaining_bytes(&self) -> u64 {
1275        let reserved = self.accounting.budget_reserved.load(Ordering::SeqCst);
1276        self.budget.device_bytes.saturating_sub(reserved)
1277    }
1278
1279    /// Reset diagnostic tracking only when no tracked bytes are live.
1280    pub fn reset_tracking(&self) -> Result<()> {
1281        let _mutation = self
1282            .accounting
1283            .mutation_lock
1284            .lock()
1285            .expect("GPU memory accounting poisoned");
1286        let admitted = self.accounting.allocated.load(Ordering::SeqCst);
1287        let reserved = self.accounting.budget_reserved.load(Ordering::SeqCst);
1288        if admitted != 0 || reserved != 0 {
1289            return Err(XlogError::Kernel(format!(
1290                "GPU accounting reset refused with live tracked bytes: admitted_bytes={} reserved_bytes={}",
1291                admitted, reserved
1292            )));
1293        }
1294        self.accounting.peak.store(0, Ordering::SeqCst);
1295        Ok(())
1296    }
1297
1298    #[cfg(test)]
1299    fn run_after_local_reservation_hook(&self, bytes: u64) {
1300        let hook = self
1301            .after_local_reservation_hook
1302            .lock()
1303            .expect("after-local-reservation test hook poisoned")
1304            .clone();
1305        if let Some(hook) = hook {
1306            hook(bytes);
1307        }
1308    }
1309}
1310
1311fn map_resource_error(e: ResourceError, prior_peak_bytes: u64) -> XlogError {
1312    match e {
1313        ResourceError::OutOfBudget {
1314            requested,
1315            current,
1316            limit,
1317            ..
1318        } => MemoryPressure {
1319            layer: "device_runtime",
1320            current_bytes: current as u128,
1321            requested_bytes: requested as u128,
1322            budget_bytes: u64::try_from(limit).unwrap_or(u64::MAX),
1323            prior_peak_bytes,
1324        }
1325        .into_error(),
1326        ResourceError::Driver(msg) => XlogError::Kernel(format!("device-runtime driver: {}", msg)),
1327        ResourceError::StreamMisuse(msg) => {
1328            XlogError::Kernel(format!("device-runtime stream misuse: {}", msg))
1329        }
1330        ResourceError::UseAfterFree { generation } => XlogError::Kernel(format!(
1331            "device-runtime use-after-free on generation {:?}",
1332            generation
1333        )),
1334        ResourceError::OutOfBounds { generation } => XlogError::Kernel(format!(
1335            "device-runtime out-of-bounds on generation {:?}",
1336            generation
1337        )),
1338    }
1339}
1340
1341/// Owned handle for a raw allocation routed through
1342/// [`GpuMemoryManager::alloc_raw`] / the v0.6 device runtime.
1343///
1344/// Manual `Debug` impl below — the runtime / manager handles
1345/// inside this struct are not `Debug`, so a derive would not
1346/// compile.
1347///
1348/// On drop, deallocates through the runtime (returning the bytes
1349/// to the runtime's bookkeeping — pending if the runtime's backend
1350/// is async) and decrements the manager's local `allocated`
1351/// counter. The block exposes only the raw device pointer and
1352/// byte length; typed views are the caller's responsibility (this
1353/// path is not yet wired into the typed `CudaSlice<T>` API — that
1354/// is a follow-up slice).
1355pub struct RuntimeAllocBlock {
1356    bytes: u64,
1357    manager: Arc<GpuMemoryManager>,
1358    runtime: Arc<XlogDeviceRuntime>,
1359    /// `None` after Drop fires; `Some(_)` while the block is live.
1360    /// Wrapped in Option so `Drop` can move the block out and pass
1361    /// it by value to `runtime.deallocate`.
1362    block: Option<DeviceBlock>,
1363}
1364
1365impl RuntimeAllocBlock {
1366    /// Raw device pointer for this allocation. Live until the
1367    /// block is dropped.
1368    pub fn ptr(&self) -> u64 {
1369        self.block
1370            .as_ref()
1371            .expect("RuntimeAllocBlock used after drop")
1372            .ptr
1373    }
1374
1375    /// Allocation size in bytes.
1376    pub fn bytes(&self) -> usize {
1377        self.bytes as usize
1378    }
1379
1380    /// Borrow the underlying [`DeviceBlock`] metadata. Test/
1381    /// diagnostic accessor.
1382    pub fn device_block(&self) -> &DeviceBlock {
1383        self.block
1384            .as_ref()
1385            .expect("RuntimeAllocBlock used after drop")
1386    }
1387}
1388
1389impl std::fmt::Debug for RuntimeAllocBlock {
1390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1391        let mut dbg = f.debug_struct("RuntimeAllocBlock");
1392        dbg.field("bytes", &self.bytes);
1393        match &self.block {
1394            Some(b) => {
1395                dbg.field("ptr", &format_args!("{:#x}", b.ptr));
1396                dbg.field("device_ordinal", &b.device_ordinal);
1397                dbg.field("alloc_stream", &b.alloc_stream);
1398                dbg.field("tag", &b.tag);
1399                dbg.field("generation", &b.generation);
1400                dbg.field("state", &b.state);
1401            }
1402            None => {
1403                dbg.field("block", &"<dropped>");
1404            }
1405        }
1406        dbg.finish()
1407    }
1408}
1409
1410impl Drop for RuntimeAllocBlock {
1411    fn drop(&mut self) {
1412        if let Some(block) = self.block.take() {
1413            match self.runtime.deallocate(block) {
1414                Ok(()) => {
1415                    let release = self.manager.release_owned_allocation(self.bytes);
1416                    debug_assert!(release.is_ok(), "allocation release must be balanced");
1417                }
1418                Err(_) => self.manager.record_deallocation_failure(self.bytes),
1419            }
1420        }
1421    }
1422}
1423
1424/// Column data stored in device memory.
1425///
1426/// Most columns are owned by XLOG (`Owned`) and tracked against the memory budget. Columns may
1427/// also be imported via DLPack (`Dlpack`) or Arrow device (`ArrowDevice`) without copies; these are
1428/// freed via the DLPack deleter or Arrow release callback.
1429pub enum CudaColumn {
1430    Owned(TrackedCudaSlice<u8>),
1431    Dlpack(DlpackColumn),
1432    ArrowDevice(ArrowDeviceColumn),
1433}
1434
1435pub struct DlpackColumn {
1436    ptr: cudarc::driver::sys::CUdeviceptr,
1437    len_bytes: usize,
1438    stream: Arc<CudaStream>,
1439    _tensor: DlpackManagedTensor,
1440    /// `Some` when this DLPack column wraps memory that xlog
1441    /// itself owns through the device runtime — i.e. the
1442    /// caller exported an xlog-allocated slice via DLPack and
1443    /// kept ownership inside xlog. The strong reference keeps
1444    /// the source slice's [`crate::device_runtime::DeviceBlock`]
1445    /// reachable for runtime-block identity propagation, and
1446    /// keeps the underlying allocation alive across the
1447    /// DLPack handoff (drop order: column → tensor →
1448    /// `source_slice` → `runtime.deallocate`).
1449    ///
1450    /// `None` for true external DLPack producers; those
1451    /// columns continue to be rejected by strict-mode launch
1452    /// recorders.
1453    source_slice: Option<Arc<TrackedCudaSlice<u8>>>,
1454}
1455
1456pub struct ArrowDeviceColumn {
1457    ptr: cudarc::driver::sys::CUdeviceptr,
1458    len_bytes: usize,
1459    stream: Arc<CudaStream>,
1460    _import: Arc<ArrowDeviceImport>,
1461    /// Same role as [`DlpackColumn::source_slice`]: `Some` for
1462    /// xlog-owned Arrow device columns, `None` for true
1463    /// external Arrow producers.
1464    source_slice: Option<Arc<TrackedCudaSlice<u8>>>,
1465}
1466
1467impl CudaColumn {
1468    pub fn owned(slice: TrackedCudaSlice<u8>) -> Self {
1469        Self::Owned(slice)
1470    }
1471
1472    pub fn dlpack(
1473        ptr: cudarc::driver::sys::CUdeviceptr,
1474        len_bytes: usize,
1475        stream: Arc<CudaStream>,
1476        tensor: DlpackManagedTensor,
1477    ) -> Self {
1478        Self::Dlpack(DlpackColumn {
1479            ptr,
1480            len_bytes,
1481            stream,
1482            _tensor: tensor,
1483            source_slice: None,
1484        })
1485    }
1486
1487    /// Construct a DLPack column that wraps memory **xlog
1488    /// itself owns** through the device runtime.
1489    ///
1490    /// Use this when xlog allocated `source_slice` via the
1491    /// runtime-backed manager and is exporting it as a DLPack
1492    /// tensor for inspection by external code while retaining
1493    /// ownership. The resulting column reports
1494    /// [`Self::is_external`] as `false` and
1495    /// [`Self::runtime_block`] returns the slice's
1496    /// [`crate::device_runtime::DeviceBlock`] — strict-mode
1497    /// launch recorders will record it normally instead of
1498    /// rejecting.
1499    ///
1500    /// True external DLPack producers (DLPack tensors handed
1501    /// to xlog by another framework) must continue to use
1502    /// [`Self::dlpack`].
1503    pub fn dlpack_xlog_owned(
1504        source_slice: Arc<TrackedCudaSlice<u8>>,
1505        stream: Arc<CudaStream>,
1506        tensor: DlpackManagedTensor,
1507    ) -> Self {
1508        let ptr = *source_slice.device_ptr();
1509        let len_bytes = source_slice.len();
1510        Self::Dlpack(DlpackColumn {
1511            ptr,
1512            len_bytes,
1513            stream,
1514            _tensor: tensor,
1515            source_slice: Some(source_slice),
1516        })
1517    }
1518
1519    pub fn arrow_device(
1520        ptr: cudarc::driver::sys::CUdeviceptr,
1521        len_bytes: usize,
1522        stream: Arc<CudaStream>,
1523        import: Arc<ArrowDeviceImport>,
1524    ) -> Self {
1525        Self::ArrowDevice(ArrowDeviceColumn {
1526            ptr,
1527            len_bytes,
1528            stream,
1529            _import: import,
1530            source_slice: None,
1531        })
1532    }
1533
1534    /// Construct an Arrow device column that wraps memory
1535    /// **xlog itself owns** through the device runtime. Same
1536    /// contract as [`Self::dlpack_xlog_owned`]: identity is
1537    /// preserved, strict recorders accept the column, and
1538    /// drop order keeps the underlying allocation alive
1539    /// through the Arrow handoff.
1540    ///
1541    /// True external Arrow device producers must continue to
1542    /// use [`Self::arrow_device`].
1543    pub fn arrow_device_xlog_owned(
1544        source_slice: Arc<TrackedCudaSlice<u8>>,
1545        stream: Arc<CudaStream>,
1546        import: Arc<ArrowDeviceImport>,
1547    ) -> Self {
1548        let ptr = *source_slice.device_ptr();
1549        let len_bytes = source_slice.len();
1550        Self::ArrowDevice(ArrowDeviceColumn {
1551            ptr,
1552            len_bytes,
1553            stream,
1554            _import: import,
1555            source_slice: Some(source_slice),
1556        })
1557    }
1558
1559    pub fn stream(&self) -> &Arc<CudaStream> {
1560        match self {
1561            CudaColumn::Owned(slice) => slice.stream(),
1562            CudaColumn::Dlpack(col) => &col.stream,
1563            CudaColumn::ArrowDevice(col) => &col.stream,
1564        }
1565    }
1566
1567    pub fn device_ptr(&self) -> &cudarc::driver::sys::CUdeviceptr {
1568        match self {
1569            CudaColumn::Owned(slice) => slice.device_ptr(),
1570            CudaColumn::Dlpack(col) => &col.ptr,
1571            CudaColumn::ArrowDevice(col) => &col.ptr,
1572        }
1573    }
1574
1575    /// Stable identity of the xlog memory manager that owns this column.
1576    ///
1577    /// True external DLPack and Arrow device columns return `None`; wrappers
1578    /// retaining an xlog-owned source slice preserve that slice's identity.
1579    pub fn memory_manager_ptr_value(&self) -> Option<usize> {
1580        match self {
1581            CudaColumn::Owned(slice) => Some(slice.memory_manager_ptr_value()),
1582            CudaColumn::Dlpack(col) => col
1583                .source_slice
1584                .as_ref()
1585                .map(|slice| slice.memory_manager_ptr_value()),
1586            CudaColumn::ArrowDevice(col) => col
1587                .source_slice
1588                .as_ref()
1589                .map(|slice| slice.memory_manager_ptr_value()),
1590        }
1591    }
1592
1593    pub(crate) fn runtime_allocation_identity(&self) -> Result<Option<RuntimeAllocationIdentity>> {
1594        match self {
1595            CudaColumn::Owned(slice) => slice.runtime_allocation_identity(),
1596            CudaColumn::Dlpack(col) => {
1597                let Some(source) = &col.source_slice else {
1598                    return Ok(None);
1599                };
1600                let mut identity = source.runtime_allocation_identity()?;
1601                if let Some(identity) = &mut identity {
1602                    identity.allocation_ptr = col.ptr;
1603                    identity.allocation_bytes = col.len_bytes;
1604                }
1605                Ok(identity)
1606            }
1607            CudaColumn::ArrowDevice(col) => {
1608                let Some(source) = &col.source_slice else {
1609                    return Ok(None);
1610                };
1611                let mut identity = source.runtime_allocation_identity()?;
1612                if let Some(identity) = &mut identity {
1613                    identity.allocation_ptr = col.ptr;
1614                    identity.allocation_bytes = col.len_bytes;
1615                }
1616                Ok(identity)
1617            }
1618        }
1619    }
1620
1621    /// Borrow the underlying [`crate::device_runtime::DeviceBlock`].
1622    ///
1623    /// Returns `Some(&block)` when xlog owns the memory through
1624    /// the runtime — `Owned` slices that were allocated via a
1625    /// runtime-backed manager, AND `Dlpack` / `ArrowDevice`
1626    /// columns constructed via the `*_xlog_owned` constructors
1627    /// (where the source slice's block is reachable through
1628    /// the retained `Arc<TrackedCudaSlice<u8>>`).
1629    ///
1630    /// Returns `None` for legacy cudarc-backed `Owned` slices
1631    /// (no runtime block exists) and for true external
1632    /// `Dlpack` / `ArrowDevice` columns (xlog never owned the
1633    /// allocation). Strict-mode launch recorders reject `None`
1634    /// returns; permissive recorders silently skip.
1635    pub fn runtime_block(&self) -> Option<&crate::device_runtime::DeviceBlock> {
1636        match self {
1637            CudaColumn::Owned(slice) => slice.runtime_block(),
1638            CudaColumn::Dlpack(col) => col.source_slice.as_ref().and_then(|s| s.runtime_block()),
1639            CudaColumn::ArrowDevice(col) => {
1640                col.source_slice.as_ref().and_then(|s| s.runtime_block())
1641            }
1642        }
1643    }
1644
1645    /// Whether this column wraps externally-managed device
1646    /// memory.
1647    ///
1648    /// Returns `true` only for `Dlpack` / `ArrowDevice` columns
1649    /// where xlog never owned the allocation (no `source_slice`).
1650    /// `Dlpack` / `ArrowDevice` columns built via
1651    /// `*_xlog_owned` constructors return `false` — xlog still
1652    /// owns the memory; the DLPack / Arrow handle is just an
1653    /// export wrapper.
1654    ///
1655    /// External memory has no xlog-side runtime identity;
1656    /// strict launch recorders reject such columns and require
1657    /// callers to coordinate cross-stream synchronization
1658    /// themselves.
1659    pub fn is_external(&self) -> bool {
1660        match self {
1661            CudaColumn::Owned(_) => false,
1662            CudaColumn::Dlpack(col) => col.source_slice.is_none(),
1663            CudaColumn::ArrowDevice(col) => col.source_slice.is_none(),
1664        }
1665    }
1666}
1667
1668impl From<TrackedCudaSlice<u8>> for CudaColumn {
1669    fn from(value: TrackedCudaSlice<u8>) -> Self {
1670        CudaColumn::Owned(value)
1671    }
1672}
1673
1674impl DeviceSlice<u8> for CudaColumn {
1675    fn len(&self) -> usize {
1676        match self {
1677            CudaColumn::Owned(slice) => slice.len(),
1678            CudaColumn::Dlpack(col) => col.len_bytes,
1679            CudaColumn::ArrowDevice(col) => col.len_bytes,
1680        }
1681    }
1682
1683    fn stream(&self) -> &Arc<CudaStream> {
1684        self.stream()
1685    }
1686}
1687
1688impl DevicePtr<u8> for CudaColumn {
1689    fn device_ptr<'a>(
1690        &'a self,
1691        stream: &'a CudaStream,
1692    ) -> (cudarc::driver::sys::CUdeviceptr, SyncOnDrop<'a>) {
1693        match self {
1694            CudaColumn::Owned(slice) => DevicePtr::device_ptr(slice, stream),
1695            CudaColumn::Dlpack(col) => (col.ptr, SyncOnDrop::Sync(None)),
1696            CudaColumn::ArrowDevice(col) => (col.ptr, SyncOnDrop::Sync(None)),
1697        }
1698    }
1699}
1700
1701impl DevicePtrMut<u8> for CudaColumn {
1702    fn device_ptr_mut<'a>(
1703        &'a mut self,
1704        stream: &'a CudaStream,
1705    ) -> (cudarc::driver::sys::CUdeviceptr, SyncOnDrop<'a>) {
1706        match self {
1707            CudaColumn::Owned(slice) => DevicePtrMut::device_ptr_mut(slice, stream),
1708            CudaColumn::Dlpack(col) => (col.ptr, SyncOnDrop::Sync(None)),
1709            CudaColumn::ArrowDevice(col) => (col.ptr, SyncOnDrop::Sync(None)),
1710        }
1711    }
1712}
1713
1714impl AsKernelParam for &CudaColumn {
1715    fn as_kernel_param(&self) -> *mut std::ffi::c_void {
1716        ((self.device_ptr()) as *const cudarc::driver::sys::CUdeviceptr)
1717            .cast_mut()
1718            .cast()
1719    }
1720}
1721
1722impl AsKernelParam for &mut CudaColumn {
1723    fn as_kernel_param(&self) -> *mut std::ffi::c_void {
1724        ((self.device_ptr()) as *const cudarc::driver::sys::CUdeviceptr)
1725            .cast_mut()
1726            .cast()
1727    }
1728}
1729
1730impl<'a> IntoKernelParamStorage for &'a CudaColumn {
1731    type Storage = DeviceParamStorage<'a>;
1732
1733    fn into_kernel_param_storage(self) -> Self::Storage {
1734        match self {
1735            CudaColumn::Owned(slice) => slice.into_kernel_param_storage(),
1736            CudaColumn::Dlpack(col) => DeviceParamStorage::unsynced(col.ptr),
1737            CudaColumn::ArrowDevice(col) => DeviceParamStorage::unsynced(col.ptr),
1738        }
1739    }
1740}
1741
1742impl<'a> IntoKernelParamStorage for &'a mut CudaColumn {
1743    type Storage = DeviceParamStorage<'a>;
1744
1745    fn into_kernel_param_storage(self) -> Self::Storage {
1746        match self {
1747            CudaColumn::Owned(slice) => slice.into_kernel_param_storage(),
1748            CudaColumn::Dlpack(col) => DeviceParamStorage::unsynced(col.ptr),
1749            CudaColumn::ArrowDevice(col) => DeviceParamStorage::unsynced(col.ptr),
1750        }
1751    }
1752}
1753
1754/// Column-oriented GPU buffer
1755///
1756/// Holds columnar data on the GPU with an associated schema.
1757/// Each column is stored as a separate `CudaSlice<u8>`.
1758pub struct CudaBuffer {
1759    /// Column data stored as raw bytes
1760    pub(crate) columns: Vec<CudaColumn>,
1761    /// Row capacity for allocated columns
1762    pub(crate) row_cap: u64,
1763    /// Device-resident row count (len = 1)
1764    pub(crate) d_num_rows: TrackedCudaSlice<u32>,
1765    /// Schema describing the column types
1766    pub(crate) schema: Schema,
1767    /// Cached host-side row count (u32::MAX = not yet cached).
1768    /// Avoids repeated synchronous D2H transfers between explicit mutations,
1769    /// whose public accessors invalidate this cache before returning.
1770    cached_row_count: AtomicU32,
1771    /// True only when construction or a set operation proves that rows are
1772    /// lexicographically sorted by every schema column and full-row unique.
1773    canonical_full_row_set_certified: bool,
1774}
1775
1776impl CudaBuffer {
1777    /// Create a buffer from existing columns
1778    ///
1779    /// # Arguments
1780    /// * `columns` - Pre-allocated column data
1781    /// * `row_cap` - Row capacity for the buffer
1782    /// * `d_num_rows` - Device-resident row count
1783    /// * `schema` - Schema describing the columns
1784    ///
1785    /// # Panics
1786    /// Panics if the number of columns doesn't match the schema arity
1787    pub fn from_columns(
1788        columns: Vec<CudaColumn>,
1789        row_cap: u64,
1790        d_num_rows: TrackedCudaSlice<u32>,
1791        schema: Schema,
1792    ) -> Self {
1793        assert_eq!(
1794            columns.len(),
1795            schema.arity(),
1796            "Number of columns ({}) must match schema arity ({})",
1797            columns.len(),
1798            schema.arity()
1799        );
1800        Self {
1801            columns,
1802            row_cap,
1803            d_num_rows,
1804            schema,
1805            cached_row_count: AtomicU32::new(u32::MAX),
1806            canonical_full_row_set_certified: false,
1807        }
1808    }
1809
1810    /// Like `from_columns`, but eagerly populates the row-count cache.
1811    /// Use when the host already knows the exact row count (e.g., `buffer_from_columns`).
1812    pub fn from_columns_with_host_count(
1813        columns: Vec<CudaColumn>,
1814        row_cap: u64,
1815        d_num_rows: TrackedCudaSlice<u32>,
1816        schema: Schema,
1817        host_row_count: u32,
1818    ) -> Self {
1819        assert_eq!(
1820            columns.len(),
1821            schema.arity(),
1822            "Number of columns ({}) must match schema arity ({})",
1823            columns.len(),
1824            schema.arity()
1825        );
1826        Self {
1827            columns,
1828            row_cap,
1829            d_num_rows,
1830            schema,
1831            cached_row_count: AtomicU32::new(host_row_count),
1832            canonical_full_row_set_certified: false,
1833        }
1834    }
1835
1836    /// Returns the cached row count if available (not sentinel `u32::MAX`).
1837    pub fn cached_row_count(&self) -> Option<u32> {
1838        let v = self.cached_row_count.load(Ordering::Relaxed);
1839        if v == u32::MAX {
1840            None
1841        } else {
1842            Some(v)
1843        }
1844    }
1845
1846    /// Sets the cached row count if not already set (CAS from sentinel).
1847    /// No-op if already cached.
1848    pub(crate) fn set_cached_row_count_if_unset(&self, count: u32) {
1849        let _ = self.cached_row_count.compare_exchange(
1850            u32::MAX,
1851            count,
1852            Ordering::Relaxed,
1853            Ordering::Relaxed,
1854        );
1855    }
1856
1857    /// Whether rows are sorted in schema-column order and full-row unique.
1858    pub fn canonical_full_row_set_certified(&self) -> bool {
1859        self.canonical_full_row_set_certified
1860    }
1861
1862    /// Record a full-schema ordering and uniqueness proof from a set operation.
1863    pub(crate) fn certify_canonical_full_row_set(&mut self) {
1864        self.canonical_full_row_set_certified = true;
1865    }
1866
1867    /// Borrow every column without permitting mutation of certified contents.
1868    pub fn columns(&self) -> &[CudaColumn] {
1869        &self.columns
1870    }
1871
1872    /// Mutably borrow columns after invalidating canonical-set metadata.
1873    pub fn columns_mut(&mut self) -> &mut [CudaColumn] {
1874        self.canonical_full_row_set_certified = false;
1875        &mut self.columns
1876    }
1877
1878    /// Replace the schema after invalidating canonical ordering metadata.
1879    pub fn set_schema(&mut self, schema: Schema) {
1880        assert_eq!(self.columns.len(), schema.arity());
1881        self.schema = schema;
1882        self.canonical_full_row_set_certified = false;
1883    }
1884
1885    /// Set row capacity and invalidate all host-derived row metadata.
1886    pub fn set_row_capacity(&mut self, row_cap: u64) {
1887        self.row_cap = row_cap;
1888        self.cached_row_count.store(u32::MAX, Ordering::Relaxed);
1889        self.canonical_full_row_set_certified = false;
1890    }
1891
1892    /// Mutably borrow the device row count after invalidating derived metadata.
1893    pub fn num_rows_device_mut(&mut self) -> &mut TrackedCudaSlice<u32> {
1894        self.cached_row_count.store(u32::MAX, Ordering::Relaxed);
1895        self.canonical_full_row_set_certified = false;
1896        &mut self.d_num_rows
1897    }
1898
1899    /// Get the row capacity
1900    pub fn num_rows(&self) -> u64 {
1901        self.row_cap
1902    }
1903
1904    /// Get the device-resident row count
1905    pub fn num_rows_device(&self) -> &TrackedCudaSlice<u32> {
1906        &self.d_num_rows
1907    }
1908
1909    /// Check if the buffer has zero row capacity
1910    pub fn is_empty(&self) -> bool {
1911        self.row_cap == 0
1912    }
1913
1914    /// Get the schema
1915    pub fn schema(&self) -> &Schema {
1916        &self.schema
1917    }
1918
1919    /// Get the number of columns (arity)
1920    pub fn arity(&self) -> usize {
1921        self.schema.arity()
1922    }
1923
1924    /// Estimated memory usage in bytes
1925    pub fn estimated_bytes(&self) -> u64 {
1926        self.row_cap * self.schema.row_size_bytes() as u64
1927    }
1928
1929    /// Get a reference to a specific column by index
1930    pub fn column(&self, index: usize) -> Option<&CudaColumn> {
1931        self.columns.get(index)
1932    }
1933}
1934
1935pub fn validate_logical_row_count(row_cap: u64, logical_rows: usize) -> Result<usize> {
1936    let row_cap_usize = usize::try_from(row_cap)
1937        .map_err(|_| XlogError::Kernel(format!("Row capacity {} exceeds usize::MAX", row_cap)))?;
1938    if logical_rows > row_cap_usize {
1939        return Err(XlogError::Kernel(format!(
1940            "Logical row count {} exceeds row capacity {}",
1941            logical_rows, row_cap
1942        )));
1943    }
1944    debug_assert!(logical_rows <= row_cap_usize);
1945    Ok(logical_rows)
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951    use crate::device_runtime::{DeviceMemoryResource, DirectCudaResource, ResourceResult};
1952    use xlog_core::ScalarType;
1953
1954    #[test]
1955    fn reservation_exposes_exact_memory_manager_identity() {
1956        let _: fn(&GpuMemoryReservation) -> usize = GpuMemoryReservation::memory_manager_ptr_value;
1957    }
1958
1959    #[test]
1960    fn cuda_column_exposes_optional_memory_manager_identity() {
1961        let _: fn(&CudaColumn) -> Option<usize> = CudaColumn::memory_manager_ptr_value;
1962    }
1963
1964    #[test]
1965    fn tracked_slice_exposes_runtime_allocation_identity_snapshot() {
1966        let _: fn(&TrackedCudaSlice<u32>) -> Result<Option<RuntimeAllocationIdentity>> =
1967            TrackedCudaSlice::<u32>::runtime_allocation_identity;
1968    }
1969
1970    #[test]
1971    fn cuda_column_exposes_runtime_allocation_identity_snapshot() {
1972        let _: fn(&CudaColumn) -> Result<Option<RuntimeAllocationIdentity>> =
1973            CudaColumn::runtime_allocation_identity;
1974    }
1975
1976    struct FailAfterDeallocateResource {
1977        inner: DirectCudaResource,
1978        deallocate_calls: Arc<AtomicU64>,
1979    }
1980
1981    struct FailFirstAllocationResource {
1982        inner: DirectCudaResource,
1983        fail_next: std::sync::atomic::AtomicBool,
1984    }
1985
1986    impl DeviceMemoryResource for FailFirstAllocationResource {
1987        fn allocate(
1988            &self,
1989            bytes: usize,
1990            stream: StreamId,
1991            tag: AllocTag,
1992        ) -> ResourceResult<DeviceBlock> {
1993            if self.fail_next.swap(false, Ordering::SeqCst) {
1994                return Err(ResourceError::Driver(
1995                    "injected allocation failure".to_string(),
1996                ));
1997            }
1998            self.inner.allocate(bytes, stream, tag)
1999        }
2000
2001        fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
2002            self.inner.deallocate(block)
2003        }
2004
2005        fn device_ordinal(&self) -> u32 {
2006            self.inner.device_ordinal()
2007        }
2008
2009        fn bytes_outstanding(&self) -> usize {
2010            self.inner.bytes_outstanding()
2011        }
2012    }
2013
2014    impl DeviceMemoryResource for FailAfterDeallocateResource {
2015        fn allocate(
2016            &self,
2017            bytes: usize,
2018            stream: StreamId,
2019            tag: AllocTag,
2020        ) -> ResourceResult<DeviceBlock> {
2021            self.inner.allocate(bytes, stream, tag)
2022        }
2023
2024        fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
2025            self.inner.deallocate(block)?;
2026            self.deallocate_calls.fetch_add(1, Ordering::SeqCst);
2027            Err(ResourceError::Driver(
2028                "injected deallocation completion failure".to_string(),
2029            ))
2030        }
2031
2032        fn device_ordinal(&self) -> u32 {
2033            self.inner.device_ordinal()
2034        }
2035
2036        fn bytes_outstanding(&self) -> usize {
2037            self.inner.bytes_outstanding()
2038        }
2039    }
2040
2041    fn try_device() -> Option<Arc<CudaDevice>> {
2042        match CudaDevice::new(0) {
2043            Ok(d) => Some(Arc::new(d)),
2044            Err(e) if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") => {
2045                panic!("XLOG_REQUIRE_CUDA=1 but CUDA initialization failed: {e}")
2046            }
2047            Err(e) => {
2048                eprintln!("Skipping test: CUDA runtime unavailable: {}", e);
2049                None
2050            }
2051        }
2052    }
2053
2054    fn assert_memory_pressure(
2055        error: XlogError,
2056        expected_context: &str,
2057        expected_required: u64,
2058        expected_budget: u64,
2059    ) {
2060        match error {
2061            XlogError::ResourceExhausted {
2062                context,
2063                estimated_bytes,
2064                budget_bytes,
2065            } => {
2066                assert_eq!(context, expected_context);
2067                assert_eq!(estimated_bytes, expected_required);
2068                assert_eq!(budget_bytes, expected_budget);
2069            }
2070            other => panic!("expected ResourceExhausted, got {other:?}"),
2071        }
2072    }
2073
2074    // Test CudaBuffer without requiring a GPU
2075    #[test]
2076    fn test_cuda_buffer_empty() {
2077        let Some(device) = try_device() else {
2078            return;
2079        };
2080        let budget = MemoryBudget::with_limit(1024 * 1024);
2081        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2082        let mut d_num_rows = manager.alloc::<u32>(1).unwrap();
2083        manager
2084            .device()
2085            .inner()
2086            .htod_sync_copy_into(&[0u32], &mut d_num_rows)
2087            .unwrap();
2088        let buffer = CudaBuffer::from_columns(Vec::new(), 0, d_num_rows, Schema::new(vec![]));
2089        assert!(buffer.is_empty());
2090        assert_eq!(buffer.num_rows(), 0);
2091        assert_eq!(buffer.arity(), 0);
2092        assert_eq!(buffer.estimated_bytes(), 0);
2093    }
2094
2095    #[test]
2096    fn test_cuda_buffer_schema() {
2097        let schema = Schema::new(vec![
2098            ("a".to_string(), ScalarType::U32),
2099            ("b".to_string(), ScalarType::U64),
2100        ]);
2101
2102        let Some(device) = try_device() else {
2103            return;
2104        };
2105        let budget = MemoryBudget::with_limit(1024 * 1024);
2106        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2107        let mut d_num_rows = manager.alloc::<u32>(1).unwrap();
2108        manager
2109            .device()
2110            .inner()
2111            .htod_sync_copy_into(&[100u32], &mut d_num_rows)
2112            .unwrap();
2113
2114        // Allocate dummy columns matching the schema arity (100 rows each)
2115        let col_a = CudaColumn::owned(manager.alloc::<u8>(100 * 4).unwrap()); // U32: 4 bytes
2116        let col_b = CudaColumn::owned(manager.alloc::<u8>(100 * 8).unwrap()); // U64: 8 bytes
2117        let buffer = CudaBuffer::from_columns(vec![col_a, col_b], 100, d_num_rows, schema.clone());
2118
2119        assert_eq!(buffer.num_rows(), 100);
2120        assert_eq!(buffer.arity(), 2);
2121        // 4 bytes (U32) + 8 bytes (U64) = 12 bytes per row * 100 rows
2122        assert_eq!(buffer.estimated_bytes(), 1200);
2123        assert_eq!(buffer.schema(), &schema);
2124    }
2125
2126    // Tests requiring GPU
2127    #[test]
2128    fn test_memory_manager_creation() {
2129        let Some(device) = try_device() else {
2130            return;
2131        };
2132        let budget = MemoryBudget::with_limit(1024 * 1024); // 1 MB
2133        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2134
2135        assert_eq!(manager.allocated_bytes(), 0);
2136        assert_eq!(manager.budget().device_bytes, 1024 * 1024);
2137        assert_eq!(manager.remaining_bytes(), 1024 * 1024);
2138    }
2139
2140    #[test]
2141    fn test_memory_manager_alloc() {
2142        let Some(device) = try_device() else {
2143            return;
2144        };
2145        let budget = MemoryBudget::with_limit(1024 * 1024); // 1 MB
2146        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2147
2148        // Allocate 256 u32 values = 1024 bytes
2149        let _slice = manager
2150            .alloc::<u32>(256)
2151            .expect("Allocation should succeed");
2152
2153        assert_eq!(manager.allocated_bytes(), 1024);
2154        assert_eq!(manager.remaining_bytes(), 1024 * 1024 - 1024);
2155    }
2156
2157    #[test]
2158    fn test_memory_manager_budget_exceeded() {
2159        let Some(device) = try_device() else {
2160            return;
2161        };
2162        let budget = MemoryBudget::with_limit(1024); // 1 KB limit
2163        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2164
2165        // Try to allocate 512 u32 values = 2048 bytes (exceeds 1KB budget)
2166        let result = manager.alloc::<u32>(512);
2167
2168        assert!(result.is_err());
2169        if let Err(XlogError::ResourceExhausted {
2170            estimated_bytes,
2171            budget_bytes,
2172            ..
2173        }) = result
2174        {
2175            assert_eq!(estimated_bytes, 2048);
2176            assert_eq!(budget_bytes, 1024);
2177        } else {
2178            panic!("Expected ResourceExhausted error");
2179        }
2180    }
2181
2182    #[test]
2183    fn test_memory_manager_check_budget() {
2184        let Some(device) = try_device() else {
2185            return;
2186        };
2187        let budget = MemoryBudget::with_limit(1000);
2188        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2189
2190        // Check that 500 bytes is within budget
2191        assert!(manager.check_budget(500).is_ok());
2192
2193        // Check that 1001 bytes exceeds budget
2194        assert!(manager.check_budget(1001).is_err());
2195    }
2196
2197    #[test]
2198    fn check_budget_does_not_reserve_a_multi_allocation_request() {
2199        let Some(device) = try_device() else {
2200            return;
2201        };
2202        let manager = Arc::new(GpuMemoryManager::new(
2203            device,
2204            MemoryBudget::with_limit(8192),
2205        ));
2206        let checked = Arc::new(std::sync::Barrier::new(2));
2207        let competitor_allocated = Arc::new(std::sync::Barrier::new(2));
2208        let release_competitor = Arc::new(std::sync::Barrier::new(2));
2209
2210        let checked_in_thread = Arc::clone(&checked);
2211        let allocated_in_thread = Arc::clone(&competitor_allocated);
2212        let release_in_thread = Arc::clone(&release_competitor);
2213        let competitor_manager = Arc::clone(&manager);
2214        let competitor = std::thread::spawn(move || {
2215            checked_in_thread.wait();
2216            let allocation = competitor_manager
2217                .alloc::<u8>(4096)
2218                .expect("competing allocation must fit after the check-only query");
2219            allocated_in_thread.wait();
2220            release_in_thread.wait();
2221            drop(allocation);
2222        });
2223
2224        manager
2225            .check_budget(8192)
2226            .expect("the complete request fits before the competing allocation");
2227        checked.wait();
2228        competitor_allocated.wait();
2229        let first = manager
2230            .alloc::<u8>(4096)
2231            .expect("the first materialized allocation must fit");
2232        let second = manager.alloc::<u8>(4096);
2233        assert!(
2234            matches!(second, Err(XlogError::ResourceExhausted { .. })),
2235            "a check-only query cannot protect later allocations from a competitor"
2236        );
2237
2238        release_competitor.wait();
2239        competitor.join().expect("competitor thread panicked");
2240        drop(first);
2241        assert_eq!(manager.allocated_bytes(), 0);
2242        assert_eq!(manager.remaining_bytes(), 8192);
2243    }
2244
2245    #[test]
2246    fn reservation_rejects_the_complete_request_before_any_allocation() {
2247        let Some(device) = try_device() else {
2248            return;
2249        };
2250        let manager = Arc::new(GpuMemoryManager::new(
2251            device,
2252            MemoryBudget::with_limit(4095),
2253        ));
2254        manager.reset_alloc_count();
2255
2256        let error = manager
2257            .reserve_bytes(4096)
2258            .expect_err("a request one byte above the budget must be rejected atomically");
2259
2260        assert_memory_pressure(
2261            error,
2262            "GPU memory pressure: layer=manager_reserve current_bytes=0 requested_bytes=4096 required_bytes=4096 required_u64_overflow=false budget_bytes=4095 prior_peak_bytes=0",
2263            4096,
2264            4095,
2265        );
2266        assert_eq!(manager.alloc_count(), 0);
2267        assert_eq!(manager.allocated_bytes(), 0);
2268        assert_eq!(manager.peak_bytes(), 0);
2269        assert_eq!(manager.remaining_bytes(), 4095);
2270    }
2271
2272    #[test]
2273    fn competing_complete_reservations_cannot_both_claim_the_budget() {
2274        let Some(device) = try_device() else {
2275            return;
2276        };
2277        let manager = Arc::new(GpuMemoryManager::new(
2278            device,
2279            MemoryBudget::with_limit(4096),
2280        ));
2281        let attempted = Arc::new(std::sync::Barrier::new(2));
2282
2283        let reserve = |manager: Arc<GpuMemoryManager>, attempted: Arc<std::sync::Barrier>| {
2284            std::thread::spawn(move || {
2285                let reservation = manager.reserve_bytes(4096);
2286                attempted.wait();
2287                let admitted = reservation.is_ok();
2288                drop(reservation);
2289                admitted
2290            })
2291        };
2292        let left = reserve(Arc::clone(&manager), Arc::clone(&attempted));
2293        let right = reserve(Arc::clone(&manager), Arc::clone(&attempted));
2294
2295        let admitted = [
2296            left.join().expect("left reservation thread panicked"),
2297            right.join().expect("right reservation thread panicked"),
2298        ];
2299        assert_eq!(admitted.into_iter().filter(|value| *value).count(), 1);
2300        assert_eq!(manager.allocated_bytes(), 0);
2301        assert_eq!(manager.remaining_bytes(), 4096);
2302    }
2303
2304    #[test]
2305    fn competing_managers_share_one_runtime_reservation_budget() {
2306        let Some((device, runtime)) = try_runtime_with_budget(4096) else {
2307            return;
2308        };
2309        let left_manager = Arc::new(GpuMemoryManager::with_runtime(
2310            Arc::clone(&device),
2311            MemoryBudget::with_limit(4096),
2312            Arc::clone(&runtime),
2313        ));
2314        let right_manager = Arc::new(GpuMemoryManager::with_runtime(
2315            device,
2316            MemoryBudget::with_limit(4096),
2317            runtime,
2318        ));
2319        let attempted = Arc::new(std::sync::Barrier::new(2));
2320
2321        let reserve = |manager: Arc<GpuMemoryManager>, attempted: Arc<std::sync::Barrier>| {
2322            std::thread::spawn(move || {
2323                let reservation = manager.reserve_bytes(4096);
2324                attempted.wait();
2325                let admitted = reservation.is_ok();
2326                drop(reservation);
2327                admitted
2328            })
2329        };
2330        let left = reserve(left_manager, Arc::clone(&attempted));
2331        let right = reserve(right_manager, attempted);
2332
2333        let admitted = [
2334            left.join().expect("left reservation thread panicked"),
2335            right.join().expect("right reservation thread panicked"),
2336        ];
2337        assert_eq!(admitted.into_iter().filter(|value| *value).count(), 1);
2338    }
2339
2340    #[test]
2341    fn runtime_reservation_blocks_competing_ordinary_allocation_until_materialized() {
2342        let Some((device, runtime)) = try_runtime_with_budget(4096) else {
2343            return;
2344        };
2345        let reserving_manager = Arc::new(GpuMemoryManager::with_runtime(
2346            Arc::clone(&device),
2347            MemoryBudget::with_limit(4096),
2348            Arc::clone(&runtime),
2349        ));
2350        let competing_manager = Arc::new(GpuMemoryManager::with_runtime(
2351            device,
2352            MemoryBudget::with_limit(4096),
2353            runtime,
2354        ));
2355        let mut reservation = reserving_manager
2356            .reserve_bytes(4096)
2357            .expect("complete runtime reservation");
2358
2359        assert!(matches!(
2360            competing_manager.alloc::<u8>(1),
2361            Err(XlogError::ResourceExhausted { .. })
2362        ));
2363        assert_eq!(competing_manager.allocated_bytes(), 0);
2364        assert_eq!(competing_manager.remaining_bytes(), 4096);
2365
2366        let allocation = reservation
2367            .alloc::<u8>(4096)
2368            .expect("reserved bytes cannot be stolen by an ordinary allocator");
2369        assert_eq!(reserving_manager.allocated_bytes(), 4096);
2370        drop(reservation);
2371        drop(allocation);
2372        assert_eq!(reserving_manager.allocated_bytes(), 0);
2373    }
2374
2375    #[test]
2376    fn global_budget_rejects_complete_manifest_before_first_inner_allocation() {
2377        let Some((device, runtime, sink)) = try_runtime_with_logging_budget(4095) else {
2378            return;
2379        };
2380        let manager = Arc::new(GpuMemoryManager::with_runtime(
2381            device,
2382            MemoryBudget::with_limit(8192),
2383            runtime,
2384        ));
2385
2386        let error = manager
2387            .reserve_bytes(4096)
2388            .expect_err("the complete request is one byte above the runtime budget");
2389        assert_memory_pressure(
2390            error,
2391            "GPU memory pressure: layer=device_runtime current_bytes=0 requested_bytes=4096 required_bytes=4096 required_u64_overflow=false budget_bytes=4095 prior_peak_bytes=0",
2392            4096,
2393            4095,
2394        );
2395        assert!(sink.snapshot().is_empty());
2396        assert_eq!(manager.allocated_bytes(), 0);
2397        assert_eq!(manager.remaining_bytes(), 8192);
2398    }
2399
2400    #[test]
2401    fn runtime_backed_reservation_requires_a_reservable_global_budget() {
2402        let Some((device, runtime)) = try_unbudgeted_runtime() else {
2403            return;
2404        };
2405        let manager = Arc::new(GpuMemoryManager::with_runtime(
2406            device,
2407            MemoryBudget::with_limit(4096),
2408            runtime,
2409        ));
2410
2411        let error = manager
2412            .reserve_bytes(1024)
2413            .expect_err("an unbudgeted runtime cannot promise complete admission");
2414        assert!(
2415            matches!(error, XlogError::Kernel(ref detail) if detail.contains("reservable global budget")),
2416            "unexpected error: {error}"
2417        );
2418        assert_eq!(manager.allocated_bytes(), 0);
2419        assert_eq!(manager.remaining_bytes(), 4096);
2420    }
2421
2422    #[test]
2423    fn reservation_materializes_exactly_its_declared_bytes() {
2424        let Some(device) = try_device() else {
2425            return;
2426        };
2427        let manager = Arc::new(GpuMemoryManager::new(
2428            device,
2429            MemoryBudget::with_limit(4096),
2430        ));
2431        let mut reservation = manager
2432            .reserve_bytes(4096)
2433            .expect("the exact complete request must fit");
2434        assert_eq!(reservation.total_bytes(), 4096);
2435        assert_eq!(reservation.remaining_bytes(), 4096);
2436        assert_eq!(reservation.used_bytes(), 0);
2437        assert_eq!(manager.remaining_bytes(), 0);
2438
2439        let words = reservation
2440            .alloc::<u32>(512)
2441            .expect("first reserved allocation");
2442        let bytes = reservation
2443            .alloc::<u8>(2048)
2444            .expect("second reserved allocation");
2445        assert_eq!(reservation.remaining_bytes(), 0);
2446        assert_eq!(reservation.used_bytes(), 4096);
2447        assert_eq!(manager.allocated_bytes(), 4096);
2448        assert_eq!(manager.peak_bytes(), 4096);
2449
2450        let error = match reservation.alloc::<u8>(1) {
2451            Err(error) => error,
2452            Ok(_) => panic!("a reservation cannot materialize more than its declaration"),
2453        };
2454        assert_memory_pressure(
2455            error,
2456            "GPU memory pressure: layer=manager_reservation_alloc current_bytes=4096 requested_bytes=1 required_bytes=4097 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=4096",
2457            4097,
2458            4096,
2459        );
2460
2461        drop(reservation);
2462        assert_eq!(manager.remaining_bytes(), 0);
2463        drop(bytes);
2464        assert_eq!(manager.remaining_bytes(), 2048);
2465        drop(words);
2466        assert_eq!(manager.allocated_bytes(), 0);
2467        assert_eq!(manager.remaining_bytes(), 4096);
2468    }
2469
2470    #[test]
2471    fn failed_reserved_raw_allocation_preserves_unused_claim_until_drop() {
2472        let Some((device, runtime)) = try_runtime_with_first_allocation_failure(4096) else {
2473            return;
2474        };
2475        let manager = Arc::new(GpuMemoryManager::with_runtime(
2476            device,
2477            MemoryBudget::with_limit(4096),
2478            runtime,
2479        ));
2480        let mut reservation = manager
2481            .reserve_bytes(4096)
2482            .expect("local complete request must fit");
2483
2484        let error = reservation
2485            .alloc_raw(2048, AllocTag::UNTAGGED)
2486            .expect_err("the injected underlying allocation must fail");
2487        assert!(
2488            matches!(error, XlogError::Kernel(ref detail) if detail.contains("injected allocation failure")),
2489            "unexpected error: {error}"
2490        );
2491        assert_eq!(reservation.remaining_bytes(), 4096);
2492        assert_eq!(manager.allocated_bytes(), 0);
2493        assert_eq!(manager.remaining_bytes(), 0);
2494
2495        let allocation = reservation
2496            .alloc_raw(1024, AllocTag::UNTAGGED)
2497            .expect("a smaller raw suballocation must fit both budgets");
2498        assert_eq!(reservation.remaining_bytes(), 3072);
2499        assert_eq!(manager.allocated_bytes(), 1024);
2500
2501        drop(reservation);
2502        assert_eq!(manager.remaining_bytes(), 3072);
2503        drop(allocation);
2504        assert_eq!(manager.allocated_bytes(), 0);
2505        assert_eq!(manager.remaining_bytes(), 4096);
2506    }
2507
2508    #[test]
2509    fn partial_typed_materialization_releases_each_byte_once_in_either_drop_order() {
2510        let Some(device) = try_device() else {
2511            return;
2512        };
2513        let manager = Arc::new(GpuMemoryManager::new(
2514            device,
2515            MemoryBudget::with_limit(4096),
2516        ));
2517        let mut reservation = manager.reserve_bytes(4096).expect("complete request");
2518        let allocation = reservation
2519            .alloc::<u8>(1024)
2520            .expect("partial materialization");
2521        let error = match reservation.alloc::<u8>(4096) {
2522            Err(error) => error,
2523            Ok(_) => panic!("the remaining reservation is only 3072 bytes"),
2524        };
2525        assert_memory_pressure(
2526            error,
2527            "GPU memory pressure: layer=manager_reservation_alloc current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
2528            5120,
2529            4096,
2530        );
2531        assert_eq!(reservation.remaining_bytes(), 3072);
2532
2533        drop(allocation);
2534        assert_eq!(manager.allocated_bytes(), 0);
2535        assert_eq!(manager.remaining_bytes(), 1024);
2536        drop(reservation);
2537        assert_eq!(manager.remaining_bytes(), 4096);
2538    }
2539
2540    #[test]
2541    fn ordinary_allocations_and_complete_reservations_share_one_budget() {
2542        let Some(device) = try_device() else {
2543            return;
2544        };
2545        let manager = Arc::new(GpuMemoryManager::new(
2546            device,
2547            MemoryBudget::with_limit(4096),
2548        ));
2549        let ordinary = manager
2550            .alloc::<u8>(1024)
2551            .expect("ordinary allocation before reservation");
2552        let mut reservation = manager
2553            .reserve_bytes(3072)
2554            .expect("reservation must fit beside the ordinary owner");
2555        assert_eq!(manager.remaining_bytes(), 0);
2556        assert!(matches!(
2557            manager.alloc::<u8>(1),
2558            Err(XlogError::ResourceExhausted { .. })
2559        ));
2560
2561        let reserved = reservation
2562            .alloc::<u8>(3072)
2563            .expect("reserved allocation bypasses the already-paid local claim");
2564        assert_eq!(manager.allocated_bytes(), 4096);
2565        drop(reservation);
2566        drop(reserved);
2567        assert_eq!(manager.allocated_bytes(), 1024);
2568        assert_eq!(manager.remaining_bytes(), 3072);
2569        drop(ordinary);
2570        assert_eq!(manager.allocated_bytes(), 0);
2571        assert_eq!(manager.remaining_bytes(), 4096);
2572    }
2573
2574    #[test]
2575    fn public_accounting_mutators_refuse_live_reservations_and_allocations() {
2576        let Some(device) = try_device() else {
2577            return;
2578        };
2579        let manager = Arc::new(GpuMemoryManager::new(
2580            device,
2581            MemoryBudget::with_limit(4096),
2582        ));
2583        let reservation = manager.reserve_bytes(2048).expect("reservation");
2584
2585        let free_error = manager
2586            .record_free(1)
2587            .expect_err("public accounting cannot release a live reservation");
2588        assert!(
2589            matches!(free_error, XlogError::Kernel(detail) if detail.contains("live tracked bytes"))
2590        );
2591        let reset_error = manager
2592            .reset_tracking()
2593            .expect_err("public reset cannot erase a live reservation");
2594        assert!(
2595            matches!(reset_error, XlogError::Kernel(detail) if detail.contains("live tracked bytes"))
2596        );
2597        assert_eq!(manager.remaining_bytes(), 2048);
2598
2599        drop(reservation);
2600        let allocation = manager.alloc::<u8>(1024).expect("allocation");
2601        assert!(manager.record_free(1024).is_err());
2602        assert!(manager.reset_tracking().is_err());
2603        assert_eq!(manager.allocated_bytes(), 1024);
2604        assert_eq!(manager.remaining_bytes(), 3072);
2605
2606        drop(allocation);
2607        manager
2608            .reset_tracking()
2609            .expect("quiescent accounting can reset diagnostic peaks");
2610        assert_eq!(manager.allocated_bytes(), 0);
2611        assert_eq!(manager.remaining_bytes(), 4096);
2612    }
2613
2614    #[test]
2615    fn public_record_free_refuses_underflow_without_mutation() {
2616        let Some(device) = try_device() else {
2617            return;
2618        };
2619        let manager = Arc::new(GpuMemoryManager::new(
2620            device,
2621            MemoryBudget::with_limit(4096),
2622        ));
2623
2624        let error = manager
2625            .record_free(1)
2626            .expect_err("unowned bytes cannot be released from accounting");
2627        assert!(matches!(error, XlogError::Kernel(detail) if detail.contains("underflow")));
2628        assert_eq!(manager.allocated_bytes(), 0);
2629        assert_eq!(manager.remaining_bytes(), 4096);
2630        manager
2631            .record_free(0)
2632            .expect("zero-byte release is a no-op");
2633    }
2634
2635    #[test]
2636    fn typed_deallocation_failure_retains_local_charge_and_records_failure() {
2637        let Some((device, runtime, deallocate_calls)) = try_runtime_with_deallocation_failure()
2638        else {
2639            return;
2640        };
2641        let manager = Arc::new(GpuMemoryManager::with_runtime(
2642            device,
2643            MemoryBudget::with_limit(4096),
2644            Arc::clone(&runtime),
2645        ));
2646        let allocation = manager.alloc::<u8>(1024).expect("typed allocation");
2647
2648        drop(allocation);
2649
2650        assert_eq!(deallocate_calls.load(Ordering::SeqCst), 1);
2651        assert_eq!(runtime.bytes_outstanding(), 0);
2652        assert_eq!(manager.allocated_bytes(), 1024);
2653        assert_eq!(manager.remaining_bytes(), 3072);
2654        assert_eq!(manager.deallocation_failure_count(), 1);
2655        assert_eq!(manager.deallocation_failure_bytes(), 1024);
2656        assert!(manager.reset_tracking().is_err());
2657    }
2658
2659    #[test]
2660    fn raw_deallocation_failure_retains_local_charge_and_records_failure() {
2661        let Some((device, runtime, deallocate_calls)) = try_runtime_with_deallocation_failure()
2662        else {
2663            return;
2664        };
2665        let manager = Arc::new(GpuMemoryManager::with_runtime(
2666            device,
2667            MemoryBudget::with_limit(4096),
2668            Arc::clone(&runtime),
2669        ));
2670        let allocation = manager
2671            .alloc_raw(512, AllocTag::UNTAGGED)
2672            .expect("raw allocation");
2673
2674        drop(allocation);
2675
2676        assert_eq!(deallocate_calls.load(Ordering::SeqCst), 1);
2677        assert_eq!(runtime.bytes_outstanding(), 0);
2678        assert_eq!(manager.allocated_bytes(), 512);
2679        assert_eq!(manager.remaining_bytes(), 3584);
2680        assert_eq!(manager.deallocation_failure_count(), 1);
2681        assert_eq!(manager.deallocation_failure_bytes(), 512);
2682        assert!(manager.reset_tracking().is_err());
2683    }
2684
2685    #[test]
2686    fn test_memory_manager_multiple_allocs() {
2687        let Some(device) = try_device() else {
2688            return;
2689        };
2690        let budget = MemoryBudget::with_limit(4096); // 4 KB
2691        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2692
2693        // First allocation: 256 u32 = 1024 bytes
2694        let _slice1 = manager
2695            .alloc::<u32>(256)
2696            .expect("First allocation should succeed");
2697        assert_eq!(manager.allocated_bytes(), 1024);
2698
2699        // Second allocation: 256 u32 = 1024 bytes
2700        let _slice2 = manager
2701            .alloc::<u32>(256)
2702            .expect("Second allocation should succeed");
2703        assert_eq!(manager.allocated_bytes(), 2048);
2704
2705        // Third allocation that would exceed budget
2706        let result = manager.alloc::<u32>(1024); // 4096 bytes, would make total 6144
2707        assert!(result.is_err());
2708
2709        // Allocated should still be 2048
2710        assert_eq!(manager.allocated_bytes(), 2048);
2711    }
2712
2713    #[test]
2714    fn test_memory_manager_record_free() {
2715        let Some(device) = try_device() else {
2716            return;
2717        };
2718        let budget = MemoryBudget::with_limit(4096);
2719        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2720
2721        // Allocate
2722        let slice = manager
2723            .alloc::<u32>(256)
2724            .expect("Allocation should succeed");
2725        assert_eq!(manager.allocated_bytes(), 1024);
2726
2727        // Drop should automatically update tracking
2728        drop(slice);
2729        assert_eq!(manager.allocated_bytes(), 0);
2730        assert_eq!(manager.remaining_bytes(), 4096);
2731    }
2732
2733    #[test]
2734    fn test_memory_manager_peak_tracking() {
2735        let Some(device) = try_device() else {
2736            return;
2737        };
2738        let budget = MemoryBudget::with_limit(8192);
2739        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2740
2741        let a = manager.alloc::<u32>(256).expect("alloc a"); // 1024 B
2742        let b = manager.alloc::<u32>(512).expect("alloc b"); // 2048 B
2743        assert_eq!(manager.peak_bytes(), 3072);
2744
2745        // Frees lower `allocated` but never the peak.
2746        drop(b);
2747        assert_eq!(manager.allocated_bytes(), 1024);
2748        assert_eq!(manager.peak_bytes(), 3072);
2749
2750        // reset_peak restarts the window from live state.
2751        manager.reset_peak();
2752        assert_eq!(manager.peak_bytes(), 1024);
2753
2754        let c = manager.alloc::<u32>(128).expect("alloc c"); // 512 B
2755        assert_eq!(manager.peak_bytes(), 1536);
2756
2757        drop(c);
2758        drop(a);
2759        assert_eq!(manager.allocated_bytes(), 0);
2760        assert_eq!(manager.peak_bytes(), 1536);
2761    }
2762
2763    #[test]
2764    fn memory_pressure_alloc_reports_exact_cumulative_pressure() {
2765        let Some(device) = try_device() else {
2766            return;
2767        };
2768        let manager = Arc::new(GpuMemoryManager::new(
2769            device,
2770            MemoryBudget::with_limit(4096),
2771        ));
2772        let baseline = manager.alloc::<u8>(1024).expect("baseline allocation");
2773
2774        let error = match manager.alloc::<u8>(4096) {
2775            Err(error) => error,
2776            Ok(_) => panic!("cumulative allocation must exceed the local budget"),
2777        };
2778
2779        assert_memory_pressure(
2780            error,
2781            "GPU memory pressure: layer=manager_alloc current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
2782            5120,
2783            4096,
2784        );
2785        assert_eq!(manager.allocated_bytes(), 1024);
2786        assert_eq!(manager.peak_bytes(), 1024, "refusal must not raise peak");
2787        drop(baseline);
2788    }
2789
2790    #[test]
2791    fn memory_pressure_check_budget_reports_exact_cumulative_pressure() {
2792        let Some(device) = try_device() else {
2793            return;
2794        };
2795        let manager = Arc::new(GpuMemoryManager::new(
2796            device,
2797            MemoryBudget::with_limit(1000),
2798        ));
2799        let baseline = manager.alloc::<u8>(512).expect("baseline allocation");
2800
2801        let error = manager
2802            .check_budget(600)
2803            .expect_err("cumulative request must exceed the local budget");
2804
2805        assert_memory_pressure(
2806            error,
2807            "GPU memory pressure: layer=manager_check_budget current_bytes=512 requested_bytes=600 required_bytes=1112 required_u64_overflow=false budget_bytes=1000 prior_peak_bytes=512",
2808            1112,
2809            1000,
2810        );
2811        assert_eq!(manager.allocated_bytes(), 512);
2812        assert_eq!(
2813            manager.peak_bytes(),
2814            512,
2815            "check-only refusal must not raise peak"
2816        );
2817        drop(baseline);
2818    }
2819
2820    #[test]
2821    fn memory_pressure_check_budget_reports_u64_representability_overflow() {
2822        let Some(device) = try_device() else {
2823            return;
2824        };
2825        let manager = Arc::new(GpuMemoryManager::new(
2826            device,
2827            MemoryBudget::with_limit(u64::MAX),
2828        ));
2829        manager
2830            .accounting
2831            .budget_reserved
2832            .store(u64::MAX - 3, Ordering::SeqCst);
2833        manager
2834            .accounting
2835            .allocated
2836            .store(u64::MAX - 3, Ordering::SeqCst);
2837        manager
2838            .accounting
2839            .peak
2840            .store(u64::MAX - 3, Ordering::SeqCst);
2841
2842        let error = manager
2843            .check_budget(8)
2844            .expect_err("the exact required byte count is not representable as u64");
2845
2846        assert_memory_pressure(
2847            error,
2848            "GPU memory pressure: layer=manager_check_budget current_bytes=18446744073709551612 requested_bytes=8 required_bytes=18446744073709551620 required_u64_overflow=true budget_bytes=18446744073709551615 prior_peak_bytes=18446744073709551612",
2849            u64::MAX,
2850            u64::MAX,
2851        );
2852        assert_eq!(manager.allocated_bytes(), u64::MAX - 3);
2853        assert_eq!(manager.peak_bytes(), u64::MAX - 3);
2854
2855        // Restore the synthetic accounting state before dropping the fixture.
2856        manager
2857            .accounting
2858            .budget_reserved
2859            .store(0, Ordering::SeqCst);
2860        manager.accounting.allocated.store(0, Ordering::SeqCst);
2861        manager.accounting.peak.store(0, Ordering::SeqCst);
2862    }
2863
2864    #[test]
2865    fn test_cuda_buffer_from_columns() {
2866        let Some(device) = try_device() else {
2867            return;
2868        };
2869        let budget = MemoryBudget::with_limit(1024 * 1024);
2870        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2871
2872        let schema = Schema::new(vec![
2873            ("col1".to_string(), ScalarType::U32),
2874            ("col2".to_string(), ScalarType::U32),
2875        ]);
2876
2877        // Allocate columns (100 rows * 4 bytes = 400 bytes each)
2878        let col1 = manager.alloc::<u8>(400).expect("Alloc col1");
2879        let col2 = manager.alloc::<u8>(400).expect("Alloc col2");
2880
2881        let mut d_num_rows = manager.alloc::<u32>(1).expect("Alloc row count");
2882        manager
2883            .device()
2884            .inner()
2885            .htod_sync_copy_into(&[100u32], &mut d_num_rows)
2886            .expect("Upload row count");
2887        let buffer =
2888            CudaBuffer::from_columns(vec![col1.into(), col2.into()], 100, d_num_rows, schema);
2889
2890        assert_eq!(buffer.num_rows(), 100);
2891        assert_eq!(buffer.arity(), 2);
2892        assert!(!buffer.is_empty());
2893        assert!(buffer.column(0).is_some());
2894        assert!(buffer.column(1).is_some());
2895        assert!(buffer.column(2).is_none());
2896    }
2897
2898    #[test]
2899    fn test_cuda_buffer_from_columns_mismatch() {
2900        let schema = Schema::new(vec![
2901            ("col1".to_string(), ScalarType::U32),
2902            ("col2".to_string(), ScalarType::U32),
2903        ]);
2904
2905        let Some(device) = try_device() else {
2906            return;
2907        };
2908        let budget = MemoryBudget::with_limit(1024 * 1024);
2909        let manager = Arc::new(GpuMemoryManager::new(device, budget));
2910        let mut d_num_rows = manager.alloc::<u32>(1).expect("Alloc row count");
2911        manager
2912            .device()
2913            .inner()
2914            .htod_sync_copy_into(&[100u32], &mut d_num_rows)
2915            .expect("Upload row count");
2916
2917        // This should panic: 0 columns but schema has 2.
2918        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2919            CudaBuffer::from_columns(vec![], 100, d_num_rows, schema);
2920        }));
2921        assert!(
2922            result.is_err(),
2923            "Expected from_columns to panic on schema mismatch"
2924        );
2925    }
2926
2927    fn try_runtime() -> Option<(
2928        Arc<CudaDevice>,
2929        Arc<crate::device_runtime::XlogDeviceRuntime>,
2930    )> {
2931        use crate::device_runtime::{
2932            AsyncCudaResource, DeviceMemoryResource, GlobalDeviceBudget, StreamPool,
2933            XlogDeviceRuntime,
2934        };
2935        let device = try_device()?;
2936        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
2937        let async_resource: Box<dyn DeviceMemoryResource + Send + Sync> = Box::new(
2938            AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool)),
2939        );
2940        let budget: Box<dyn DeviceMemoryResource + Send + Sync> =
2941            Box::new(GlobalDeviceBudget::new(async_resource, 64 * 1024 * 1024));
2942        Some((
2943            Arc::clone(&device),
2944            Arc::new(XlogDeviceRuntime::with_resource(
2945                Arc::clone(&device),
2946                0,
2947                pool,
2948                budget,
2949            )),
2950        ))
2951    }
2952
2953    fn try_unbudgeted_runtime() -> Option<(
2954        Arc<CudaDevice>,
2955        Arc<crate::device_runtime::XlogDeviceRuntime>,
2956    )> {
2957        use crate::device_runtime::{
2958            AsyncCudaResource, DeviceMemoryResource, StreamPool, XlogDeviceRuntime,
2959        };
2960        let device = try_device()?;
2961        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
2962        let resource: Box<dyn DeviceMemoryResource + Send + Sync> = Box::new(
2963            AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool)),
2964        );
2965        Some((
2966            Arc::clone(&device),
2967            Arc::new(XlogDeviceRuntime::with_resource(
2968                Arc::clone(&device),
2969                0,
2970                pool,
2971                resource,
2972            )),
2973        ))
2974    }
2975
2976    fn try_runtime_with_deallocation_failure() -> Option<(
2977        Arc<CudaDevice>,
2978        Arc<crate::device_runtime::XlogDeviceRuntime>,
2979        Arc<AtomicU64>,
2980    )> {
2981        use crate::device_runtime::{StreamPool, XlogDeviceRuntime};
2982        let device = try_device()?;
2983        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
2984        let deallocate_calls = Arc::new(AtomicU64::new(0));
2985        let resource: Box<dyn DeviceMemoryResource + Send + Sync> =
2986            Box::new(FailAfterDeallocateResource {
2987                inner: DirectCudaResource::new(Arc::clone(&device), 0),
2988                deallocate_calls: Arc::clone(&deallocate_calls),
2989            });
2990        Some((
2991            Arc::clone(&device),
2992            Arc::new(XlogDeviceRuntime::with_resource(
2993                Arc::clone(&device),
2994                0,
2995                pool,
2996                resource,
2997            )),
2998            deallocate_calls,
2999        ))
3000    }
3001
3002    fn try_runtime_with_first_allocation_failure(
3003        limit: usize,
3004    ) -> Option<(
3005        Arc<CudaDevice>,
3006        Arc<crate::device_runtime::XlogDeviceRuntime>,
3007    )> {
3008        use crate::device_runtime::{GlobalDeviceBudget, StreamPool, XlogDeviceRuntime};
3009        let device = try_device()?;
3010        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
3011        let failing_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3012            Box::new(FailFirstAllocationResource {
3013                inner: DirectCudaResource::new(Arc::clone(&device), 0),
3014                fail_next: std::sync::atomic::AtomicBool::new(true),
3015            });
3016        let budget_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3017            Box::new(GlobalDeviceBudget::new(failing_resource, limit));
3018        Some((
3019            Arc::clone(&device),
3020            Arc::new(XlogDeviceRuntime::with_resource(
3021                Arc::clone(&device),
3022                0,
3023                pool,
3024                budget_resource,
3025            )),
3026        ))
3027    }
3028
3029    fn try_runtime_with_budget(
3030        limit: usize,
3031    ) -> Option<(
3032        Arc<CudaDevice>,
3033        Arc<crate::device_runtime::XlogDeviceRuntime>,
3034    )> {
3035        use crate::device_runtime::{
3036            DeviceMemoryResource, DirectCudaResource, GlobalDeviceBudget, StreamPool,
3037            XlogDeviceRuntime,
3038        };
3039        let device = try_device()?;
3040        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
3041        let direct_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3042            Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
3043        let budget_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3044            Box::new(GlobalDeviceBudget::new(direct_resource, limit));
3045        Some((
3046            Arc::clone(&device),
3047            Arc::new(XlogDeviceRuntime::with_resource(
3048                Arc::clone(&device),
3049                0,
3050                pool,
3051                budget_resource,
3052            )),
3053        ))
3054    }
3055
3056    fn try_runtime_with_logging_budget(
3057        limit: usize,
3058    ) -> Option<(
3059        Arc<CudaDevice>,
3060        Arc<crate::device_runtime::XlogDeviceRuntime>,
3061        Arc<crate::device_runtime::InMemorySink>,
3062    )> {
3063        use crate::device_runtime::{
3064            DeviceMemoryResource, DirectCudaResource, GlobalDeviceBudget, InMemorySink,
3065            LoggingResource, LoggingSink, StreamPool, XlogDeviceRuntime,
3066        };
3067        let device = try_device()?;
3068        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
3069        let direct_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3070            Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
3071        let budget_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3072            Box::new(GlobalDeviceBudget::new(direct_resource, limit));
3073        let sink = Arc::new(InMemorySink::new());
3074        let logging_sink: Arc<dyn LoggingSink> = sink.clone();
3075        let logging_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
3076            Box::new(LoggingResource::new(budget_resource, logging_sink));
3077        Some((
3078            Arc::clone(&device),
3079            Arc::new(XlogDeviceRuntime::with_resource(
3080                Arc::clone(&device),
3081                0,
3082                pool,
3083                logging_resource,
3084            )),
3085            sink,
3086        ))
3087    }
3088
3089    fn pause_request_after_local_reservation(
3090        manager: &GpuMemoryManager,
3091        paused_bytes: u64,
3092    ) -> (Arc<std::sync::Barrier>, Arc<std::sync::Barrier>) {
3093        let entered = Arc::new(std::sync::Barrier::new(2));
3094        let release = Arc::new(std::sync::Barrier::new(2));
3095        let hook_entered = Arc::clone(&entered);
3096        let hook_release = Arc::clone(&release);
3097        *manager
3098            .after_local_reservation_hook
3099            .lock()
3100            .expect("after-local-reservation test hook poisoned") = Some(Arc::new(move |bytes| {
3101            if bytes == paused_bytes {
3102                hook_entered.wait();
3103                hook_release.wait();
3104            }
3105        }));
3106        (entered, release)
3107    }
3108
3109    #[test]
3110    fn memory_pressure_concurrent_alloc_raw_refusal_excludes_provisional_bytes() {
3111        let Some((device, runtime)) = try_runtime_with_budget(4096) else {
3112            return;
3113        };
3114        let manager = Arc::new(GpuMemoryManager::with_runtime(
3115            device,
3116            MemoryBudget::with_limit(8192),
3117            runtime,
3118        ));
3119        let (paused, release) = pause_request_after_local_reservation(&manager, 4096);
3120
3121        let refused_manager = Arc::clone(&manager);
3122        let refused = std::thread::spawn(move || {
3123            refused_manager
3124                .alloc_raw(4096, AllocTag::UNTAGGED)
3125                .expect_err("runtime must refuse the paused request")
3126        });
3127        paused.wait();
3128
3129        let admitted = manager
3130            .alloc_raw(1024, AllocTag::UNTAGGED)
3131            .expect("concurrent smaller request must be admitted");
3132        release.wait();
3133        let error = refused.join().expect("paused allocation thread panicked");
3134
3135        assert_memory_pressure(
3136            error,
3137            "GPU memory pressure: layer=device_runtime current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
3138            5120,
3139            4096,
3140        );
3141        assert_eq!(manager.allocated_bytes(), 1024);
3142        assert_eq!(
3143            manager.peak_bytes(),
3144            1024,
3145            "refused provisional bytes must never enter the admitted peak"
3146        );
3147        drop(admitted);
3148    }
3149
3150    #[test]
3151    fn memory_pressure_concurrent_typed_alloc_refusal_excludes_provisional_bytes() {
3152        let Some((device, runtime)) = try_runtime_with_budget(4096) else {
3153            return;
3154        };
3155        let manager = Arc::new(GpuMemoryManager::with_runtime(
3156            device,
3157            MemoryBudget::with_limit(8192),
3158            runtime,
3159        ));
3160        let (paused, release) = pause_request_after_local_reservation(&manager, 4096);
3161
3162        let refused_manager = Arc::clone(&manager);
3163        let refused = std::thread::spawn(move || match refused_manager.alloc::<u8>(4096) {
3164            Err(error) => error,
3165            Ok(_) => panic!("runtime must refuse the paused typed request"),
3166        });
3167        paused.wait();
3168
3169        let admitted = manager
3170            .alloc::<u8>(1024)
3171            .expect("concurrent smaller typed request must be admitted");
3172        release.wait();
3173        let error = refused.join().expect("paused allocation thread panicked");
3174
3175        assert_memory_pressure(
3176            error,
3177            "GPU memory pressure: layer=device_runtime current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
3178            5120,
3179            4096,
3180        );
3181        assert_eq!(manager.allocated_bytes(), 1024);
3182        assert_eq!(
3183            manager.peak_bytes(),
3184            1024,
3185            "refused provisional bytes must never enter the admitted peak"
3186        );
3187        drop(admitted);
3188    }
3189
3190    #[test]
3191    fn memory_pressure_alloc_raw_reports_exact_local_pressure() {
3192        let Some((device, runtime)) = try_runtime_with_budget(64 * 1024) else {
3193            return;
3194        };
3195        let manager = Arc::new(GpuMemoryManager::with_runtime(
3196            device,
3197            MemoryBudget::with_limit(4096),
3198            runtime,
3199        ));
3200        let baseline = manager
3201            .alloc_raw(1024, AllocTag::UNTAGGED)
3202            .expect("baseline allocation");
3203
3204        let error = manager
3205            .alloc_raw(4096, AllocTag::UNTAGGED)
3206            .expect_err("cumulative allocation must exceed the local budget");
3207
3208        assert_memory_pressure(
3209            error,
3210            "GPU memory pressure: layer=manager_alloc_raw current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
3211            5120,
3212            4096,
3213        );
3214        assert_eq!(manager.allocated_bytes(), 1024);
3215        assert_eq!(manager.peak_bytes(), 1024, "refusal must not raise peak");
3216        drop(baseline);
3217    }
3218
3219    #[test]
3220    fn memory_pressure_alloc_raw_drop_restores_local_headroom() {
3221        let Some((device, runtime)) = try_runtime_with_budget(64 * 1024) else {
3222            return;
3223        };
3224        let manager = Arc::new(GpuMemoryManager::with_runtime(
3225            device,
3226            MemoryBudget::with_limit(1024),
3227            runtime,
3228        ));
3229
3230        let allocation = manager
3231            .alloc_raw(1024, AllocTag::UNTAGGED)
3232            .expect("initial allocation must consume the local budget");
3233        assert_eq!(manager.allocated_bytes(), 1024);
3234        assert_eq!(manager.remaining_bytes(), 0);
3235
3236        drop(allocation);
3237        assert_eq!(manager.allocated_bytes(), 0);
3238        assert_eq!(
3239            manager.remaining_bytes(),
3240            1024,
3241            "dropping an admitted raw allocation must restore local headroom"
3242        );
3243
3244        let replacement = manager
3245            .alloc_raw(1024, AllocTag::UNTAGGED)
3246            .expect("restored local headroom must admit a replacement allocation");
3247        drop(replacement);
3248    }
3249
3250    #[test]
3251    fn memory_pressure_runtime_rejection_preserves_peak() {
3252        let Some((device, runtime)) = try_runtime_with_budget(4096) else {
3253            return;
3254        };
3255        let manager = Arc::new(GpuMemoryManager::with_runtime(
3256            device,
3257            MemoryBudget::with_limit(8192),
3258            runtime,
3259        ));
3260        let baseline = manager
3261            .alloc_raw(1024, AllocTag::UNTAGGED)
3262            .expect("baseline allocation");
3263
3264        let error = manager
3265            .alloc_raw(4096, AllocTag::UNTAGGED)
3266            .expect_err("runtime budget must reject the cumulative allocation");
3267
3268        assert_memory_pressure(
3269            error,
3270            "GPU memory pressure: layer=device_runtime current_bytes=1024 requested_bytes=4096 required_bytes=5120 required_u64_overflow=false budget_bytes=4096 prior_peak_bytes=1024",
3271            5120,
3272            4096,
3273        );
3274        assert_eq!(
3275            manager.allocated_bytes(),
3276            1024,
3277            "local reservation must roll back"
3278        );
3279        assert_eq!(
3280            manager.peak_bytes(),
3281            1024,
3282            "runtime refusal must not advance the manager peak"
3283        );
3284        drop(baseline);
3285    }
3286
3287    /// xlog-owned DLPack column constructed from a
3288    /// runtime-backed slice exposes its `DeviceBlock` via
3289    /// `runtime_block()` and reports `is_external() == false`.
3290    /// The recorder will record it normally instead of
3291    /// strict-rejecting.
3292    ///
3293    /// Uses a null-pointer `DlpackManagedTensor` purely as a
3294    /// drop-safe placeholder — the recorder never derefs the
3295    /// tensor, only the source slice.
3296    #[test]
3297    fn test_xlog_owned_dlpack_runtime_backed_carries_identity() {
3298        let Some((device, runtime)) = try_runtime() else {
3299            return;
3300        };
3301        let manager = Arc::new(GpuMemoryManager::with_runtime(
3302            Arc::clone(&device),
3303            MemoryBudget::with_limit(1024 * 1024),
3304            Arc::clone(&runtime),
3305        ));
3306        let slice = manager.alloc::<u8>(64).expect("alloc runtime-backed");
3307        assert!(slice.runtime_block().is_some());
3308        let stream = device.inner().stream().clone();
3309        // SAFETY: null-pointer DlpackManagedTensor is drop-safe
3310        // (the Drop impl checks for null before invoking the
3311        // deleter). Acceptable for a unit fixture that exercises
3312        // identity propagation, not the tensor lifecycle.
3313        let tensor = unsafe { DlpackManagedTensor::from_raw(std::ptr::null_mut()) };
3314        let col = CudaColumn::dlpack_xlog_owned(Arc::new(slice), stream, tensor);
3315        assert!(
3316            !col.is_external(),
3317            "xlog-owned DLPack column must report is_external=false"
3318        );
3319        assert!(
3320            col.runtime_block().is_some(),
3321            "xlog-owned DLPack column over a runtime-backed slice must expose runtime_block"
3322        );
3323    }
3324
3325    /// xlog-owned DLPack over a LEGACY (cudarc-backed) slice:
3326    /// `is_external()` is still false (xlog owns the
3327    /// allocation), but `runtime_block()` is None because the
3328    /// underlying slice has no runtime block. Strict recorders
3329    /// will reject with the "legacy cudarc-backed" message
3330    /// rather than the "external memory" message.
3331    #[test]
3332    fn test_xlog_owned_dlpack_legacy_backed_no_runtime_block() {
3333        let Some(device) = try_device() else {
3334            return;
3335        };
3336        let manager = Arc::new(GpuMemoryManager::new(
3337            Arc::clone(&device),
3338            MemoryBudget::with_limit(1024 * 1024),
3339        ));
3340        let slice = manager.alloc::<u8>(64).expect("alloc legacy");
3341        assert!(slice.runtime_block().is_none());
3342        let stream = device.inner().stream().clone();
3343        let tensor = unsafe { DlpackManagedTensor::from_raw(std::ptr::null_mut()) };
3344        let col = CudaColumn::dlpack_xlog_owned(Arc::new(slice), stream, tensor);
3345        assert!(
3346            !col.is_external(),
3347            "xlog-owned DLPack column is owned regardless of allocator backing"
3348        );
3349        assert!(
3350            col.runtime_block().is_none(),
3351            "legacy-backed slice has no runtime block, even when wrapped xlog-owned"
3352        );
3353    }
3354
3355    /// True external DLPack (no source_slice) — the existing
3356    /// `dlpack` constructor — keeps reporting `is_external=true`
3357    /// and `runtime_block=None`. Strict recorders reject with
3358    /// the "external memory" message.
3359    #[test]
3360    fn test_external_dlpack_remains_external() {
3361        let Some(device) = try_device() else {
3362            return;
3363        };
3364        let stream = device.inner().stream().clone();
3365        let tensor = unsafe { DlpackManagedTensor::from_raw(std::ptr::null_mut()) };
3366        // Bogus ptr/len — never dereferenced in this unit test
3367        // (we only inspect the column metadata).
3368        let col = CudaColumn::dlpack(0, 0, stream, tensor);
3369        assert!(
3370            col.is_external(),
3371            "true external DLPack column must report is_external=true"
3372        );
3373        assert!(
3374            col.runtime_block().is_none(),
3375            "true external DLPack column has no xlog-side runtime block"
3376        );
3377    }
3378
3379    /// xlog-owned Arrow device column carries identity through
3380    /// `arrow_device_xlog_owned`. Mirrors the DLPack test;
3381    /// builds a minimal `ArrowDeviceImport` from an empty
3382    /// `ArrayData`.
3383    #[test]
3384    fn test_xlog_owned_arrow_device_runtime_backed_carries_identity() {
3385        let Some((device, runtime)) = try_runtime() else {
3386            return;
3387        };
3388        let manager = Arc::new(GpuMemoryManager::with_runtime(
3389            Arc::clone(&device),
3390            MemoryBudget::with_limit(1024 * 1024),
3391            Arc::clone(&runtime),
3392        ));
3393        let slice = manager.alloc::<u8>(64).expect("alloc runtime-backed");
3394        assert!(slice.runtime_block().is_some());
3395        let stream = device.inner().stream().clone();
3396        // Synthesize a minimal ArrowDeviceImport via empty
3397        // ArrayData; Arrow is not exercised on the data path
3398        // here — the recorder only reads the column metadata.
3399        let import = Arc::new(crate::arrow_device::ArrowDeviceImport::new(
3400            arrow::array::ArrayData::new_null(&arrow::datatypes::DataType::UInt8, 0),
3401        ));
3402        let col = CudaColumn::arrow_device_xlog_owned(Arc::new(slice), stream, import);
3403        assert!(
3404            !col.is_external(),
3405            "xlog-owned Arrow device column must report is_external=false"
3406        );
3407        assert!(
3408            col.runtime_block().is_some(),
3409            "xlog-owned Arrow column over a runtime-backed slice must expose runtime_block"
3410        );
3411    }
3412
3413    /// True external Arrow device column (no source_slice)
3414    /// keeps reporting external + no runtime block.
3415    #[test]
3416    fn test_external_arrow_device_remains_external() {
3417        let Some(device) = try_device() else {
3418            return;
3419        };
3420        let stream = device.inner().stream().clone();
3421        let import = Arc::new(crate::arrow_device::ArrowDeviceImport::new(
3422            arrow::array::ArrayData::new_null(&arrow::datatypes::DataType::UInt8, 0),
3423        ));
3424        let col = CudaColumn::arrow_device(0, 0, stream, import);
3425        assert!(
3426            col.is_external(),
3427            "true external Arrow column must report is_external=true"
3428        );
3429        assert!(
3430            col.runtime_block().is_none(),
3431            "true external Arrow column has no xlog-side runtime block"
3432        );
3433    }
3434}