Skip to main content

mlx_native/
lib.rs

1//! # mlx-native
2//!
3//! Pure-Rust Metal GPU compute library for MLX-compatible inference on Apple
4//! Silicon.
5//!
6//! This crate provides a thin, safe wrapper around Apple's Metal framework
7//! focused on compute shader dispatch for neural network inference.  It is
8//! designed to be the GPU backend for the `hf2q` inference engine.
9//!
10//! ## Key Types
11//!
12//! | Type | Purpose |
13//! |------|---------|
14//! | [`MlxDevice`]       | Metal device + command queue (entry point) |
15//! | [`CommandEncoder`]   | Batched compute command submission |
16//! | [`MlxBuffer`]        | Typed Metal buffer with shape/dtype metadata |
17//! | [`MlxBufferPool`]    | Arena allocator with power-of-two bucketing |
18//! | [`KernelRegistry`]   | Lazy MSL compilation + pipeline cache |
19//! | [`DType`]            | Element data type enum |
20//! | [`MlxError`]         | Unified error type (never panics) |
21//!
22//! ## Quick Start
23//!
24//! ```ignore
25//! use mlx_native::{MlxDevice, DType};
26//!
27//! let device = MlxDevice::new()?;
28//! let buf = device.alloc_buffer(1024, DType::F32, vec![256])?;
29//! let encoder = device.command_encoder()?;
30//! ```
31//!
32//! ## Design Principles
33//!
34//! * **No panics** — all public APIs return `Result<T, MlxError>`.
35//! * **Zero-copy** — `StorageModeShared` buffers on Apple Silicon unified memory.
36//! * **Thread-safe** — `MlxDevice` and `MlxBuffer` are `Send + Sync`.
37//! * **Lazy compilation** — MSL shaders compiled on first use, then cached.
38
39// Enforce the no-panic policy at compile time.
40#![deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
41// The `objc` crate's `msg_send!` macro internally checks `cfg(feature = "cargo-clippy")`
42// which triggers unexpected_cfgs warnings. Suppress at crate level since we can't
43// control the macro expansion site.
44#![allow(unexpected_cfgs)]
45
46// ---- internal modules ----
47#[macro_use]
48mod error;
49mod buffer;
50mod buffer_pool;
51mod device;
52mod dtypes;
53mod encoder;
54mod encoder_session;
55pub mod encoder_worker;
56mod env_flags;
57mod kernel_registry;
58mod mem_ranges;
59mod residency;
60pub mod gguf;
61pub mod kernel_profile;
62pub mod graph;
63pub mod metal_capture;
64pub mod ops;
65pub mod turboquant;
66pub mod tq_oracle;
67pub mod weight;
68
69// ---- public re-exports ----
70pub use buffer::MlxBuffer;
71pub use buffer_pool::MlxBufferPool;
72pub use device::MlxDevice;
73pub use dtypes::DType;
74pub use encoder::{
75    auto_barrier_concurrent_count, auto_barrier_count, barrier_count, barrier_total_ns,
76    cmd_buf_count, dispatch_count, gpu_busy_ns, pipeline_dispatch_buckets, reset_counters,
77    reset_pipeline_dispatch_buckets, set_encode_trace, sync_count, CapturedNode, CapturedOpKind,
78    CommandEncoder, DispatchKind, DispatchRecord, KernelArg, RecordedBinding,
79};
80pub use encoder_session::EncoderSession;
81pub use mem_ranges::{BufferRange, MemRangeRole, MemRanges};
82pub use error::{MlxError, Result};
83pub use graph::{
84    barrier_ns, barrier_ns_reset, ComputeGraph, GraphExecutor, GraphSession, OpKind,
85};
86pub use kernel_registry::KernelRegistry;
87// Test-only counters and gate-reset helpers.  Marked #[doc(hidden)] so
88// they don't appear in published rustdoc; consumers should not depend
89// on them outside test code.  Not feature-gated because integration
90// tests in tests/ are a separate crate and cannot rely on the lib's
91// `test` cfg flag.
92#[doc(hidden)]
93pub use residency::{
94    macos_15_or_newer_for_test, reset_residency_env_cache_for_test,
95    reset_residency_test_counters, residency_allocation_count_for_test,
96    residency_commit_call_count_for_test,
97};
98
99// Re-export GGUF parser.
100pub use gguf::{GgufFile, MetadataValue, TensorInfo};
101
102// Re-export ops.
103pub use ops::dense_mm_bf16::{dense_matmul_bf16_f32_tensor, DenseMmBf16F32Params};
104pub use ops::dense_mm_f16::{dense_matmul_f16_f32_tensor, DenseMmF16F32Params};
105pub use ops::dense_mm_f32_f32::{dense_matmul_f32_f32_tensor, DenseMmF32F32Params};
106pub use ops::quantized_matmul::{quantized_matmul, quantized_matmul_simd, QuantizedMatmulParams};
107pub use ops::quantized_matmul_ggml::{
108    dispatch_mm_for_test, dispatch_mv_q6k_mn, dispatch_mv_q6k_mn_adaptive,
109    quantized_matmul_ggml, quantized_matmul_mm_tensor_perm021,
110    quantized_matmul_mm_tensor_perm021_f16,
111    GgmlQuantizedMatmulParams, GgmlQuantizedMatmulPerm021Params, GgmlType,
112    MM_ROUTING_THRESHOLD,
113};
114pub use ops::mul_mv_ext::{mul_mv_ext_dispatch, MulMvExtParams};
115pub use ops::quantized_matmul_id::{
116    quantized_matmul_id, quantized_matmul_id_into, QuantizedMatmulIdParams,
117};
118pub use ops::quantized_matmul_id_ggml::{
119    dispatch_id_mm_for_test, quantized_matmul_id_ggml, quantized_matmul_id_ggml_pooled,
120    quantized_matmul_id_swiglu_q4_0,
121    GgmlIdMmDispatchParams, GgmlQuantizedMatmulIdParams, IdMmScratch,
122    MM_ID_ROUTING_THRESHOLD,
123};
124
125// Re-export weight loading utilities.
126pub use weight::{
127    load_quantized_weights, safetensors_to_metal_buffer, QuantizationConfig, QuantizedWeight,
128    SafetensorsFile, TensorQuantConfig,
129};
130
131// Re-export metal types that appear in the public API.
132pub use metal::MTLSize;
133pub use metal;
134
135#[cfg(test)]
136#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
137mod tests {
138    use super::*;
139
140    // ---- T10.7: compile-time Send + Sync assertions ----
141    fn _assert_send<T: Send>() {}
142    fn _assert_sync<T: Sync>() {}
143
144    #[allow(dead_code)]
145    fn assert_send_sync() {
146        _assert_send::<MlxDevice>();
147        _assert_sync::<MlxDevice>();
148        _assert_send::<MlxBuffer>();
149        _assert_sync::<MlxBuffer>();
150        _assert_send::<MlxError>();
151        _assert_sync::<MlxError>();
152    }
153
154    // ---- T10.1: device initialization ----
155    #[test]
156    fn test_device_init() {
157        let device = MlxDevice::new().expect("MlxDevice::new() should succeed on Apple Silicon");
158        let name = device.name();
159        assert!(!name.is_empty(), "Device name should not be empty");
160        println!("Metal device: {name}");
161    }
162
163    // ---- T10.2: buffer allocation ----
164    #[test]
165    fn test_buffer_alloc() {
166        let device = MlxDevice::new().expect("device");
167        let shape = vec![2, 3, 4];
168        let byte_len = 2 * 3 * 4 * DType::F32.size_of(); // 96 bytes
169        let buf = device
170            .alloc_buffer(byte_len, DType::F32, shape.clone())
171            .expect("alloc_buffer");
172
173        assert_eq!(buf.dtype(), DType::F32);
174        assert_eq!(buf.shape(), &shape);
175        assert_eq!(buf.byte_len(), byte_len);
176        assert_eq!(buf.element_count(), 24);
177    }
178
179    // ---- T10.3: buffer read/write round-trip ----
180    #[test]
181    fn test_buffer_readwrite() {
182        let device = MlxDevice::new().expect("device");
183        let n = 64;
184        let byte_len = n * std::mem::size_of::<f32>();
185        let mut buf = device
186            .alloc_buffer(byte_len, DType::F32, vec![n])
187            .expect("alloc_buffer");
188
189        // Write known data.
190        {
191            let slice: &mut [f32] = buf.as_mut_slice().expect("as_mut_slice");
192            assert_eq!(slice.len(), n);
193            for (i, val) in slice.iter_mut().enumerate() {
194                *val = i as f32 * 1.5;
195            }
196        }
197
198        // Read back and verify.
199        {
200            let slice: &[f32] = buf.as_slice().expect("as_slice");
201            for (i, &val) in slice.iter().enumerate() {
202                let expected = i as f32 * 1.5;
203                assert!(
204                    (val - expected).abs() < f32::EPSILON,
205                    "Mismatch at index {i}: got {val}, expected {expected}"
206                );
207            }
208        }
209    }
210
211    // ---- T10.4: encoder lifecycle ----
212    #[test]
213    fn test_encoder_lifecycle() {
214        let device = MlxDevice::new().expect("device");
215        let mut enc = device.command_encoder().expect("command_encoder");
216        // Commit an empty command buffer — should succeed (no-op on GPU).
217        enc.commit_and_wait()
218            .expect("commit_and_wait on empty encoder");
219    }
220
221    // ---- T10.5: buffer pool reuse ----
222    #[test]
223    fn test_buffer_pool_reuse() {
224        let device = MlxDevice::new().expect("device");
225        let mut pool = MlxBufferPool::new();
226
227        // Allocate a buffer.
228        let buf1 = pool
229            .alloc(&device, 1024, DType::F32, vec![256])
230            .expect("pool alloc 1");
231        let buf1_ptr = buf1.contents_ptr();
232        let buf1_byte_len = buf1.byte_len();
233
234        // Release it back to the pool.
235        pool.release(buf1);
236        assert_eq!(pool.free_count(), 1);
237
238        // Allocate again — should reuse the same Metal buffer.
239        let buf2 = pool
240            .alloc(&device, 1024, DType::F32, vec![256])
241            .expect("pool alloc 2");
242        let buf2_ptr = buf2.contents_ptr();
243        let buf2_byte_len = buf2.byte_len();
244
245        assert_eq!(buf1_ptr, buf2_ptr, "Pool should reuse the same Metal buffer");
246        assert_eq!(buf1_byte_len, buf2_byte_len, "Byte lengths should match");
247        assert_eq!(pool.free_count(), 0, "Free list should be empty after reuse");
248    }
249
250    // ---- T10.6: kernel registry caching ----
251    #[test]
252    fn test_kernel_registry_caching() {
253        let device = MlxDevice::new().expect("device");
254        let mut registry = KernelRegistry::new();
255
256        // Register a minimal test kernel.
257        registry.register_source(
258            "test_add",
259            r#"
260            #include <metal_stdlib>
261            using namespace metal;
262            kernel void test_add(
263                device float *a [[buffer(0)]],
264                device float *b [[buffer(1)]],
265                device float *c [[buffer(2)]],
266                uint id [[thread_position_in_grid]]
267            ) {
268                c[id] = a[id] + b[id];
269            }
270            "#,
271        );
272
273        // First call — compiles the shader.
274        assert!(!registry.is_cached("test_add"));
275        let p1 = registry
276            .get_pipeline("test_add", device.metal_device())
277            .expect("get_pipeline first call");
278        let p1_ptr = p1 as *const _;
279        assert!(registry.is_cached("test_add"));
280
281        // Second call — returns cached pipeline.
282        let p2 = registry
283            .get_pipeline("test_add", device.metal_device())
284            .expect("get_pipeline second call");
285        let p2_ptr = p2 as *const _;
286
287        assert_eq!(
288            p1_ptr, p2_ptr,
289            "Second get_pipeline call should return the same cached pipeline"
290        );
291    }
292
293    // ---- Additional: test alloc_buffer with zero length returns error ----
294    #[test]
295    fn test_buffer_alloc_zero_len_error() {
296        let device = MlxDevice::new().expect("device");
297        let result = device.alloc_buffer(0, DType::F32, vec![]);
298        assert!(result.is_err(), "Zero-length allocation should fail");
299        match result {
300            Err(MlxError::InvalidArgument(_)) => {}
301            other => panic!("Expected InvalidArgument, got {:?}", other),
302        }
303    }
304
305    // ---- Additional: test kernel not found ----
306    #[test]
307    fn test_kernel_not_found() {
308        let device = MlxDevice::new().expect("device");
309        let mut registry = KernelRegistry::new();
310        let result = registry.get_pipeline("nonexistent_kernel", device.metal_device());
311        assert!(result.is_err());
312        match result {
313            Err(MlxError::KernelNotFound(name)) => {
314                assert_eq!(name, "nonexistent_kernel");
315            }
316            other => panic!("Expected KernelNotFound, got {:?}", other),
317        }
318    }
319
320    // ---- Additional: test DType properties ----
321    #[test]
322    fn test_dtype_sizes() {
323        assert_eq!(DType::F32.size_of(), 4);
324        assert_eq!(DType::F16.size_of(), 2);
325        assert_eq!(DType::BF16.size_of(), 2);
326        assert_eq!(DType::U8.size_of(), 1);
327        assert_eq!(DType::U16.size_of(), 2);
328        assert_eq!(DType::U32.size_of(), 4);
329        assert_eq!(DType::I32.size_of(), 4);
330    }
331
332    // ---- Additional: test MlxBuffer Debug ----
333    #[test]
334    fn test_buffer_debug() {
335        let device = MlxDevice::new().expect("device");
336        let buf = device
337            .alloc_buffer(64, DType::F16, vec![4, 8])
338            .expect("alloc_buffer");
339        let debug_str = format!("{:?}", buf);
340        assert!(debug_str.contains("MlxBuffer"));
341        assert!(debug_str.contains("F16"));
342        assert!(debug_str.contains("[4, 8]"));
343    }
344
345    // ---- Additional: test MlxError Display ----
346    #[test]
347    fn test_error_display() {
348        let e = MlxError::DeviceNotFound;
349        assert!(format!("{e}").contains("Metal GPU device"));
350
351        let e = MlxError::ShaderCompilationError {
352            name: "foo".into(),
353            message: "syntax error".into(),
354        };
355        assert!(format!("{e}").contains("foo"));
356        assert!(format!("{e}").contains("syntax error"));
357    }
358
359    // ---- Additional: test buffer pool with different sizes ----
360    #[test]
361    fn test_buffer_pool_size_buckets() {
362        let device = MlxDevice::new().expect("device");
363        let mut pool = MlxBufferPool::new();
364
365        // Allocate a 100-byte buffer (rounds to 128-byte bucket).
366        let buf_100 = pool.alloc(&device, 100, DType::U8, vec![100]).expect("alloc 100");
367        assert!(
368            buf_100.byte_len() >= 100,
369            "Buffer should be at least 100 bytes"
370        );
371        pool.release(buf_100);
372
373        // Allocate a 128-byte buffer — should reuse the same Metal buffer.
374        let buf_128 = pool.alloc(&device, 128, DType::U8, vec![128]).expect("alloc 128");
375        assert!(buf_128.byte_len() >= 128);
376        pool.release(buf_128);
377
378        // Allocate a 200-byte buffer — different bucket (256), fresh allocation.
379        let buf_200 = pool.alloc(&device, 200, DType::U8, vec![200]).expect("alloc 200");
380        assert!(buf_200.byte_len() >= 200);
381        pool.release(buf_200);
382
383        assert_eq!(pool.free_count(), 2, "Two different bucket sizes in pool");
384    }
385}