mlx-optiq
Workflow · LoRA

LoRA fine-tuning

mlx-optiq ships a LoRA trainer that reads its own per-layer sensitivity assignments and gives sensitive layers proportionally more adapter capacity. Output is PEFT-compatible (adapter_config.json + adapters.safetensors) plus an mlx-optiq sidecar describing the per-layer rank distribution.

The same layers mlx-optiq kept at 8-bit during quantization also get more adapter rank during fine-tuning. The same sensitivity signal drives quantization and LoRA rank.

The basic recipe

train.shbash
# Defaults: all transformer blocks adapted, all 7 trainable linears per
# block (Unsloth-aligned), rank 8 with by_bits sensitivity overlay,
# alpha = rank, mask_prompt enabled, max_seq=512. LR + iterations are
# method-aware: SFT runs 3 epochs at 2e-4 (see "Iterations & learning
# rate" below). Pass --iters for an absolute step count instead.
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
    --data ./my_training_data \
    -o ./my_adapter

# Show the per-layer rank distribution
$ optiq lora info ./my_adapter

Preset bundles for quick rank selection: --preset small (r=8, α=16), default (r=8, α=8), medium (r=16, α=16), large (r=32, α=32), xl (r=64, α=64), xxl (r=128, α=128). Presets set the BASE rank; with --rank-scaling by_bits (default), per-layer rank still scales up on layers OptiQ kept at higher bits.

Data format

JSONL with either messages (chat format) or prompt/completion pairs. Use one of these formats, not bare text. The text format can't expose a prompt/response boundary, so prompt masking falls through to full-sequence loss and degrades quality on tasks where the base model is already competent.

data.jsonljson
{"messages": [{"role": "user", "content": "..."},
              {"role": "assistant", "content": "..."}]}
{"prompt": "...", "completion": "..."}

Chat template is applied automatically; do not pre-template the data. mask_prompt is on by default, so loss is computed only on the assistant's response tokens.

Layout on disk:

directory layoutbash
my_training_data/
├── train.jsonl
└── valid.jsonl   # optional, used for validation loss

SFT and DPO

The same optiq lora train command trains both objectives; pick with --method. SFT (--method sft, the default) is standard supervised fine-tuning on your responses. DPO (--method dpo, Direct Preference Optimization) aligns the model on preferences and is the usual second stage: SFT first to teach the format and task, then DPO to prefer better responses over worse ones.

dpo.shbash
# DPO on top of an SFT adapter (the standard two-stage flow):
# --mount-adapter starts DPO from your SFT weights; --dpo-beta is the
# KL constraint (default 0.1); LR defaults to 5e-5 for DPO.
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
--method dpo \
--data ./my_preference_data \
--mount-adapter ./my_sft_adapter \
--dpo-beta 0.1 \
--fused-dpo \                        # chunked logp for >4k context (see below)
--iters 200 \
-o ./my_dpo_adapter

DPO data is JSONL with a chosen and a rejected completion for the same context. Single-turn uses {prompt, chosen, rejected} strings; multi-turn / agentic uses full message lists:

preferences.jsonljson
// single-turn
{"prompt": "...", "chosen": "...", "rejected": "..."}
// multi-turn (chosen/rejected are full {messages} lists)
{"chosen": [{"role": "user", ...}, ...],
 "rejected": [{"role": "user", ...}, ...]}
Both live in OptiQ Lab too Prefer a UI? optiq labFine-tune exposes the same knobs: pick sft or dpo, point at a dataset, mount an adapter, and launch. Same engine, same defaults as the CLI.
Preference-pair requirement Both chosen and rejected must be plausible completions of the same prompt under the base model. If chosen answers a different context than the prompt introduces, both reward terms drift in lockstep and the margin signal saturates near zero (the "loss=0, rewards drifting to -hundreds" pathology). The trainer prints a one-shot warning if the first validation pass shows this signature.

Iterations & learning rate

--iters is optional. Omit it and OptiQ trains by epochnum_epochs × ceil(examples / batch) — defaulting to 3 epochs for SFT and 1 for DPO (one epoch is a preference nudge; more invites the collapse pathology). Pass --num-epochs N to change the count, or --iters N for an absolute step count. An explicit value always wins.

