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;