Skip to main content

nuvec_gpu/metal/
mod.rs

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