Skip to main content

xlog_cuda/device_runtime/
runtime.rs

1//! [`XlogDeviceRuntime`] — per-CUDA-ordinal singleton hosting the
2//! device-runtime allocator stack.
3//!
4//! Replaces the per-`CudaKernelProvider` `GpuMemoryManager` model with
5//! a single live runtime per physical GPU. All `CudaKernelProvider`s
6//! on a given ordinal share the same runtime once the migration
7//! commit lands; until then this type is constructed and used by
8//! tests only.
9//!
10//! Singleton lifetime: leaked-Box, so the returned `&'static` borrows
11//! are valid for the process. No teardown on drop — appropriate for a
12//! GPU device runtime that should outlive any single executor.
13//!
14//! # Initialization race semantics
15//!
16//! Earlier revisions used `OnceLock::get_or_init(|| leaked_box)`
17//! after building the runtime outside the lock. That pattern leaked
18//! the loser's runtime (and its CUDA context handle) when two
19//! threads raced on the first access for an ordinal.
20//!
21//! This module now uses an explicit per-ordinal `Mutex` plus
22//! `OnceLock`: callers fast-path on `OnceLock::get()`, and on a miss
23//! take the per-ordinal mutex, double-check the `OnceLock`, and only
24//! the winner inside the mutex builds and stores the runtime. The
25//! mutex is held only across the build, so subsequent reads are still
26//! lock-free.
27
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::Arc;
30use std::sync::Mutex;
31use std::sync::OnceLock;
32
33use cudarc::driver::{CudaEvent, CudaStream};
34use xlog_core::{Result, XlogError};
35
36use super::direct::DirectCudaResource;
37use super::resource::{
38    Access, AllocTag, BlockId, DeviceBlock, DeviceMemoryResource, ResourceError, ResourceResult,
39    StreamId,
40};
41use super::stream_pool::StreamPool;
42use crate::CudaDevice;
43
44/// Maximum CUDA ordinal supported by the singleton table. CUDA itself
45/// caps at 16 visible devices in typical configurations; raise here
46/// only when a multi-GPU node demands it.
47pub const MAX_DEVICE_ORDINALS: usize = 16;
48
49/// Per-ordinal singleton table. Each slot is initialized at most once
50/// via `OnceLock`, gated by [`INIT_LOCKS`] so failed initialization
51/// does not leak partial state.
52static RUNTIMES: [OnceLock<&'static XlogDeviceRuntime>; MAX_DEVICE_ORDINALS] =
53    [const { OnceLock::new() }; MAX_DEVICE_ORDINALS];
54
55/// Per-ordinal initialization mutex. Only the holder may build and
56/// store a runtime in [`RUNTIMES`]. Held across the device-open and
57/// resource-construction calls so concurrent first callers do not
58/// race-leak loser runtimes.
59static INIT_LOCKS: [Mutex<()>; MAX_DEVICE_ORDINALS] =
60    [const { Mutex::new(()) }; MAX_DEVICE_ORDINALS];
61
62/// Execution counters for the device-controlled conditional-graph route.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct ConditionalGraphStats {
65    /// Successfully enqueued parent-graph launches.
66    pub launches: u64,
67    /// Terminal event synchronizations performed by the host.
68    pub terminal_synchronizations: u64,
69    /// Host-side fixpoint iterations (required to remain zero).
70    pub host_iterations: u64,
71    /// Allocations performed after a graph launch (required to remain zero).
72    pub host_allocations: u64,
73    /// Device status-writer kernels included in launches.
74    pub device_status_writer_launches: u64,
75    /// Terminal statuses written directly by the host (required to remain zero).
76    pub host_status_injections: u64,
77}
78
79/// Lifetime counters for CUDA events owned by resident graph launches.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub struct EventLifecycleStats {
82    /// Events that currently own a real CUDA event handle.
83    pub live_events: u64,
84    /// Events successfully created and recorded.
85    pub created_events: u64,
86    /// Event handles destroyed after completion.
87    pub destroyed_events: u64,
88    /// In-flight drops that had to wait for completion.
89    pub drop_waits: u64,
90}
91
92/// Lifetime counters for resident CUDA graph and executable handles.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
94pub struct ResidentGraphHandleLifecycleStats {
95    /// Parent graph handles currently retained by a prepared or in-flight run.
96    pub live_graphs: u64,
97    /// Instantiated graph executable handles currently retained.
98    pub live_graph_execs: u64,
99    /// Parent graph handles successfully created.
100    pub created_graphs: u64,
101    /// Parent graph handles destroyed.
102    pub destroyed_graphs: u64,
103    /// Graph executable handles successfully instantiated.
104    pub created_graph_execs: u64,
105    /// Graph executable handles destroyed.
106    pub destroyed_graph_execs: u64,
107}
108
109#[derive(Default)]
110struct ResidentRuntimeTelemetry {
111    launches: AtomicU64,
112    terminal_synchronizations: AtomicU64,
113    host_iterations: AtomicU64,
114    host_allocations: AtomicU64,
115    device_status_writer_launches: AtomicU64,
116    host_status_injections: AtomicU64,
117    live_events: AtomicU64,
118    created_events: AtomicU64,
119    destroyed_events: AtomicU64,
120    drop_waits: AtomicU64,
121    live_graphs: AtomicU64,
122    live_graph_execs: AtomicU64,
123    created_graphs: AtomicU64,
124    destroyed_graphs: AtomicU64,
125    created_graph_execs: AtomicU64,
126    destroyed_graph_execs: AtomicU64,
127}
128
129/// RAII proof that one live graph and executable pair is retained.
130///
131/// Construct this only after both CUDA handles have been created successfully,
132/// and retain it beside the owning graph object so its counters follow the
133/// actual handle lifetime.
134pub(crate) struct ResidentGraphHandleLease {
135    telemetry: Arc<ResidentRuntimeTelemetry>,
136}
137
138impl Drop for ResidentGraphHandleLease {
139    fn drop(&mut self) {
140        self.telemetry
141            .live_graph_execs
142            .fetch_sub(1, Ordering::AcqRel);
143        self.telemetry
144            .destroyed_graph_execs
145            .fetch_add(1, Ordering::Relaxed);
146        self.telemetry.live_graphs.fetch_sub(1, Ordering::AcqRel);
147        self.telemetry
148            .destroyed_graphs
149            .fetch_add(1, Ordering::Relaxed);
150    }
151}
152
153/// Completion event whose accounting is tied to a real cudarc event handle.
154pub struct ResidentCompletionEvent {
155    event: Option<CudaEvent>,
156    telemetry: Arc<ResidentRuntimeTelemetry>,
157    synchronized: bool,
158}
159
160impl ResidentCompletionEvent {
161    /// Wait for the single terminal event. Repeated calls are no-ops.
162    pub fn synchronize(&mut self) -> Result<()> {
163        if self.synchronized {
164            return Ok(());
165        }
166        self.event
167            .as_ref()
168            .expect("resident completion event missing before drop")
169            .synchronize()
170            .map_err(|error| {
171                XlogError::Kernel(format!(
172                    "resident conditional graph terminal event synchronization failed: {error}"
173                ))
174            })?;
175        self.synchronized = true;
176        self.telemetry
177            .terminal_synchronizations
178            .fetch_add(1, Ordering::Relaxed);
179        Ok(())
180    }
181}
182
183impl Drop for ResidentCompletionEvent {
184    fn drop(&mut self) {
185        if !self.synchronized {
186            self.telemetry.drop_waits.fetch_add(1, Ordering::Relaxed);
187            if let Some(event) = &self.event {
188                // Buffer and module lifetimes cannot end while the graph is in
189                // flight. Drop cannot return an error, so this is best effort.
190                let _ = event.synchronize();
191            }
192        }
193        if self.event.take().is_some() {
194            self.telemetry.live_events.fetch_sub(1, Ordering::AcqRel);
195            self.telemetry
196                .destroyed_events
197                .fetch_add(1, Ordering::Relaxed);
198        }
199    }
200}
201
202/// Per-CUDA-ordinal device-runtime singleton.
203///
204/// Owns the device handle, stream pool, and resource stack. Allocate
205/// / deallocate calls forward to the resource. The resource is fixed
206/// at construction (currently always [`DirectCudaResource`]); a
207/// future commit will swap in [`AsyncCudaResource`] as the default
208/// while keeping the direct backend reachable for sanitizer mode.
209pub struct XlogDeviceRuntime {
210    device_ordinal: u32,
211    device: Arc<CudaDevice>,
212    stream_pool: Arc<StreamPool>,
213    resource: Mutex<Box<dyn DeviceMemoryResource + Send + Sync>>,
214    /// Complete-request bytes promised but not yet materialized through the
215    /// resource stack. Always inspected while `resource` is locked when an
216    /// allocation or new reservation competes for budget.
217    reservation_bytes: Mutex<usize>,
218    resident_telemetry: Arc<ResidentRuntimeTelemetry>,
219}
220
221/// One complete byte claim against a runtime resource stack's global budget.
222pub(crate) struct RuntimeMemoryReservation {
223    runtime: Arc<XlogDeviceRuntime>,
224    total_bytes: usize,
225    remaining_bytes: usize,
226}
227
228impl RuntimeMemoryReservation {
229    pub(crate) fn allocate(
230        &mut self,
231        bytes: usize,
232        stream: StreamId,
233        tag: AllocTag,
234    ) -> ResourceResult<DeviceBlock> {
235        if bytes > self.remaining_bytes {
236            return Err(ResourceError::OutOfBudget {
237                requested: bytes,
238                current: self.total_bytes - self.remaining_bytes,
239                remaining: self.remaining_bytes,
240                limit: self.total_bytes,
241            });
242        }
243
244        let resource = self
245            .runtime
246            .resource
247            .lock()
248            .expect("device-runtime resource poisoned");
249        let mut reserved = self
250            .runtime
251            .reservation_bytes
252            .lock()
253            .expect("device-runtime reservation accounting poisoned");
254        *reserved = reserved.checked_sub(bytes).ok_or_else(|| {
255            ResourceError::Driver("device-runtime reservation accounting underflow".to_string())
256        })?;
257        self.remaining_bytes -= bytes;
258
259        match resource.allocate(bytes, stream, tag) {
260            Ok(block) => Ok(block),
261            Err(error) => {
262                *reserved = reserved.checked_add(bytes).ok_or_else(|| {
263                    ResourceError::Driver(
264                        "device-runtime reservation rollback overflow".to_string(),
265                    )
266                })?;
267                self.remaining_bytes =
268                    self.remaining_bytes.checked_add(bytes).ok_or_else(|| {
269                        ResourceError::Driver("device-runtime token rollback overflow".to_string())
270                    })?;
271                Err(error)
272            }
273        }
274    }
275}
276
277impl Drop for RuntimeMemoryReservation {
278    fn drop(&mut self) {
279        let mut reserved = self
280            .runtime
281            .reservation_bytes
282            .lock()
283            .expect("device-runtime reservation accounting poisoned");
284        *reserved = reserved
285            .checked_sub(self.remaining_bytes)
286            .expect("device-runtime reservation accounting underflow");
287        self.remaining_bytes = 0;
288    }
289}
290
291impl XlogDeviceRuntime {
292    /// Compose an owned runtime around a caller-supplied resource
293    /// stack. **Not** a singleton — the returned value is *not*
294    /// stored in [`RUNTIMES`] and does not interact with `try_get`.
295    ///
296    /// Intended uses:
297    ///   * Tests that need to drive a specific backend (e.g.,
298    ///     `AsyncCudaResource`) through the same facade production
299    ///     code uses, instead of constructing the resource directly.
300    ///   * Future decorator stacks (`LoggingResource`,
301    ///     `GlobalDeviceBudget`, `DebugGuardResource`) that wrap the
302    ///     base resource before installation.
303    ///
304    /// The `device` and `stream_pool` arguments must be consistent
305    /// with `device_ordinal` (the pool must be bound to the same
306    /// device handle, and the device must be the one the resource
307    /// allocates against). The constructor does not verify this —
308    /// callers that compose mismatched parts get undefined
309    /// runtime-level behavior, but the per-resource device-ordinal
310    /// check on `deallocate` will still surface obvious mistakes as
311    /// `ResourceError::Driver`.
312    ///
313    /// The singleton path remains [`Self::try_get`], which today
314    /// always installs the cudarc default (non-pooled) backend
315    /// ([`DirectCudaResource`]). Swapping the singleton's default
316    /// resource is a separate later change gated on
317    /// `GlobalDeviceBudget` and `LoggingResource` landing.
318    pub fn with_resource(
319        device: Arc<CudaDevice>,
320        device_ordinal: u32,
321        stream_pool: Arc<StreamPool>,
322        resource: Box<dyn DeviceMemoryResource + Send + Sync>,
323    ) -> Self {
324        Self {
325            device_ordinal,
326            device,
327            stream_pool,
328            resource: Mutex::new(resource),
329            reservation_bytes: Mutex::new(0),
330            resident_telemetry: Arc::new(ResidentRuntimeTelemetry::default()),
331        }
332    }
333
334    /// Atomically promise `bytes` against the complete resource-stack budget.
335    /// The stack must expose a finite reservable budget; otherwise complete
336    /// multi-allocation admission cannot be guaranteed and is refused.
337    pub(crate) fn reserve_memory(
338        self: &Arc<Self>,
339        bytes: usize,
340    ) -> ResourceResult<RuntimeMemoryReservation> {
341        let resource = self
342            .resource
343            .lock()
344            .expect("device-runtime resource poisoned");
345        let snapshot = resource.budget_snapshot().ok_or_else(|| {
346            ResourceError::Driver(
347                "device-runtime resource stack has no reservable global budget".to_string(),
348            )
349        })?;
350        let mut reserved = self
351            .reservation_bytes
352            .lock()
353            .expect("device-runtime reservation accounting poisoned");
354        let current = snapshot.reserved.checked_add(*reserved).ok_or_else(|| {
355            ResourceError::Driver("device-runtime reservation accounting overflow".to_string())
356        })?;
357        let remaining = snapshot.limit.saturating_sub(current);
358        if bytes > remaining {
359            return Err(ResourceError::OutOfBudget {
360                requested: bytes,
361                current,
362                remaining,
363                limit: snapshot.limit,
364            });
365        }
366        *reserved = reserved.checked_add(bytes).ok_or_else(|| {
367            ResourceError::Driver("device-runtime reservation accounting overflow".to_string())
368        })?;
369        Ok(RuntimeMemoryReservation {
370            runtime: Arc::clone(self),
371            total_bytes: bytes,
372            remaining_bytes: bytes,
373        })
374    }
375
376    /// Get the singleton for `ordinal`, initializing it on first
377    /// access. Subsequent calls return the same `&'static`.
378    ///
379    /// Errors:
380    ///   * `XlogError::Kernel` if `ordinal >= MAX_DEVICE_ORDINALS`.
381    ///   * `XlogError::Kernel` if the CUDA device cannot be opened.
382    ///
383    /// Concurrency: at most one thread builds the runtime for a
384    /// given ordinal. Other concurrent first callers block on the
385    /// per-ordinal init mutex until the winner publishes via
386    /// `OnceLock::set`, after which they observe the published
387    /// runtime via the inside-mutex double-check or the lock-free
388    /// fast path on subsequent calls.
389    pub fn try_get(ordinal: u32) -> Result<&'static XlogDeviceRuntime> {
390        let idx = ordinal as usize;
391        if idx >= MAX_DEVICE_ORDINALS {
392            return Err(XlogError::Kernel(format!(
393                "XlogDeviceRuntime: ordinal {} exceeds MAX_DEVICE_ORDINALS={}",
394                ordinal, MAX_DEVICE_ORDINALS
395            )));
396        }
397        // Fast path: another thread already initialized this slot.
398        if let Some(rt) = RUNTIMES[idx].get() {
399            return Ok(*rt);
400        }
401
402        // Slow path: take the per-ordinal init mutex. Only one
403        // thread per ordinal builds the runtime; the rest wait here
404        // and observe the published value on the double-check below.
405        let _guard = INIT_LOCKS[idx]
406            .lock()
407            .expect("XlogDeviceRuntime init mutex poisoned");
408
409        // Double-check inside the lock: a previous holder may have
410        // initialized while we were waiting for the mutex.
411        if let Some(rt) = RUNTIMES[idx].get() {
412            return Ok(*rt);
413        }
414
415        // We are the first writer for this ordinal. Build the
416        // runtime; if any step fails, return the error and leave
417        // RUNTIMES[idx] uninitialized so the next caller can retry.
418        let device = Arc::new(CudaDevice::new(ordinal as usize).map_err(|e| {
419            XlogError::Kernel(format!(
420                "XlogDeviceRuntime: failed to open device {}: {}",
421                ordinal, e
422            ))
423        })?);
424        let stream_pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
425        let resource: Box<dyn DeviceMemoryResource + Send + Sync> =
426            Box::new(DirectCudaResource::new(Arc::clone(&device), ordinal));
427        let runtime = Box::new(XlogDeviceRuntime {
428            device_ordinal: ordinal,
429            device,
430            stream_pool,
431            resource: Mutex::new(resource),
432            reservation_bytes: Mutex::new(0),
433            resident_telemetry: Arc::new(ResidentRuntimeTelemetry::default()),
434        });
435        let leaked: &'static XlogDeviceRuntime = Box::leak(runtime);
436
437        // We hold INIT_LOCKS[idx] and confirmed RUNTIMES[idx] is
438        // empty under that lock, so this `set` cannot fail. Fall
439        // through to a hard panic if it does — it indicates a
440        // process-internal bug we cannot recover from.
441        RUNTIMES[idx]
442            .set(leaked)
443            .map_err(|_| ())
444            .expect("XlogDeviceRuntime: OnceLock::set raced under INIT_LOCKS — bug");
445        Ok(leaked)
446    }
447
448    /// CUDA ordinal this runtime serves.
449    pub fn device_ordinal(&self) -> u32 {
450        self.device_ordinal
451    }
452
453    /// Borrow the device handle.
454    pub fn device(&self) -> &Arc<CudaDevice> {
455        &self.device
456    }
457
458    /// Borrow the stream pool.
459    pub fn stream_pool(&self) -> &Arc<StreamPool> {
460        &self.stream_pool
461    }
462
463    /// Snapshot conditional-graph execution counters.
464    pub fn conditional_graph_stats(&self) -> ConditionalGraphStats {
465        let telemetry = &self.resident_telemetry;
466        ConditionalGraphStats {
467            launches: telemetry.launches.load(Ordering::Relaxed),
468            terminal_synchronizations: telemetry.terminal_synchronizations.load(Ordering::Relaxed),
469            host_iterations: telemetry.host_iterations.load(Ordering::Relaxed),
470            host_allocations: telemetry.host_allocations.load(Ordering::Relaxed),
471            device_status_writer_launches: telemetry
472                .device_status_writer_launches
473                .load(Ordering::Relaxed),
474            host_status_injections: telemetry.host_status_injections.load(Ordering::Relaxed),
475        }
476    }
477
478    /// Reset per-execution conditional-graph counters.
479    ///
480    /// Handle and event lifetime counters are intentionally cumulative and are
481    /// not reset because callers compare snapshots around an execution.
482    pub fn reset_conditional_graph_stats(&self) {
483        let telemetry = &self.resident_telemetry;
484        telemetry.launches.store(0, Ordering::Relaxed);
485        telemetry
486            .terminal_synchronizations
487            .store(0, Ordering::Relaxed);
488        telemetry.host_iterations.store(0, Ordering::Relaxed);
489        telemetry.host_allocations.store(0, Ordering::Relaxed);
490        telemetry
491            .device_status_writer_launches
492            .store(0, Ordering::Relaxed);
493        telemetry.host_status_injections.store(0, Ordering::Relaxed);
494    }
495
496    /// Snapshot resident completion-event lifetime counters.
497    pub fn event_lifecycle_stats(&self) -> EventLifecycleStats {
498        let telemetry = &self.resident_telemetry;
499        EventLifecycleStats {
500            live_events: telemetry.live_events.load(Ordering::Acquire),
501            created_events: telemetry.created_events.load(Ordering::Relaxed),
502            destroyed_events: telemetry.destroyed_events.load(Ordering::Relaxed),
503            drop_waits: telemetry.drop_waits.load(Ordering::Relaxed),
504        }
505    }
506
507    /// Snapshot resident graph-handle lifetime counters.
508    pub fn resident_graph_handle_lifecycle_stats(&self) -> ResidentGraphHandleLifecycleStats {
509        let telemetry = &self.resident_telemetry;
510        ResidentGraphHandleLifecycleStats {
511            live_graphs: telemetry.live_graphs.load(Ordering::Acquire),
512            live_graph_execs: telemetry.live_graph_execs.load(Ordering::Acquire),
513            created_graphs: telemetry.created_graphs.load(Ordering::Relaxed),
514            destroyed_graphs: telemetry.destroyed_graphs.load(Ordering::Relaxed),
515            created_graph_execs: telemetry.created_graph_execs.load(Ordering::Relaxed),
516            destroyed_graph_execs: telemetry.destroyed_graph_execs.load(Ordering::Relaxed),
517        }
518    }
519
520    /// Tie lifecycle accounting to a successfully created graph/exec pair.
521    pub(crate) fn resident_graph_handle_lease(&self) -> ResidentGraphHandleLease {
522        let telemetry = Arc::clone(&self.resident_telemetry);
523        telemetry.live_graphs.fetch_add(1, Ordering::AcqRel);
524        telemetry.created_graphs.fetch_add(1, Ordering::Relaxed);
525        telemetry.live_graph_execs.fetch_add(1, Ordering::AcqRel);
526        telemetry
527            .created_graph_execs
528            .fetch_add(1, Ordering::Relaxed);
529        ResidentGraphHandleLease { telemetry }
530    }
531
532    /// Record that one prepared parent graph was successfully enqueued.
533    #[doc(hidden)]
534    pub fn record_conditional_graph_launch(&self, has_device_status_writer: bool) {
535        self.resident_telemetry
536            .launches
537            .fetch_add(1, Ordering::Relaxed);
538        if has_device_status_writer {
539            self.resident_telemetry
540                .device_status_writer_launches
541                .fetch_add(1, Ordering::Relaxed);
542        }
543    }
544
545    /// Record a real completion event immediately after a graph launch.
546    #[doc(hidden)]
547    pub fn record_resident_completion_event(
548        &self,
549        stream: &CudaStream,
550    ) -> Result<ResidentCompletionEvent> {
551        let event = stream.record_event(None).map_err(|error| {
552            XlogError::Kernel(format!(
553                "resident conditional graph completion event record failed: {error}"
554            ))
555        })?;
556        let telemetry = Arc::clone(&self.resident_telemetry);
557        telemetry.live_events.fetch_add(1, Ordering::AcqRel);
558        telemetry.created_events.fetch_add(1, Ordering::Relaxed);
559        Ok(ResidentCompletionEvent {
560            event: Some(event),
561            telemetry,
562            synchronized: false,
563        })
564    }
565
566    /// Allocate via the underlying resource. Stream-ordered: the
567    /// returned [`DeviceBlock`] is bound to `stream`.
568    pub fn allocate(
569        &self,
570        bytes: usize,
571        stream: StreamId,
572        tag: AllocTag,
573    ) -> ResourceResult<DeviceBlock> {
574        let resource = self
575            .resource
576            .lock()
577            .expect("device-runtime resource poisoned");
578        if let Some(snapshot) = resource.budget_snapshot() {
579            let reserved = self
580                .reservation_bytes
581                .lock()
582                .expect("device-runtime reservation accounting poisoned");
583            let current = snapshot.reserved.checked_add(*reserved).ok_or_else(|| {
584                ResourceError::Driver("device-runtime reservation accounting overflow".to_string())
585            })?;
586            let remaining = snapshot.limit.saturating_sub(current);
587            if bytes > remaining {
588                return Err(ResourceError::OutOfBudget {
589                    requested: bytes,
590                    current,
591                    remaining,
592                    limit: snapshot.limit,
593                });
594            }
595        }
596        resource.allocate(bytes, stream, tag)
597    }
598
599    /// Deallocate via the underlying resource.
600    pub fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
601        self.resource
602            .lock()
603            .expect("device-runtime resource poisoned")
604            .deallocate(block)
605    }
606
607    /// Sum of bytes currently outstanding on this device, as reported
608    /// by the underlying resource. Used by the global-budget adaptor
609    /// (later commit) and the parallel-stress acceptance test.
610    pub fn bytes_outstanding(&self) -> usize {
611        self.resource
612            .lock()
613            .expect("device-runtime resource poisoned")
614            .bytes_outstanding()
615    }
616
617    /// Drain pending async frees on the underlying resource. No-op
618    /// for synchronous backends. Callers that need an accurate
619    /// `bytes_outstanding` reading after a burst of asynchronous
620    /// deallocations should call this first.
621    pub fn reap_pending(&self) -> ResourceResult<()> {
622        self.resource
623            .lock()
624            .expect("device-runtime resource poisoned")
625            .reap_pending()
626    }
627
628    /// Record that work has been (or is being) submitted on
629    /// `use_stream` that touches `block`. Forwards to the
630    /// underlying resource stack
631    /// (`GlobalDeviceBudget` → `LoggingResource` → `AsyncCudaResource`),
632    /// where the stream-ordered backend attaches a CUDA event so
633    /// `block.alloc_stream` waits on it before the queued
634    /// `cuMemFreeAsync` runs. This is the production-reachable
635    /// hook the future xlog launch builder will call for
636    /// `read` / `write` / `read_write` buffer args; until that
637    /// lands, callers that submit raw CUDA work on a stream
638    /// other than `block.alloc_stream` should call this directly.
639    /// See [`DeviceMemoryResource::record_block_use`] for the
640    /// underlying contract.
641    pub fn record_block_use(
642        &self,
643        block: &DeviceBlock,
644        use_stream: StreamId,
645    ) -> ResourceResult<()> {
646        self.resource
647            .lock()
648            .expect("device-runtime resource poisoned")
649            .record_block_use(block, use_stream)
650    }
651
652    /// Whether the active resource stack tracks cross-stream
653    /// uses (i.e., supports `record_block_use`). The launch
654    /// recorder's preflight checks this BEFORE queuing CUDA
655    /// work, so a misconfigured runtime fails loudly at the
656    /// boundary rather than after the launch is in flight.
657    pub fn supports_block_use_tracking(&self) -> bool {
658        self.resource
659            .lock()
660            .expect("device-runtime resource poisoned")
661            .supports_block_use_tracking()
662    }
663
664    /// Pre-launch hook: queue cross-stream waits required for
665    /// `use_stream` to safely access `block` with `access`
666    /// semantics. MUST be called BEFORE the GPU work is enqueued
667    /// on `use_stream`. Forwards to the resource stack; see
668    /// [`DeviceMemoryResource::prepare_block_use`] for the
669    /// underlying contract.
670    pub fn prepare_block_use(
671        &self,
672        block: BlockId,
673        use_stream: StreamId,
674        access: Access,
675    ) -> ResourceResult<()> {
676        self.resource
677            .lock()
678            .expect("device-runtime resource poisoned")
679            .prepare_block_use(block, use_stream, access)
680    }
681
682    /// Post-launch hook: record an event on `use_stream`
683    /// capturing the work just enqueued and update `block`'s
684    /// dependency state. MUST be called AFTER the launch /
685    /// copy is queued. Forwards to the resource stack; see
686    /// [`DeviceMemoryResource::finish_block_use`] for the
687    /// underlying contract.
688    pub fn finish_block_use(
689        &self,
690        block: BlockId,
691        use_stream: StreamId,
692        access: Access,
693    ) -> ResourceResult<()> {
694        self.resource
695            .lock()
696            .expect("device-runtime resource poisoned")
697            .finish_block_use(block, use_stream, access)
698    }
699
700    /// Convenience for helper-internal scratch allocations that
701    /// will be immediately written / read on `use_stream`.
702    ///
703    /// Looks up the [`BlockId`] from the slice's runtime block
704    /// and calls [`Self::prepare_block_use`] with `access`. Use
705    /// this directly after `GpuMemoryManager::alloc` when the
706    /// buffer's first cross-stream consumer is the same operator
707    /// (e.g., a hash-table bucket array memset on `launch_stream`
708    /// against a buffer freshly allocated on the manager's
709    /// default stream).
710    ///
711    /// Returns `Err(ResourceError::StreamMisuse)` if `slice` is
712    /// not runtime-backed — strict callers should ensure their
713    /// memory manager carries a runtime.
714    pub fn prepare_first_use<T: cudarc::driver::DeviceRepr>(
715        &self,
716        slice: &crate::memory::TrackedCudaSlice<T>,
717        use_stream: StreamId,
718        access: Access,
719    ) -> ResourceResult<()> {
720        let block = slice.runtime_block().ok_or_else(|| {
721            super::resource::ResourceError::StreamMisuse(
722                "prepare_first_use: slice is not runtime-backed (the helper's \
723                 GpuMemoryManager must be built via with_runtime)"
724                    .to_string(),
725            )
726        })?;
727        self.prepare_block_use(BlockId::from_block(block), use_stream, access)
728    }
729
730    /// Convenience for helper-internal scratch finish: looks up
731    /// the [`BlockId`] from the slice and forwards to
732    /// [`Self::finish_block_use`].
733    pub fn finish_first_use<T: cudarc::driver::DeviceRepr>(
734        &self,
735        slice: &crate::memory::TrackedCudaSlice<T>,
736        use_stream: StreamId,
737        access: Access,
738    ) -> ResourceResult<()> {
739        let block = slice.runtime_block().ok_or_else(|| {
740            super::resource::ResourceError::StreamMisuse(
741                "finish_first_use: slice is not runtime-backed".to_string(),
742            )
743        })?;
744        self.finish_block_use(BlockId::from_block(block), use_stream, access)
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    fn try_runtime() -> Option<&'static XlogDeviceRuntime> {
753        match XlogDeviceRuntime::try_get(0) {
754            Ok(runtime) => Some(runtime),
755            Err(error) => {
756                if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") {
757                    panic!(
758                        "XLOG_REQUIRE_CUDA=1 but CUDA is unavailable \
759                         (XlogDeviceRuntime::try_get): {error}"
760                    );
761                }
762                eprintln!(
763                    "Skipping device-runtime test: CUDA unavailable \
764                     (XlogDeviceRuntime::try_get): {error}"
765                );
766                None
767            }
768        }
769    }
770
771    #[test]
772    fn try_get_returns_same_singleton() {
773        let Some(a) = try_runtime() else {
774            return;
775        };
776        let b = XlogDeviceRuntime::try_get(0).expect("re-get");
777        assert!(std::ptr::eq(a, b), "singleton must be stable for ordinal 0");
778        assert_eq!(a.device_ordinal(), 0);
779    }
780
781    #[test]
782    fn allocate_then_deallocate_via_runtime() {
783        let Some(rt) = try_runtime() else {
784            return;
785        };
786        let before = rt.bytes_outstanding();
787        let block = rt
788            .allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED)
789            .expect("alloc");
790        assert_eq!(block.bytes, 2048);
791        assert_eq!(rt.bytes_outstanding(), before + 2048);
792        rt.deallocate(block).expect("dealloc");
793        rt.reap_pending().expect("reap pending");
794        assert_eq!(rt.bytes_outstanding(), before);
795    }
796
797    #[test]
798    fn try_get_rejects_out_of_range_ordinal() {
799        let err = XlogDeviceRuntime::try_get(MAX_DEVICE_ORDINALS as u32);
800        assert!(err.is_err());
801    }
802
803    #[test]
804    fn with_resource_composes_owned_runtime_outside_singleton() {
805        use super::super::async_resource::AsyncCudaResource;
806
807        let Some(rt) = try_runtime() else {
808            return;
809        };
810        let device = Arc::clone(rt.device());
811        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
812        let resource = Box::new(AsyncCudaResource::new(
813            Arc::clone(&device),
814            0,
815            Arc::clone(&pool),
816        ));
817
818        let owned = XlogDeviceRuntime::with_resource(device, 0, pool, resource);
819        assert_eq!(owned.device_ordinal(), 0);
820
821        let block = owned
822            .allocate(1024, StreamId::DEFAULT, AllocTag::UNTAGGED)
823            .expect("alloc through composed runtime");
824        assert_eq!(block.bytes, 1024);
825        assert_eq!(owned.bytes_outstanding(), 1024);
826        owned.deallocate(block).expect("dealloc");
827        owned.reap_pending().expect("reap");
828        assert_eq!(owned.bytes_outstanding(), 0);
829
830        // Composed runtime is not stored in the singleton table:
831        // the singleton for ordinal 0 is whatever `try_get` returns,
832        // which must be a different memory address.
833        let singleton = XlogDeviceRuntime::try_get(0).expect("singleton");
834        assert!(
835            !std::ptr::eq(&owned, singleton),
836            "with_resource must not aliase the singleton slot"
837        );
838    }
839
840    #[test]
841    fn resident_completion_event_accounts_a_real_recorded_event() {
842        let Some(runtime) = try_runtime() else {
843            return;
844        };
845        let stream = runtime
846            .stream_pool()
847            .resolve(StreamId::DEFAULT)
848            .expect("default stream");
849        let before = runtime.event_lifecycle_stats();
850        let mut completion = runtime
851            .record_resident_completion_event(&stream)
852            .expect("record completion event");
853        let live = runtime.event_lifecycle_stats();
854        assert_eq!(live.live_events, before.live_events + 1);
855        assert_eq!(live.created_events, before.created_events + 1);
856        completion
857            .synchronize()
858            .expect("synchronize completion event");
859        drop(completion);
860        let after = runtime.event_lifecycle_stats();
861        assert_eq!(after.live_events, before.live_events);
862        assert_eq!(after.destroyed_events, before.destroyed_events + 1);
863        assert_eq!(after.drop_waits, before.drop_waits);
864    }
865
866    #[test]
867    fn resident_graph_handle_lease_balances_one_owner_slot() {
868        let Some(runtime) = try_runtime() else {
869            return;
870        };
871        let before = runtime.resident_graph_handle_lifecycle_stats();
872        let lease = runtime.resident_graph_handle_lease();
873        let live = runtime.resident_graph_handle_lifecycle_stats();
874        assert_eq!(live.live_graphs, before.live_graphs + 1);
875        assert_eq!(live.live_graph_execs, before.live_graph_execs + 1);
876        drop(lease);
877        let after = runtime.resident_graph_handle_lifecycle_stats();
878        assert_eq!(after.live_graphs, before.live_graphs);
879        assert_eq!(after.live_graph_execs, before.live_graph_execs);
880        assert_eq!(
881            after.created_graphs - before.created_graphs,
882            after.destroyed_graphs - before.destroyed_graphs
883        );
884        assert_eq!(
885            after.created_graph_execs - before.created_graph_execs,
886            after.destroyed_graph_execs - before.destroyed_graph_execs
887        );
888    }
889
890    /// `try_get` installs `DirectCudaResource` by default. The
891    /// runtime's `record_block_use` must therefore return
892    /// `StreamMisuse` (the trait's default) rather than silently
893    /// claiming success — anything else would let a launch
894    /// builder running against the singleton observe `Ok(())`
895    /// while no event is actually recorded, reproducing the
896    /// cross-stream use-after-free this whole layer exists to
897    /// prevent. See the trait-level doc on
898    /// `DeviceMemoryResource::record_block_use`.
899    #[test]
900    fn try_get_runtime_record_block_use_rejected_with_stream_misuse() {
901        let Some(rt) = try_runtime() else {
902            return;
903        };
904        let block = rt
905            .allocate(64, StreamId::DEFAULT, AllocTag::UNTAGGED)
906            .expect("alloc through runtime");
907        let err = rt.record_block_use(&block, StreamId::DEFAULT);
908        match err {
909            Err(super::super::resource::ResourceError::StreamMisuse(msg)) => {
910                assert!(
911                    msg.contains("unsupported"),
912                    "expected 'unsupported' in StreamMisuse message, got {:?}",
913                    msg
914                );
915            }
916            other => panic!(
917                "XlogDeviceRuntime::try_get default (DirectCudaResource) must \
918                 reject record_block_use with StreamMisuse; got {:?}",
919                other
920            ),
921        }
922        rt.deallocate(block).expect("dealloc still works");
923    }
924}