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::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError};
13
14use allocator::Pool;
15pub use buffer::Buffer;
16pub use command::{Arg, CommandBuffer, Pending, Submitted, Timeline};
17use nuvec_kernels::{MetallibMode, NaxMode};
18use objc2::rc::{Retained, autoreleasepool};
19use objc2::runtime::ProtocolObject;
20use objc2_foundation::NSString;
21use objc2_metal::{
22    MTLCommandQueue, MTLComputePipelineState, MTLCreateSystemDefaultDevice, MTLDataType, MTLDevice,
23    MTLFunctionConstantValues, MTLLibrary,
24};
25
26use crate::info::{NaxOverride, NaxReason, NaxStatus, nax_status, parse_architecture};
27use crate::{DeviceInfo, GpuError, MemoryStats, Result};
28
29type Library = Retained<ProtocolObject<dyn MTLLibrary>>;
30
31/// nuvec's kernels: one precompiled library, or (runtime sources) one library per shader
32/// file, with the library of each kernel name.
33struct Libraries {
34    libraries: Vec<Library>,
35    /// Kernel names, sorted, with their library (the first that defines each). A sorted
36    /// vector rather than a hash map: every allocation is referenced from its start, so
37    /// `leaks` never mistakes this lifelong index for leaked memory (a hash table is
38    /// referenced from inside its allocation).
39    by_name: Vec<(Box<str>, usize)>,
40}
41
42impl Libraries {
43    fn new(libraries: Vec<Library>) -> Self {
44        let mut by_name: Vec<(Box<str>, usize)> = Vec::new();
45        for (i, library) in libraries.iter().enumerate() {
46            for name in library.functionNames().iter() {
47                by_name.push((name.to_string().into_boxed_str(), i));
48            }
49        }
50        // Stable: the first library's entry of a name comes first and is kept.
51        by_name.sort_by(|a, b| a.0.cmp(&b.0));
52        by_name.dedup_by(|later, first| later.0 == first.0);
53        Libraries { libraries, by_name }
54    }
55
56    /// The library that defines kernel `name`.
57    fn get(&self, name: &str) -> Result<&Library> {
58        self.by_name
59            .binary_search_by(|(n, _)| n.as_ref().cmp(name))
60            .map(|at| &self.libraries[self.by_name[at].1])
61            .map_err(|_| GpuError::MissingKernel(name.to_owned()))
62    }
63}
64
65/// A pipeline cache entry: empty until compiled (a failed compilation leaves it empty).
66type Slot = Arc<Mutex<Option<Pipeline>>>;
67
68/// A function constant value for [`Device::pipeline_with_constants`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Constant {
71    /// A `bool` constant.
72    Bool(bool),
73    /// An `int` constant.
74    Int(i32),
75}
76
77/// A Metal GPU with its command queue, kernel library and pipeline cache. Cheap to clone.
78#[derive(Clone)]
79pub struct Device {
80    inner: Arc<Inner>,
81}
82
83struct Inner {
84    device: Retained<ProtocolObject<dyn MTLDevice>>,
85    queue: Retained<ProtocolObject<dyn MTLCommandQueue>>,
86    info: DeviceInfo,
87    library: OnceLock<Result<Libraries>>,
88    /// The Neural Accelerator (NAX) kernels' library, loaded on first use (macOS 26.2+).
89    nax_library: OnceLock<Result<Libraries>>,
90    /// The NAX decision, made once ([`Device::nax`]).
91    nax: OnceLock<NaxStatus>,
92    /// Set when a NAX pipeline failed to build: the existing kernels from then on.
93    nax_failed: AtomicBool,
94    /// Compiled pipelines by key: library kernels by name (and function constants), source
95    /// kernels by `src:{name}`. The map's lock is held only to find a key's slot; a slot's
96    /// lock is held while that one pipeline compiles, so a thread asking for the same key
97    /// waits for that compilation (one compilation per key) and other keys are not blocked.
98    pipelines: Mutex<HashMap<String, Slot>>,
99    pool: Arc<Pool>,
100    in_flight: InFlight,
101}
102
103impl Drop for Inner {
104    /// Releases the cached buffers in an autorelease pool (a Metal object's deallocation
105    /// may autorelease others, and the last device handle may be dropped on any thread).
106    fn drop(&mut self) {
107        autoreleasepool(|_| self.pool.clear_cache());
108    }
109}
110
111/// The number of committed command buffers that have not completed yet.
112#[derive(Default)]
113pub(crate) struct InFlight {
114    count: Mutex<usize>,
115    done: Condvar,
116}
117
118impl InFlight {
119    pub(crate) fn begin(&self) {
120        *self.count.lock().unwrap_or_else(PoisonError::into_inner) += 1;
121    }
122
123    pub(crate) fn end(&self) {
124        let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner);
125        *count = count.saturating_sub(1);
126        self.done.notify_all();
127    }
128
129    fn get(&self) -> usize {
130        *self.count.lock().unwrap_or_else(PoisonError::into_inner)
131    }
132
133    fn wait_below(&self, max: usize) {
134        let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner);
135        while *count >= max.max(1) {
136            count = self
137                .done
138                .wait(count)
139                .unwrap_or_else(PoisonError::into_inner);
140        }
141    }
142}
143
144impl Device {
145    /// The system's default Metal device, or `None` if there is none (for example in some
146    /// virtual machines).
147    pub fn system_default() -> Option<Device> {
148        autoreleasepool(|_| Self::create())
149    }
150
151    fn create() -> Option<Device> {
152        let device = MTLCreateSystemDefaultDevice()?;
153        let queue = device.newCommandQueue()?;
154        let architecture = device.architecture().name().to_string();
155        let (generation, class) = parse_architecture(&architecture);
156        let info = DeviceInfo {
157            name: device.name().to_string(),
158            architecture,
159            generation,
160            class,
161            recommended_working_set: device.recommendedMaxWorkingSetSize(),
162            max_threads_per_threadgroup: device.maxThreadsPerThreadgroup().width,
163            physical_memory: objc2_foundation::NSProcessInfo::processInfo().physicalMemory(),
164        };
165        let ram = info.physical_memory;
166        let options = objc2_metal::MTLResourceOptions::StorageModeShared;
167        let placeholder = device.newBufferWithLength_options(16, options);
168        let residency = residency::Residency::new(residency::attach(&device, &queue));
169        let pool = Arc::new(Pool::new(
170            info.recommended_working_set,
171            ram,
172            placeholder,
173            residency,
174        ));
175        Some(Device {
176            inner: Arc::new(Inner {
177                device,
178                queue,
179                info,
180                library: OnceLock::new(),
181                nax_library: OnceLock::new(),
182                nax: OnceLock::new(),
183                nax_failed: AtomicBool::new(false),
184                pipelines: Mutex::new(HashMap::new()),
185                pool,
186                in_flight: InFlight::default(),
187            }),
188        })
189    }
190
191    /// The process-wide default device, created on first use: one allocator, kernel cache
192    /// and queue shared by everything that uses the GPU.
193    pub fn shared() -> Option<&'static Device> {
194        static SHARED: OnceLock<Option<Device>> = OnceLock::new();
195        SHARED.get_or_init(Device::system_default).as_ref()
196    }
197
198    /// Memory use and limits of this device's allocator.
199    pub fn memory(&self) -> MemoryStats {
200        self.inner.pool.stats()
201    }
202
203    /// Sets the memory limit and returns the previous one (as MLX's `set_memory_limit`).
204    ///
205    /// The limit bounds `active + mapped` ([`MemoryStats::active_with_mapped`]): an
206    /// allocation or a file mapping that would pass it fails with
207    /// [`GpuError::OutOfMemory`] before anything is wired, so a server's memory budget can
208    /// refuse work instead of swapping. This deliberately differs from MLX 0.32.4, whose
209    /// limit is "a guideline for the maximum amount of memory to use during graph
210    /// evaluation" (allocations fail there only when no RAM or swap is left). A limit below
211    /// what is already in use is accepted; the next allocation or mapping then fails. The
212    /// default is `min(1.5 × recommended working set, 0.95 × RAM)`, as MLX.
213    pub fn set_memory_limit(&self, bytes: usize) -> usize {
214        autoreleasepool(|_| self.inner.pool.set_memory_limit(bytes))
215    }
216
217    /// Sets the cache limit, trimming the cache to it, and returns the previous one.
218    pub fn set_cache_limit(&self, bytes: usize) -> usize {
219        autoreleasepool(|_| self.inner.pool.set_cache_limit(bytes))
220    }
221
222    /// Sets the wired limit and returns the previous one (MLX's `set_wired_limit`; 0 by
223    /// default). Buffers, mapped files included, are kept resident up to this many bytes
224    /// while GPU work runs, instead of being made resident per command buffer, which can
225    /// page memory in again under pressure. Lowering it unwires buffers until the wired set
226    /// fits. A limit above the recommended working set is an error. On a GPU without
227    /// residency sets (before Metal 3) the limit is recorded but nothing is wired.
228    pub fn set_wired_limit(&self, bytes: usize) -> Result<usize> {
229        if bytes as u64 > self.inner.info.recommended_working_set {
230            return Err(GpuError::InvalidArgument(format!(
231                "wired limit {bytes} exceeds the recommended working set ({} bytes)",
232                self.inner.info.recommended_working_set
233            )));
234        }
235        Ok(autoreleasepool(|_| self.inner.pool.set_wired_limit(bytes)))
236    }
237
238    /// Whether buffers can be wired on this system (residency sets: a Metal 3 GPU).
239    pub fn can_wire(&self) -> bool {
240        self.inner.pool.can_wire()
241    }
242
243    /// Releases every cached buffer.
244    pub fn clear_cache(&self) {
245        autoreleasepool(|_| self.inner.pool.clear_cache());
246    }
247
248    /// Command buffers committed on this device that have not completed yet.
249    pub fn in_flight(&self) -> usize {
250        self.inner.in_flight.get()
251    }
252
253    /// Blocks until fewer than `max` command buffers are in flight (back-pressure for
254    /// callers that submit without waiting).
255    pub fn wait_for_capacity(&self, max: usize) {
256        self.inner.in_flight.wait_below(max);
257    }
258
259    pub(crate) fn flights(&self) -> &InFlight {
260        &self.inner.in_flight
261    }
262
263    /// Resets the peaks to the current use: `peak` to `active`, `peak_with_mapped` to
264    /// `active + mapped`.
265    pub fn reset_peak(&self) {
266        self.inner.pool.reset_peak();
267    }
268
269    pub(crate) fn pool(&self) -> &Arc<Pool> {
270        &self.inner.pool
271    }
272
273    /// What this GPU is and can do.
274    pub fn info(&self) -> &DeviceInfo {
275        &self.inner.info
276    }
277
278    /// The names of the kernels in nuvec's kernel library, sorted (loading the library on
279    /// first use).
280    pub fn kernel_names(&self) -> Result<Vec<String>> {
281        // Sorted already.
282        Ok(self
283            .library()?
284            .by_name
285            .iter()
286            .map(|(n, _)| n.to_string())
287            .collect())
288    }
289
290    /// The compute pipeline for the kernel `name` from nuvec's kernel library, compiled on
291    /// first use and cached for the life of the device. The cache is keyed by kernel name
292    /// and function constants, so it is bounded by the library's kernels times the constant
293    /// combinations the operations use (a few thousand at most); pipelines from source are
294    /// keyed by their name (one entry per distinct custom kernel instantiation).
295    pub fn pipeline(&self, name: &str) -> Result<Pipeline> {
296        self.pipeline_with(name, &[])
297    }
298
299    /// The pipeline for kernel `name` specialised with boolean function constants
300    /// `(index, value)`, compiled on first use and cached per name and constants.
301    pub fn pipeline_with(&self, name: &str, constants: &[(usize, bool)]) -> Result<Pipeline> {
302        let constants: Vec<(usize, Constant)> = constants
303            .iter()
304            .map(|&(i, v)| (i, Constant::Bool(v)))
305            .collect();
306        self.pipeline_with_constants(name, &constants)
307    }
308
309    /// The pipeline for kernel `name` specialised with boolean or integer function constants
310    /// `(index, value)`, compiled on first use and cached per name and constants.
311    pub fn pipeline_with_constants(
312        &self,
313        name: &str,
314        constants: &[(usize, Constant)],
315    ) -> Result<Pipeline> {
316        thread_local! {
317            /// The cache key being looked up, reused across calls (a hit allocates nothing).
318            static KEY: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
319        }
320        let hit = KEY.with(|k| {
321            use std::fmt::Write;
322            let mut k = k.borrow_mut();
323            k.clear();
324            k.push_str(name);
325            for (i, v) in constants {
326                let _ = match v {
327                    Constant::Bool(b) => write!(k, "#{i}={}", u8::from(*b)),
328                    Constant::Int(n) => write!(k, "#{i}=i{n}"),
329                };
330            }
331            let slot = {
332                let cache = self
333                    .inner
334                    .pipelines
335                    .lock()
336                    .unwrap_or_else(PoisonError::into_inner);
337                cache.get(k.as_str()).cloned()
338            };
339            match slot.and_then(|slot| slot.lock().unwrap_or_else(PoisonError::into_inner).clone())
340            {
341                Some(pipeline) => Ok(pipeline),
342                None => Err(k.clone()),
343            }
344        });
345        let key = match hit {
346            Ok(pipeline) => return Ok(pipeline),
347            Err(key) => key,
348        };
349        // Loaded (or compiled, from runtime sources) before any cache lock is taken. Any
350        // NAX failure while NAX is active (no library, a missing kernel, a pipeline error)
351        // turns it off for the process, so later products take the existing kernels.
352        let nax = name.starts_with("nuvec_nax_");
353        let built = match nax {
354            true => self.nax_library().and_then(|l| l.get(name)),
355            false => self.library().and_then(|l| l.get(name)),
356        }
357        .and_then(|library| self.build_pipeline(name, &key, library, constants));
358        if nax && built.is_err() && self.nax().is_active() {
359            self.inner.nax_failed.store(true, Ordering::Relaxed);
360        }
361        built
362    }
363
364    fn build_pipeline(
365        &self,
366        name: &str,
367        key: &str,
368        library: &Library,
369        constants: &[(usize, Constant)],
370    ) -> Result<Pipeline> {
371        let key = key.to_owned();
372        self.cached(key.clone(), || {
373            let ns_name = NSString::from_str(name);
374            let function = if constants.is_empty() {
375                library
376                    .newFunctionWithName(&ns_name)
377                    .ok_or_else(|| GpuError::MissingKernel(name.to_owned()))?
378            } else {
379                let values = MTLFunctionConstantValues::new();
380                for &(index, value) in constants {
381                    match value {
382                        // SAFETY: `b` is a live local, which Metal copies during the call;
383                        // `bool` matches `MTLDataType::Bool` (one byte).
384                        Constant::Bool(b) => unsafe {
385                            values.setConstantValue_type_atIndex(
386                                NonNull::from(&b).cast(),
387                                MTLDataType::Bool,
388                                index,
389                            );
390                        },
391                        // SAFETY: as above; `i32` matches `MTLDataType::Int`.
392                        Constant::Int(n) => unsafe {
393                            values.setConstantValue_type_atIndex(
394                                NonNull::from(&n).cast(),
395                                MTLDataType::Int,
396                                index,
397                            );
398                        },
399                    }
400                }
401                library
402                    .newFunctionWithName_constantValues_error(&ns_name, &values)
403                    .map_err(|e| GpuError::Pipeline {
404                        name: key.clone(),
405                        message: e.localizedDescription().to_string(),
406                    })?
407            };
408            let state = self
409                .inner
410                .device
411                .newComputePipelineStateWithFunction_error(&function)
412                .map_err(|e| GpuError::Pipeline {
413                    name: key.clone(),
414                    message: e.localizedDescription().to_string(),
415                })?;
416            Ok(Pipeline {
417                state,
418                name: key.as_str().into(),
419            })
420        })
421    }
422
423    /// The pipeline for kernel `name` from Metal source that `source` generates, compiled
424    /// at runtime on first use and cached by name (so a name must always mean the same
425    /// source; `source` runs only on a cache miss). Source kernels have their own namespace:
426    /// a source kernel named like one of nuvec's library kernels is a different pipeline.
427    /// The source may include nuvec's headers (`#include "common.h"`); it is compiled as
428    /// MLX compiles its runtime kernels: Metal 3.2, safe math, Metal's default (fast)
429    /// math functions.
430    ///
431    /// The source is trusted code: the pipeline is run with the `unsafe`
432    /// [`CommandBuffer::dispatch`] family, whose contract (in-bounds accesses only) the
433    /// kernel must keep.
434    pub fn pipeline_from_source(
435        &self,
436        name: &str,
437        source: impl FnOnce() -> String,
438    ) -> Result<Pipeline> {
439        self.cached(format!("src:{name}"), || {
440            let failed = |message: String| GpuError::Pipeline {
441                name: name.to_owned(),
442                message,
443            };
444            let source = inline_sources(&[("generated.metal", &source())]);
445            // As MLX compiles its runtime kernels (`mx.compile`, custom kernels): safe math
446            // with Metal's default, fast `metal::` functions (pow, exp, tanh, …), so their
447            // results can differ in the low bits from the precompiled kernels' (D-104).
448            let options = objc2_metal::MTLCompileOptions::new();
449            options.setMathMode(objc2_metal::MTLMathMode::Safe);
450            options.setLanguageVersion(objc2_metal::MTLLanguageVersion::Version3_2);
451            let library = self
452                .inner
453                .device
454                .newLibraryWithSource_options_error(&NSString::from_str(&source), Some(&options))
455                .map_err(|e| failed(e.localizedDescription().to_string()))?;
456            let function = library
457                .newFunctionWithName(&NSString::from_str(name))
458                .ok_or_else(|| GpuError::MissingKernel(name.to_owned()))?;
459            let state = self
460                .inner
461                .device
462                .newComputePipelineStateWithFunction_error(&function)
463                .map_err(|e| failed(e.localizedDescription().to_string()))?;
464            Ok(Pipeline {
465                state,
466                name: name.into(),
467            })
468        })
469    }
470
471    /// The cached pipeline for `key`, compiled by `compile` on a miss. The cache's lock is
472    /// not held while compiling (only the key's own slot is); compilation runs in an
473    /// autorelease pool, so the temporary Objective-C objects it creates are released on
474    /// long-lived threads too.
475    fn cached(&self, key: String, compile: impl FnOnce() -> Result<Pipeline>) -> Result<Pipeline> {
476        let slot = {
477            let mut cache = self
478                .inner
479                .pipelines
480                .lock()
481                .unwrap_or_else(PoisonError::into_inner);
482            cache.entry(key).or_default().clone()
483        };
484        let mut slot = slot.lock().unwrap_or_else(PoisonError::into_inner);
485        if let Some(p) = &*slot {
486            return Ok(p.clone());
487        }
488        let pipeline = autoreleasepool(|_| compile())?;
489        *slot = Some(pipeline.clone());
490        Ok(pipeline)
491    }
492
493    /// nuvec's kernel libraries, loaded once.
494    fn library(&self) -> Result<&Libraries> {
495        self.inner
496            .library
497            .get_or_init(|| autoreleasepool(|_| load_library(&self.inner.device)))
498            .as_ref()
499            .map_err(Clone::clone)
500    }
501
502    fn nax_library(&self) -> Result<&Libraries> {
503        self.inner
504            .nax_library
505            .get_or_init(|| autoreleasepool(|_| load_nax_library(&self.inner.device)))
506            .as_ref()
507            .map_err(Clone::clone)
508    }
509
510    /// Whether products run on the Neural Accelerator kernels (Metal 4 TensorOps), and why
511    /// not: MLX's eligibility (an M5-class GPU on macOS 26.2 or later), the `NUVEC_NAX`
512    /// override (`auto`, `on`, `off`), and whether this build has the kernels. `auto` keeps
513    /// them off on eligible GPUs until an M5 measurement is recorded; `on` forces them on
514    /// any GPU with TensorOps (macOS 26.2), for testing. After a NAX pipeline fails to build,
515    /// [`NaxReason::PipelineError`].
516    pub fn nax(&self) -> NaxStatus {
517        if self.inner.nax_failed.load(Ordering::Relaxed) {
518            return NaxStatus::Off(NaxReason::PipelineError);
519        }
520        *self.inner.nax.get_or_init(|| {
521            let v = objc2_foundation::NSProcessInfo::processInfo().operatingSystemVersion();
522            let os = (v.majorVersion as u32, v.minorVersion as u32);
523            let info = &self.inner.info;
524            nax_status(
525                info.generation,
526                info.class,
527                os,
528                NaxOverride::from_env(),
529                nuvec_kernels::NAX_MODE != NaxMode::Absent,
530            )
531        })
532    }
533
534    pub(crate) fn raw(&self) -> &ProtocolObject<dyn MTLDevice> {
535        &self.inner.device
536    }
537
538    pub(crate) fn queue(&self) -> &ProtocolObject<dyn MTLCommandQueue> {
539        &self.inner.queue
540    }
541}
542
543impl std::fmt::Debug for Device {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        f.debug_struct("Device")
546            .field("info", &self.inner.info)
547            .finish_non_exhaustive()
548    }
549}
550
551/// Loads the precompiled library, or compiles the embedded sources as the build script
552/// would have: each shader file on its own (headers inlined, as the runtime compiler has no
553/// include path for them), with the same language version, IEEE-conformant math and
554/// floating-point contraction setting, so that both modes give the same results. The files
555/// compile in parallel.
556fn load_library(device: &ProtocolObject<dyn MTLDevice>) -> Result<Libraries> {
557    let error = |e: Retained<objc2_foundation::NSError>| {
558        GpuError::LibraryLoad(e.localizedDescription().to_string())
559    };
560    match nuvec_kernels::MODE {
561        MetallibMode::Precompiled => {
562            let bytes = nuvec_kernels::metallib().ok_or(GpuError::NoKernels)?;
563            let library = device
564                .newLibraryWithData_error(&dispatch2::DispatchData::from_static_bytes(bytes))
565                .map_err(error)?;
566            Ok(Libraries::new(vec![library]))
567        }
568        MetallibMode::RuntimeSource => {
569            let files: Vec<(&str, &str)> = nuvec_kernels::sources()
570                .iter()
571                .copied()
572                .filter(|(p, _)| p.ends_with(".metal"))
573                .collect();
574            let compile = |&(path, text): &(&str, &str)| -> Result<Library> {
575                let mut source = String::new();
576                if !nuvec_kernels::contracted(path) {
577                    // The build script's `-ffp-contract=off`.
578                    source.push_str("#pragma clang fp contract(off)\n");
579                }
580                source.push_str(&inline_sources(&[(path, text)]));
581                let options = objc2_metal::MTLCompileOptions::new();
582                options.setMathMode(objc2_metal::MTLMathMode::Safe);
583                // The build's `-fno-fast-math`: `metal::` functions (pow, exp, …) are the precise
584                // ones (the default is the fast ones).
585                options.setMathFloatingPointFunctions(
586                    objc2_metal::MTLMathFloatingPointFunctions::Precise,
587                );
588                options.setLanguageVersion(objc2_metal::MTLLanguageVersion::Version3_2);
589                device
590                    .newLibraryWithSource_options_error(
591                        &NSString::from_str(&source),
592                        Some(&options),
593                    )
594                    .map_err(|e| {
595                        GpuError::LibraryLoad(format!("{path}: {}", e.localizedDescription()))
596                    })
597            };
598            let libraries = std::thread::scope(|scope| {
599                let handles: Vec<_> = files
600                    .iter()
601                    .map(|file| scope.spawn(move || compile(file)))
602                    .collect();
603                handles
604                    .into_iter()
605                    .map(|h| h.join().expect("shader compilation does not panic"))
606                    .collect::<Result<Vec<_>>>()
607            })?;
608            Ok(Libraries::new(libraries))
609        }
610        _ => Err(GpuError::NoKernels),
611    }
612}
613
614/// Loads the precompiled NAX library, or compiles its embedded sources with Metal 4.0 (only
615/// called when [`Device::nax`] is active, so on macOS 26.2 or later).
616fn load_nax_library(device: &ProtocolObject<dyn MTLDevice>) -> Result<Libraries> {
617    match nuvec_kernels::NAX_MODE {
618        NaxMode::Precompiled => {
619            let bytes = nuvec_kernels::nax_metallib().ok_or(GpuError::NoKernels)?;
620            let library = device
621                .newLibraryWithData_error(&dispatch2::DispatchData::from_static_bytes(bytes))
622                .map_err(|e| GpuError::LibraryLoad(e.localizedDescription().to_string()))?;
623            Ok(Libraries::new(vec![library]))
624        }
625        NaxMode::RuntimeSource => {
626            let libraries = nuvec_kernels::nax_sources()
627                .iter()
628                .filter(|(p, _)| p.ends_with(".metal"))
629                .map(|(path, text)| {
630                    let options = objc2_metal::MTLCompileOptions::new();
631                    options.setMathMode(objc2_metal::MTLMathMode::Safe);
632                    options.setMathFloatingPointFunctions(
633                        objc2_metal::MTLMathFloatingPointFunctions::Precise,
634                    );
635                    options.setLanguageVersion(objc2_metal::MTLLanguageVersion::Version4_0);
636                    device
637                        .newLibraryWithSource_options_error(
638                            &NSString::from_str(text),
639                            Some(&options),
640                        )
641                        .map_err(|e| {
642                            GpuError::LibraryLoad(format!("{path}: {}", e.localizedDescription()))
643                        })
644                })
645                .collect::<Result<Vec<_>>>()?;
646            Ok(Libraries::new(libraries))
647        }
648        _ => Err(GpuError::NoKernels),
649    }
650}
651
652/// The `.metal` sources as one translation unit, each `#include "header.h"` of one of
653/// nuvec's headers (in a source or in another header) replaced by the header's text on
654/// first use.
655fn inline_sources(sources: &[(&str, &str)]) -> String {
656    let headers: HashMap<&str, &str> = nuvec_kernels::sources()
657        .iter()
658        .chain(sources)
659        .copied()
660        .chain([("common.h", nuvec_kernels::COMMON_H)])
661        .filter(|(p, _)| p.ends_with(".h"))
662        .collect();
663    let mut included = HashSet::new();
664    let mut out = String::new();
665    for (_, source) in sources.iter().filter(|(p, _)| p.ends_with(".metal")) {
666        inline_into(source, &headers, &mut included, &mut out);
667    }
668    out
669}
670
671fn inline_into<'a>(
672    source: &str,
673    headers: &HashMap<&'a str, &'a str>,
674    included: &mut HashSet<&'a str>,
675    out: &mut String,
676) {
677    for line in source.lines() {
678        let header = line
679            .trim()
680            .strip_prefix("#include \"")
681            .and_then(|r| r.strip_suffix('"'));
682        match header.and_then(|h| headers.get_key_value(h)) {
683            Some((&h, &text)) => {
684                if included.insert(h) {
685                    inline_into(text, headers, included, out);
686                    out.push('\n');
687                }
688            }
689            None => {
690                out.push_str(line);
691                out.push('\n');
692            }
693        }
694    }
695}
696
697/// A compiled compute kernel. Cheap to clone.
698#[derive(Clone)]
699pub struct Pipeline {
700    state: Retained<ProtocolObject<dyn MTLComputePipelineState>>,
701    name: Arc<str>,
702}
703
704impl Pipeline {
705    /// The kernel's name.
706    pub fn name(&self) -> &str {
707        &self.name
708    }
709
710    /// The most threads one threadgroup of this kernel may have.
711    pub fn max_threads_per_threadgroup(&self) -> usize {
712        self.state.maxTotalThreadsPerThreadgroup()
713    }
714
715    /// The SIMD-group width (threads that execute in lockstep).
716    pub fn execution_width(&self) -> usize {
717        self.state.threadExecutionWidth()
718    }
719
720    /// Whether two handles refer to the same compiled pipeline.
721    pub fn same_as(&self, other: &Pipeline) -> bool {
722        Retained::as_ptr(&self.state) == Retained::as_ptr(&other.state)
723    }
724
725    pub(crate) fn raw(&self) -> &ProtocolObject<dyn MTLComputePipelineState> {
726        &self.state
727    }
728}
729
730impl std::fmt::Debug for Pipeline {
731    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732        f.debug_struct("Pipeline")
733            .field("name", &self.name)
734            .finish_non_exhaustive()
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::inline_sources;
741
742    #[test]
743    fn inlines_each_header_once() {
744        let sources = [
745            ("a.metal", "#include \"h.h\"\nkernel A;"),
746            ("b.metal", "  #include \"h.h\"\nkernel B;"),
747            ("h.h", "#pragma once\ninline int f();"),
748        ];
749        let out = inline_sources(&sources);
750        assert_eq!(out.matches("inline int f();").count(), 1);
751        assert!(out.contains("kernel A;") && out.contains("kernel B;"));
752        assert!(!out.contains("#include \"h.h\""));
753    }
754
755    #[test]
756    fn inlines_headers_included_by_headers() {
757        let sources = [
758            (
759                "a.metal",
760                "#include \"outer.h\"\n#include \"inner.h\"\nkernel A;",
761            ),
762            ("outer.h", "#include \"inner.h\"\ninline int g();"),
763            ("inner.h", "inline int f();"),
764        ];
765        let out = inline_sources(&sources);
766        assert_eq!(out.matches("inline int f();").count(), 1);
767        assert!(out.find("inline int f();") < out.find("inline int g();"));
768        assert!(!out.contains("#include"));
769    }
770}