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;