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