Skip to main content

oxicuda_backend/
capabilities.rs

1//! Backend capability discovery and device enumeration.
2//!
3//! These types let consumers query, at runtime, what a backend can do
4//! (mixed-precision support, Tensor Cores, unified memory, …) and what
5//! devices it exposes — without coupling to any concrete GPU API.
6//!
7//! Everything here is **host-side data**: a backend fills the structs in
8//! from its own driver queries, and consumers (autotuners, schedulers)
9//! read them to pick algorithms and tile shapes. No device execution is
10//! involved, so the logic is fully testable on a machine with no GPU.
11
12use std::fmt;
13
14/// Per-backend capability report.
15///
16/// A concrete backend populates this from its driver during
17/// [`ComputeBackend::capabilities`](crate::ComputeBackend::capabilities).
18/// Consumers use it to decide whether a mixed-precision or Tensor-Core
19/// path is available before issuing work.
20///
21/// The [`Default`] value is the **conservative CPU profile**: no
22/// accelerated precisions, no Tensor Cores, no peer access, but generous
23/// thread/shared-memory limits that any host can honour.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Capabilities {
26    /// Native FP16 (half precision) compute is available.
27    pub supports_fp16: bool,
28    /// Native BF16 (bfloat16) compute is available.
29    pub supports_bf16: bool,
30    /// FP8 (E4M3/E5M2) compute is available.
31    pub supports_fp8: bool,
32    /// Dedicated matrix/Tensor-Core (WMMA / `mma.sync`) units are present.
33    pub tensor_cores: bool,
34    /// Peer-to-peer access between devices of this backend is possible.
35    pub peer_access: bool,
36    /// Unified / managed memory (host and device share an address space).
37    pub unified_memory: bool,
38    /// Thread-block clusters (Hopper `sm_90`+) are supported.
39    pub cluster_launch: bool,
40    /// Asynchronous global→shared copy (`cp.async`, Ampere `sm_80`+).
41    pub async_copy: bool,
42    /// Maximum threads per block / workgroup.
43    pub max_threads_per_block: u32,
44    /// Maximum shared / local memory per block in bytes.
45    pub max_shared_mem_per_block: u32,
46    /// Warp / wavefront / subgroup width in lanes.
47    pub warp_size: u32,
48}
49
50impl Capabilities {
51    /// Conservative capability profile for a pure-CPU reference backend.
52    ///
53    /// No accelerated precisions or matrix units, but limits large enough
54    /// that the consumer never rejects a launch for being "too big".
55    #[must_use]
56    pub const fn cpu() -> Self {
57        Self {
58            supports_fp16: false,
59            supports_bf16: false,
60            supports_fp8: false,
61            tensor_cores: false,
62            peer_access: false,
63            unified_memory: true, // host memory is trivially "unified"
64            cluster_launch: false,
65            async_copy: false,
66            max_threads_per_block: 1024,
67            max_shared_mem_per_block: 48 * 1024,
68            warp_size: 1,
69        }
70    }
71
72    /// Returns `true` if **any** reduced-precision compute path is available.
73    #[must_use]
74    pub const fn supports_mixed_precision(&self) -> bool {
75        self.supports_fp16 || self.supports_bf16 || self.supports_fp8
76    }
77
78    /// Returns `true` if a GEMM of the given shape can plausibly use a
79    /// Tensor-Core path: matrix units present and all three dimensions are
80    /// multiples of the smallest standard WMMA tile (16).
81    #[must_use]
82    pub const fn can_use_tensor_cores(&self, m: usize, n: usize, k: usize) -> bool {
83        self.tensor_cores && m % 16 == 0 && n % 16 == 0 && k % 16 == 0
84    }
85}
86
87impl Default for Capabilities {
88    fn default() -> Self {
89        Self::cpu()
90    }
91}
92
93impl fmt::Display for Capabilities {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(
96            f,
97            "fp16={} bf16={} fp8={} tensor_cores={} peer={} unified={} cluster={} async_copy={} \
98             max_threads/block={} max_smem/block={}B warp={}",
99            self.supports_fp16,
100            self.supports_bf16,
101            self.supports_fp8,
102            self.tensor_cores,
103            self.peer_access,
104            self.unified_memory,
105            self.cluster_launch,
106            self.async_copy,
107            self.max_threads_per_block,
108            self.max_shared_mem_per_block,
109            self.warp_size,
110        )
111    }
112}
113
114/// A GEMM tile-shape hint `(tile_m, tile_n, tile_k)` returned by
115/// [`ComputeBackend::recommended_tile_for`](crate::ComputeBackend::recommended_tile_for).
116///
117/// Autotuners use this as a starting point before searching nearby shapes.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct TileShape {
120    /// Tile rows (M dimension).
121    pub tile_m: usize,
122    /// Tile columns (N dimension).
123    pub tile_n: usize,
124    /// Tile depth (K dimension / inner accumulation).
125    pub tile_k: usize,
126}
127
128impl TileShape {
129    /// Construct a tile shape.
130    #[must_use]
131    pub const fn new(tile_m: usize, tile_n: usize, tile_k: usize) -> Self {
132        Self {
133            tile_m,
134            tile_n,
135            tile_k,
136        }
137    }
138
139    /// Number of output elements in one tile (`tile_m * tile_n`).
140    #[must_use]
141    pub const fn output_elems(&self) -> usize {
142        self.tile_m * self.tile_n
143    }
144}
145
146impl fmt::Display for TileShape {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(f, "{}x{}x{}", self.tile_m, self.tile_n, self.tile_k)
149    }
150}
151
152/// Default tile heuristic shared by the trait's
153/// [`recommended_tile_for`](crate::ComputeBackend::recommended_tile_for):
154/// small problems get a small tile (less wasted compute on the fringe),
155/// large problems get the canonical 128×128×8 tile, mid-size gets 64×64×16.
156///
157/// `caps` lets the heuristic snap to a Tensor-Core-friendly tile when the
158/// backend reports matrix units.
159#[must_use]
160pub fn default_tile_for(m: usize, n: usize, k: usize, caps: &Capabilities) -> TileShape {
161    let smallest = m.min(n).min(k);
162    if caps.tensor_cores && m % 16 == 0 && n % 16 == 0 && k % 16 == 0 {
163        // WMMA-aligned: prefer wide tiles that are multiples of 16.
164        return if smallest >= 512 {
165            TileShape::new(128, 128, 32)
166        } else {
167            TileShape::new(64, 64, 16)
168        };
169    }
170    if smallest <= 64 {
171        TileShape::new(16, 16, 16)
172    } else if smallest <= 512 {
173        TileShape::new(64, 64, 16)
174    } else {
175        TileShape::new(128, 128, 8)
176    }
177}
178
179/// Memory-type classification, abstracting each backend's notion of where
180/// a buffer physically lives. Used by [`DeviceInfo`] and cross-backend
181/// transfer planning.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
183pub enum MemoryKind {
184    /// Dedicated device (VRAM) memory, not host-addressable.
185    Device,
186    /// Pinned/page-locked host memory.
187    HostPinned,
188    /// Unified / managed memory addressable from host and device.
189    Unified,
190    /// Plain pageable host memory (the CPU reference backend).
191    Host,
192}
193
194impl fmt::Display for MemoryKind {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::Device => write!(f, "device"),
198            Self::HostPinned => write!(f, "host-pinned"),
199            Self::Unified => write!(f, "unified"),
200            Self::Host => write!(f, "host"),
201        }
202    }
203}
204
205/// A single device exposed by a backend, in a backend-agnostic shape.
206///
207/// `compute_capability` is interpreted per backend: `(major, minor)` for
208/// CUDA, the GCN/RDNA arch level for ROCm, the shader-model for Vulkan/
209/// Metal/WebGPU, etc. A value of `(0, 0)` means "not applicable" (e.g. the
210/// CPU reference device).
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct DeviceInfo {
213    /// Backend-local device ordinal (0-based).
214    pub ordinal: usize,
215    /// Human-readable device name.
216    pub name: String,
217    /// `(major, minor)` capability/shader-model, or `(0, 0)` if N/A.
218    pub compute_capability: (u32, u32),
219    /// Total memory available on the device in bytes.
220    pub total_memory_bytes: u64,
221    /// Predominant memory kind for buffers on this device.
222    pub memory_kind: MemoryKind,
223    /// Per-device capability report.
224    pub capabilities: Capabilities,
225}
226
227impl DeviceInfo {
228    /// Build the canonical descriptor for the host CPU reference device.
229    #[must_use]
230    pub fn cpu_reference(total_memory_bytes: u64) -> Self {
231        Self {
232            ordinal: 0,
233            name: "CPU (reference)".to_string(),
234            compute_capability: (0, 0),
235            total_memory_bytes,
236            memory_kind: MemoryKind::Host,
237            capabilities: Capabilities::cpu(),
238        }
239    }
240}
241
242impl fmt::Display for DeviceInfo {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        let mem_mb = self.total_memory_bytes / (1024 * 1024);
245        let (major, minor) = self.compute_capability;
246        write!(
247            f,
248            "Device[{}] {} (cc {major}.{minor}, {mem_mb} MB, {})",
249            self.ordinal, self.name, self.memory_kind,
250        )
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn cpu_capabilities_are_conservative() {
260        let caps = Capabilities::cpu();
261        assert!(!caps.supports_fp16);
262        assert!(!caps.supports_bf16);
263        assert!(!caps.supports_fp8);
264        assert!(!caps.tensor_cores);
265        assert!(!caps.peer_access);
266        assert!(caps.unified_memory);
267        assert!(!caps.supports_mixed_precision());
268        assert_eq!(Capabilities::default(), Capabilities::cpu());
269    }
270
271    #[test]
272    fn mixed_precision_detection() {
273        let mut caps = Capabilities::cpu();
274        assert!(!caps.supports_mixed_precision());
275        caps.supports_bf16 = true;
276        assert!(caps.supports_mixed_precision());
277    }
278
279    #[test]
280    fn tensor_core_eligibility_requires_alignment_and_units() {
281        let mut caps = Capabilities::cpu();
282        // No units: never eligible regardless of shape.
283        assert!(!caps.can_use_tensor_cores(256, 256, 256));
284        caps.tensor_cores = true;
285        // Aligned shape: eligible.
286        assert!(caps.can_use_tensor_cores(256, 256, 256));
287        // Misaligned K: not eligible.
288        assert!(!caps.can_use_tensor_cores(256, 256, 255));
289    }
290
291    #[test]
292    fn tile_heuristic_scales_with_problem_size() {
293        let caps = Capabilities::cpu();
294        assert_eq!(
295            default_tile_for(32, 32, 32, &caps),
296            TileShape::new(16, 16, 16)
297        );
298        assert_eq!(
299            default_tile_for(256, 256, 256, &caps),
300            TileShape::new(64, 64, 16)
301        );
302        assert_eq!(
303            default_tile_for(4096, 4096, 4096, &caps),
304            TileShape::new(128, 128, 8)
305        );
306    }
307
308    #[test]
309    fn tile_heuristic_snaps_to_wmma_when_tensor_cores() {
310        let mut caps = Capabilities::cpu();
311        caps.tensor_cores = true;
312        // Large aligned problem → wide WMMA tile.
313        let t = default_tile_for(1024, 1024, 1024, &caps);
314        assert_eq!(t, TileShape::new(128, 128, 32));
315        assert_eq!(t.tile_m % 16, 0);
316        assert_eq!(t.tile_n % 16, 0);
317        assert_eq!(t.tile_k % 16, 0);
318        // Small aligned problem → smaller aligned tile.
319        assert_eq!(
320            default_tile_for(64, 64, 64, &caps),
321            TileShape::new(64, 64, 16)
322        );
323    }
324
325    #[test]
326    fn tile_output_elems() {
327        assert_eq!(TileShape::new(128, 64, 8).output_elems(), 128 * 64);
328    }
329
330    #[test]
331    fn memory_kind_display() {
332        assert_eq!(MemoryKind::Device.to_string(), "device");
333        assert_eq!(MemoryKind::HostPinned.to_string(), "host-pinned");
334        assert_eq!(MemoryKind::Unified.to_string(), "unified");
335        assert_eq!(MemoryKind::Host.to_string(), "host");
336    }
337
338    #[test]
339    fn device_info_cpu_reference() {
340        let dev = DeviceInfo::cpu_reference(8 * 1024 * 1024 * 1024);
341        assert_eq!(dev.ordinal, 0);
342        assert_eq!(dev.compute_capability, (0, 0));
343        assert_eq!(dev.memory_kind, MemoryKind::Host);
344        assert_eq!(dev.capabilities, Capabilities::cpu());
345        assert!(dev.to_string().contains("CPU (reference)"));
346        assert!(dev.to_string().contains("8192 MB"));
347    }
348
349    #[test]
350    fn capabilities_display_round_trips_fields() {
351        let s = Capabilities::cpu().to_string();
352        assert!(s.contains("unified=true"));
353        assert!(s.contains("tensor_cores=false"));
354        assert!(s.contains("warp=1"));
355    }
356}