mlx-rs

repository·main·Indexed 18 days ago

https://github.com/oxiglade/mlx-rs

Safe, idiomatic Rust bindings for Apple's MLX machine learning library, optimized for Apple Silicon. It features lazy evaluation, dynamic graphs, unified memory, and multi-device support (CPU/GPU). The project includes mlx-sys for low-level C API access and mlx-lm for LLM inference, providing tools for Llama model loading, KV cache management via the KeyValueCache trait, and text generation using a builder-pattern API.

Tokens
22K
Snippets
85
Records
101
Agent score
62%

What's inside mlx-rs

  1. Core features of mlx-rs

    main

    The mlx-rs library provides safe and idiomatic Rust bindings for Apple's MLX framework, featuring:

    • Performance: Optimized for Apple Silicon.
    • Lazy Evaluation: Arrays are only materialized when needed to optimize memory and performance.
    • Dynamic Graphs: Computation graphs are constructed dynamically; changing argument shapes does not require recompilation.
    • Multi-Device Support: Supports CPU and GPU.
    • Unified Memory: Arrays live in a unified memory space, allowing operations across different devices without data copying.
  2. How to use automatic differentiation in mlx-rs

    main

    Unlike Python's MLX, mlx-rs requires you to be explicit about which arrays are traced in the computation graph. To avoid segfaults, do not capture arrays from the outer scope inside your loss function closure. Instead, pass all required arrays as inputs to the closure.

    Correct Pattern

    1. Define a loss function that accepts a slice of arrays: |inputs: &[Array]| -> Result<Array, Exception>.
    2. Extract the necessary arrays (weights, data, targets) from the inputs slice inside the closure.
    3. Use transforms::grad to create the gradient function.
    4. Pass all required arrays in the input slice to the gradient function.
    5. When training, manually update the arrays in your input vector and call .eval() on the updated array.
    // 1. Define loss function with explicit inputs
    let loss_fn = |inputs: &[Array]| -> Result<Array, Exception> {
        let w = &inputs[0];
        let x = &inputs[1];
        let y = &inputs[2];
    
        let y_pred = x.matmul(w)?;
        let loss = Array::from_f32(0.5) * ops::mean(&ops::square(y_pred - y)?, None, None)?;
        Ok(loss)
    };
    
    let argnums = &[0]; // Differentiate with respect to the first argument (w)
    let mut inputs = vec![w, x, y];
    
    // 2. Compute gradients
    let grad = transforms::grad(loss_fn, argnums)(&inputs)?;
    
    // 3. Update weights in the training loop
    inputs[0] = &inputs[0] - Array::from_f32(learning_rate) * grad;
    inputs[0].eval()?; // Ensure the update is materialized
  3. How lazy evaluation works in MLX

    main

    In MLX, performing operations on arrays does not trigger immediate computation; instead, it records a compute graph. To execute the operations and compute the actual values, you must explicitly call Array::eval or trigger an implicit evaluation.

    Explicit Evaluation

    Use Array::eval() to evaluate the output of an operation.

    Implicit Evaluation

    An array is automatically evaluated when you:

    • Use Array::item to get a scalar value.
    • Print the array with println!.
    • Access the underlying data via Array::as_slice.
    • Save the array using Array::save_numpy or Array::save_safetensors.

    Best Practices

    • Batching: Avoid calling eval() too frequently (e.g., inside every single arithmetic operation) as there is fixed overhead per evaluation. A good pattern is to evaluate at the end of each training iteration (e.g., after computing loss and gradients).
    • Control Flow: Using scalar arrays for control flow (e.g., if array.item() > 0.5 { ... }) triggers an evaluation, which can be inefficient if done frequently.
    use mlx_rs::{array, transforms::eval};
    
    let a = array!([1, 2, 3, 4]);
    let b = array!([1.0, 2.0, 3.0, 4.0]);
    
    let c = &a + &b; // c is not evaluated
    c.eval().unwrap(); // evaluates c
    
    let d = &a + &b;
    println!("{:?}", d); // evaluates d
    
    let e = &a + &b;
    let e_slice: &[f32] = e.as_slice(); // evaluates e
  4. How unified memory works in MLX

    main

    MLX leverages Apple Silicon's unified memory architecture, where the CPU and GPU share the same memory pool. When you create an array, you do not need to specify a device location (like 'cpu' or 'gpu').

    Instead of moving data between devices, you specify the device at the time of the operation. This allows any device to perform operations on the same array without expensive memory transfers.

    // Arrays are created in unified memory by default
    let a = mlx_rs::normal!(shape=&[100]).unwrap();
    let b = mlx_rs::normal!(shape=&[100]).unwrap();
    
    // Specify the device during the operation
    mlx_rs::add!(&a, &b, stream=StreamOrDevice::cpu()).unwrap();
    mlx_rs::add!(&a, &b, stream=StreamOrDevice::gpu()).unwrap();
  5. How Module and ModuleParameters work together

    main

    In mlx-rs, a Module represents a functional unit of a neural network (like a layer or a full model), while ModuleParameters represents the state (the weights) of that unit.

    • Composition: A Module is composed of other Modules. When you call training_mode or freeze_parameters on a parent module, it is expected that these calls propagate down to all child modules.
    • Parameter Hierarchy: Parameters can be accessed as a NestedHashMap (preserving the structural hierarchy of the model) or as a FlattenedModuleParam (a simple HashMap<Rc<str>, Array> where keys are full paths to the parameters).
    • Training vs. Eval: The training_mode method is crucial for layers like Dropout or Batch Normalization, which behave differently during training and inference. The ModuleParametersExt::eval() method can be used to evaluate the module parameters.
  6. Manage reproducible random number generation with `RandomState`

    main

    RandomState is used for reproducible random number generation. It holds the PRNG state and is compatible with compile_with_state, allowing random state to be tracked across JIT compilation boundaries (similar to Python's @partial(mx.compile, inputs=mx.random.state, ...)).

    Key Methods

    • RandomState::new(): Creates a new state using a time-based seed.
    • RandomState::with_seed(seed: u64): Creates a state from a specific seed for reproducibility.
    • RandomState::from_key(key: Array): Creates a state from an existing PRNG key array.
    • RandomState::next_key(): Advances the state and returns a new key for use in random operations.
    • RandomState::seed(seed: u64): Reseeds the existing state.
    • RandomState::as_array(): Returns a reference to the underlying state array.

    Usage with Compiled Functions

    To use RandomState within a compiled function, pass it as a mutable argument to compile_with_state so the state can be updated during execution.

    use mlx_rs::random::RandomState;
    use mlx_rs::transforms::compile::compile_with_state;
    use mlx_rs::random::categorical;
    use mlx_rs::Array;
    
    let mut state = RandomState::with_seed(42).unwrap();
    let logits = Array::zeros::<f32>(&[1, 10]).unwrap();
    let mut compiled = compile_with_state(
        |state: &mut RandomState, x: &Array| {
            let key = state.next_key()?;
            categorical(x, None, None, Some(&key))
        },
        None
    );
    let result = compiled(&mut state, &logits).unwrap();
  7. Use `fftshift` and `ifftshift` for FFT spectrum centering

    main

    In Fourier analysis, the zero-frequency component is often at the start of the array. fftshift moves this component to the center of the spectrum by swapping half-spaces. To revert this transformation, use ifftshift.

    Note on Odd Lengths: For arrays with an odd number of elements, fftshift and ifftshift are not identical. You must use ifftshift to correctly undo a fftshift operation on odd-length data.

  8. Configure RoPE scaling via `FloatOrString`

    main

    The scaling_config passed to RoPE initialization uses a HashMap<String, FloatOrString>. The FloatOrString enum allows configuration values to be either a literal f32 or a String (which can be parsed into an f32).

    This is useful for handling configuration files where numbers might be represented as strings (e.g., "4096") or where special keywords like "default" or "linear" are used to select the RoPE type.

  9. Handle MLX exceptions and errors

    main

    Most errors in mlx-rs originate from the underlying C++ MLX API and are wrapped in the Exception struct. An Exception contains a descriptive error message (what()) and the Location in the code where the error was first encountered.

    Many high-level functions return a Result<T>, which is a type alias for std::result::Result<T, Exception>. When an operation fails, you can inspect the Exception to understand the cause (e.g., shape mismatches during tensor operations).

    // Example of handling a shape mismatch error
    let a = array!([1.0, 2.0, 3.0]);
    let b = array!([4.0, 5.0]);
    
    let result = a.add(&b);
    if let Err(e) = result {
        println!("Error: {}", e.what());
        // Output might be: "Shapes (3) and (2) cannot be broadcast." at [location]
    }
  10. Configure mlx-rs feature flags

    main

    You can enable specific hardware acceleration features using feature flags in your Cargo.toml:

    • metal: Enables Metal (GPU) usage.
    • accelerate: Enables the use of Apple's Accelerate framework.
    [dependencies]
    mlx-rs = {
        version = "0.21.0",
        features = ["metal", "accelerate"]
    }