Skip to main content

nuvec_gpu/metal/
mod.rs

1//! The Metal runtime on `objc2-metal` (macOS only).
2
3mod allocator;
4mod buffer;
5mod command;
6pub mod profile;
7mod residency;
8
9use std::collections::{HashMap, HashSet};
10use std::ptr::NonNull;
11use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError};
12
13use allocator::Pool;
14pub use buffer::Buffer;
15pub use command::{Arg, CommandBuffer, Pending, Submitted, Timeline};
16use nuvec_kernels::MetallibMode;
17use objc2::rc::Retained;
18use objc2::runtime::ProtocolObject;
19use objc2_foundation::NSString;
20use objc2_metal::{
21    MTLCommandQueue, MTLComputePipelineState, MTLCreateSystemDefaultDevice, MTLDataType, MTLDevice,
22    MTLFunctionConstantValues, MTLLibrary,
23};
24
25use crate::info::parse_architecture;
26use crate::{DeviceInfo, GpuError, MemoryStats, Result};
27
28type Library = Retained<ProtocolObject<dyn MTLLibrary>>;
29
30/// nuvec's kernels: one precompiled library, or (runtime sources) one library per shader
31/// file, with the library of each kernel name.
32struct Libraries {
33    libraries: Vec<Library>,
34    by_name: HashMap<String, usize>,
35}
36
37impl Libraries {
38    fn new(libraries: Vec<Library>) -> Self {
39        let mut by_name = HashMap::new();
40        for (i, library) in libraries.iter().enumerate() {
41            for name in library.functionNames().iter() {
42                by_name.entry(name.to_string()).or_insert(i);
43            }
44        }
45        Libraries { libraries, by_name }
46    }
47
48    /// The library that defines kernel `name`.
49    fn get(&self, name: &str) -> Result<&Library> {
50        self.by_name
51            .get(name)
52            .map(|&i| &self.libraries[i])
53            .ok_or_else(|| GpuError::MissingKernel(name.to_owned()))
54    }
55}
56
57/// A function constant value for [`Device::pipeline_with_constants`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Constant {
60    /// A `bool` constant.
61    Bool(bool),
62    /// An `int` constant.
63    Int(i32),
64}
65
66/// A Metal GPU with its command queue, kernel library and pipeline cache. Cheap to clone.
67#[derive(Clone)]
68pub struct Device {
69    inner: Arc<Inner>,
70}
71
72struct Inner {
73    device: Retained<ProtocolObject<dyn MTLDevice>>,
74    queue: Retained<ProtocolObject<dyn MTLCommandQueue>>,
75    info: DeviceInfo,
76    library: OnceLock<Result<Libraries>>,
77    pipelines: Mutex<HashMap<String, Pipeline>>,
78    pool: Arc<Pool>,
79    in_flight: InFlight,
80}
81
82/// The number of committed command buffers that have not completed yet.
83#[derive(Default)]
84pub(crate) struct InFlight {
85    count: Mutex<usize>,
86    done: Condvar,
87}
88
89impl InFlight {
90    pub(crate) fn begin(&self) {
91        *self.count.lock().unwrap_or_else(PoisonError::into_inner) += 1;
92    }
93
94    pub(crate) fn end(&self) {
95        let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner);
96        *count = count.saturating_sub(1);
97        self.done.notify_all();
98    }
99
100    fn get(&self) -> usize {
101        *self.count.lock().unwrap_or_else(PoisonError::into_inner)
102    }
103
104    fn wait_below(&self, max: usize) {
105        let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner);
106        while *count >= max.max(1) {
107            count = self
108                .done
109                .wait(count)
110                .unwrap_or_else(PoisonError::into_inner);
111        }
112    }
113}
114
115impl Device {
116    /// The system's default Metal device, or `None` if there is none (for example in some
117    /// virtual machines).
118    pub fn system_default() -> Option<Device> {
119        let device = MTLCreateSystemDefaultDevice()?;
120        let queue = device.newCommandQueue()?;
121        let architecture = device.architecture().name().to_string();
122        let (generation, class) = parse_architecture(&architecture);
123        let info = DeviceInfo {
124            name: device.name().to_string(),
125            architecture,
126            generation,
127            class,
128            recommended_working_set: device.recommendedMaxWorkingSetSize(),
129            max_threads_per_threadgroup: device.maxThreadsPerThreadgroup().width,
130        };
131        let ram = objc2_foundation::NSProcessInfo::processInfo().physicalMemory();
132        let options = objc2_metal::MTLResourceOptions::StorageModeShared;
133        let placeholder = device.newBufferWithLength_options(16, options);
134        let residency = residency::Residency::new(residency::attach(&device, &queue));
135        let pool = Arc::new(Pool::new(
136            info.recommended_working_set,
137            ram,
138            placeholder,
139            residency,
140        ));
141        Some(Device {
142            inner: Arc::new(Inner {
143                device,
144                queue,
145                info,
146                library: OnceLock::new(),
147                pipelines: Mutex::new(HashMap::new()),
148                pool,
149                in_flight: InFlight::default(),
150            }),
151        })
152    }
153
154    /// The process-wide default device, created on first use: one allocator, kernel cache
155    /// and queue shared by everything that uses the GPU.
156    pub fn shared() -> Option<&'static Device> {
157        static SHARED: OnceLock<Option<Device>> = OnceLock::new();
158        SHARED.get_or_init(Device::system_default).as_ref()
159    }
160
161    /// Memory use and limits of this device's allocator.
162    pub fn memory(&self) -> MemoryStats {
163        self.inner.pool.stats()
164    }
165
166    /// Sets the memory limit (allocations beyond it fail) and returns the previous one.
167    pub fn set_memory_limit(&self, bytes: usize) -> usize {
168        self.inner.pool.set_memory_limit(bytes)
169    }
170
171    /// Sets the cache limit, trimming the cache to it, and returns the previous one.
172    pub fn set_cache_limit(&self, bytes: usize) -> usize {
173        self.inner.pool.set_cache_limit(bytes)
174    }
175
176    /// Sets the wired limit and returns the previous one (MLX's `set_wired_limit`; 0 by
177    /// default). Buffers, mapped files included, are kept resident up to this many bytes
178    /// while GPU work runs, instead of being made resident per command buffer, which can
179    /// page memory in again under pressure. Lowering it unwires buffers until the wired set
180    /// fits. A limit above the recommended working set is an error. Without residency sets
181    /// (before macOS 15, or on GPUs before Metal 3) the limit is recorded but nothing is
182    /// wired.
183    pub fn set_wired_limit(&self, bytes: usize) -> Result<usize> {
184        if bytes as u64 > self.inner.info.recommended_working_set {
185            return Err(GpuError::InvalidArgument(format!(
186                "wired limit {bytes} exceeds the recommended working set ({} bytes)",
187                self.inner.info.recommended_working_set
188            )));
189        }
190        Ok(self.inner.pool.set_wired_limit(bytes))
191    }
192
193    /// Whether buffers can be wired on this system (residency sets: macOS 15 and a Metal 3
194    /// GPU).
195    pub fn can_wire(&self) -> bool {
196        self.inner.pool.can_wire()
197    }
198
199    /// Releases every cached buffer.
200    pub fn clear_cache(&self) {
201        self.inner.pool.clear_cache();
202    }
203
204    /// Command buffers committed on this device that have not completed yet.
205    pub fn in_flight(&self) -> usize {
206        self.inner.in_flight.get()
207    }
208
209    /// Blocks until fewer than `max` command buffers are in flight (back-pressure for
210    /// callers that submit without waiting).
211    pub fn wait_for_capacity(&self, max: usize) {
212        self.inner.in_flight.wait_below(max);
213    }
214
215    pub(crate) fn flights(&self) -> &InFlight {
216        &self.inner.in_flight
217    }
218
219    /// Resets the peak to the current active memory.
220    pub fn reset_peak(&self) {
221        self.inner.pool.reset_peak();
222    }
223
224    pub(crate) fn pool(&self) -> &Arc<Pool> {
225        &self.inner.pool
226    }
227
228    /// What this GPU is and can do.
229    pub fn info(&self) -> &DeviceInfo {
230        &self.inner.info
231    }
232
233    /// The compute pipeline for the kernel `name` from nuvec's kernel library, compiled on
234    /// first use and cached.
235    pub fn pipeline(&self, name: &str) -> Result<Pipeline> {
236        self.pipeline_with(name, &[])
237    }
238
239    /// The pipeline for kernel `name` specialised with boolean function constants
240    /// `(index, value)`, compiled on first use and cached per name and constants.
241    pub fn pipeline_with(&self, name: &str, constants: &[(usize, bool)]) -> Result<Pipeline> {
242        let constants: Vec<(usize, Constant)> = constants
243            .iter()
244            .map(|&(i, v)| (i, Constant::Bool(v)))
245            .collect();
246        self.pipeline_with_constants(name, &constants)
247    }
248
249    /// The pipeline for kernel `name` specialised with boolean or integer function constants
250    /// `(index, value)`, compiled on first use and cached per name and constants.
251    pub fn pipeline_with_constants(
252        &self,
253        name: &str,
254        constants: &[(usize, Constant)],
255    ) -> Result<Pipeline> {
256        let key = constants.iter().fold(name.to_owned(), |mut k, (i, v)| {
257            match v {
258                Constant::Bool(b) => k.push_str(&format!("#{i}={}", u8::from(*b))),
259                Constant::Int(n) => k.push_str(&format!("#{i}=i{n}")),
260            }
261            k
262        });
263        let mut cache = self
264            .inner
265            .pipelines
266            .lock()
267            .unwrap_or_else(PoisonError::into_inner);
268        if let Some(p) = cache.get(&key) {
269            return Ok(p.clone());
270        }
271        let library = self.library()?.get(name)?;
272        let ns_name = NSString::from_str(name);
273        let function = if constants.is_empty() {
274            library
275                .newFunctionWithName(&ns_name)
276                .ok_or_else(|| GpuError::MissingKernel(name.to_owned()))?
277        } else {
278            let values = MTLFunctionConstantValues::new();
279            for &(index, value) in constants {
280                match value {
281                    // SAFETY: `b` is a live local, which Metal copies during the call; `bool`
282                    // matches `MTLDataType::Bool` (one byte).
283                    Constant::Bool(b) => unsafe {
284                        values.setConstantValue_type_atIndex(
285                            NonNull::from(&b).cast(),
286                            MTLDataType::Bool,
287                            index,
288                        );
289                    },
290                    // SAFETY: as above; `i32` matches `MTLDataType::Int`.
291                    Constant::Int(n) => unsafe {
292                        values.setConstantValue_type_atIndex(
293                            NonNull::from(&n).cast(),
294                            MTLDataType::Int,
295                            index,
296                        );
297                    },
298                }
299            }
300            library
301                .newFunctionWithName_constantValues_error(&ns_name, &values)
302                .map_err(|e| GpuError::Pipeline {
303                    name: key.clone(),
304                    message: e.localizedDescription().to_string(),
305                })?
306        };
307        let state = self
308            .inner
309            .device
310            .newComputePipelineStateWithFunction_error(&function)
311            .map_err(|e| GpuError::Pipeline {
312                name: key.clone(),
313                message: e.localizedDescription().to_string(),
314            })?;
315        let pipeline = Pipeline {
316            state,
317            name: key.as_str().into(),
318        };
319        cache.insert(key, pipeline.clone());
320        Ok(pipeline)
321    }
322
323    /// The pipeline for kernel `name` from Metal source that `source` generates, compiled
324    /// at runtime on first use and cached by name (so a name must always mean the same
325    /// source). The source may include nuvec's headers (`#include "common.h"`); it is
326    /// compiled with IEEE-conformant math, as nuvec's own kernels are.
327    pub fn pipeline_from_source(
328        &self,
329        name: &str,
330        source: impl FnOnce() -> String,
331    ) -> Result<Pipeline> {
332        let mut cache = self
333            .inner
334            .pipelines
335            .lock()
336            .unwrap_or_else(PoisonError::into_inner);
337        if let Some(p) = cache.get(name) {
338            return Ok(p.clone());
339        }
340        let failed = |message: String| GpuError::Pipeline {
341            name: name.to_owned(),
342            message,
343        };
344        let source = inline_sources(&[("generated.metal", &source())]);
345        let options = objc2_metal::MTLCompileOptions::new();
346        options.setMathMode(objc2_metal::MTLMathMode::Safe);
347        let library = self
348            .inner
349            .device
350            .newLibraryWithSource_options_error(&NSString::from_str(&source), Some(&options))
351            .map_err(|e| failed(e.localizedDescription().to_string()))?;
352        let function = library
353            .newFunctionWithName(&NSString::from_str(name))
354            .ok_or_else(|| GpuError::MissingKernel(name.to_owned()))?;
355        let state = self
356            .inner
357            .device
358            .newComputePipelineStateWithFunction_error(&function)
359            .map_err(|e| failed(e.localizedDescription().to_string()))?;
360        let pipeline = Pipeline {
361            state,
362            name: name.into(),
363        };
364        cache.insert(name.to_owned(), pipeline.clone());
365        Ok(pipeline)
366    }
367
368    /// nuvec's kernel libraries, loaded once.
369    fn library(&self) -> Result<&Libraries> {
370        self.inner
371            .library
372            .get_or_init(|| load_library(&self.inner.device))
373            .as_ref()
374            .map_err(Clone::clone)
375    }
376
377    pub(crate) fn raw(&self) -> &ProtocolObject<dyn MTLDevice> {
378        &self.inner.device
379    }
380
381    pub(crate) fn queue(&self) -> &ProtocolObject<dyn MTLCommandQueue> {
382        &self.inner.queue
383    }
384}
385
386impl std::fmt::Debug for Device {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("Device")
389            .field("info", &self.inner.info)
390            .finish_non_exhaustive()
391    }
392}
393
394/// Loads the precompiled library, or compiles the embedded sources as the build script
395/// would have: each shader file on its own (headers inlined, as the runtime compiler has no
396/// include path for them), with the same language version, IEEE-conformant math and
397/// floating-point contraction setting, so that both modes give the same results. The files
398/// compile in parallel.
399fn load_library(device: &ProtocolObject<dyn MTLDevice>) -> Result<Libraries> {
400    let error = |e: Retained<objc2_foundation::NSError>| {
401        GpuError::LibraryLoad(e.localizedDescription().to_string())
402    };
403    match nuvec_kernels::MODE {
404        MetallibMode::Precompiled => {
405            let bytes = nuvec_kernels::metallib().ok_or(GpuError::NoKernels)?;
406            let library = device
407                .newLibraryWithData_error(&dispatch2::DispatchData::from_static_bytes(bytes))
408                .map_err(error)?;
409            Ok(Libraries::new(vec![library]))
410        }
411        MetallibMode::RuntimeSource => {
412            let files: Vec<(&str, &str)> = nuvec_kernels::sources()
413                .iter()
414                .copied()
415                .filter(|(p, _)| p.ends_with(".metal"))
416                .collect();
417            let compile = |&(path, text): &(&str, &str)| -> Result<Library> {
418                let mut source = String::new();
419                if !nuvec_kernels::contracted(path) {
420                    // The build script's `-ffp-contract=off`.
421                    source.push_str("#pragma clang fp contract(off)\n");
422                }
423                source.push_str(&inline_sources(&[(path, text)]));
424                let options = objc2_metal::MTLCompileOptions::new();
425                options.setMathMode(objc2_metal::MTLMathMode::Safe);
426                options.setLanguageVersion(objc2_metal::MTLLanguageVersion::Version3_2);
427                device
428                    .newLibraryWithSource_options_error(
429                        &NSString::from_str(&source),
430                        Some(&options),
431                    )
432                    .map_err(|e| {
433                        GpuError::LibraryLoad(format!("{path}: {}", e.localizedDescription()))
434                    })
435            };
436            let libraries = std::thread::scope(|scope| {
437                let handles: Vec<_> = files
438                    .iter()
439                    .map(|file| scope.spawn(move || compile(file)))
440                    .collect();
441                handles
442                    .into_iter()
443                    .map(|h| h.join().expect("shader compilation does not panic"))
444                    .collect::<Result<Vec<_>>>()
445            })?;
446            Ok(Libraries::new(libraries))
447        }
448        _ => Err(GpuError::NoKernels),
449    }
450}
451
452/// The `.metal` sources as one translation unit, each `#include "header.h"` of one of
453/// nuvec's headers replaced by the header's text on first use.
454fn inline_sources(sources: &[(&str, &str)]) -> String {
455    let headers: HashMap<&str, &str> = nuvec_kernels::sources()
456        .iter()
457        .chain(sources)
458        .copied()
459        .chain([("common.h", nuvec_kernels::COMMON_H)])
460        .filter(|(p, _)| p.ends_with(".h"))
461        .collect();
462    let mut included = HashSet::new();
463    let mut out = String::new();
464    for (_, source) in sources.iter().filter(|(p, _)| p.ends_with(".metal")) {
465        for line in source.lines() {
466            let header = line
467                .trim()
468                .strip_prefix("#include \"")
469                .and_then(|r| r.strip_suffix('"'));
470            match header.and_then(|h| headers.get(h).map(|text| (h, text))) {
471                Some((h, text)) => {
472                    if included.insert(h) {
473                        out.push_str(text);
474                        out.push('\n');
475                    }
476                }
477                None => {
478                    out.push_str(line);
479                    out.push('\n');
480                }
481            }
482        }
483    }
484    out
485}
486
487/// A compiled compute kernel. Cheap to clone.
488#[derive(Clone)]
489pub struct Pipeline {
490    state: Retained<ProtocolObject<dyn MTLComputePipelineState>>,
491    name: Arc<str>,
492}
493
494impl Pipeline {
495    /// The kernel's name.
496    pub fn name(&self) -> &str {
497        &self.name
498    }
499
500    /// The most threads one threadgroup of this kernel may have.
501    pub fn max_threads_per_threadgroup(&self) -> usize {
502        self.state.maxTotalThreadsPerThreadgroup()
503    }
504
505    /// The SIMD-group width (threads that execute in lockstep).
506    pub fn execution_width(&self) -> usize {
507        self.state.threadExecutionWidth()
508    }
509
510    /// Whether two handles refer to the same compiled pipeline.
511    pub fn same_as(&self, other: &Pipeline) -> bool {
512        Retained::as_ptr(&self.state) == Retained::as_ptr(&other.state)
513    }
514
515    pub(crate) fn raw(&self) -> &ProtocolObject<dyn MTLComputePipelineState> {
516        &self.state
517    }
518}
519
520impl std::fmt::Debug for Pipeline {
521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522        f.debug_struct("Pipeline")
523            .field("name", &self.name)
524            .finish_non_exhaustive()
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::inline_sources;
531
532    #[test]
533    fn inlines_each_header_once() {
534        let sources = [
535            ("a.metal", "#include \"h.h\"\nkernel A;"),
536            ("b.metal", "  #include \"h.h\"\nkernel B;"),
537            ("h.h", "#pragma once\ninline int f();"),
538        ];
539        let out = inline_sources(&sources);
540        assert_eq!(out.matches("inline int f();").count(), 1);
541        assert!(out.contains("kernel A;") && out.contains("kernel B;"));
542        assert!(!out.contains("#include \"h.h\""));
543    }
544}