Learning rate is method-aware: omit --learning-rate and SFT uses 2e-4, DPO uses 5e-5. These defaults resolve inside OptiqLoraConfig, so the CLI, OptiQ Lab, and direct construction all agree — a method="dpo" config no longer silently trains at the SFT rate.

epochs.shbash
# No --iters: 3 epochs (SFT default) at 2e-4 over the dataset
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
    --data ./my_training_data -o ./my_adapter

# Two epochs instead of the default three
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
    --data ./my_training_data --num-epochs 2 -o ./my_adapter

NEFTune noisy-embedding SFT

NEFTune (Jain et al. 2023) adds uniform noise — scaled by alpha / sqrt(seq_len × embed_dim) — to the token embeddings during the SFT forward pass, and nothing at inference. It's a near-free regularizer that stops the adapter from over-memorizing the exact surface form of a small dataset, which measurably improves instruction-following. Pass --neftune-noise-alpha N (the paper suggests 5–15); it's off by default (matching TRL/Unsloth), and SFT only (mirroring TRL, which applies it in the SFTTrainer, not DPO). The noise is gated on training mode, so your validation loss is still measured on clean embeddings.

neftune.shbash
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
    --data ./my_training_data -o ./my_adapter \
    --neftune-noise-alpha 5

Early stopping & experiment logging

Both are optional and off by default, and both work the same in the CLI and in OptiQ Lab (the Lab surfaces them through the same config, and its live chart keeps updating alongside them).

