Apple Silicon MLX Backend
Soup v0.25.0 adds a native MLX training backend for M1 to M4 Macs, running on unified memory without CUDA, Rosetta, or x86 emulation. Supervised fine-tuning is the only task it supports: every other task, DPO and GRPO included, is refused at config load. See the table below.
Install
pip install "soup-cli[mlx]"This pulls mlx>=0.20 and mlx-lm>=0.31.3 as optional dependencies. The mlx-lm floor was raised in v0.73.1, to the version the MLX supervised path is actually built against.
Enable the backend
Set backend: mlx in soup.yaml. It is a config key only: soup train has no --backend flag (--backend belongs to soup serve). A minimal config:
base: mlx-community/Llama-3.1-8B-Instruct-4bit
backend: mlx
task: sft
data:
train: data.jsonl
format: chatml
training:
epochs: 3
lr: 1e-4
batch_size: 2
lora:
r: 16
alpha: 32Supported tasks
| Task | MLX | Notes |
|---|---|---|
| sft | ✓ | LoRA and QLoRA (mlx-community 4bit models) |
| dpo | – | Refused at config load: upstream mlx-lm exposes no training helper. Use backend: transformers |
| grpo | – | Refused at config load, same reason |
| ppo | – | Refused — use the transformers backend |
| reward_model | – | Refused at config load, same reason |
| embedding | – | Refused at config load, same reason |
| pretrain | – | Refused at config load, same reason |
The MLX backend supports sft and nothing else: every other task is rejected by the same cross-validator, with the same message, before anything loads. The rows above differ in what you would use them for, not in how they fail.
Diagnostics
soup doctorOn Apple Silicon this reports Backend: MPS (Apple Silicon) and whether it is available. It does not report the MLX version, the chip name, unified memory or a recommended batch size: the helper that would fill those in has no caller, which is the known limitation stated at the end of this page. soup doctor --config soup.yaml is the part of this command that does know about MLX, and it is documented below.
Since v0.73.3, device detection knows this backend exists. Before that, detect_device() only probed PyTorch MPS and fell back to cpu, so an MLX run announced "CPU (no GPU detected)" — and the label was never the harm. That fallback also fired a "4bit quantization is not supported on CPU" warning and silently rewrote quantization: 4bit to none, on every MLX run that asked for 4-bit. With backend: mlx set, detection now resolves to MLX with the chip name, reports Apple unified memory in telemetry, and preserves 4bit for pre-quantized mlx-community checkpoints, which is a different thing from bitsandbytes NF4 and was never what that warning was written about. The CUDA-shaped analytical VRAM pre-flight is also skipped on this path, because Apple unified memory is managed by Metal rather than by a fixed VRAM pool.
Also since v0.73.3: [mlx] is a self-contained install for supervised fine-tuning on local JSONL, JSON or CSV data, so you never need the PyTorch stack just to train on a Mac. Since v0.74.0 you may combine them: pip install "soup-cli[train,mlx]" resolves, because both extras now declare the same transformers>=5.16.1,<6.0.0 floor. Before that release the two declared ranges that could not be satisfied together at all, so the combination was not a style preference but an outright resolver failure. [train] is still the PyTorch and TRL stack the Transformers backend uses. A Hugging Face Hub or streaming dataset still needs datasets, because that data source owns the dependency; a local file path does not.
MLX-native recipes
llama3.1-8b-sft-mlx— M2+ 16GB. Its model,mlx-community/Llama-3.1-8B-Instruct-4bit, was also trained on an 8 GB M1 under a smaller config than the recipe's (LoRA r=8, batch 1,max_length: 512, 48 rows, one epoch): 48 iterations in 71 s at a 5.154 GB peak, adapter written and reloadable. The recipe's own settings (r=16, batch 2,max_length: 2048, 3 epochs) were not run on that machineqwen3-8b-sft-mlx— M2+ 16GB. Its repo ID was broken until v0.75.0, pointing atmlx-community/Qwen3-8B-Instruct-4bit, which does not exist; it now namesmlx-community/Qwen3-8B-4bitgemma3-4b-sft-mlx— M1+ 16GB. Renamed in v0.75.0, fromgemma3-9b-sft-mlx: there is no 9B Gemma 3, so the old name pointed at a repository that raisedRepositoryNotFoundError. It now namesmlx-community/gemma-3-4b-it-4bit
Use any of them with soup recipes use <name>. The old gemma3-9b-sft-mlx name exits 1.
qwen3-8b-sft-mlxbehaves differently after v0.75.0, and the change is a refusal rather than a regression. It does not setdata.train_on_responses_only, so it takes thetruedefault, which reached nothing on this backend until now. Qwen3's chat template injects its thinking block only for the last assistant turn, which means partial renderings are not prefixes of the full one and MLX cannot build a per-token mask it can vouch for. Single-turn and system-plus-single-turn rows train as before; multi-turn rows are now refused at dataset construction, before the training loop starts, with a message namingdata.train_on_responses_only: falseas the remedy. Set that to restore the old behaviour on multi-turn Qwen3 data, knowing it trains on the prompt.llama3.1-8b-sft-mlxandgemma3-4b-sft-mlxmask correctly on both shapes and are unaffected.
The six options this backend accepted and never read
v0.75.0 is the release named for this page. Training options were validated by the schema, documented in these pages, accepted by the config loader, and then read by nothing on backend: mlx. Upstream's release notes count six; the table below lists every setting its fix entries name, eight in all. The same soup.yaml trained a different recipe on Apple Silicon than it did on a CUDA box, silently, and nothing warned.
| Option | What actually happened before v0.75.0 |
|---|---|
data.train_on_responses_only | Defaults to true and reached nothing, so every MLX run trained on system and user turns, and the adapter metadata recorded mask_prompt: false regardless |
training.optimizer | Every run built a bare AdamW. All 32 allowlisted names became AdamW |
training.scheduler | No schedule at all: a constant learning rate, whatever the recipe said |
training.warmup_ratio | Ignored, for the same reason |
training.weight_decay | Ignored |
training.max_grad_norm | Ignored. The documented default clips every transformers run at 1.0; on MLX nothing clipped anywhere, and the MLX backend ignores: advisory did not even mention it |
training.gradient_accumulation_steps | mlx-lm's own dataclass default of 1 always applied, so the effective batch size and the optimizer-update cadence were not the ones you configured. adapter_config.json also hardcoded 1, so the drift was not detectable from the output afterwards |
training.gradient_checkpointing | mlx-lm's default of False always applied, giving up the memory saving you enabled it for. adapter_config.json hardcoded false here too |
All of them are honoured now, and three of the consequences are worth stating with their measurements rather than in the abstract.
Response-only masking is not comparable between the two backends, and MLX is the stricter one. Setting mlx-lm's own flag was not the fix: it masks a single prefix ending before the last message, so on multi-turn chat it supervises only the final assistant turn. Measured on Apple Silicon over 16 two-turn conversations: 772 supervised tokens unmasked, 71 with upstream's flag, 146 with a correct mask. Soup injects its own per-token mask for chat rows, uses upstream's flag for prompt-and-completion rows where it is correct, and warns on plain-text rows because upstream raises there. MLX excludes the assistant header from the loss where the transformers path includes it, and MLX refuses a chat template whose partial renderings are not prefixes of the full one rather than approximating. data.train_on_messages_with_train_field and data.train_on_prompt have no MLX equivalent and now appear in the MLX backend ignores: line instead of vanishing.
max_grad_norm rescues a specific failure, not every run. One M1 step on a deliberately ill-conditioned batch moved SGD weights by 388.03 unclipped against 0.0085 at max_grad_norm: 1.0. AdamW moved 0.126505 against 0.126504, because Adam normalises by its second moment anyway. So this matters for optimizer: sgd and for gradient spikes, and barely registers on an Adam-family run. The norm that actually ran is recorded in adapter_config.json.
Upgrade note, and it applies to anyone who never touched the field.
gradient_accumulation_stepsdefaults to 4. An MLX run that used to update the optimizer on every micro-batch now accumulates over four first: a 4x larger effective batch size and roughly a quarter as many optimizer updates for the sameiters. That is the schema default finally taking effect, so a differently-converging run after upgrading is expected rather than a regression.itersis also rounded down to a whole number of accumulation groups, with a floor of one group, because mlx-lm only updates onit % accum == 0and never flushes a trailing partial group: without that, a dataset smaller than the accumulation window trained for zero optimizer updates.
Optimizers and schedules
Soup's optimizer allowlist is far wider than anything MLX ships, so most valid values have no equivalent here. Since v0.75.0 a value MLX cannot express is refused by name when the optimizer is built, rather than silently becoming AdamW.
| Setting | Accepted on MLX |
|---|---|
training.optimizer | adamw_torch, adamw_hf, adamw_torch_fused, sgd, adafactor, adagrad, rmsprop, muon — eight of the thirty-two on Soup's allowlist |
training.scheduler | cosine, linear, constant, constant_with_warmup |
training.weight_decay | Any value, except that a non-zero decay on adagrad or rmsprop is refused, because those MLX constructors take no weight_decay at all and dropping it silently is the bug this module exists to fix |
Run a recipe that needs anything else on backend: transformers.
The schedule is built in optimizer-update units, not iterations, because that is what MLX drives a callable learning rate from. Your real warmup is warmup_ratio x (iters // gradient_accumulation_steps); building it against iterations would stretch the warmup by the accumulation factor and never reach the cosine floor. Measured on Apple Silicon, a cosine run with warmup_ratio: 0.2 now reports 10 distinct learning rates across 40 iterations where the old path reported one. A warmup_ratio that rounds to zero updates says so.
The effective plan is written into adapter_config.json — optimizer, scheduler, warmup_updates, total_updates, weight_decay, peak_lr — so what ran is recoverable from the output directory rather than inferred from the recipe.
This backend now drives the dashboard, the tracker and the stream
MLXSFTTrainerWrapper.train() accepted display, tracker and run_id and discarded them, so an MLX run showed mlx-lm's raw stdout while the same config on the transformers path got the Rich panel. Since v0.75.0 it drives all three: the live terminal dashboard, the soup ui SSE stream and the SQLite experiment tracker.
It is an adapter rather than a reuse of the transformers callback, and the reason is worth knowing: mlx-lm exposes only on_train_loss_report and on_val_loss_report, so bridging through a Hugging Face TrainerCallback would have meant faking trainer state. Two deliberate consequences: speed carries iterations_per_second to match the transformers path, because the panel hard-labels that field it/s; and grad_norm is left unset, because mlx-lm does not compute one and a field reading a plausible 0.0 on one backend is worse than an absent one. Verified against real mlx-lm on an 8 GB M1.
Validation loss is recorded at all for the first time, on every backend, and MLX produces it too. Before v0.75.0 the callback read the training loss and never the evaluation loss, so an evaluation step left the last training loss in place and re-reported it: the evaluated number existed nowhere. The live panel gains a Val loss row, and the metrics table and the SSE frame each carry val_loss as its own series, never folded into loss. The panel carries the last measured value forward between evaluations; the stored and streamed values do not, so a step where no evaluation ran records NULL and a series read back has one point per measurement rather than one per logged step. Existing ~/.soup/experiments.db files are migrated in place, and rows written before this ship read NULL rather than a fabricated 0.0, because an unmeasured value must not read as a measured one.
soup doctor --config: the settings your backend does not read
soup doctor --config soup.yamlA field can be declared, validated and documented and still be read by nothing on the backend you chose. That is what this whole page is about, and v0.75.0 added the command that tells you before the run instead of during it.
It loads the config, prints Config check - task=<t> backend=<b>, and then either says every setting this config writes is read, or lists the ones that are not, with the reason and the issue that recorded it. Only fields you actually set are listed, never the ones sitting at a schema default: a wall of hundreds of rows is not a pre-flight check.
The scope is deliberately narrow, and that is the honest framing. The table covers task: sft on backend: mlx, seven entries. Every other task-and-backend pair has no table at all, so for those the command always prints Every setting this config writes is read on this backend., which there means "not reviewed" and never "checked and fine" (a DPO config setting use_ring_attention: true, which no trainer applies, gets that line). Every one of those seven is already something mlx_sft.py warns about at runtime, so the value added is the timing, not new knowledge. Backend support is declared by hand rather than inferred, because inference does not work: reachability over the import graph detected none of five independently-known MLX gaps, reading the trainer module alone invents gaps for fields that live in helper modules, and --dry-run exits before a trainer is ever constructed. A guard keeps the table honest in both directions, so an entry marked unread fails the suite once any declared module reads the field.
soup doctor --config exits 2 when the config cannot be read, parsed or validated — all three deliberately agree, so this leg can gate CI — and soup doctor exits 1 when a required core dependency is missing or out of range, while the optional [train] group stays advisory.
Two disclosures upstream publishes with it, and they belong here. Five entries were removed from that table before merge, because the same release wired those fields — the guard is what noticed. And the all-clear message overclaims: the field-consumer guard behind it catches a field nothing reads, not a field something reads wrongly, and CUDA-only kernels such as
use_ligerare unread on MLX and absent from the table entirely. Read "every setting is read" as "nothing in the declared table is unread", which is a narrower statement.
Limitations
- Single-device only, no distributed MLX training.
- Base models must be MLX-format (typically from the
mlx-communityHF org). bitsandbytesis unused on this backend: quantization comes from the MLX model itself.- Unsloth backend is not MLX-compatible (they're separate execution paths).
- Apple Silicon streaming and MPS training are experimental. v0.74.0 turned on BF16 autocast for hardware-validated resident supervised, DPO, reward-model, PRM and GRPO training on capable MPS runtimes behind a live capability probe, with the process-reward path deliberately keeping fp32 master weights to avoid a fatal Metal optimizer dtype mismatch, and unvalidated MPS and CPU staying fp32. Layer streaming keeps its host store genuinely on the CPU there, refusing any allocation that is not real CPU memory, and preserves a BF16 checkpoint instead of silently doubling the store and disk cache to FP32. No claim is made that streaming fits a larger model or runs faster than resident MPS training, and
backend: mlxis still rejected withstream_layersbecause they are separate model-loading paths. - It reads neither
training.seednortraining.data_seed. Setting either is a warning, not a rejection, because a config valid on transformers should not become unloadable by switching backend. Seeding MLX for real needs a separate generator and is separate work. See seeds and reproducibility.
If you resumed an MLX run before v0.74.0, it started from scratch
--resume auto and a direct --resume <path> only recognised checkpoint-N directories, which is the shape the transformers and Unsloth paths write. mlx-lm's tuner saves step-numbered NNNNNNN_adapters.safetensors files instead, so an MLX run's own output never matched, and training silently restarted from zero every time.
The located checkpoint's adapter weights are loaded before training starts now, and what that is, is stated plainly: a weights-only warm start, not a full resume. mlx-lm's LoRA trainer exposes no optimizer state and no step count, so the step counter and the data position both restart at zero however far the checkpoint got, and the run says so rather than implying otherwise. The same fix also covers a config with experiment_name set, which the first attempt missed.
Known limitation, still open at v0.75.0:
soup doctornever reports MLX. The MLX version always readsunknown, because_get_mlx_info()is defined in the doctor command and called from nowhere. It was filed during v0.74.0 and did not ship in v0.75.0 either, so this is now two releases old.soup doctor --config soup.yamlis unaffected: that leg reads the config rather than the runtime.
If you trained on this backend before v0.73.1, re-run it
Two defects meant an MLX run could complete successfully and be worthless, and both are repaired in v0.73.1.
backend: mlxnever dispatched to the MLX trainer at all. It fell through silently to the transformers path. And without a freeze first, what it saved was not an adapter but a full fine-tune: 172 tensors where 24 were expected on a 1.2B model.- The MLX
adapter_config.jsonsavedtarget_modulesunresolved, as{"keys": ["auto"]}. Loading that file dropped every LoRA tensor without a word, so generation with the adapter was bit-identical to the base model. Sinceautois the schema default, this was every MLX run that did not name its modules by hand. The file cannot be repaired after the fact; the run has to be redone.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.