On an Apple Silicon Mac with Go and the Hugging Face CLI (hf) installed,
run from this repository's root:
brew install mlx-c
hf download Qwen/Qwen2.5-0.5B-Instruct config.json model.safetensors tokenizer.json tokenizer_config.json \
--revision 7ae557604adf67be50417f59c2c2f167def9a775 \
--local-dir models/Qwen2.5-0.5B-Instruct
go run -tags mlx ./cmd/generate -prompt "Explain why the sky is blue in one sentence."Or use the same downloaded model from Go (build with -tags mlx):
import (
"fmt"
mlx "github.com/moncho/mlxgo"
"github.com/moncho/mlxgo/inference"
)
func generate() error {
if err := mlx.SetDefaultGPU(); err != nil { return err }
model, err := inference.Open("models/Qwen2.5-0.5B-Instruct", inference.Options{})
if err != nil { return err }
defer model.Close()
result, err := model.Generate("Explain why the sky is blue.", 64)
if err != nil { return err }
fmt.Println(result.Text)
return nil
}Go bindings for Apple's MLX through the official MLX C bridge, with array, autograd and optimizer APIs, Qwen2.5-0.5B inference, and LoRA fine-tuning. The higher-level model packages are experimental and deliberately narrow.
This source tree targets v0.3.0, still a pre-1.0 library. Check published releases for availability. See compatibility and API stability, release notes, and the release procedure.
The DeepSeek experimental package runs a reduced float32
text backbone with sparse attention, MoE, residual mixing and per-session caches.
It shares an inference interface and greedy decoder (lm) with Qwen. It does
not yet load or run released DeepSeek-V4.1-Flash checkpoints.
The reduced model also supports float32 Engram with prepared token-history
hashes; its reference tests cover lookup/gating gradients and incremental state.
MLX itself does not expose an official Go API. The supported native path is:
Go -> cgo -> mlx-c -> MLX
- Apple Silicon Mac
- macOS 14 or newer
- Go 1.26 or 1.27 with cgo enabled
- Homebrew
mlx-c0.7.0 or newer with matching MLX headers and libraries
Install the native dependencies:
brew install mlx-cInstall a published Go module version (this command requires the v0.3.0 tag):
go get github.com/moncho/mlxgo@v0.3.0The cgo binding uses Homebrew's Apple Silicon paths:
- headers:
/opt/homebrew/include - library:
/opt/homebrew/lib/libmlxc.dylib
The local tested native baseline is MLX 0.32.4 with mlx-c 0.7.0_1. The
CumsumAxis binding requires the mlx-c 0.7 API; mlx-c 0.6 is no longer supported.
Homebrew dependencies are not bundled or pinned by go get; review the
compatibility matrix and rerun runtime tests after native upgrades.
Homebrew's mlx-c does not provide a pkg-config file. Default flags are declared
once in cgo_flags_mlx.go. For a compatible installation in another prefix,
set both C and C++ include paths and the linker search path:
prefix=/path/to/mlx-install
CGO_CFLAGS="-I$prefix/include" CGO_CXXFLAGS="-I$prefix/include" \
CGO_LDFLAGS="-L$prefix/lib -Wl,-rpath,$prefix/lib" \
go run -tags mlx ./cmd/smokeEnvironment flags are combined with the in-source defaults (before them in the tested Go toolchain); missing default directories are harmless. Keep headers and libraries from the same compatible installation, including MLX's runtime dependencies. This does not extend the tested platform/version support matrix.
The real MLX bindings are behind the mlx build tag so that normal Go tooling
still works before the native library is installed.
CGO_ENABLED=1 go run -tags mlx ./cmd/smokeExpected behavior: the program creates two MLX float32 arrays, adds them, forces evaluation, and prints the resulting Go slice.
Operations use GPU index 0 by default. Call SetDefaultCPU before creating or
running arrays when you want CPU execution instead. SetDefaultDevice currently
supports CPU or GPU index 0.
Run the linear-regression loss example:
CGO_ENABLED=1 go run -tags mlx ./cmd/linearThe examples use the higher-level helpers where possible:
predictions, err := mlx.Linear(features, weights, bias)
loss, err := mlx.MSELoss(predictions, labels)
nextParams, err := mlx.SGDWithLearningRate(params, grads, learningRate)Run the small MLP classifier example:
CGO_ENABLED=1 go run -tags mlx ./cmd/mlpRun the manual-gradient linear-regression training example:
CGO_ENABLED=1 go run -tags mlx ./cmd/train-linearRun the autograd linear-regression training example:
CGO_ENABLED=1 go run -tags mlx ./cmd/autograd-linearCGO_ENABLED=1 go run -tags mlx ./cmd/finetune-mlp
# To run on Metal:
CGO_ENABLED=1 go run -tags mlx ./cmd/finetune-mlp -device gpu -out checkpoints/mlp-gpuThis self-contained example trains a float32 1 -> 16 (tanh) -> 1 MLP with
49 parameters. It pretrains on y = sin(1.5*x), saves and closes the model,
then loads the checkpoint and fine-tunes all four parameter tensors on
y = 0.6*sin(1.5*x) + 0.5. Both phases use autograd and full-batch SGD,
with each training step wrapped in Batch.
There are 64 training inputs in [-1, 1] and 63 separate held-out inputs at
their midpoints. A fixed initialization seed makes runs reproducible. The
command prints training loss and held-out MSE before and after adaptation;
it exits with an error unless source and adapted MSE are at most 0.02,
adaptation reduces target MSE by more than 90%, and reloading the final
checkpoint preserves predictions within 1e-6.
Weights and architecture metadata are saved to pretrained.safetensors and
finetuned.safetensors under -out (default checkpoints/finetune-mlp, ignored
by Git). Existing files at those paths are overwritten. The example defaults
to CPU; the runtime test exercises both CPU and GPU. This demonstrates toy
model fine-tuning, not a pretrained LLM or LoRA training pipeline.
In sandboxed or headless macOS processes, MLX may abort with No Metal device available during library initialization. In that case, run the smoke command
from a normal Terminal session with Metal access.
cmd/generate uses the common local loader. It selects
the architecture from config.json; -device gpu (default) or -device cpu
selects the backend. Applications can use inference.Open and Model.Generate
without importing an architecture package. Experimental DeepSeek bundles use
the same command with -tokens, but still cannot generate pretrained text.
Download the public bf16 checkpoint once (about 1 GB) with the Hugging Face CLI:
hf download Qwen/Qwen2.5-0.5B-Instruct config.json model.safetensors tokenizer.json tokenizer_config.json \
--revision 7ae557604adf67be50417f59c2c2f167def9a775 \
--local-dir models/Qwen2.5-0.5B-Instruct
go run -tags mlx ./cmd/generate -prompt "Explain why the sky is blue in one sentence."Generation runs entirely in Go through MLX, with GPU by default and optional
CPU selection. It uses the Qwen chat
template, greedy decoding by default, tied embeddings, and a preallocated KV cache.
Cache buffers grow in blocks of 256 positions in the weights' dtype. Direct
cache users construct a cache with qwen2.NewKVCache(config, dtype).
For sampling, add -temperature 0.8 -top-p 0.9 -seed 42. Temperature zero
preserves greedy decoding; top-p 0 or 1 disables nucleus filtering. The CLI
requires a positive temperature when top-p is supplied. lm.Sample,
inference.Model.GenerateWith, and GenerateTokensWith accept
lm.SamplingOptions. Sampling seeds MLX's global RNG; reproducibility assumes
no interleaved random operations from other callers.
Prompt-mode generation streams text as tokens arrive. inference.Model.Stream
accepts a text callback; lm.Stream exposes token callbacks. They run on the
calling goroutine outside the worker batch. Callback errors stop generation and
return partial output; incomplete UTF-8 suffixes are withheld until complete
or flushed at the end. Do not invoke streaming inside a worker callback or Batch
if its consumer needs MLX work on another goroutine.
Only the standard Qwen2 architecture with SiLU, full attention, unscaled RoPE,
tied embeddings, zero dropout, and bf16 safetensors weights is supported.
Weights can be a single file or an indexed set of shards; see the
checkpoint loader for validation and storage limits.
Configuration and tensor mismatches return errors. The tokenizer is pure Go
and supports Qwen2's byte-level BPE, NFC normalization, and added tokens.
Fine-tune q/v LoRA adapters in all 24 layers, then generate with them:
go run -tags mlx ./cmd/finetune-qwen
go run -tags mlx ./cmd/generate \
-adapters checkpoints/qwen-lora.safetensors \
-prompt "Please state amber's assigned code."The included synthetic dataset teaches two code assignments using eight training prompts and two different validation prompts. It is an end-to-end engineering demonstration, not a general capability benchmark. The command requires validation loss to improve and checks that reloading adapters reproduces it. It saves only float32 LoRA factors and metadata; bf16 base weights remain frozen. The default rank is 4, alpha is 4, batch size is 2, learning rate is 0.001, global gradient norm limit is 1, and training lasts 30 AdamW steps.
For your own data, use JSONL records in separate training and validation files:
{"prompt":"What is the code for amber?","completion":"The code for amber is 7."}go run -tags mlx ./cmd/finetune-qwen -train train.jsonl -valid valid.jsonl \
-steps 100 -rank 8 -batch-size 2 -learning-rate 0.0001 \
-max-length 128 -out checkpoints/custom-lora.safetensorsPrompt tokens are masked out of the loss. Gradients are accumulated across
variable-length examples and averaged by the number of completion tokens.
Global L2 gradient clipping is applied after averaging, before AdamW; use
-max-grad-norm 0 to disable clipping. Nonfinite gradient norms are rejected
before updating parameters or optimizer state. The library exposes this as
mlx.ClipGradNorm; qwen2.TrainOptions.MaxGradNorm defaults to zero (disabled).
Examples are processed individually, so padding is unnecessary. Overlong
examples are rejected rather than truncated. Keep validation examples separate
from training. Memory use grows with sequence length and vocabulary logits;
this implementation has no activation checkpointing or quantization.
Adapters are bound to the exact base checkpoint SHA256. Single-file bundles
retain their existing hash; sharded bundles hash the tensor routing and shard
contents. Repacking or renaming shards changes this identity. Loading adapters
against another checkpoint or incompatible configuration fails. Each training call
starts fresh AdamW moments unless ResumeFrom is supplied; adapter-only files
are not full optimizer checkpoints.
Models, caches, adapters and optimizers must not be mutated or closed while in
use. A cache belongs to one generation, and must be discarded after an error.
See resumable training and the extraction benchmark and
reference verification for the detailed workflows.
The BANKING77 classification pilot compares
fine-tuning against both prompted Qwen and a simple non-LLM classifier on public
labeled data.
For finite-label tasks, lm.NewChoices builds a trie of allowed token sequences;
its Greedy method constrains any lm.Session to those choices. Include EOS in
each sequence and check errors before using the output. This guarantees label
membership on success, not that the chosen label is correct.
Default stub build:
go test ./...Native compile and validation tests:
go test -tags mlx ./...Runtime MLX tests, from a normal Terminal session with Metal access:
CGO_ENABLED=1 go test -tags "mlx mlxruntime" ./...The same commands are available through make:
make test
make test-native
make test-runtime
make vet
make vet-native
make test-race
make test-race-native
make smoke
make linear
make mlp
make train-linear
make autograd-linear
make finetune-mlp- Constructors:
NewFloat32,NewFloat64,NewInt32,NewInt64,Arange,ArangeDType,Zeros,Ones,Full,ZerosLike,OnesLike, scalar constructors - Introspection:
Shape,Size,DType, shared close state across copiedArrayvalues - Data copies:
Float32Data,Float64Data,Int32Data,Int64Data,UInt8Data,UInt32Data,UInt64Data,BoolData - Elementwise ops:
Add,Subtract,Multiply,Divide,Maximum,Minimum,Power,Clip,Abs,Exp,Log,Negative,Square,Sqrt,Sigmoid,Tanh,Sin,Cos,ReLU,StopGradient - Matrix/reduction ops:
Matmul,Sum,SumAxis,SumAxes,Mean,MeanAxis,MeanAxes,LogSumExp,LogSumExpAxis,LogSumExpAxes,AddMM,CumsumAxis - Device/stream control:
SetDefaultGPU,SetDefaultCPU,SetDefaultDevice,Batch,Synchronize - Shape/type ops:
Reshape,Transpose,TransposeAxes,BroadcastTo,ExpandDims,ExpandDimsAxes,Squeeze,SqueezeAxis,SqueezeAxes,Flatten,AsType,Contiguous,Slice,SliceUpdate - Model ops:
Softmax,SoftmaxAxis,SoftmaxAxes,Argmax,ArgmaxAxis,Argmin,ArgminAxis,Equal,Greater,GreaterEqual,Less,LessEqual,Where,Take,TakeAxis,TakeAlongAxis,Gather,GatherSlices,Concatenate,ConcatenateAxis,Stack,StackAxis - IO:
Load,Save,LoadSafetensors,SaveSafetensors - Random:
RandomSeed,RandomKey,RandomNormal,RandomUniform,RandomRandint,RandomBernoulli,RandomCategorical - Transforms:
Closure,NewClosure,Compile,ValueAndGrad,NewValueAndGrad,Eval,AsyncEval - NN/loss helpers:
Linear,LinearNoBias,SiLU,RMSNorm,RoPE,ScaledDotProductAttention,MSELoss,LogSoftmaxAxis,SoftmaxCrossEntropyAxis,CrossEntropyAxis - Optimizers/utilities:
SGD,SGDWithLearningRate,NewAdamW,CloseArrays - Weight-only quantized matmul:
MXFP8Matmul,MXFP4Matmul(packed UInt32 weights and UInt8 E8M0 scales; floating inputs). See the experimental DeepSeek adapters and validation. - Device-indexed matrix products:
GatherMM,GatherMXFP4. The experimental expert-bank prototype keeps MoE routing indices on the device for float32 and packed FP4 execution. DeepSeek sessions can opt into device decode with sparse prefill.
mlx.LiveArrays() counts owned Go array handles that have not been closed;
copies sharing one handle count once, borrowed handles do not count, and the
stub build returns zero. Compare before/after counts when testing cleanup.
The snapshot is process-wide, not a native-buffer or byte count; lazy graphs
can retain buffers after their Go handles close. No finalizers are installed.
mlx.GetMemoryUsage() reports process-wide MLX allocator active, cached and
peak bytes, not Go heap size or process RSS. Evaluate pending work and call
mlx.Synchronize() before a measurement window to drain previously submitted
work on the selected device's default stream. Synchronization does not evaluate
unsubmitted lazy graphs or wait for unrelated streams, and other callers can
still affect these process-wide counters. mlx.ResetPeakMemory() resets the global peak statistic without
freeing allocations; use it only during coordinated profiling, not within
ordinary inference calls. The packed attention report
shows whole-attention timing and allocator measurements on real weight samples.
The combined quantization report
and FP4 expert report cover the newer paths.
The sparse expert report measures optional
token-to-expert dispatch and documents prepared packed checkpoint loading.
- MLX computation is lazy. Call
Evalor a data-copy method such asFloat32Databefore reading results. - File loading uses a CPU stream because MLX's load primitive has no GPU implementation. Loaded arrays can feed GPU operations without changing the selected device.
- The wrapper defaults to GPU index 0. Call
SetDefaultCPUwhen you want CPU execution, orSetDefaultDeviceto choose CPU/GPU index 0 explicitly. This does not bypass MLX's Metal initialization requirement in sandboxed processes that cannot enumerate a Metal device. - Native MLX calls run on a dedicated OS thread. This keeps MLX stream affinity stable across lazy graph construction and evaluation, so callers can use ordinary Go goroutines; the native calls themselves are serialized.
- Use
Batchto amortize dispatcher overhead across a sequence of MLX calls, such as a full training step. - Data-copy methods call
Contiguousinternally before touching MLX's raw data pointers, so transposed and broadcasted views copy back correctly. Numeric readers bulk-copy into owned Go slices; bool readers convert each element. - The native build installs an MLX error handler during package initialization. MLX operation failures should return Go errors with MLX's diagnostic text instead of aborting the process.
- Close arrays explicitly with
defer arr.Close()when you allocate them. Copies share close state, so closing one copy prevents later use through other copies. - Helper functions close their own intermediate arrays, but they do not close
inputs or returned arrays. Call
CloseArraysfor returned parameter, value, or gradient slices. - Keep Go slices alive until after cgo calls return. The current constructors use
mlx_array_new_data, which copies the input buffer. NewValueAndGradwraps MLX's closure-based autograd. Callback inputs are temporary handles managed by the wrapper. Callback outputs are transferred to MLX, so return freshly created arrays rather than arrays you intend to keep using after the callback.- Closure and value-and-gradient callbacks run on the MLX worker thread. Do not delegate MLX work from a callback to another goroutine, and do not block a callback on anything that needs to call MLX.
- Expand the wrapper a few operations at a time. MLX C's API is broad, and a typed Go surface is easier to maintain than a generated one-to-one binding at the start.