metrale_gpu_runtime/
gpu.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The `GpuBackend` trait: allocation, copies, kernel launches,
4//! graphs, events and memory queries for one device.
5//!
6//! Owner: gpu-runtime.
7//! Invariants: none beyond the types.
8
9use anyhow::Result;
10use std::fmt;
11use std::sync::atomic::Ordering;
12mod baseline;
13pub use baseline::{baseline_free_bytes, set_baseline_free_bytes};
14mod handles;
15pub use handles::{DevicePtr, GraphHandle, KernelArg, KernelHandle};
16
17pub use crate::gpu_args::pack_kernel_args;
18
19/// 2026-09-25: The device interface the model code calls. Implementations:
20/// `MetraleCudaBackend` (`cuda` feature), `MetalGpuBackend` (`metal` feature) and
21/// `MockGpuBackend` (tests and the `test-utils` feature).
22pub trait GpuBackend: Send + Sync {
23    /// 2026-09-25: Allocate `bytes` of device memory.
24    ///
25    /// `#[track_caller]` is on the declaration as well as the CUDA impl, so the
26    /// allocation ledger records the caller's location through `&dyn GpuBackend`.
27    #[track_caller]
28    fn alloc(&self, bytes: usize) -> Result<DevicePtr>;
29
30    /// 2026-09-25: Allocate managed memory: `cuMemAllocManaged` on CUDA, plain
31    /// `alloc` on Metal and the mock.
32    #[track_caller]
33    fn alloc_managed(&self, bytes: usize) -> Result<DevicePtr>;
34
35    /// 2026-09-25: Free device memory. A null pointer is a no-op.
36    fn free(&self, ptr: DevicePtr) -> Result<()>;
37
38    /// 2026-09-25: Free every allocation still on this backend's ledger and return
39    /// how many there were. The default returns 0 and frees nothing; only the
40    /// CUDA backend overrides it.
41    fn sweep_unreleased(&self) -> usize {
42        0
43    }
44
45    /// 2026-09-25: Live device bytes this backend has allocated and not freed:
46    /// `Some` on CUDA and the mock, `None` (the default) on Metal.
47    fn live_bytes(&self) -> Option<usize> {
48        None
49    }
50
51    /// 2026-10-01: Device memory the driver holds for this backend's small allocations beyond
52    /// their bytes (`cuda_backend::alloc_ledger::chunk_slack`): `Some` on CUDA, `None` (the
53    /// default) on a backend without an allocation ledger.
54    fn chunk_slack_bytes(&self) -> Option<usize> {
55        None
56    }
57
58    /// 2026-09-25: Live device memory by allocating call site; `None` (the
59    /// default) on every backend but CUDA.
60    fn alloc_report(&self, _top_n: usize, _min_mb: usize) -> Option<String> {
61        None
62    }
63
64    /// 2026-10-02: Every live allocation on the ledger: its address, bytes and allocating call
65    /// site (`file:line`); `None` (the default) on every backend but CUDA.
66    fn live_allocations(&self) -> Option<Vec<(DevicePtr, usize, String)>> {
67        None
68    }
69
70    /// 2026-09-25: Copy from host to device; returns when the copy is done.
71    fn copy_h2d(&self, src: &[u8], dst: DevicePtr) -> Result<()>;
72
73    /// 2026-09-25: Copy from device to host; returns when the copy is done.
74    fn copy_d2h(&self, src: DevicePtr, dst: &mut [u8]) -> Result<()>;
75
76    /// 2026-09-25: Blocking device-to-host copy ordered after the work already
77    /// on `stream`. `copy_d2h` orders only against the default stream; use this
78    /// to read what kernels on another stream just wrote. The CUDA backend
79    /// enqueues the copy on `stream` and waits; the default synchronises
80    /// `stream` and then calls `copy_d2h`.
81    fn copy_d2h_on_stream(&self, src: DevicePtr, dst: &mut [u8], stream: u64) -> Result<()> {
82        self.synchronize(stream)?;
83        self.copy_d2h(src, dst)
84    }
85
86    /// 2026-09-25: Copy device to device; returns when the copy is done.
87    fn copy_d2d(&self, src: DevicePtr, dst: DevicePtr, bytes: usize) -> Result<()>;
88
89    /// 2026-09-25: Launch a kernel with untyped parameter pointers. The Metal
90    /// backend refuses this; use `launch_typed`.
91    fn launch(
92        &self,
93        func: KernelHandle,
94        grid: [u32; 3],
95        block: [u32; 3],
96        shared_mem: u32,
97        stream: u64,
98        params: &mut [*mut std::ffi::c_void],
99    ) -> Result<()>;
100
101    /// 2026-09-25: Launch a kernel with typed arguments.
102    ///
103    /// The default packs them with `pack_kernel_args` and calls `launch`. The
104    /// Metal backend binds each `KernelArg::Buffer` with `setBuffer:offset:atIndex:`
105    /// and each `KernelArg::Bytes` with `setBytes:length:atIndex:`; the mock
106    /// records them.
107    fn launch_typed(
108        &self,
109        func: KernelHandle,
110        grid: [u32; 3],
111        block: [u32; 3],
112        shared_mem: u32,
113        stream: u64,
114        args: &[KernelArg<'_>],
115    ) -> Result<()> {
116        // 2026-09-25: With `launch_trace` on, record each launch so two steps can
117        // be diffed; a `Bytes` arg is recorded as its first 8 bytes.
118        if metrale_telemetry::launch_trace::on() {
119            let words = args
120                .iter()
121                .map(|a| match a {
122                    KernelArg::Buffer(p) => p.0,
123                    KernelArg::Bytes(b) => {
124                        let mut w = [0u8; 8];
125                        let n = b.len().min(8);
126                        w[..n].copy_from_slice(&b[..n]);
127                        u64::from_le_bytes(w)
128                    }
129                })
130                .collect();
131            metrale_telemetry::launch_trace::record(metrale_telemetry::launch_trace::Entry {
132                kind: "kernel",
133                func: func.0,
134                grid,
135                block,
136                smem: shared_mem,
137                args: words,
138            });
139        }
140        // 2026-09-25: `params` points into `storage`, which lives until
141        // `launch` returns.
142        let (storage, starts) = pack_kernel_args(args);
143        let mut params: Vec<*mut std::ffi::c_void> = starts
144            .iter()
145            .map(|&i| &storage[i] as *const u64 as *mut std::ffi::c_void)
146            .collect();
147        self.launch(func, grid, block, shared_mem, stream, &mut params)
148    }
149
150    /// 2026-09-25: Whether `stream` is inside an active CUDA-graph capture. Check
151    /// it before a sync or D2H on a stream that may be capturing: those invalidate
152    /// the capture (`CUDA_ERROR_STREAM_CAPTURE_INVALIDATED`, 901). The CUDA
153    /// backend reports a failed query as capturing, and `metrale_scale` builds
154    /// always report false; the default is false.
155    fn stream_is_capturing(&self, _stream: u64) -> bool {
156        false
157    }
158
159    /// 2026-09-25: Block until all work queued on `stream` has completed.
160    fn synchronize(&self, stream: u64) -> Result<()>;
161
162    /// 2026-09-25: Read every allocation's trailing guard band back and return how
163    /// many no longer hold the fill byte. `Ok(0)` when `METRALE_REDZONE` is unset,
164    /// and on every backend but CUDA.
165    fn scan_redzones(&self) -> Result<usize> {
166        Ok(0)
167    }
168
169    /// 2026-09-25: Refill the guard bands of allocations with index in `[lo, hi)`
170    /// with `0xEE` and the rest with `0x00`; nothing is allocated, moved or
171    /// resized. A no-op when `METRALE_REDZONE` is unset, and on every backend but
172    /// CUDA.
173    fn poison_redzones(&self, _lo: usize, _hi: usize) -> Result<()> {
174        Ok(())
175    }
176
177    /// 2026-09-25: The default stream handle.
178    fn default_stream(&self) -> u64;
179
180    /// 2026-09-25: Look up a kernel function by module and function name.
181    ///
182    /// `#[track_caller]` is on the declaration so the CUDA backend's kernel audit
183    /// records the caller's location through `&dyn GpuBackend`.
184    #[track_caller]
185    fn kernel(&self, module: &str, func_name: &str) -> Result<KernelHandle>;
186
187    /// 2026-09-29: The output-column (N) tile width `kernel` publishes as the
188    /// `extern "C" __device__ unsigned int <entry>_n_tile` of its module, read when the
189    /// handle was resolved. A launch whose grid covers N in tiles must size it from this,
190    /// never from a width assumed at the call site: one entry name can carry different
191    /// tiles in different target trees. An error for a kernel that publishes none, a
192    /// zero width included.
193    fn kernel_n_tile(&self, kernel: KernelHandle) -> Result<u32> {
194        match self.op_cache().n_tile(kernel) {
195            Some(t) if t > 0 => Ok(t),
196            Some(_) => anyhow::bail!("kernel {:#x} publishes an N tile of 0", kernel.0),
197            None => anyhow::bail!(
198                "kernel {:#x} publishes no `<entry>_n_tile`; its grid cannot be sized",
199                kernel.0
200            ),
201        }
202    }
203
204    /// 2026-09-29: Whether `kernel` casts its BF16 A operand to E4M3 with no scale, as its
205    /// module declares with `extern "C" __device__ unsigned int <entry>_a_e4m3`. Values
206    /// past 448 saturate there; launchers check the range in debug builds
207    /// (`ops::check_e4m3_activation_range` in model-layers).
208    fn kernel_casts_a_to_e4m3(&self, kernel: KernelHandle) -> bool {
209        self.op_cache().a_e4m3(kernel)
210    }
211
212    /// 2026-09-25: Whether `module` is loaded in this backend. Ask before looking
213    /// up a kernel that only some targets carry: the CUDA backend's kernel audit
214    /// records every failed lookup.
215    fn has_module(&self, module: &str) -> bool;
216
217    /// 2026-09-25: This backend's memoized kernel handles and scratch
218    /// allocations. It has no default, so every backend owns its own cache.
219    fn op_cache(&self) -> &crate::op_cache::OpCache;
220
221    /// 2026-09-25: Whether `KernelLaunch::launch` synchronises after every
222    /// launch, so an asynchronous fault is reported at the kernel that caused it.
223    /// The CUDA backend reads `METRALE_DEBUG_SYNC_KERNELS=1` once, when it is
224    /// built; the default is false.
225    fn debug_sync_kernels(&self) -> bool {
226        false
227    }
228
229    /// 2026-09-25: This backend's kernel module registry, for callers that need
230    /// more than a kernel handle. `None` (the default) on every backend but CUDA.
231    #[cfg(feature = "cuda")]
232    fn kernel_registry(&self) -> Option<std::sync::Arc<crate::registry::MetraleRegistry>> {
233        None
234    }
235
236    /// 2026-09-25: Host-to-device copy on `stream`; `src` may be dropped or
237    /// overwritten as soon as this returns.
238    ///
239    /// The CUDA backend synchronises `stream` after the enqueue when `src` is
240    /// page-locked memory registered in [`crate::pinned_hosts`]. The default is
241    /// the blocking `copy_h2d`. Use [`GpuBackend::copy_h2d_async_retained`] when
242    /// the source outlives the next synchronisation.
243    fn copy_h2d_async(&self, src: &[u8], dst: DevicePtr, _stream: u64) -> Result<()> {
244        self.copy_h2d(src, dst)
245    }
246
247    /// 2026-09-25: Host-to-device copy on `stream` for a source the caller keeps
248    /// alive: `src` must stay valid and unchanged until the next synchronisation
249    /// point on `stream`. The CUDA backend adds no synchronisation; the default
250    /// is `copy_h2d_async`.
251    fn copy_h2d_async_retained(&self, src: &[u8], dst: DevicePtr, stream: u64) -> Result<()> {
252        self.copy_h2d_async(src, dst, stream)
253    }
254
255    /// 2026-09-25: Device-to-host copy enqueued on `stream` without waiting.
256    /// `dst` must stay valid, and must not be read or reused, until the next
257    /// synchronisation point on `stream`. On CUDA, `copy_d2h` and
258    /// `copy_d2h_on_stream` each wait inside the call, so this is the D2H to use
259    /// for many chunks followed by one `synchronize`. The default, used by Metal,
260    /// is the blocking `copy_d2h`; the mock copies at once and counts the call.
261    fn copy_d2h_async(&self, src: DevicePtr, dst: &mut [u8], _stream: u64) -> Result<()> {
262        self.copy_d2h(src, dst)
263    }
264
265    /// 2026-09-25: Device-to-device copy enqueued on `stream`. The default is
266    /// the blocking `copy_d2d`.
267    fn copy_d2d_async(
268        &self,
269        src: DevicePtr,
270        dst: DevicePtr,
271        bytes: usize,
272        _stream: u64,
273    ) -> Result<()> {
274        self.copy_d2d(src, dst, bytes)
275    }
276
277    /// 2026-09-25: Pitched device-to-device copy: `height` rows of `width_bytes`,
278    /// source rows `src_pitch` apart and destination rows `dst_pitch` apart. The
279    /// default loops `copy_d2d_async` per row; the CUDA backend issues one
280    /// `cudaMemcpy2DAsync`.
281    #[allow(clippy::too_many_arguments)]
282    fn copy_d2d_2d_async(
283        &self,
284        src: DevicePtr,
285        src_pitch: usize,
286        dst: DevicePtr,
287        dst_pitch: usize,
288        width_bytes: usize,
289        height: usize,
290        stream: u64,
291    ) -> Result<()> {
292        for r in 0..height {
293            self.copy_d2d_async(
294                src.offset(r * src_pitch),
295                dst.offset(r * dst_pitch),
296                width_bytes,
297                stream,
298            )?;
299        }
300        Ok(())
301    }
302
303    /// 2026-09-25: Begin capturing the work enqueued on `stream` into a graph
304    /// (`cuStreamBeginCapture` on CUDA); captured work is recorded, not run. The
305    /// default does nothing.
306    fn begin_capture(&self, _stream: u64) -> Result<()> {
307        Ok(())
308    }
309
310    /// 2026-09-25: End the capture and return the instantiated graph. The
311    /// default returns `GraphHandle(0)`.
312    fn end_capture(&self, _stream: u64) -> Result<GraphHandle> {
313        Ok(GraphHandle(0))
314    }
315
316    /// 2026-09-25: Replay a captured graph on `stream`.
317    fn launch_graph(&self, _graph: GraphHandle, _stream: u64) -> Result<()> {
318        Ok(())
319    }
320
321    /// 2026-09-25: Destroy an instantiated graph.
322    fn destroy_graph(&self, _graph: GraphHandle) -> Result<()> {
323        Ok(())
324    }
325
326    /// 2026-09-25: Best effort: end any capture active on `stream` and discard
327    /// the partial graph, so the stream runs work again. Call it on an error path
328    /// that left a `begin_capture`/`end_capture` region early. The default does
329    /// nothing.
330    fn abort_capture_if_active(&self, _stream: u64) {}
331
332    /// 2026-09-25: Set device memory to a byte value; returns when done.
333    fn memset(&self, ptr: DevicePtr, value: u8, bytes: usize) -> Result<()>;
334
335    /// 2026-09-25: Set device memory to a byte value, enqueued on `stream` on
336    /// CUDA. Metal and the mock write at once.
337    fn memset_async(&self, ptr: DevicePtr, value: u8, bytes: usize, stream: u64) -> Result<()>;
338
339    /// 2026-09-25: Total device memory in bytes.
340    fn total_memory(&self) -> Result<usize>;
341
342    /// 2026-09-25: Free device memory in bytes. On an integrated GPU the CUDA
343    /// backend reports the larger of the driver's figure and host `MemAvailable`.
344    fn free_memory(&self) -> Result<usize>;
345
346    /// 2026-09-25: Free device memory as the driver reports it
347    /// (`cuMemGetInfo` on CUDA), never substituted by host memory. The default
348    /// is `free_memory`.
349    fn device_free_memory(&self) -> Result<usize> {
350        self.free_memory()
351    }
352
353    /// 2026-09-25: Number of live device allocations on this backend: a count,
354    /// not bytes. The default, 0, means not tracked (Metal).
355    fn live_alloc_count(&self) -> usize {
356        0
357    }
358
359    /// 2026-09-25: Number of streaming multiprocessors on the device. The CUDA
360    /// backend asks the driver, the mock returns 48, and Metal returns an error.
361    /// Resolve it once at construction, not per launch.
362    fn sm_count(&self) -> Result<u32>;
363
364    /// 2026-09-25: Create a stream. The default returns 0, the default stream.
365    fn create_stream(&self) -> Result<u64> {
366        Ok(0)
367    }
368
369    /// 2026-09-25: Make the backend's CUDA context current on this thread. Call it
370    /// on any thread other than the one that built the backend before using the
371    /// GPU. The default does nothing.
372    fn bind_to_thread(&self) -> Result<()> {
373        Ok(())
374    }
375
376    /// 2026-09-25: Create an event for ordering work across streams. The
377    /// default returns 0.
378    fn create_event(&self) -> Result<u64> {
379        Ok(0)
380    }
381
382    /// 2026-09-25: Record `event` at the current point of `stream`'s work.
383    fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
384        Ok(())
385    }
386
387    /// 2026-09-25: Make `stream` wait for `event` on the device; the host does
388    /// not block.
389    fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
390        Ok(())
391    }
392
393    /// 2026-09-25: Block the host until the work recorded before `event` has
394    /// completed. Unlike `synchronize(stream)`, it does not wait for work
395    /// enqueued after the event.
396    fn event_synchronize(&self, _event: u64) -> Result<()> {
397        Ok(())
398    }
399
400    /// 2026-09-25: Whether the work recorded before `event` has completed; never
401    /// blocks. The default returns `true`.
402    fn event_query(&self, _event: u64) -> Result<bool> {
403        Ok(true)
404    }
405
406    /// 2026-09-25: Destroy an event.
407    fn destroy_event(&self, _event: u64) -> Result<()> {
408        Ok(())
409    }
410
411    /// 2026-09-25: The device address of a page-locked host pointer from
412    /// [`Self::alloc_host_pinned`] (`cuMemHostGetDevicePointer_v2` on CUDA), so a
413    /// kernel can write into host-visible memory. The default returns an error.
414    fn host_ptr_to_device(&self, _host: *mut u8) -> Result<DevicePtr> {
415        anyhow::bail!("host_ptr_to_device: not supported by this backend")
416    }
417
418    /// 2026-09-25: Allocate `bytes` of host memory for H2D staging: page-locked on
419    /// CUDA (`cuMemAllocHost_v2`), a shared `MTLBuffer` on Metal, and a 64-byte
420    /// aligned heap block by default. Release it with `free_host_pinned`.
421    ///
422    /// The region is zeroed on every backend, so a caller may form a `&[u8]` over
423    /// all of it, padding included.
424    fn alloc_host_pinned(&self, bytes: usize) -> Result<*mut u8> {
425        crate::host_heap::alloc_zeroed(bytes)
426    }
427
428    /// 2026-09-25: Free memory from `alloc_host_pinned`; `bytes` must be the
429    /// allocated size.
430    fn free_host_pinned(&self, ptr: *mut u8, bytes: usize) -> Result<()> {
431        crate::host_heap::free(ptr, bytes)
432    }
433
434    /// 2026-09-25: Device memory for a weight arena, freed with `free_arena`. The
435    /// CUDA backend allocates it off the allocation ledger, whose `live_bytes`
436    /// `factory::build` counts as this process's own memory when it sizes the KV
437    /// cache. The default is the ledgered `alloc`.
438    fn alloc_arena(&self, bytes: usize) -> Result<DevicePtr> {
439        self.alloc(bytes)
440    }
441    fn free_arena(&self, ptr: DevicePtr) -> Result<()> {
442        self.free(ptr)
443    }
444}
445
446#[cfg(any(test, feature = "test-utils"))]
447pub mod mock;
448
449#[cfg(test)]
450#[path = "gpu_tests.rs"]
451mod tests;