Early stopping. Pass --early-stopping-patience N to halt once the validation loss hasn't improved for N consecutive evaluations (evaluations fire every --steps-per-eval steps). It needs a validation set (valid.jsonl); without one it's a no-op with a warning. Use --early-stopping-min-delta D to require an improvement of at least D to count as progress (so tiny wiggles don't keep training alive). When it fires, the best-validation adapter is promoted to the returned adapter — the trailing steps that tripped the counter are worse than the best, so you get the good checkpoint, not the last one. (OptiQ always snapshots the best under <adapter>/best/ regardless; early stopping just also makes it the top-level adapter.)

Experiment logging. Pass --report-to wandb (needs pip install wandb + a WANDB_API_KEY or wandb login) to stream train/val metrics to Weights & Biases; swanlab is also supported, and you can pass several comma-separated. Set the project with --wandb-project. The tracker runs alongside the normal CLI log and the Lab chart, not instead of them.

early-stop.shbash
# Stop after 3 evals with no meaningful val improvement; log to W&B
$ optiq lora train mlx-community/Qwen3.5-4B-OptiQ-4bit \
    --data ./my_training_data -o ./my_adapter \
    --steps-per-eval 50 \
    --early-stopping-patience 3 --early-stopping-min-delta 0.01 \
    --report-to wandb --wandb-project my-optiq-runs

Sensitivity-aware rank scaling

--rank-scaling by_bits (default) gives each layer an adapter rank proportional to its quantization bit-width. With --rank 8:

  • Layers mlx-optiq quantized at 4-bit get rank 8.
  • Layers mlx-optiq quantized at 8-bit get rank 16.

Head-to-head on a 6-category logical-puzzles reasoning dataset (Qwen3.5-4B-OptiQ-4bit, 1 epoch over 200 training samples, 100-sample test split):

ConfigTrainable paramsTest accuracy
Constant rank-811.58 M27 %
by_bits (rank 8 / 16)13.49 M (+16%)35 %
Constant rank-1622.20 M (+92%)36 %

by_bits matches constant rank-16 on accuracy (35% vs 36%, within noise at n=100) using 39% fewer trainable parameters. Versus constant rank-8 at almost matched param budget, by_bits is +8 absolute accuracy points. Full per-category breakdown in the sensitivity-aware LoRA blog post.

Other scaling modes:

scaling.shbash
# Constant rank (matches Unsloth / PEFT default behaviour)
$ optiq lora train ... --rank-scaling constant

# Scale by raw KL sensitivity (more aggressive than by_bits)
$ optiq lora train ... --rank-scaling by_kl

Training-ceiling map (36 GB Mac)

Empirical sequence-length and peak-memory ceilings at the system-default iogpu.wired_limit_mb=0. Measured under the conservative recipe: num_layers=16 (only the last 16 transformer blocks adapted), target_modules=q_proj,v_proj, rank=8. The current default (num_layers=-1, all 7 target modules) adapts ~3× more LoRA modules so pushes seq-length proportionally lower for the same model on the same machine; drop num_layers or max_seq_length to land within these ceilings if you need the full capacity headroom for very long contexts.

Long context on 24 GB Above max_seq_length=4096 the full [seq, vocab] logit tensor is what OOMs first (a ~250k-token vocab makes it multi-GB). OptiQ auto-enables a fused cut-cross-entropy at that point (set OPTIQ_FUSED_CE=0/1 to override): the vocab head + loss are computed in chunks so the full logit tensor is never materialized, gradient-equivalent to the plain path. This trains 8k context at ~14.7 GB peak on a 24 GB M4 where the plain path OOMs, and scales to ~12k. It composes with all layers adapted; it does not change the attention-side ceilings in the table below.
Long context for DPO DPO has the same problem worse: its per-sequence logp materializes the full [seq, vocab] logits four times per step (policy + reference × chosen + rejected), so on a 24 GB Mac the plain path OOMs by ~4k. The --fused-dpo flag (or OPTIQ_FUSED_DPO=1) applies the same chunked-head trick to the logp, gradient-verified exact against autograd. Unlike SFT this is opt-in, not auto, the break point is VRAM-dependent.

Measured · Qwen3.5-4B-OptiQ-4bit · M3 Max 36 GB · batch 1, all layers, --grad-checkpoint
contextSFT stepSFT peakDPO step (--fused-dpo)DPO peak
20482.8 s7.7 GB29 s8.7 GB
40966.7 s11.4 GB144 s10.9 GB
819245 s19.9 GB216 s17.3 GB

Both fit 8k on this machine, and DPO's peak (17.3 GB) leaves room on a 24 GB Mac. Step time still grows faster than context (attention is O(seq²), and DPO pays it across four forwards) so 4k remains the comfortable working point and 8k is for when you need it, not by default.

Tuning knobs you rarely need

flag / envdefaultwhat it does
--adapt-expertsoff Adapt a MoE model's expert pools. Every expert gets its own LoRA, so the adapter grows by the expert count, on a 256-expert model that is 128×, a 1.2 B-parameter adapter at r=8. Attention projections are adapted either way.
OPTIQ_FLASH_ATTNauto auto uses MLX's fused SDPA whenever its [B, Hq, T, T] score tensor fits a memory budget, and a tiled FlashAttention-2 backward otherwise. always / never force it.
OPTIQ_FLASH_ATTN_BUDGET_GB25% of wired limit How much of Metal's wired limit stock SDPA's scores may occupy before the tiled backward takes over.
OPTIQ_FLASH_BLOCK128 Query-block size for the tiled backward. Peak memory scales with it; step time does not.
OPTIQ_FUSED_CEauto (on) Fused cut-cross-entropy for the SFT LM head.
--fused-dpooff The same chunked head for DPO's four logp passes. Needed beyond ~4k on a 24 GB Mac.
These numbers changed in 0.3.0 Earlier docs quoted ~30 min per DPO step at 4k and called anything beyond 4k impractical. Those figures reflected a slow kernel that ran on every step (fixed in 0.3.0): OptiQ's flash-attention Metal kernel is 14–137× slower than MLX's fused SDPA. It is now taken only when stock SDPA's [B, Hq, T, T] score tensor would not fit in memory. Same math, ~13× faster DPO at 4k. See the changelog.
directory layoutbash
my_adapter/
├── adapter_config.json       # PEFT-compatible config
├── adapters.safetensors      # PEFT-compatible weights
└── optiq_lora_config.json    # mlx-optiq sidecar with per-layer ranks

Inspect the per-layer rank distribution:

terminalbash
$ optiq lora info ./my_adapter
# OptiQ LoRA adapter
#   base: mlx-community/Qwen3.5-4B-OptiQ-4bit
#   rank: 8 (scaling: by_bits)
#   scale: 1.0  dropout: 0.0
#   rank distribution: {8: 101, 16: 27} across 128 adapted modules

Loading an adapter

load_adapter.pypython
from mlx_lm import load, generate

model, tok = load(
    "mlx-community/Qwen3.5-9B-OptiQ-4bit",
    adapter_path="./my_adapter",
)
print(generate(model, tok, prompt="...", max_tokens=200))

Hot-swap adapters at serve time

mlx-optiq's mounted-LoRA primitive lets you keep N adapters resident on one base, switching per-request. See the serving guide.