| title | Training |
|---|---|
| description | LoRA fine-tuning, gradient computation, AdamW optimiser, and loss functions. |
go-mlx provides a complete LoRA fine-tuning pipeline on Metal: low-rank adapters, reverse-mode autodiff, the AdamW optimiser, loss functions, and gradient checkpointing. The training primitives are re-exported from internal/metal/ at the root package level for use by downstream consumers like go-ml.
Low-Rank Adaptation (LoRA) injects small trainable matrices into frozen model layers. Only the LoRA parameters are updated during training -- the base model weights remain unchanged.
Each targeted Linear layer is wrapped with a LoRALinear struct:
type LoRALinear struct {
Base *Linear // frozen base weights (may be quantised)
A *Array // [rank, in_features] -- Kaiming normal initialisation
B *Array // [out_features, rank] -- zero initialisation
Scale float32 // alpha / rank
}The forward pass computes: base(x) + scale * (x @ A^T) @ B^T
B is zero-initialised, so LoRA starts as the identity transformation -- no change to the base output until training begins.
Through the go-inference TrainableModel interface:
m, err := inference.LoadModel("/path/to/model/")
trainable := m.(inference.TrainableModel)
adapter := trainable.ApplyLoRA(inference.LoRAConfig{
Rank: 8,
Alpha: 16,
TargetKeys: []string{"q_proj", "v_proj"},
BFloat16: true, // use BFloat16 for A/B matrices
})Or directly via the Metal types:
concreteAdapter := mlx.ConcreteAdapter(adapter)
fmt.Printf("LoRA params: %d\n", concreteAdapter.TotalParams())type LoRAConfig struct {
Rank int // decomposition rank (default 8)
Alpha float32 // scaling factor (default 16)
TargetKeys []string // weight name suffixes to target (default: q_proj, v_proj)
DType DType // training dtype for A/B (default Float32; BFloat16 for mixed precision)
}DefaultLoRAConfig() returns {Rank: 8, Alpha: 16, TargetKeys: ["q_proj", "v_proj"], DType: Float32}.
Common target keys: q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj.
Save trained adapter weights (only A and B matrices, not base weights):
concreteAdapter := mlx.ConcreteAdapter(adapter)
err := concreteAdapter.Save("/path/to/adapter.safetensors")Load a pre-trained adapter at model load time:
m, err := inference.LoadModel("/path/to/model/",
inference.WithAdapterPath("/path/to/adapter/"),
)The adapter directory must contain:
adapter_config.json-- rank, alpha, target layers- One or more
*.safetensorsfiles -- adapter weights
The loader parses weight names like layers.0.self_attn.q_proj.lora_a to inject each A/B pair into the correct model layer. This is compatible with adapters trained by mlx-lm.
Once a LoRA adapter is trained, you can bake it into the base model as a fresh, standalone safetensors pack. This eliminates the runtime cost of the adapter projections at the price of losing modularity (you can no longer swap adapters on the same base).
result, err := mlx.FuseLoRAIntoModelPack(ctx, mlx.FuseLoRAOptions{
ModelPath: "/path/to/base/model", // safetensors model pack
AdapterPath: "/path/to/adapter", // safetensors adapter directory
OutputPath: "/path/to/fused-model", // must be a directory, not a file
Labels: map[string]string{"run": "domain-classifier-v3"},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("fused %d weights into %s\n", result.FusedWeights, result.WeightPath)Fusion rewrites every targeted weight as W' = W + scale * Bᵀ @ Aᵀ, copies the source pack's metadata (config.json, tokenizer.json, etc.) into the output directory, and writes a LoRAFuseProvenance record to adapter_provenance.json listing the source model, adapter identity, and exact set of fused weight keys. The output path must not already contain *.safetensors or *.gguf weights, must differ from the source path, and the source pack must be safetensors (GGUF source fusion is not yet supported).
FuseLoRAResult carries the output pack details, the ModelPack and LoRAAdapterInfo used, the count and keys of fused tensors, and the provenance file location.
The primary API for training loops. Creates a reusable GradFn that computes both the function value and gradients:
lossFn := func(params []*mlx.Array) []*mlx.Array {
// Forward pass + loss computation
return []*mlx.Array{loss}
}
grad := mlx.ValueAndGrad(lossFn, 0) // differentiate w.r.t. first argument
// In the training loop:
values, grads, err := grad.Apply(adapterParams...)The argnums parameter specifies which arguments to differentiate with respect to. Default is {0} (first argument only).
Low-level backward pass for custom gradient computation:
outputs, vjps, err := metal.VJP(fn, primals, cotangents)Given a function, input primals, and output cotangents (upstream gradients), returns (outputs, gradients) where gradients are with respect to the primals.
Directional derivative computation:
outputs, jvps, err := metal.JVP(fn, primals, tangents)Useful for Hessian-vector products and directional derivatives.
Go functions are registered as mlx-c closures via an exported CGO callback (goGradFunc). Each closure gets a unique ID from an atomic counter (gradNextID), stored in a sync.Map for concurrent access. A C-side destructor (goGradDestructor) cleans up the registry entry when the closure is freed.
Standard AdamW with decoupled weight decay:
opt := mlx.NewAdamW(1e-4) // learning rate
cfg := mlx.DefaultAdamWConfig()
cfg.LearningRate = 1e-4
cfg.Beta1 = 0.85
opt2 := mlx.NewAdamW(cfg)Update rule per parameter per step:
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * grad^2
m_hat = m / (1 - beta1^t) // bias correction
v_hat = v / (1 - beta2^t)
param = param * (1 - lr * wd) - lr * m_hat / (sqrt(v_hat) + eps)
| Field | Default | Description |
|---|---|---|
LR |
1e-5 | Learning rate |
Beta1 |
0.9 | First moment decay |
Beta2 |
0.999 | Second moment decay |
Eps |
1e-8 | Numerical stability |
WeightDecay |
0.01 | Decoupled weight decay |
opt := mlx.NewAdamW(&mlx.AdamWConfig{
LearningRate: 1e-4,
Beta1: 0.9,
Beta2: 0.999,
Eps: 1e-8,
WeightDecay: 0.01,
})
opt.WeightDecay = 0.01
// Training loop
params := adapter.AllTrainableParams()
for step := range numSteps {
values, grads, err := gradFn.Apply(params...)
if err != nil {
log.Fatal(err)
}
loss := values[0]
mlx.Materialize(loss)
fmt.Printf("step %d: loss = %.4f\n", step, loss.Float())
params = opt.Step(params, grads)
adapter.SetAllParams(params)
}Step() returns new parameter arrays. The moment estimates are maintained internally and grow lazily on first use. Call Reset() to clear all optimiser state.
loss := mlx.CrossEntropyLoss(logits, targets)Numerically stable via logsumexp: loss_i = logsumexp(logits_i) - logits_i[target_i]. Averaged over all positions.
logits:[..., V](raw model output, pre-softmax, last dim = vocab size)targets:[...](integer token IDs, same shape minus last dim)
loss := mlx.MaskedCrossEntropyLoss(logits, targets, mask)Same as CrossEntropyLoss but only computes loss at masked positions. The mask has shape [B, L] with values 1.0 (compute loss) or 0.0 (ignore). Averaged over masked positions only.
Use this for training on chat data where system/user tokens should be excluded from the loss.
loss := metal.MSELoss(predictions, targets)Mean squared error: mean((predictions - targets)^2).
Checkpoint wraps a function so that during the backward pass, intermediate activations are recomputed rather than stored. This trades compute time for GPU memory:
checkpointedFn := mlx.Checkpoint(func(params []*mlx.Array) []*mlx.Array {
// Forward pass (intermediates discarded after forward, recomputed on backward)
return []*mlx.Array{loss}
})Use this for memory-constrained training with large models. The checkpointed function is wrapped via mlx_checkpoint on the C side, with a Go finaliser managing the closure lifetime.
LoRAConfig.DType selects the dtype for A and B matrices:
DTypeFloat32(default) -- full precision trainingDTypeBFloat16-- halves parameter memory with accuracy matching Float32 in practice
adapter := trainable.ApplyLoRA(inference.LoRAConfig{
Rank: 8,
Alpha: 16,
BFloat16: true,
})MLX auto-promotes operands for cross-dtype operations (e.g. BFloat16 LoRA matrices multiplied with Float16 base weights), so no manual casting is needed in the training loop.
The root mlx package re-exports training types from internal/metal/ for use by downstream consumers:
type Array = metal.Array
type LoRAAdapter = metal.LoRAAdapter
type LoRAConfig = metal.LoRAConfig
type GradFn = metal.GradFn
type AdamW = metal.AdamW
type Cache = metal.Cache
type DType = metal.DType
type InternalModel = metal.InternalModelExported functions:
| Function | Purpose |
|---|---|
ValueAndGrad(fn, argnums...) |
Create a GradFn for combined value + gradient computation |
NewAdamW(lrOrConfig) |
Create an AdamW optimiser from a learning rate or AdamWConfig |
CrossEntropyLoss(logits, targets) |
Standard cross-entropy loss |
MaskedCrossEntropyLoss(logits, targets, mask) |
Masked cross-entropy loss |
Checkpoint(fn) |
Memory-efficient gradient recomputation |
FromValues(slice, shape...) |
Create a Metal Array from a Go slice |
Materialize(arrays...) |
Force GPU evaluation |
Free(arrays...) |
Release Metal arrays immediately |
Zeros(shape, dtype) |
Create a zero-filled array |
ConcreteAdapter(a) |
Extract *LoRAAdapter from an inference.Adapter |
TrainingModel(tm) |
Extract InternalModel from a TrainableModel for direct Forward() access |
The typical training workflow uses go-ml, which orchestrates the training loop on top of go-mlx primitives:
// go-ml loads a TrainableModel via go-inference + go-mlx
tm, err := inference.LoadTrainable("/path/to/model/")
// Apply LoRA
adapter := tm.ApplyLoRA(inference.LoRAConfig{Rank: 8, Alpha: 16})
// Get direct model access for Forward()
model := mlx.TrainingModel(tm)
// Build training loop with ValueAndGrad, AdamW, etc.
// See go-ml for the full implementation.The InternalModel interface provides Forward(tokens, caches) and NewCache() for direct control over the forward pass in training, bypassing the generation loop.
For training code that needs direct forward pass access:
type InternalModel interface {
Forward(tokens *Array, caches []Cache) *Array
ForwardMasked(tokens *Array, mask *Array, caches []Cache) *Array
NewCache() []Cache
NumLayers() int
Tokenizer() *Tokenizer
ModelType() string
ApplyLoRA(cfg LoRAConfig) *LoRAAdapter
}Forward returns logits of shape [B, L, V]. ForwardMasked accepts an explicit attention mask for batched training with padded sequences.
LoRA fine-tuning is one entry point. The root package owns several higher-level training and evaluation pipelines that build on the same primitives:
- Knowledge distillation — KL or soft cross-entropy against a teacher's logits, with checkpoint resumption and an in-memory teacher logit cache
- GRPO — group-relative policy optimisation with pluggable reward functions and reference-model KL
- Eval — dataset-native perplexity plus pluggable quality probes, callable on a base model or with a LoRA adapter
- Model operations — fuse adapters, merge multiple finetunes, quantise to GGUF, snapshot KV state for session restore
See examples/training/ for runnable walk-throughs of each.