Skip to main content

xlog_cuda/device_runtime/
resource.rs

1//! Core [`DeviceMemoryResource`] trait and supporting types.
2//!
3//! Mirrors RMM's `device_memory_resource` shape so a future optional
4//! RMM backend can satisfy the same trait without requiring callers to
5//! change. Stream-ordered: every alloc/dealloc names a stream; cross-
6//! stream reuse requires explicit event-based synchronization.
7
8use std::fmt;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11/// Identifier for a CUDA stream owned by the runtime's stream pool.
12/// Wraps the raw cudarc stream handle the resource will use for
13/// `cuMemAllocAsync` / `cuMemFreeAsync` ordering. Construction goes
14/// through the runtime; do not fabricate.
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
16pub struct StreamId(pub u32);
17
18impl StreamId {
19    /// The "default" pool stream for tests and synchronous codepaths
20    /// that have no other stream context. Production callers should
21    /// always carry a real stream from the executor / kernel launch
22    /// site.
23    pub const DEFAULT: StreamId = StreamId(0);
24}
25
26/// Caller-supplied tag for allocation log lines. Short-lived strings
27/// are interned by the logging resource; long-lived borrows are not
28/// retained.
29#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
30pub struct AllocTag(pub &'static str);
31
32impl AllocTag {
33    pub const UNTAGGED: AllocTag = AllocTag("untagged");
34}
35
36/// Monotonic counter for distinguishing reuse of the same byte address
37/// across drop / reallocate cycles. Logging and debug-guard resources
38/// use this to detect use-after-free.
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
40pub struct Generation(pub u64);
41
42static GENERATION_COUNTER: AtomicU64 = AtomicU64::new(1);
43
44impl Generation {
45    /// Allocate a fresh, monotonically increasing generation number.
46    /// Concurrent calls return distinct values.
47    pub fn next() -> Generation {
48        Generation(GENERATION_COUNTER.fetch_add(1, Ordering::Relaxed))
49    }
50}
51
52/// Access kind for a single block use. Drives the cross-stream
53/// dependency edges the resource queues during
54/// [`DeviceMemoryResource::prepare_block_use`] and the events it
55/// records during [`DeviceMemoryResource::finish_block_use`].
56///
57///   * [`Access::Read`] — the work consumes the block's bytes.
58///     Must wait on any prior write on a different stream. The
59///     resulting event is appended to the block's outstanding-reads
60///     list so future writers (and the eventual deallocate) can
61///     wait on it.
62///   * [`Access::Write`] — the work overwrites the block's bytes
63///     unconditionally. Must wait on the block's prior write AND
64///     all outstanding reads on different streams. The resulting
65///     event becomes the block's new last-write event; the
66///     outstanding-reads list is cleared at finish time.
67///   * [`Access::ReadWrite`] — both. Same wait set as `Write`,
68///     and the resulting event likewise replaces last-write.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum Access {
71    Read,
72    Write,
73    ReadWrite,
74}
75
76impl Access {
77    /// Whether work of this access kind reads the block's bytes.
78    pub fn reads(self) -> bool {
79        matches!(self, Access::Read | Access::ReadWrite)
80    }
81
82    /// Whether work of this access kind writes the block's bytes.
83    pub fn writes(self) -> bool {
84        matches!(self, Access::Write | Access::ReadWrite)
85    }
86}
87
88/// Compact identity of a [`DeviceBlock`] suitable for snapshotting
89/// into structures whose lifetime should not be tied to the source
90/// slice's borrow. The fields needed to validate `(ptr, generation)`
91/// against the resource's live map and to resolve `alloc_stream` for
92/// cross-stream waits / dealloc ordering.
93///
94/// Created via [`BlockId::from_block`]. Pure data; no resource
95/// handle, no `Drop`. Cheap to copy.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct BlockId {
98    pub ptr: u64,
99    pub generation: Generation,
100    pub alloc_stream: StreamId,
101    pub device_ordinal: u32,
102}
103
104impl BlockId {
105    /// Snapshot a [`DeviceBlock`]'s identity. The returned id is
106    /// independent of the original block's borrow lifetime; the
107    /// runtime's generation guard catches stale ids whose backing
108    /// allocation has been recycled.
109    pub fn from_block(block: &DeviceBlock) -> Self {
110        Self {
111            ptr: block.ptr,
112            generation: block.generation,
113            alloc_stream: block.alloc_stream,
114            device_ordinal: block.device_ordinal,
115        }
116    }
117}
118
119/// State of an outstanding [`DeviceBlock`] from the runtime's
120/// perspective. Adaptors flip blocks between these states; bug-detection
121/// resources reject operations on blocks in an unexpected state.
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub enum BlockState {
124    /// Returned from `allocate`; safe to read/write on `alloc_stream`
125    /// or after a synchronization to another stream.
126    Live,
127    /// Returned from `deallocate` but still pending kernel completion
128    /// on its owning stream. Reuse must wait for stream sync.
129    Retired,
130    /// Held by `DebugGuardResource` for delayed reuse / canary
131    /// validation. Not reissued until the quarantine window passes.
132    Quarantined,
133    /// Memory has been physically freed. Any further use is a bug.
134    Freed,
135}
136
137/// One outstanding device-memory allocation. Owned by the caller until
138/// returned to its originating resource via
139/// [`DeviceMemoryResource::deallocate`].
140///
141/// Carries the metadata required for stream-ordered correctness and
142/// post-mortem debugging: the resource that owns the block, the device
143/// ordinal, the stream the allocation is bound to, byte size, alignment,
144/// caller tag, generation number, and current state.
145#[derive(Debug)]
146pub struct DeviceBlock {
147    /// Raw device pointer (opaque to safe Rust callers).
148    pub ptr: u64,
149    /// CUDA ordinal of the device this block lives on.
150    pub device_ordinal: u32,
151    /// Allocation stream. Reads/writes on a different stream require
152    /// explicit synchronization (event wait or device sync).
153    pub alloc_stream: StreamId,
154    /// Size in bytes. May exceed the caller-requested size when the
155    /// resource rounds up for alignment or pool granularity.
156    pub bytes: usize,
157    /// Alignment in bytes (always ≥ caller request).
158    pub align: usize,
159    /// Caller-supplied tag, surfaced in allocation logs.
160    pub tag: AllocTag,
161    /// Monotonic generation. Reused addresses get fresh generations.
162    pub generation: Generation,
163    /// Current state. Adaptors transition this; tests assert on it.
164    pub state: BlockState,
165}
166
167/// Errors returned by resource implementations. Distinct variants for
168/// the cases stress tests need to pin (out-of-budget vs CUDA driver
169/// failure vs use-after-free etc.).
170#[derive(Debug)]
171pub enum ResourceError {
172    /// The requested allocation would exceed the resource's budget.
173    /// Carries the exact accounting state captured at the rejecting
174    /// decision point so callers can report cumulative pressure.
175    OutOfBudget {
176        requested: usize,
177        current: usize,
178        remaining: usize,
179        limit: usize,
180    },
181    /// CUDA driver returned an error. Carries the wrapped message.
182    Driver(String),
183    /// A stream-ordered contract was violated (e.g. dealloc on a
184    /// stream that does not match the alloc stream without an
185    /// intervening sync).
186    StreamMisuse(String),
187    /// A debug-guard or logging adaptor detected a use-after-free or
188    /// double-free. Hard error in debug builds; surfaced upward.
189    UseAfterFree { generation: Generation },
190    /// A debug-guard adaptor detected an out-of-bounds write past a
191    /// canary boundary.
192    OutOfBounds { generation: Generation },
193}
194
195impl fmt::Display for ResourceError {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::OutOfBudget {
199                requested,
200                current,
201                remaining,
202                limit,
203            } => write!(
204                f,
205                "out of budget: current {} bytes, requested {} bytes, required {} bytes, limit {} bytes, remaining {} bytes",
206                current,
207                requested,
208                *current as u128 + *requested as u128,
209                limit,
210                remaining,
211            ),
212            Self::Driver(msg) => write!(f, "CUDA driver error: {}", msg),
213            Self::StreamMisuse(msg) => write!(f, "stream-ordered contract violated: {}", msg),
214            Self::UseAfterFree { generation } => {
215                write!(f, "use-after-free on generation {:?}", generation)
216            }
217            Self::OutOfBounds { generation } => {
218                write!(f, "out-of-bounds write on generation {:?}", generation)
219            }
220        }
221    }
222}
223
224impl std::error::Error for ResourceError {}
225
226pub type ResourceResult<T> = std::result::Result<T, ResourceError>;
227
228/// Exact byte-accounting snapshot exposed by a reservable resource decorator.
229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
230pub struct ResourceBudgetSnapshot {
231    pub limit: usize,
232    pub reserved: usize,
233}
234
235impl ResourceBudgetSnapshot {
236    pub fn remaining(self) -> usize {
237        self.limit.saturating_sub(self.reserved)
238    }
239}
240
241/// Stream-ordered device memory resource. Implementations:
242///   * [`crate::device_runtime::direct::DirectCudaResource`] —
243///     cudarc default (non-pooled) backend; **candidate** for the
244///     sanitizer/cert role, **unproven** until the manual Compute Sanitizer
245///     acceptance gate runs on a supported host.
246///   * [`crate::device_runtime::async_resource::AsyncCudaResource`] —
247///     stream-ordered cuMemAllocAsync/cuMemFreeAsync backend;
248///     production default when the context supports async-alloc.
249///   * [`crate::device_runtime::logging::LoggingResource`] —
250///     telemetry decorator over any inner resource.
251///   * [`crate::device_runtime::budget::GlobalDeviceBudget`] —
252///     per-runtime byte-limit decorator over any inner resource.
253///   * `PoolResource` — performance tier; v0.7+ (not implemented).
254///   * `DebugGuardResource` — canary/poison/quarantine; v0.7+
255///     (not implemented).
256///
257/// Implementations must be thread-safe. The runtime composes resources
258/// via decoration (each resource wraps an inner `Box<dyn
259/// DeviceMemoryResource + Send + Sync>`).
260pub trait DeviceMemoryResource: Send + Sync {
261    /// Allocate `bytes` bytes on the resource's device, ordered on
262    /// `stream`. The returned block is in [`BlockState::Live`].
263    fn allocate(
264        &self,
265        bytes: usize,
266        stream: StreamId,
267        tag: AllocTag,
268    ) -> ResourceResult<DeviceBlock>;
269
270    /// Return `block` to the resource. After this call the block's
271    /// state is [`BlockState::Retired`] (or [`BlockState::Quarantined`]
272    /// for debug-guard resources). Reuse of the underlying memory is
273    /// resource-specific but must respect the stream-ordered contract.
274    ///
275    /// `block.alloc_stream` is authoritative for ordering. If the
276    /// caller has touched the memory on a different stream, they must
277    /// have synchronized before calling `deallocate`.
278    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()>;
279
280    /// CUDA device ordinal this resource serves. Resources are pinned
281    /// to a single device.
282    fn device_ordinal(&self) -> u32;
283
284    /// Bytes currently outstanding (live + retired-but-not-yet-freed).
285    /// Used by tests and by the global budget adaptor.
286    fn bytes_outstanding(&self) -> usize;
287
288    /// Return the outer resource stack's reservable budget, if one exists.
289    /// Base allocators have no declarative admission limit and return `None`;
290    /// decorators must forward this query unless they enforce their own limit.
291    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
292        None
293    }
294
295    /// Drain any retired-but-not-yet-freed bytes whose underlying
296    /// CUDA work has completed. For synchronous backends this is a
297    /// no-op. For stream-ordered async backends this synchronizes
298    /// the streams that have queued `cuMemFreeAsync` calls and
299    /// re-counts `bytes_outstanding` accordingly.
300    ///
301    /// Callers that need an accurate budget reading after a burst
302    /// of asynchronous deallocations should call this before
303    /// reading `bytes_outstanding`. Calling on a synchronous backend
304    /// is harmless and free.
305    fn reap_pending(&self) -> ResourceResult<()> {
306        Ok(())
307    }
308
309    /// Record that work has been (or is being) submitted on
310    /// `use_stream` that touches `block`'s bytes. Resources that
311    /// participate in cross-stream lifetime tracking (notably the
312    /// stream-ordered async backend) MUST attach a CUDA event from
313    /// `use_stream` to the block; on `deallocate(block)`, the
314    /// block's `alloc_stream` will wait on every recorded event
315    /// before queueing the underlying free.
316    ///
317    /// **The default implementation returns
318    /// [`ResourceError::StreamMisuse`].** This is intentional: a
319    /// silent no-op default would let a launch builder call
320    /// `record_block_use` against a resource that does not
321    /// actually track cross-stream uses (e.g.,
322    /// [`crate::device_runtime::direct::DirectCudaResource`]),
323    /// observe `Ok(())`, queue a kernel on a different stream,
324    /// then drop the block — and quietly hit the cross-stream
325    /// use-after-free that this API exists to prevent. False
326    /// safety is worse than no safety. Resources that cannot
327    /// track cross-stream uses MUST inherit this default;
328    /// callers (notably the future xlog launch builder) MUST
329    /// surface the error rather than masking it.
330    ///
331    /// Override status today:
332    ///   * [`crate::device_runtime::async_resource::AsyncCudaResource`]
333    ///     overrides with real event tracking.
334    ///   * [`crate::device_runtime::logging::LoggingResource`] and
335    ///     [`crate::device_runtime::budget::GlobalDeviceBudget`]
336    ///     forward to their inner resource (so the underlying
337    ///     backend's behavior surfaces unchanged).
338    ///   * [`crate::device_runtime::direct::DirectCudaResource`]
339    ///     does NOT override — it correctly returns
340    ///     `StreamMisuse` and forces callers to either route
341    ///     allocations through `AsyncCudaResource` or take
342    ///     responsibility for cross-stream synchronization
343    ///     themselves.
344    ///
345    /// # Errors
346    ///   * [`ResourceError::StreamMisuse`] from the default impl
347    ///     when the resource cannot track cross-stream uses.
348    ///   * [`ResourceError::UseAfterFree`] if `block` is not the
349    ///     block currently live at `block.ptr` (caller likely
350    ///     handed back a stale [`DeviceBlock`] whose generation
351    ///     no longer matches the live entry).
352    ///   * [`ResourceError::StreamMisuse`] if `use_stream` does
353    ///     not resolve in the resource's stream pool.
354    ///   * [`ResourceError::Driver`] for CUDA driver / event
355    ///     creation failures.
356    ///
357    /// Callers that bypass this API and submit cross-stream work
358    /// directly (raw `cuMemcpyDtoHAsync`, raw `Vec<*mut c_void>`
359    /// kernel launches that the launch builder did not see, etc.)
360    /// are responsible for their own cross-stream synchronization.
361    /// The resource cannot infer arbitrary external CUDA work.
362    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
363        let _ = (block, use_stream);
364        Err(ResourceError::StreamMisuse(
365            "record_block_use unsupported by this resource (the active backend \
366             does not track cross-stream uses; route allocations through a \
367             stream-ordered backend such as AsyncCudaResource, or take \
368             responsibility for cross-stream synchronization explicitly)"
369                .to_string(),
370        ))
371    }
372
373    /// Whether this resource (and any inner resources it
374    /// composes) actually tracks cross-stream uses via
375    /// `record_block_use`. Used by the launch recorder's
376    /// preflight to fail BEFORE queueing CUDA work, rather than
377    /// after. The default returns `false` to match the trait's
378    /// default `record_block_use` behavior; resources that
379    /// override `record_block_use` to track events MUST override
380    /// this to return `true`. Decorators forward to inner.
381    fn supports_block_use_tracking(&self) -> bool {
382        false
383    }
384
385    /// Pre-launch / pre-copy hook: queue any cross-stream waits
386    /// required for `use_stream` to safely access `block` with
387    /// `access` semantics. MUST be called BEFORE the GPU work is
388    /// enqueued on `use_stream`.
389    ///
390    /// Concretely, on [`Access::Read`] the resource must queue
391    /// `use_stream.wait(&last_write)` if a write on a different
392    /// stream is outstanding. On [`Access::Write`] /
393    /// [`Access::ReadWrite`] the resource must additionally queue
394    /// waits on every outstanding read recorded on a different
395    /// stream — the writer must observe completion of every prior
396    /// reader. Same-stream events are skipped (CUDA stream order
397    /// already covers them).
398    ///
399    /// **The default implementation returns
400    /// [`ResourceError::StreamMisuse`].** Same rationale as
401    /// `record_block_use`: a silent no-op default would let
402    /// callers paired against a non-tracking backend believe the
403    /// dependency edge was queued. Decorators forward; tracking
404    /// backends override.
405    ///
406    /// # Errors
407    ///   * [`ResourceError::StreamMisuse`] from the default impl
408    ///     when the resource cannot track cross-stream uses.
409    ///   * [`ResourceError::UseAfterFree`] if `block` is not the
410    ///     id currently live at `block.ptr`.
411    ///   * [`ResourceError::Driver`] for CUDA driver / event-wait
412    ///     failures.
413    fn prepare_block_use(
414        &self,
415        block: BlockId,
416        use_stream: StreamId,
417        access: Access,
418    ) -> ResourceResult<()> {
419        let _ = (block, use_stream, access);
420        Err(ResourceError::StreamMisuse(
421            "prepare_block_use unsupported by this resource (the active backend \
422             does not track cross-stream uses; route allocations through \
423             AsyncCudaResource or take responsibility for cross-stream \
424             synchronization explicitly)"
425                .to_string(),
426        ))
427    }
428
429    /// Post-launch / post-copy hook: record an event on
430    /// `use_stream` capturing the work just enqueued and update
431    /// `block`'s dependency state.
432    ///
433    /// Concretely, on [`Access::Read`] the new event is appended
434    /// to the block's outstanding-reads list (so future writers
435    /// and the eventual deallocate can wait on it). On
436    /// [`Access::Write`] / [`Access::ReadWrite`] the new event
437    /// **replaces** the block's last-write event and the
438    /// outstanding-reads list is cleared (any prior reader's
439    /// dependency was queued at prepare time and is now subsumed
440    /// by the new write event).
441    ///
442    /// **The default implementation returns
443    /// [`ResourceError::StreamMisuse`].** Same rationale as
444    /// `record_block_use`. Decorators forward; tracking backends
445    /// override.
446    fn finish_block_use(
447        &self,
448        block: BlockId,
449        use_stream: StreamId,
450        access: Access,
451    ) -> ResourceResult<()> {
452        let _ = (block, use_stream, access);
453        Err(ResourceError::StreamMisuse(
454            "finish_block_use unsupported by this resource (the active backend \
455             does not track cross-stream uses; route allocations through \
456             AsyncCudaResource or take responsibility for cross-stream \
457             synchronization explicitly)"
458                .to_string(),
459        ))
460    }
461}