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    /// This backend's memoized kernel handles and scratch allocations.
263    ///
264    /// Required rather than defaulted: an op that memoizes a `KernelHandle`
265    /// or a `DevicePtr` anywhere else is caching something that belongs to
266    /// this backend's model, and a default would let a new backend forget.
267    fn op_cache(&self) -> &crate::op_cache::OpCache;
268
269    /// Synchronise the stream after every kernel launch, so an asynchronous
270    /// illegal-address fault is reported at the kernel that caused it rather
271    /// than at a later sync. Resolved once when the backend is built; read on
272    /// the launch path, which is why it is not a per-launch `getenv`.
273    fn debug_sync_kernels(&self) -> bool {
274        false
275    }
276
277    /// This backend's model-scoped kernel modules, for the few callers that
278    /// need the registry itself rather than a kernel handle — resolving a
279    /// `__device__` symbol, for instance. `None` on backends that have no such
280    /// concept, which is why it is an accessor rather than a downcast.
281    #[cfg(feature = "cuda")]
282    fn kernel_registry(&self) -> Option<std::sync::Arc<atlas_core::registry::AtlasRegistry>> {
283        None
284    }
285
286    /// Async host-to-device copy: **`src` may be dropped or overwritten the
287    /// moment this returns.**
288    ///
289    /// That is what the ~90 call sites in `spark-model` rely on — nearly all
290    /// hand over a stack array or local `Vec` that dies at the end of the
291    /// statement — and it used to hold only by accident. See
292    /// [`crate::pinned_hosts`] for why, and for how the CUDA backend now MAKES
293    /// the promise true (page-locked source ⇒ it buys the ordering that the
294    /// pageable path gets from the driver for free) instead of inheriting it.
295    ///
296    /// Use [`GpuBackend::copy_h2d_async_retained`] when the source outlives the
297    /// next synchronisation and the extra ordering is not wanted.
298    fn copy_h2d_async(&self, src: &[u8], dst: DevicePtr, _stream: u64) -> Result<()> {
299        self.copy_h2d(src, dst)
300    }
301
302    /// Async host-to-device copy for a source the CALLER keeps alive.
303    ///
304    /// `src` must remain valid, and must not be rewritten, until the next
305    /// synchronisation point on `stream`. In exchange it never inserts an
306    /// implicit sync — what makes a batched scatter out of one pinned staging
307    /// blob (N enqueues + one `synchronize`) worth doing; see
308    /// [`GpuBackend::copy_d2h_async`] for the measured shape. The name marks,
309    /// greppably, every site making a promise the compiler cannot check.
310    fn copy_h2d_async_retained(&self, src: &[u8], dst: DevicePtr, stream: u64) -> Result<()> {
311        // Default: the transient path. Strictly stronger ordering than promised,
312        // so it is always correct — just not always the fastest.
313        self.copy_h2d_async(src, dst, stream)
314    }
315
316    /// Async device-to-host copy (no stream synchronization).
317    ///
318    /// The counterpart of [`GpuBackend::copy_h2d_async`], and the ONLY D2H
319    /// primitive usable for a batched gather: `copy_d2h` and
320    /// `copy_d2h_on_stream` both `cuStreamSynchronize` INSIDE the call, so an
321    /// N-chunk gather pays N full stream drains. Measured cost of that shape:
322    /// the SSM snapshot spill moved 66,846,720 B as 60 blocking `copy_d2h`
323    /// calls in ~400 ms (~165 MB/s), while the mirror-image scatter
324    /// (`copy_h2d_async` ×60 + ONE `synchronize`) moved the same bytes through
325    /// the same host buffer in ~28 ms.
326    ///
327    /// **Lifetime requirement** (same as `copy_h2d_async`): the destination
328    /// buffer must remain valid, and must not be read or re-used, until the
329    /// next synchronization point on this stream.
330    fn copy_d2h_async(&self, src: DevicePtr, dst: &mut [u8], _stream: u64) -> Result<()> {
331        // Mock/metal fall back to the blocking copy: correct (a stricter
332        // ordering than promised), just not batched.
333        self.copy_d2h(src, dst)
334    }
335
336    /// Async device-to-device copy (no stream synchronization).
337    fn copy_d2d_async(
338        &self,
339        src: DevicePtr,
340        dst: DevicePtr,
341        bytes: usize,
342        _stream: u64,
343    ) -> Result<()> {
344        self.copy_d2d(src, dst, bytes)
345    }
346
347    /// Strided device-to-device 2D (pitched) copy: `height` rows of
348    /// `width_bytes`, source rows spaced by `src_pitch`, dest rows by
349    /// `dst_pitch`. Default = per-row `copy_d2d_async` loop; the CUDA backend
350    /// overrides with ONE `cudaMemcpy2DAsync` (replaces the per-token Z-copy
351    /// loop = up to num_tokens×num_ssm_layers launches/forward).
352    #[allow(clippy::too_many_arguments)]
353    fn copy_d2d_2d_async(
354        &self,
355        src: DevicePtr,
356        src_pitch: usize,
357        dst: DevicePtr,
358        dst_pitch: usize,
359        width_bytes: usize,
360        height: usize,
361        stream: u64,
362    ) -> Result<()> {
363        for r in 0..height {
364            self.copy_d2d_async(
365                src.offset(r * src_pitch),
366                dst.offset(r * dst_pitch),
367                width_bytes,
368                stream,
369            )?;
370        }
371        Ok(())
372    }
373
374    /// Begin capturing CUDA operations on `stream` into a graph.
375    ///
376    /// All kernel launches and async copies on this stream between
377    /// `begin_capture` and `end_capture` are recorded (not executed).
378    /// The stream must NOT be the legacy default stream (handle 0).
379    fn begin_capture(&self, _stream: u64) -> Result<()> {
380        Ok(())
381    }
382
383    /// End capture and return an instantiated graph ready for replay.
384    fn end_capture(&self, _stream: u64) -> Result<GraphHandle> {
385        Ok(GraphHandle(0))
386    }
387
388    /// Replay all operations captured in the graph on `stream`.
389    fn launch_graph(&self, _graph: GraphHandle, _stream: u64) -> Result<()> {
390        Ok(())
391    }
392
393    /// Destroy an instantiated graph, freeing resources.
394    fn destroy_graph(&self, _graph: GraphHandle) -> Result<()> {
395        Ok(())
396    }
397
398    /// Best-effort: if `stream` is mid graph-capture, end that capture so the
399    /// stream returns to normal mode (discarding any partial graph). Call this
400    /// on an error path that unwound out of a `begin_capture`/`end_capture`
401    /// region (e.g. a fold refuse bailed mid-capture) — otherwise the stream is
402    /// left recording and every subsequent op fails with
403    /// STREAM_CAPTURE_UNSUPPORTED, bricking the server. No-op if not capturing.
404    fn abort_capture_if_active(&self, _stream: u64) {}
405
406    /// Set device memory to a byte value (synchronous — waits for completion).
407    fn memset(&self, ptr: DevicePtr, value: u8, bytes: usize) -> Result<()>;
408
409    /// Set device memory to a byte value on the given stream (async — does not wait).
410    fn memset_async(&self, ptr: DevicePtr, value: u8, bytes: usize, stream: u64) -> Result<()>;
411
412    /// Total device memory in bytes.
413    fn total_memory(&self) -> Result<usize>;
414
415    /// Free device memory in bytes.
416    fn free_memory(&self) -> Result<usize>;
417
418    /// Free device memory as the DRIVER reports it, with no host leg.
419    ///
420    /// `free_memory` is `max(cuMemGetInfo, MemAvailable)` (ANOMALIES A73), so it
421    /// cannot separate driver-committed device memory from reclaimable host page
422    /// cache — which is exactly the separation a per-request leak measurement
423    /// needs. Default falls back to `free_memory` for backends that have no
424    /// distinct driver leg.
425    fn device_free_memory(&self) -> Result<usize> {
426        self.free_memory()
427    }
428
429    /// Live (allocated, not yet freed) device allocations on this backend.
430    ///
431    /// A COUNT, not bytes: it answers "did this request hand back every buffer it
432    /// took?" without an allocator-size ledger. Default 0 = not tracked.
433    fn live_alloc_count(&self) -> usize {
434        0
435    }
436
437    /// Number of streaming multiprocessors (CUDA SMs / HIP CUs) on the device.
438    ///
439    /// Queried from the driver, never assumed: dispatch rules that ask "does
440    /// this grid still fill the machine?" are wrong on every part whose SM
441    /// count differs from the one they were tuned on. Callers must resolve it
442    /// ONCE at construction and keep the value, not call it per launch.
443    fn sm_count(&self) -> Result<u32>;
444
445    /// Create a new CUDA stream (for overlapping work).
446    fn create_stream(&self) -> Result<u64> {
447        Ok(0) // Default: return legacy stream
448    }
449
450    /// Bind the CUDA context to the current thread.
451    ///
452    /// Must be called on any thread that uses GPU operations (alloc, launch, etc.)
453    /// if it's different from the thread that created the backend.
454    fn bind_to_thread(&self) -> Result<()> {
455        Ok(()) // No-op for mock backend
456    }
457
458    /// Create a CUDA event (for inter-stream synchronization).
459    fn create_event(&self) -> Result<u64> {
460        Ok(0)
461    }
462
463    /// Record an event on a stream (marks a point in the stream's work).
464    fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
465        Ok(())
466    }
467
468    /// Make a stream wait for an event (GPU-side sync, CPU does not block).
469    fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
470        Ok(())
471    }
472
473    /// Block the calling host thread until all work already
474    /// recorded against the event — e.g. an async D2H copy issued on the
475    /// graph stream followed by `record_event`, then `event_synchronize`
476    /// right before the host dereferences the destination pinned buffer.
477    /// Cheaper than `synchronize(stream)` when the stream has work beyond
478    /// the event you care about: this only waits for the recorded point,
479    /// not for everything subsequently enqueued.
480    fn event_synchronize(&self, _event: u64) -> Result<()> {
481        Ok(())
482    }
483
484    /// Destroy an event.
485    fn destroy_event(&self, _event: u64) -> Result<()> {
486        Ok(())
487    }
488
489    /// Device-side alias of a page-locked host pointer from
490    /// [`Self::alloc_host_pinned`] (cuMemHostGetDevicePointer). On UMA parts
491    /// (GB10) this lets a KERNEL write results directly into host-visible
492    /// memory, eliminating the copy-engine op for tiny readbacks entirely.
493    /// Default: unsupported.
494    fn host_ptr_to_device(&self, _host: *mut u8) -> Result<DevicePtr> {
495        anyhow::bail!("host_ptr_to_device: not supported by this backend")
496    }
497
498    /// Allocate page-locked (pinned) host memory for efficient async H2D.
499    ///
500    /// On DGX Spark (UMA/LPDDR5X), pinned memory enables true async DMA
501    /// without internal CUDA staging overhead. Small metadata buffers
502    /// should be packed into a single pinned region and copied in one call.
503    ///
504    /// Returns a raw pointer to `bytes` of page-locked host memory.
505    /// Caller must call `free_host_pinned` to release.
506    ///
507    /// **The returned region is ZEROED.** Callers pack these buffers with
508    /// alignment padding between fields and then form a `&[u8]` over the whole
509    /// packed range for one `copy_h2d`; a slice over a never-written byte is UB
510    /// no matter what the device later does with it. Every implementation must
511    /// uphold this — `cuMemAllocHost_v2` and `newBufferWithLength` do not zero
512    /// on their own and their wrappers memset explicitly.
513    fn alloc_host_pinned(&self, bytes: usize) -> Result<*mut u8> {
514        // Default: regular heap allocation (mock backend, no pinning)
515        let layout = std::alloc::Layout::from_size_align(bytes, 64)
516            .map_err(|e| anyhow::anyhow!("invalid layout: {e}"))?;
517        let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
518        if ptr.is_null() {
519            anyhow::bail!("host alloc failed: {bytes} bytes");
520        }
521        Ok(ptr)
522    }
523
524    /// Free page-locked host memory previously allocated by `alloc_host_pinned`.
525    #[allow(clippy::not_unsafe_ptr_arg_deref)]
526    fn free_host_pinned(&self, ptr: *mut u8, bytes: usize) -> Result<()> {
527        if !ptr.is_null() {
528            let layout = std::alloc::Layout::from_size_align(bytes, 64)
529                .map_err(|e| anyhow::anyhow!("invalid layout: {e}"))?;
530            unsafe { std::alloc::dealloc(ptr, layout) };
531        }
532        Ok(())
533    }
534}
535
536impl fmt::Display for DevicePtr {
537    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
538        write!(f, "DevicePtr(0x{:x})", self.0)
539    }
540}
541
542#[cfg(any(test, feature = "test-utils"))]
543pub mod mock;
544
545#[cfg(test)]
546#[path = "gpu_tests.rs"]
547mod tests;