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}