MLX Tutorial: Apple's Machine Learning Framework for Apple Silicon

# Tutorial MLX: Framework Machine Learning Apple untuk Apple Silicon MLX adalah framework machine learning open-source dari Apple yang dirancang khusus untuk Apple Silicon (M1, M2, M3, M4). Framework...

By Ruby Abdullah · · tutorial
MLXApple SiliconMachine LearningLLMPython

MLX Tutorial: Apple's Machine Learning Framework for Apple Silicon

MLX is an open-source machine learning framework from Apple, designed specifically for Apple Silicon chips (M1, M2, M3, M4). The framework offers a familiar API for NumPy and PyTorch users, with performance optimized for Apple hardware. MLX has become the go-to choice for developers who want to run ML models locally on Mac devices without requiring NVIDIA GPUs.

In this tutorial, we will cover installation, basic usage, LLM fine-tuning, model inference, and best practices to maximize MLX performance on Apple Silicon devices.

Why MLX?

Before diving in, let's understand why MLX is worth learning:

  • Unified Memory Architecture: MLX leverages the unified memory on Apple Silicon, allowing arrays to reside on CPU or GPU without manual data copying.
  • Lazy Evaluation: Computations are only executed when results are actually needed, making programs more efficient.
  • Familiar API: The API design follows NumPy and PyTorch conventions, resulting in a very low learning curve.
  • Composable Function Transformations: Supports automatic differentiation, vectorization, and graph computation in a composable manner.
  • Multi-Device: Operations can seamlessly run on CPU or GPU.
  • Installation

    Prerequisites

    • macOS 13.5 or later
    • Apple Silicon (M1/M2/M3/M4)
    • Python 3.9 or later

    Installing MLX Core

    pip install mlx
    

    Installing MLX-LM (for Language Models)

    pip install mlx-lm
    

    Installing MLX-VLM (for Vision-Language Models)

    pip install mlx-vlm
    

    Installing from Source (Optional)

    git clone https://github.com/ml-explore/mlx.git
    

    cd mlx

    pip install -e .

    Verify Installation

    import mlx.core as mx
    

    import mlx.nn as nn

    print(f"MLX version: {mx.version}")

    print(f"Default device: {mx.defaultdevice()}")

    Simple test

    a = mx.array([1, 2, 3, 4, 5])

    print(f"Array: {a}")

    print(f"Sum: {mx.sum(a)}")

    Expected output:

    MLX version: 0.x.x
    

    Default device: Device(gpu, 0)

    Array: array([1, 2, 3, 4, 5], dtype=int32)

    Sum: array(15, dtype=int32)

    Basic Usage

    Array Operations

    MLX arrays are very similar to NumPy but optimized for Apple Silicon:

    import mlx.core as mx
    
    

    Creating arrays

    a = mx.array([1.0, 2.0, 3.0, 4.0])

    b = mx.ones((3, 4))

    c = mx.zeros((2, 3))

    d = mx.random.normal((5, 5))

    print(f"Shape of b: {b.shape}")

    print(f"Dtype of a: {a.dtype}")

    Mathematical operations

    x = mx.array([[1, 2], [3, 4]], dtype=mx.float32)

    y = mx.array([[5, 6], [7, 8]], dtype=mx.float32)

    Element-wise operations

    print(f"Addition: {x + y}")

    print(f"Multiplication: {x y}")

    Matrix multiplication

    print(f"MatMul: {x @ y}")

    Reduction operations

    print(f"Sum: {mx.sum(x)}")

    print(f"Mean: {mx.mean(x)}")

    print(f"Max: {mx.max(x)}")

    Lazy Evaluation

    One of MLX's unique features is lazy evaluation. Computations are not executed until their results are actually needed:

    import mlx.core as mx
    
    

    a = mx.ones((1000, 1000))

    b = mx.ones((1000, 1000))

    These operations are not yet executed

    c = a + b

    d = c 2

    Evaluation happens when we need the result

    mx.eval(d)

    print(d)

    Or when converting to Python/NumPy

    result = d.tolist()

    Device Control

    import mlx.core as mx
    
    

    Check default device

    print(f"Default device: {mx.defaultdevice()}")

    Run on CPU

    mx.setdefaultdevice(mx.cpu)

    a = mx.ones((100, 100))

    print(f"Device: {mx.defaultdevice()}")

    Switch back to GPU

    mx.setdefaultdevice(mx.gpu)

    b = mx.ones((100, 100))

    print(f"Device: {mx.defaultdevice()}")

    Building Neural Networks with MLX

    Simple Model

    MLX provides the mlx.nn module, similar to PyTorch:

    import mlx.core as mx
    

    import mlx.nn as nn

    import mlx.optimizers as optim

    class SimpleNet(nn.Module):

    def init(self):

    super().init()

    self.layers = [

    nn.Linear(784, 256),

    nn.Linear(256, 128),

    nn.Linear(128, 10),

    ]

    def call(self, x):

    for i, layer in enumerate(self.layers[:-1]):

    x = nn.relu(layer(x))

    return self.layers-1

    Initialize model

    model = SimpleNet()

    Check parameters

    params = model.parameters()

    print(f"Model initialized with multiple layers")

    Training Loop

    import mlx.core as mx
    

    import mlx.nn as nn

    import mlx.optimizers as optim

    import numpy as np

    class MLP(nn.Module):

    def init(self, inputdim, hiddendim, outputdim):

    super().init()

    self.linear1 = nn.Linear(inputdim, hiddendim)

    self.linear2 = nn.Linear(hiddendim, outputdim)

    def call(self, x):

    x = nn.relu(self.linear1(x))

    return self.linear2(x)

    Synthetic data

    np.random.seed(42)

    X = np.random.randn(1000, 10).astype(np.float32)

    y = (X[:, 0] + X[:, 1] > 0).astype(np.int32)

    Xtrain = mx.array(X[:800])

    ytrain = mx.array(y[:800])

    Xtest = mx.array(X[800:])

    ytest = mx.array(y[800:])

    Model and optimizer

    model = MLP(10, 64, 2)

    optimizer = optim.Adam(learningrate=1e-3)

    Loss function

    def lossfn(model, x, y):

    logits = model(x)

    return mx.mean(nn.losses.crossentropy(logits, y))

    Training

    lossandgradfn = nn.valueandgrad(model, lossfn)

    for epoch in range(100):

    loss, grads = lossandgradfn(model, Xtrain, ytrain)

    optimizer.update(model, grads)

    mx.eval(model.parameters(), optimizer.state)

    if (epoch + 1) % 20 == 0:

    testlogits = model(Xtest)

    testpreds = mx.argmax(testlogits, axis=1)

    accuracy = mx.mean(testpreds == ytest)

    print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}, Test Acc: {accuracy.item():.4f}")

    Convolutional Neural Network

    import mlx.core as mx
    

    import mlx.nn as nn

    class ConvNet(nn.Module):

    def init(self, numclasses=10):

    super().init()

    self.conv1 = nn.Conv2d(1, 32, kernelsize=3, padding=1)

    self.conv2 = nn.Conv2d(32, 64, kernelsize=3, padding=1)

    self.pool = nn.MaxPool2d(kernelsize=2, stride=2)

    self.fc1 = nn.Linear(64 7 7, 128)

    self.fc2 = nn.Linear(128, numclasses)

    self.dropout = nn.Dropout(p=0.5)

    def call(self, x):

    x = self.pool(nn.relu(self.conv1(x)))

    x = self.pool(nn.relu(self.conv2(x)))

    x = x.reshape(x.shape[0], -1)

    x = self.dropout(nn.relu(self.fc1(x)))

    return self.fc2(x)

    model = ConvNet()

    print("ConvNet initialized successfully")

    Using MLX-LM for Language Models

    MLX-LM is a library that simplifies working with Large Language Models on Apple Silicon.

    Inference with Hugging Face Models

    # Download and run model
    

    mlxlm.generate --model mlx-community/Llama-3.2-3B-Instruct-4bit \

    --prompt "Explain machine learning in simple terms" \

    --max-tokens 200

    Python Inference

    from mlxlm import load, generate
    
    

    Load model (automatically downloads from Hugging Face)

    model, tokenizer = load("mlx-community/Llama-3.2-3B-Instruct-4bit")

    Generate text

    prompt = "Explain what machine learning is in simple terms:"

    messages = [{"role": "user", "content": prompt}]

    formattedprompt = tokenizer.applychattemplate(

    messages, tokenize=False, addgenerationprompt=True

    )

    response = generate(

    model,

    tokenizer,

    prompt=formattedprompt,

    maxtokens=500,

    temp=0.7,

    )

    print(response)

    Streaming Response

    from mlxlm import load, streamgenerate
    
    

    model, tokenizer = load("mlx-community/Mistral-7B-Instruct-v0.3-4bit")

    prompt = "Write a Python function to calculate fibonacci numbers:"

    messages = [{"role": "user", "content": prompt}]

    formattedprompt = tokenizer.applychattemplate(

    messages, tokenize=False, addgenerationprompt=True

    )

    Streaming output

    for token in streamgenerate(

    model,

    tokenizer,

    prompt=formattedprompt,

    maxtokens=500,

    ):

    print(token, end="", flush=True)

    print()

    Converting Models to MLX Format

    You can convert Hugging Face models to optimized MLX format:

    # Convert with 4-bit quantization
    

    mlxlm.convert \

    --hf-path meta-llama/Llama-3.2-3B-Instruct \

    --mlx-path ./mlx-llama-3.2-3b-4bit \

    --quantize \

    --q-bits 4

    Convert without quantization

    mlxlm.convert \

    --hf-path microsoft/Phi-3-mini-4k-instruct \

    --mlx-path ./mlx-phi-3-mini

    Python Conversion

    from mlxlm import convert
    
    

    convert(

    hfpath="meta-llama/Llama-3.2-1B-Instruct",

    mlxpath="./mlx-llama-1b",

    quantize=True,

    qbits=4,

    qgroupsize=64,

    )

    Fine-Tuning with MLX

    LoRA Fine-Tuning

    MLX-LM supports fine-tuning using LoRA (Low-Rank Adaptation):

    # Prepare training data in JSONL format
    

    train.jsonl format:

    {"text": "instruction: ... response: ..."}

    or chat format:

    {"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}

    Create a configuration file loraconfig.yaml:

    model: "mlx-community/Llama-3.2-3B-Instruct-4bit"
    

    train: true

    data: "./trainingdata"

    seed: 42

    loralayers: 16

    batchsize: 4

    iters: 1000

    valbatches: 25

    learningrate: 1e-5

    loraparameters:

    keys: ["selfattn.qproj", "selfattn.vproj"]

    rank: 8

    alpha: 16

    dropout: 0.0

    Run fine-tuning:

    mlxlm.lora \
    

    --model mlx-community/Llama-3.2-3B-Instruct-4bit \

    --train \

    --data ./trainingdata \

    --batch-size 4 \

    --lora-layers 16 \

    --iters 1000 \

    --learning-rate 1e-5

    Preparing Training Data

    import json
    
    

    Format data for fine-tuning

    trainingdata = [

    {

    "messages": [

    {"role": "user", "content": "What is Python?"},

    {"role": "assistant", "content": "Python is a high-level programming language designed for code readability. Python supports multiple programming paradigms including procedural, object-oriented, and functional programming."}

    ]

    },

    {

    "messages": [

    {"role": "user", "content": "How do you create a list in Python?"},

    {"role": "assistant", "content": "To create a list in Python, use square brackets []. For example: mylist = [1, 2, 3, 'hello']. Lists in Python can store various data types."}

    ]

    },

    ]

    Save as JSONL

    with open("trainingdata/train.jsonl", "w") as f:

    for item in trainingdata:

    f.write(json.dumps(item) + "\n")

    Validation data

    with open("trainingdata/valid.jsonl", "w") as f:

    for item in trainingdata[:10]:

    f.write(json.dumps(item) + "\n")

    Using Fine-Tuned Adapters

    from mlxlm import load, generate
    
    

    Load model with adapter

    model, tokenizer = load(

    "mlx-community/Llama-3.2-3B-Instruct-4bit",

    adapterpath="./adapters"

    )

    Generate with fine-tuned model

    response = generate(

    model,

    tokenizer,

    prompt="What is Python?",

    maxtokens=200

    )

    print(response)

    Fusing Adapters into the Model

    After fine-tuning, you can merge the adapter with the base model:

    mlxlm.fuse \
    

    --model mlx-community/Llama-3.2-3B-Instruct-4bit \

    --adapter-path ./adapters \

    --save-path ./fused-model

    MLX for Computer Vision

    Image Classification

    import mlx.core as mx
    

    import mlx.nn as nn

    class ResidualBlock(nn.Module):

    def init(self, channels):

    super().init()

    self.conv1 = nn.Conv2d(channels, channels, kernelsize=3, padding=1)

    self.bn1 = nn.BatchNorm(channels)

    self.conv2 = nn.Conv2d(channels, channels, kernelsize=3, padding=1)

    self.bn2 = nn.BatchNorm(channels)

    def call(self, x):

    residual = x

    x = nn.relu(self.bn1(self.conv1(x)))

    x = self.bn2(self.conv2(x))

    return nn.relu(x + residual)

    class SimpleResNet(nn.Module):

    def init(self, numclasses=10):

    super().init()

    self.conv1 = nn.Conv2d(3, 64, kernelsize=7, stride=2, padding=3)

    self.bn1 = nn.BatchNorm(64)

    self.pool = nn.MaxPool2d(kernelsize=3, stride=2, padding=1)

    self.block1 = ResidualBlock(64)

    self.block2 = ResidualBlock(64)

    self.globalpool = nn.AvgPool2d(kernelsize=7)

    self.fc = nn.Linear(64, numclasses)

    def call(self, x):

    x = self.pool(nn.relu(self.bn1(self.conv1(x))))

    x = self.block1(x)

    x = self.block2(x)

    x = self.globalpool(x)

    x = x.reshape(x.shape[0], -1)

    return self.fc(x)

    model = SimpleResNet(numclasses=10)

    dummyinput = mx.random.normal((1, 3, 224, 224))

    output = model(dummyinput)

    print(f"Output shape: {output.shape}")

    MLX Server: OpenAI-Compatible API

    MLX-LM provides a server compatible with the OpenAI API:

    # Start server
    

    mlxlm.server --model mlx-community/Llama-3.2-3B-Instruct-4bit --port 8080

    Use with OpenAI SDK:

    from openai import OpenAI
    
    

    client = OpenAI(

    baseurl="http://localhost:8080/v1",

    apikey="not-needed"

    )

    response = client.chat.completions.create(

    model="mlx-community/Llama-3.2-3B-Instruct-4bit",

    messages=[

    {"role": "system", "content": "You are a helpful assistant."},

    {"role": "user", "content": "What are the benefits of machine learning?"}

    ],

    maxtokens=300,

    temperature=0.7

    )

    print(response.choices[0].message.content)

    Or use curl:

    curl http://localhost:8080/v1/chat/completions \
    

    -H "Content-Type: application/json" \

    -d '{

    "model": "mlx-community/Llama-3.2-3B-Instruct-4bit",

    "messages": [{"role": "user", "content": "Hello!"}],

    "maxtokens": 100

    }'

    Benchmarks and Performance

    Measuring Throughput

    import mlx.core as mx
    

    import time

    def benchmarkmatmul(size, iterations=100):

    a = mx.random.normal((size, size))

    b = mx.random.normal((size, size))

    mx.eval(a, b)

    start = time.time()

    for in range(iterations):

    c = a @ b

    mx.eval(c)

    elapsed = time.time() - start

    flops = 2 size3 iterations

    gflops = flops / elapsed / 1e9

    print(f"Size {size}x{size}: {gflops:.1f} GFLOPS ({elapsed/iterations1000:.2f} ms/iter)")

    for size in [512, 1024, 2048, 4096]:

    benchmarkmatmul(size)

    LLM Inference Speed Comparison

    from mlxlm import load, generate
    

    import time

    modelstotest = [

    "mlx-community/Llama-3.2-1B-Instruct-4bit",

    "mlx-community/Llama-3.2-3B-Instruct-4bit",

    "mlx-community/Mistral-7B-Instruct-v0.3-4bit",

    ]

    prompt = "Explain the concept of neural networks in detail:"

    for modelname in modelstotest:

    model, tokenizer = load(modelname)

    messages = [{"role": "user", "content": prompt}]

    formatted = tokenizer.applychattemplate(

    messages, tokenize=False, addgenerationprompt=True

    )

    start = time.time()

    response = generate(model, tokenizer, prompt=formatted, maxtokens=200)

    elapsed = time.time() - start

    tokens = len(tokenizer.encode(response))

    print(f"{modelname}: {tokens/elapsed:.1f} tokens/sec")

    Advanced: Custom Operations

    Function Transformations

    MLX supports function transformations such as gradient computation and vectorization:

    import mlx.core as mx
    
    

    Automatic differentiation

    def f(x):

    return mx.sum(x 2)

    Gradient

    gradf = mx.grad(f)

    x = mx.array([1.0, 2.0, 3.0])

    print(f"Gradient: {gradf(x)}")

    Value and gradient

    valandgradf = mx.valueandgrad(f)

    value, gradient = valandgradf(x)

    print(f"Value: {value}, Gradient: {gradient}")

    Vectorized map

    def singlefn(x):

    return x 2 + 2 x + 1

    batch = mx.array([[1.0], [2.0], [3.0], [4.0]])

    results = mx.vmap(singlefn)(batch)

    print(f"Vectorized results: {results}")

    Custom Layer

    import mlx.core as mx
    

    import mlx.nn as nn

    class MultiHeadSelfAttention(nn.Module):

    def init(self, dims, numheads):

    super().init()

    self.numheads = numheads

    self.headdim = dims // numheads

    self.query = nn.Linear(dims, dims)

    self.key = nn.Linear(dims, dims)

    self.value = nn.Linear(dims, dims)

    self.out = nn.Linear(dims, dims)

    def call(self, x):

    B, T, C = x.shape

    q = self.query(x).reshape(B, T, self.numheads, self.headdim).transpose(0, 2, 1, 3)

    k = self.key(x).reshape(B, T, self.numheads, self.headdim).transpose(0, 2, 1, 3)

    v = self.value(x).reshape(B, T, self.numheads, self.headdim).transpose(0, 2, 1, 3)

    scale = self.headdim * -0.5

    attn = (q @ k.transpose(0, 1, 3, 2)) scale

    attn = mx.softmax(attn, axis=-1)

    out = (attn @ v).transpose(0, 2, 1, 3).reshape(B, T, C)

    return self.out(out)

    Test

    attention = MultiHeadSelfAttention(256, 8)

    x = mx.random.normal((2, 10, 256))

    output = attention(x)

    print(f"Attention output shape: {output.shape}")

    Best Practices

    1. Leverage Lazy Evaluation

    import mlx.core as mx
    
    

    Good: batch evaluations

    a = mx.random.normal((1000, 1000))

    b = mx.random.normal((1000, 1000))

    c = a @ b

    d = c + mx.oneslike(c)

    e = mx.sum(d)

    mx.eval(e) # Evaluate once at the end

    Less optimal: evaluating too frequently

    a = mx.random.normal((1000, 1000))

    mx.eval(a) # Unnecessary evaluation

    b = mx.random.normal((1000, 1000))

    mx.eval(b) # Unnecessary evaluation

    c = a @ b

    mx.eval(c) # Only this is needed

    2. Use Quantization for Large Models

    from mlxlm import load
    
    

    4-bit quantization saves ~75% memory

    model, tokenizer = load("mlx-community/Llama-3.2-3B-Instruct-4bit")

    8-bit for balance between quality and memory

    model, tokenizer = load("mlx-community/Llama-3.2-3B-Instruct-8bit")

    3. Manage Memory Properly

    import mlx.core as mx
    
    

    Delete arrays that are no longer needed

    largearray = mx.random.normal((10000, 10000))

    result = mx.sum(largearray)

    mx.eval(result)

    del largearray

    Use metal.clearcache() when needed

    mx.metal.clearcache()

    4. Batch Processing for Efficiency

    from mlxlm import load, generate
    
    

    model, tokenizer = load("mlx-community/Llama-3.2-3B-Instruct-4bit")

    prompts = [

    "What is Python?",

    "Explain Docker briefly.",

    "What is Kubernetes?"

    ]

    for prompt in prompts:

    messages = [{"role": "user", "content": prompt}]

    formatted = tokenizer.applychattemplate(

    messages, tokenize=False, addgenerationprompt=True

    )

    response = generate(model, tokenizer, prompt=formatted, maxtokens=100)

    print(f"Q: {prompt}")

    print(f"A: {response}\n")

    5. Monitor GPU Usage

    import mlx.core as mx
    
    

    Check memory usage

    peakmemory = mx.metal.getpeakmemory()

    activememory = mx.metal.getactivememory()

    cachememory = mx.metal.getcachememory()

    print(f"Peak Memory: {peakmemory / 1e9:.2f} GB")

    print(f"Active Memory: {activememory / 1e9:.2f} GB")

    print(f"Cache Memory: {cachememory / 1e9:.2f} GB")

    MLX Ecosystem

    MLX has a growing ecosystem of libraries:

    | Library | Purpose |

    |---------|---------|

    | mlx | Core framework (array ops, neural networks, optimizers) |

    | mlx-lm | Language model inference and fine-tuning |

    | mlx-vlm | Vision-language models |

    | mlx-audio | Audio processing and speech models |

    | mlx-data | Data loading and preprocessing |

    | mlx-graphs | Graph neural networks |

    | mlx-image | Computer vision utilities |

    Available Models in MLX Community

    The MLX community on Hugging Face provides hundreds of models pre-converted to MLX format:

    • Llama 3.x (1B, 3B, 8B, 70B) in various quantization levels
    • Mistral/Mixtral for efficient MoE models
    • Phi-3/Phi-4 small models from Microsoft
    • Qwen 2.5 from Alibaba
    • Gemma 2 from Google
    • DeepSeek models
    • Whisper for speech-to-text
    • Stable Diffusion for image generation

    Browse models at: https://huggingface.co/mlx-community

    Conclusion

    MLX is an incredibly powerful framework for running machine learning workloads on Apple Silicon. With its familiar API, lazy evaluation support, and unified memory architecture, MLX enables developers to fully leverage the capabilities of Apple chips.

    Key takeaways:

  • Easy installation: Simply pip install mlx mlx-lm to get started
  • Familiar API: If you already know NumPy or PyTorch, transitioning to MLX is seamless
  • Fast LLM inference: MLX-LM delivers competitive inference performance for popular models
  • Efficient fine-tuning: LoRA fine-tuning enables model customization with minimal resources
  • OpenAI-compatible server: Easy integration with applications already using the OpenAI API
  • Growing ecosystem: Community support and additional libraries continue to expand
  • MLX is ideal for prototyping, local development, research, and even deployment on Apple devices. With an ever-growing number of models available in MLX format, this framework has become the primary choice for ML on Apple Silicon.

    Related Articles

    DSPy: A Framework for Programmatic LLM Optimization

    DSPy: Framework untuk Optimasi LLM Secara Programatik Prompt engineering secara manual adalah proses yang melelahkan dan...

    Complete vLLM Tutorial: High-Performance LLM Serving

    Tutorial Lengkap vLLM: High-Performance LLM Serving vLLM adalah library Python untuk inference dan serving LLM dengan pe...

    Complete Ollama Tutorial: Deploy LLMs Locally

    Tutorial Lengkap Ollama: Deploy LLM Secara Lokal Ollama adalah tool open-source yang memudahkan Anda menjalankan Large L...

    DSPy: Stop Hand-Tuning Prompts, Let the Compiler Optimize Them

    DSPy: Berhenti Ngoprek Prompt Manual, Biarkan Compiler yang Optimasi Halo temen-temen, kali ini aku mau ngenalin satu li...