spark_runtime/
gpu.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GPU backend abstraction (SBIO IORouter for GPU operations).
4//!
5//! All CUDA interactions flow through [`GpuBackend`]. Business logic
6//! (model forward pass, KV cache management) never calls cuLaunchKernel
7//! or cuMemAlloc directly.
8
9use anyhow::Result;
10use std::fmt;
11use std::sync::atomic::Ordering;
12// The free-memory baseline is a field of the single run mailbox,
13// `crate::run_metrics::RunMetrics`: it is read by the dashboard and by KV
14// sizing from threads with no carrier, and it is cleared at run start so a
15// second model measures against its own baseline rather than the first
16// model's pre-load free memory.
17
18/// Record the free-memory baseline at GPU-context init. Call once, early,
19/// before weight loading. Idempotent-last-write; intended to be set exactly once.
20pub fn set_baseline_free_bytes(bytes: usize) {
21    crate::run_metrics::metrics()
22        .baseline_free_bytes
23        .store(bytes, Ordering::Relaxed);
24}
25
26/// The free-memory baseline captured at context init, or `None` if never set.
27pub fn baseline_free_bytes() -> Option<usize> {
28    match crate::run_metrics::metrics()
29        .baseline_free_bytes
30        .load(Ordering::Relaxed)
31    {
32        0 => None,
33        v => Some(v),
34    }
35}
36
37/// Opaque device pointer wrapping a CUDA CUdeviceptr (u64).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct DevicePtr(pub u64);
40
41impl DevicePtr {
42    pub const NULL: Self = Self(0);
43
44    pub fn is_null(self) -> bool {
45        self.0 == 0
46    }
47
48    /// Byte offset from this pointer.
49    pub fn offset(self, bytes: usize) -> Self {
50        Self(self.0 + bytes as u64)
51    }
52}
53
54/// Handle to a loaded CUDA kernel function.
55#[derive(Debug, Clone, Copy)]
56pub struct KernelHandle(pub u64);
57
58/// Handle to an instantiated CUDA graph (CUgraphExec).
59#[derive(Debug, Clone, Copy)]
60pub struct GraphHandle(pub u64);
61
62/// Typed kernel argument, used by `launch_typed`.
63///
64/// CUDA's `cuLaunchKernel` is type-blind — every arg is `void*` and the
65/// driver interprets bytes by kernel signature. Metal's
66/// `MTLComputeCommandEncoder` is not: buffer arguments require
67/// `setBuffer:offset:atIndex:` (the encoder tracks the resource) while
68/// scalar/struct args require `setBytes:length:atIndex:`. `KernelArg`
69/// preserves that distinction so both backends can dispatch correctly.
70#[derive(Debug, Clone, Copy)]
71pub enum KernelArg<'a> {
72    /// A device buffer at this base GPU address. The metal backend
73    /// resolves it to its owning `MTLBuffer` + offset via the alloc
74    /// registry; the cuda backend forwards the raw `u64` to the driver.
75    Buffer(DevicePtr),
76    /// Inline scalar/struct bytes, e.g. a `u32` count or an `f32` eps.
77    /// Length is forwarded to Metal's `setBytes:length:`; the cuda
78    /// backend zero-pads up to 8 bytes per slot.
79    Bytes(&'a [u8]),
80}
81
82pub use crate::gpu_args::pack_kernel_args;
83
84/// GPU backend trait — SBIO IORouter for all CUDA operations.
85///
86/// Implementations: `AtlasCudaBackend` (production), `MockGpuBackend` (tests).
87pub trait GpuBackend: Send + Sync {
88    /// Allocate `bytes` of device memory.
89    ///
90    /// `#[track_caller]` so the CUDA backend's ledger records WHICH code
91    /// asked for the memory. It must stay on the trait declaration as well as
92    /// the impl: nearly every caller goes through `&dyn GpuBackend`, and
93    /// without it here the vtable would attribute every allocation in the
94    /// process to the one line inside the backend.
95    #[track_caller]
96    fn alloc(&self, bytes: usize) -> Result<DevicePtr>;
97
98    /// Allocate managed (unified) memory. On GB10, this allows over-subscribing
99    /// physical GPU memory — Linux pages overflow to NVMe swap automatically.
100    /// Managed memory is slower than device memory but avoids OOM.
101    #[track_caller]
102    fn alloc_managed(&self, bytes: usize) -> Result<DevicePtr>;
103
104    /// Free device memory.
105    fn free(&self, ptr: DevicePtr) -> Result<()>;
106
107    /// Free every allocation this backend made that nobody released, and
108    /// report how many there were.
109    ///
110    /// The teardown backstop. Enumerating owners does not scale: the loaders
111    /// fuse weights into fresh allocations owned by layer structs, which no
112    /// pool releases — measured at 15.3 GB per cycle on a 27B, linear over six
113    /// cycles. A backend is created per model, so its outstanding set IS that
114    /// model's leak.
115    ///
116    /// Default `0`: a backend that does not track allocations has nothing to
117    /// sweep, which is honest for the mock and for Metal.
118    fn sweep_unreleased(&self) -> usize {
119        0
120    }
121
122    /// Live device bytes this backend has allocated and not freed, if it
123    /// tracks them. `None` for backends with no ledger (mock/CPU).
124    fn live_bytes(&self) -> Option<usize> {
125        None
126    }
127
128    /// Attribution of live device memory by allocating call site, biggest
129    /// first. `None` for backends with no ledger.
130    fn alloc_report(&self, _top_n: usize, _min_mb: usize) -> Option<String> {
131        None
132    }
133
134    /// Copy from host to device.
135    fn copy_h2d(&self, src: &[u8], dst: DevicePtr) -> Result<()>;
136
137    /// Copy from device to host.
138    fn copy_d2h(&self, src: DevicePtr, dst: &mut [u8]) -> Result<()>;
139
140    /// Synchronous device-to-host copy ordered after work on `stream`.
141    ///
142    /// Unlike `copy_d2h` (which uses the default stream and only orders
143    /// against work already on the default stream), this method enqueues
144    /// the copy on `stream`. CUDA serializes the copy after any prior
145    /// kernel launches on `stream`, so the bytes read are guaranteed to
146    /// reflect post-kernel state.
147    ///
148    /// Required when reading bytes that were just written by kernels on
149    /// a non-default stream — e.g. `high_speed_swap_offload_new_blocks`
150    /// reading WHT+quantize output bytes.
151    fn copy_d2h_on_stream(&self, src: DevicePtr, dst: &mut [u8], stream: u64) -> Result<()> {
152        // Default impl for mocks: sync the caller's stream then fall
153        // back to copy_d2h. The CUDA backend overrides this for a
154        // single-stream copy + sync.
155        self.synchronize(stream)?;
156        self.copy_d2h(src, dst)
157    }
158
159    /// Copy device to device.
160    fn copy_d2d(&self, src: DevicePtr, dst: DevicePtr, bytes: usize) -> Result<()>;
161
162    /// Launch a kernel on the given CUDA stream.
163    fn launch(
164        &self,
165        func: KernelHandle,
166        grid: [u32; 3],
167        block: [u32; 3],
168        shared_mem: u32,
169        stream: u64,
170        params: &mut [*mut std::ffi::c_void],
171    ) -> Result<()>;
172
173    /// Typed-args kernel launch.
174    ///
175    /// CUDA's default impl packs args into u64 slots and forwards to
176    /// `launch()`. The Metal backend overrides this to map each
177    /// `KernelArg::Buffer` to `setBuffer:offset:atIndex:` and each
178    /// `KernelArg::Bytes` to `setBytes:length:atIndex:`.
179    fn launch_typed(
180        &self,
181        func: KernelHandle,
182        grid: [u32; 3],
183        block: [u32; 3],
184        shared_mem: u32,
185        stream: u64,
186        args: &[KernelArg<'_>],
187    ) -> Result<()> {
188        // ANOMALIES A56: record what this step enqueues so two steps can be
189        // diffed. A graph bakes these bytes; anything that moves between steps
190        // is a host value the replay froze. No-op unless `launch_trace::begin`.
191        if crate::launch_trace::on() {
192            let words = args
193                .iter()
194                .map(|a| match a {
195                    KernelArg::Buffer(p) => p.0,
196                    KernelArg::Bytes(b) => {
197                        let mut w = [0u8; 8];
198                        let n = b.len().min(8);
199                        w[..n].copy_from_slice(&b[..n]);
200                        u64::from_le_bytes(w)
201                    }
202                })
203                .collect();
204            crate::launch_trace::record(crate::launch_trace::Entry {
205                kind: "kernel",
206                func: func.0,
207                grid,
208                block,
209                smem: shared_mem,
210                args: words,
211            });
212        }
213        // CUDA-compatible default: each arg becomes one u64 slot. The
214        // storage stays alive across the launch call so the *mut c_void
215        // pointers we hand to `launch()` remain valid.
216        let (storage, starts) = pack_kernel_args(args);
217        let mut params: Vec<*mut std::ffi::c_void> = starts
218            .iter()
219            .map(|&i| &storage[i] as *const u64 as *mut std::ffi::c_void)
220            .collect();
221        self.launch(func, grid, block, shared_mem, stream, &mut params)
222    }
223
224    /// Whether `stream` is inside an active CUDA-graph capture. Telemetry
225    /// taps MUST check this before any sync/D2H on a potentially-captured
226    /// stream — those calls invalidate the capture (CUDA 901) and wedge the
227    /// serve. Default `false` (backends without capture, or without a query
228    /// API, never capture through this trait's eager paths).
229    fn stream_is_capturing(&self, _stream: u64) -> bool {
230        false
231    }
232
233    /// Synchronize a CUDA stream (blocks until all work completes).
234    fn synchronize(&self, stream: u64) -> Result<()>;
235
236    /// A55 diagnostic: read every allocation's trailing guard band back and report the ones
237    /// a kernel wrote past. Returns the violation count. `Ok(0)` when `ATLAS_REDZONE` is
238    /// unset or the backend has no red zones — every backend but CUDA.
239    fn scan_redzones(&self) -> Result<usize> {
240        Ok(0)
241    }
242
243    /// A55 bisection: poison guard bands `[lo, hi)` with `0xEE` and the rest with `0x00`.
244    /// Layout-preserving by construction — nothing is allocated, moved or resized.
245    fn poison_redzones(&self, _lo: usize, _hi: usize) -> Result<()> {
246        Ok(())
247    }
248
249    /// Get the default stream handle.
250    fn default_stream(&self) -> u64;
251
252    /// Look up a kernel function by module and function name.
253    ///
254    /// `#[track_caller]` on the DECLARATION is what makes the caller location
255    /// survive the `&dyn GpuBackend` vtable — every lookup in Atlas goes
256    /// through dynamic dispatch, so without it the audit can only ever name
257    /// the backend's own line. The location is what turns an unresolved-lookup
258    /// report from a name list into a work item.
259    #[track_caller]
260    fn kernel(&self, module: &str, func_name: &str) -> Result<KernelHandle>;
261
262    /// Whether `module` is compiled into this backend at all — the question
263    /// to ask BEFORE looking up a kernel that only some targets carry. A
264    /// lookup that fails is recorded by the boot audit as a dispatch site on
265    /// a silent fallback path; a target that never built the source has no
266    /// such site, so it must not issue the lookup.
267    fn has_module(&self, module: &str) -> bool;
268
269    /// This backend's memoized kernel handles and scratch allocations.
270    ///
271    /// Required rather than defaulted: an op that memoizes a `KernelHandle`
272    /// or a `DevicePtr` anywhere else is caching something that belongs to
273    /// this backend's model, and a default would let a new backend forget.
274    fn op_cache(&self) -> &crate::op_cache::OpCache;
275
276    /// Synchronise the stream after every kernel launch, so an asynchronous
277    /// illegal-address fault is reported at the kernel that caused it rather
278    /// than at a later sync. Resolved once when the backend is built; read on
279    /// the launch path, which is why it is not a per-launch `getenv`.
280    fn debug_sync_kernels(&self) -> bool {
281        false
282    }
283
284    /// This backend's model-scoped kernel modules, for the few callers that
285    /// need the registry itself rather than a kernel handle — resolving a
286    /// `__device__` symbol, for instance. `None` on backends that have no such
287    /// concept, which is why it is an accessor rather than a downcast.
288    #[cfg(feature = "cuda")]
289    fn kernel_registry(&self) -> Option<std::sync::Arc<atlas_core::registry::AtlasRegistry>> {
290        None
291    }
292
293    /// Async host-to-device copy: **`src` may be dropped or overwritten the
294    /// moment this returns.**
295    ///
296    /// That is what the ~90 call sites in `spark-model` rely on — nearly all
297    /// hand over a stack array or local `Vec` that dies at the end of the
298    /// statement — and it used to hold only by accident. See
299    /// [`crate::pinned_hosts`] for why, and for how the CUDA backend now MAKES
300    /// the promise true (page-locked source ⇒ it buys the ordering that the
301    /// pageable path gets from the driver for free) instead of inheriting it.
302    ///
303    /// Use [`GpuBackend::copy_h2d_async_retained`] when the source outlives the
304    /// next synchronisation and the extra ordering is not wanted.
305    fn copy_h2d_async(&self, src: &[u8], dst: DevicePtr, _stream: u64) -> Result<()> {
306        self.copy_h2d(src, dst)
307    }
308
309    /// Async host-to-device copy for a source the CALLER keeps alive.
310    ///
311    /// `src` must remain valid, and must not be rewritten, until the next
312    /// synchronisation point on `stream`. In exchange it never inserts an
313    /// implicit sync — what makes a batched scatter out of one pinned staging
314    /// blob (N enqueues + one `synchronize`) worth doing; see
315    /// [`GpuBackend::copy_d2h_async`] for the measured shape. The name marks,
316    /// greppably, every site making a promise the compiler cannot check.
317    fn copy_h2d_async_retained(&self, src: &[u8], dst: DevicePtr, stream: u64) -> Result<()> {
318        // Default: the transient path. Strictly stronger ordering than promised,
319        // so it is always correct — just not always the fastest.
320        self.copy_h2d_async(src, dst, stream)
321    }
322
323    /// Async device-to-host copy (no stream synchronization).
324    ///
325    /// The counterpart of [`GpuBackend::copy_h2d_async`], and the ONLY D2H
326    /// primitive usable for a batched gather: `copy_d2h` and
327    /// `copy_d2h_on_stream` both `cuStreamSynchronize` INSIDE the call, so an
328    /// N-chunk gather pays N full stream drains. Measured cost of that shape:
329    /// the SSM snapshot spill moved 66,846,720 B as 60 blocking `copy_d2h`
330    /// calls in ~400 ms (~165 MB/s), while the mirror-image scatter
331    /// (`copy_h2d_async` ×60 + ONE `synchronize`) moved the same bytes through
332    /// the same host buffer in ~28 ms.
333    ///
334    /// **Lifetime requirement** (same as `copy_h2d_async`): the destination
335    /// buffer must remain valid, and must not be read or re-used, until the
336    /// next synchronization point on this stream.
337    fn copy_d2h_async(&self, src: DevicePtr, dst: &mut [u8], _stream: u64) -> Result<()> {
338        // Mock/metal fall back to the blocking copy: correct (a stricter
339        // ordering than promised), just not batched.
340        self.copy_d2h(src, dst)
341    }
342
343    /// Async device-to-device copy (no stream synchronization).
344    fn copy_d2d_async(
345        &self,
346        src: DevicePtr,
347        dst: DevicePtr,
348        bytes: usize,
349        _stream: u64,
350    ) -> Result<()> {
351        self.copy_d2d(src, dst, bytes)
352    }
353
354    /// Strided device-to-device 2D (pitched) copy: `height` rows of
355    /// `width_bytes`, source rows spaced by `src_pitch`, dest rows by
356    /// `dst_pitch`. Default = per-row `copy_d2d_async` loop; the CUDA backend
357    /// overrides with ONE `cudaMemcpy2DAsync` (replaces the per-token Z-copy
358    /// loop = up to num_tokens×num_ssm_layers launches/forward).
359    #[allow(clippy::too_many_arguments)]
360    fn copy_d2d_2d_async(
361        &self,
362        src: DevicePtr,
363        src_pitch: usize,
364        dst: DevicePtr,
365        dst_pitch: usize,
366        width_bytes: usize,
367        height: usize,
368        stream: u64,
369    ) -> Result<()> {
370        for r in 0..height {
371            self.copy_d2d_async(
372                src.offset(r * src_pitch),
373                dst.offset(r * dst_pitch),
374                width_bytes,
375                stream,
376            )?;
377        }
378        Ok(())
379    }
380
381    /// Begin capturing CUDA operations on `stream` into a graph.
382    ///
383    /// All kernel launches and async copies on this stream between
384    /// `begin_capture` and `end_capture` are recorded (not executed).
385    /// The stream must NOT be the legacy default stream (handle 0).
386    fn begin_capture(&self, _stream: u64) -> Result<()> {
387        Ok(())
388    }
389
390    /// End capture and return an instantiated graph ready for replay.
391    fn end_capture(&self, _stream: u64) -> Result<GraphHandle> {
392        Ok(GraphHandle(0))
393    }
394
395    /// Replay all operations captured in the graph on `stream`.
396    fn launch_graph(&self, _graph: GraphHandle, _stream: u64) -> Result<()> {
397        Ok(())
398    }
399
400    /// Destroy an instantiated graph, freeing resources.
401    fn destroy_graph(&self, _graph: GraphHandle) -> Result<()> {
402        Ok(())
403    }
404
405    /// Best-effort: if `stream` is mid graph-capture, end that capture so the
406    /// stream returns to normal mode (discarding any partial graph). Call this
407    /// on an error path that unwound out of a `begin_capture`/`end_capture`
408    /// region (e.g. a fold refuse bailed mid-capture) — otherwise the stream is
409    /// left recording and every subsequent op fails with
410    /// STREAM_CAPTURE_UNSUPPORTED, bricking the server. No-op if not capturing.
411    fn abort_capture_if_active(&self, _stream: u64) {}
412
413    /// Set device memory to a byte value (synchronous — waits for completion).
414    fn memset(&self, ptr: DevicePtr, value: u8, bytes: usize) -> Result<()>;
415
416    /// Set device memory to a byte value on the given stream (async — does not wait).
417    fn memset_async(&self, ptr: DevicePtr, value: u8, bytes: usize, stream: u64) -> Result<()>;
418
419    /// Total device memory in bytes.
420    fn total_memory(&self) -> Result<usize>;
421
422    /// Free device memory in bytes.
423    fn free_memory(&self) -> Result<usize>;
424
425    /// Free device memory as the DRIVER reports it, with no host leg.
426    ///
427    /// `free_memory` is `max(cuMemGetInfo, MemAvailable)` (ANOMALIES A73), so it
428    /// cannot separate driver-committed device memory from reclaimable host page
429    /// cache — which is exactly the separation a per-request leak measurement
430    /// needs. Default falls back to `free_memory` for backends that have no
431    /// distinct driver leg.
432    fn device_free_memory(&self) -> Result<usize> {
433        self.free_memory()
434    }
435
436    /// Live (allocated, not yet freed) device allocations on this backend.
437    ///
438    /// A COUNT, not bytes: it answers "did this request hand back every buffer it
439    /// took?" without an allocator-size ledger. Default 0 = not tracked.
440    fn live_alloc_count(&self) -> usize {
441        0
442    }
443
444    /// Number of streaming multiprocessors (CUDA SMs / HIP CUs) on the device.
445    ///
446    /// Queried from the driver, never assumed: dispatch rules that ask "does
447    /// this grid still fill the machine?" are wrong on every part whose SM
448    /// count differs from the one they were tuned on. Callers must resolve it
449    /// ONCE at construction and keep the value, not call it per launch.
450    fn sm_count(&self) -> Result<u32>;
451
452    /// Create a new CUDA stream (for overlapping work).
453    fn create_stream(&self) -> Result<u64> {
454        Ok(0) // Default: return legacy stream
455    }
456
457    /// Bind the CUDA context to the current thread.
458    ///
459    /// Must be called on any thread that uses GPU operations (alloc, launch, etc.)
460    /// if it's different from the thread that created the backend.
461    fn bind_to_thread(&self) -> Result<()> {
462        Ok(()) // No-op for mock backend
463    }
464
465    /// Create a CUDA event (for inter-stream synchronization).
466    fn create_event(&self) -> Result<u64> {
467        Ok(0)
468    }
469
470    /// Record an event on a stream (marks a point in the stream's work).
471    fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
472        Ok(())
473    }
474
475    /// Make a stream wait for an event (GPU-side sync, CPU does not block).
476    fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
477        Ok(())
478    }
479
480    /// Block the calling host thread until all work already
481    /// recorded against the event — e.g. an async D2H copy issued on the
482    /// graph stream followed by `record_event`, then `event_synchronize`
483    /// right before the host dereferences the destination pinned buffer.
484    /// Cheaper than `synchronize(stream)` when the stream has work beyond
485    /// the event you care about: this only waits for the recorded point,
486    /// not for everything subsequently enqueued.
487    fn event_synchronize(&self, _event: u64) -> Result<()> {
488        Ok(())
489    }
490
491    /// Destroy an event.
492    fn destroy_event(&self, _event: u64) -> Result<()> {
493        Ok(())
494    }
495
496    /// Device-side alias of a page-locked host pointer from
497    /// [`Self::alloc_host_pinned`] (cuMemHostGetDevicePointer). On UMA parts
498    /// (GB10) this lets a KERNEL write results directly into host-visible
499    /// memory, eliminating the copy-engine op for tiny readbacks entirely.
500    /// Default: unsupported.
501    fn host_ptr_to_device(&self, _host: *mut u8) -> Result<DevicePtr> {
502        anyhow::bail!("host_ptr_to_device: not supported by this backend")
503    }
504
505    /// Allocate page-locked (pinned) host memory for efficient async H2D.
506    ///
507    /// On DGX Spark (UMA/LPDDR5X), pinned memory enables true async DMA
508    /// without internal CUDA staging overhead. Small metadata buffers
509    /// should be packed into a single pinned region and copied in one call.
510    ///
511    /// Returns a raw pointer to `bytes` of page-locked host memory.
512    /// Caller must call `free_host_pinned` to release.
513    ///
514    /// **The returned region is ZEROED.** Callers pack these buffers with
515    /// alignment padding between fields and then form a `&[u8]` over the whole
516    /// packed range for one `copy_h2d`; a slice over a never-written byte is UB
517    /// no matter what the device later does with it. Every implementation must
518    /// uphold this — `cuMemAllocHost_v2` and `newBufferWithLength` do not zero
519    /// on their own and their wrappers memset explicitly.
520    fn alloc_host_pinned(&self, bytes: usize) -> Result<*mut u8> {
521        // Default: regular heap allocation (mock backend, no pinning)
522        let layout = std::alloc::Layout::from_size_align(bytes, 64)
523            .map_err(|e| anyhow::anyhow!("invalid layout: {e}"))?;
524        let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
525        if ptr.is_null() {
526            anyhow::bail!("host alloc failed: {bytes} bytes");
527        }
528        Ok(ptr)
529    }
530
531    /// Free page-locked host memory previously allocated by `alloc_host_pinned`.
532    #[allow(clippy::not_unsafe_ptr_arg_deref)]
533    fn free_host_pinned(&self, ptr: *mut u8, bytes: usize) -> Result<()> {
534        if !ptr.is_null() {
535            let layout = std::alloc::Layout::from_size_align(bytes, 64)
536                .map_err(|e| anyhow::anyhow!("invalid layout: {e}"))?;
537            unsafe { std::alloc::dealloc(ptr, layout) };
538        }
539        Ok(())
540    }
541}
542
543impl fmt::Display for DevicePtr {
544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545        write!(f, "DevicePtr(0x{:x})", self.0)
546    }
547}
548
549#[cfg(any(test, feature = "test-utils"))]
550pub mod mock;
551
552#[cfg(test)]
553#[path = "gpu_tests.rs"]
554mod tests;