Skip to content

MLX engine for training + gradio (Apple Silicon)#10

Merged
Cortexelus merged 19 commits into
mps-supportfrom
mlx-engine
Jul 16, 2026
Merged

MLX engine for training + gradio (Apple Silicon)#10
Cortexelus merged 19 commits into
mps-supportfrom
mlx-engine

Conversation

@Cortexelus

Copy link
Copy Markdown
Contributor

Adds an engine selector (torch default | mlx) so underfit can train + serve on Apple Silicon via MLX, alongside the existing torch (MPS/CUDA/CPU) path. The torch path is byte-for-byte unchanged when engine=torch — this is purely additive.

Pairs with the MLX trainer/runtime in Stability-AI/stable-audio-3#72; this PR is the underfit glue that drives it. Stacked on #9 (MPS training support) — base retargets to main when #9 merges.

What it does (engine=mlx)

  • Trainingrun_training shells out to the sibling stable-audio-3 MLX trainer (optimized/mlx/scripts/lora_train_mlx.py) in its own MLX venv, maps underfit's model/dataset/training config onto its CLI (adapter type/rank/alpha, timestep sampler, distribution shift + use_effective_length, AdamW betas/eps/weight-decay, InverseLR schedule, CFG-dropout, demos, resume offsets), and translates the trainer's stdout into the dashboard's progress + loss/lr-chart format. The base-model npz is auto-downloaded by the trainer, so no local base file is required.
  • Gradio — launches the MLX web UI (sa3_gradio.py) with the trained LoRA preloaded (--lora), instead of the torch gradio.
  • Dashboard — an Engine dropdown (torch | mlx) wired into both the training and gradio launch commands.

Files

  • underfit/backends/mlx_engine.py (new) — torch-free launcher: MLX root/venv resolution, arg mapping, stdout→dashboard log translation, training + gradio runners.
  • --engine {torch,mlx} in lora_train.py + run_gradio.py; engine = torch in defaults.ini (precedence: CLI > UNDERFIT_ENGINE env > defaults.ini > torch).
  • dashboard/server.py + dashboard/index.html — Engine dropdown + launch-command wiring.

Config / paths

Overridable via env: UNDERFIT_MLX_ROOT (default: the sibling stable-audio-3/optimized/mlx checkout), UNDERFIT_MLX_PYTHON (default: <root>/.venv/bin/python), UNDERFIT_MLX_BASE_WEIGHTS (optional; otherwise the trainer downloads the base npz from HF).

Verified (M4 Pro)

  • engine=mlx training end-to-end: 5 steps → checkpoint + demos + translated dashboard log lines, exit 0; losses match the direct MLX trainer.
  • Gradio launches with the trained LoRA preloaded (banner + HTTP 200).
  • Config conventions bit-exact vs the torch loop (InverseLR Δ=0, signal-only loss, effective-length) and the MLX-vs-torch forward+backward parity holds to the fp32 floor (see stable-audio-3#72).

“Cortexelus” added 5 commits July 15, 2026 12:07
Adds an alternate Apple-Silicon MLX engine alongside the default torch loop.
engine=mlx shells out to the separate MLX trainer/gradio in the sibling
stable-audio-3 checkout via a new underfit/backends/mlx_engine.py launcher.

- defaults.ini: engine = torch (values torch|mlx)
- lora_train.py / run_gradio.py: --engine flag (CLI > env UNDERFIT_ENGINE >
  defaults.ini > torch), dispatch to mlx_engine when engine=mlx
- mlx_engine.py: path/name resolution (env-overridable), args->locked MLX
  trainer CLI mapping, stdout re-emit in dashboard-parseable format, gradio cmd
- dashboard: Engine dropdown in New Finetune modal; server threads --engine onto
  both training and gradio launch commands, stores engine on the run

Torch path is unchanged for engine=torch (default).
…-config list

The MLX trainer's --demo-config is a flat LIST of {prompt,cfg,seed,steps,...}
entries (its demo_cond equivalent), not underfit's whole training.demo dict.
Map demo_cond -> entries (cfg from demo_cfg_scales[0], steps from demo_steps),
skipping ARC entries (the MLX trainer finetunes the base model; ARC demos need
a weight-swap it doesn't do).
…efault

Map the SA3 template's training conventions onto the MLX trainer CLI: AdamW
betas/eps/weight_decay, InverseLR scheduler (+inv_gamma/power/warmup/final_lr),
model.diffusion.use_effective_length_for_schedule → --use-effective-length. Also
default adapter_type to 'lora' when the lora_config omits it (underfit's config-layer
default lora_config.get('adapter_type','lora'); the MLX trainer's own default is
dora-rows, which would otherwise mismatch a template without an explicit type).
…e npz)

The MLX trainer now auto-downloads the base-model npz from HF, so underfit no longer
needs a local base file. Pass --dit-weights only when UNDERFIT_MLX_BASE_WEIGHTS is set
or a local base npz exists; otherwise omit it and let the trainer download. Removes the
hard FileNotFoundError on a missing local base file.
…CUDA)

The dashboard modeled "devices" as an nvidia-smi CUDA-GPU list and hard-gated on
it — on a Mac it showed "No CUDA GPUs detected ... require an NVIDIA GPU" and left
Start disabled, even though the training loop already resolves cuda>mps>cpu and the
MLX engine exists.

server.py:
- _detect_platform() (torch-free: Darwin+arm64 -> apple, else nvidia count -> cuda, else cpu).
- _apple_gpu_info() — the Metal counterpart of the nvidia-smi query: chip/GPU name +
  core count via "system_profiler SPDisplaysDataType", unified-memory total via
  "sysctl hw.memsize"; _apple_gpu_mem_used_mb() via "vm_stat".
- /api/gpu returns platform + engines + (on apple) a single synthetic Metal device
  {kind:apple, name, cores, used/total/free_mb}. CUDA path unchanged.
- Launch env: _cuda_env_prefix() omits CUDA_VISIBLE_DEVICES on apple/cpu;
  _free_gpu_memory no-ops off-CUDA.

index.html:
- Store platform/engines; _noGpuHtml() keeps the exact CUDA message on cuda, softer
  "CPU (slow)" otherwise; Apple renders a normal device tile (name + N-core / unified
  memory) — no warning.
- Auto-select the single Apple device (Start/Launch enable without a click).
- Engine dropdown filtered/relabelled to the platform: torch -> "MPS (PyTorch)" + "MLX"
  on apple (default MLX); mlx hidden on cuda/cpu (default torch).

All Apple/CPU branches guarded on platform -> the Linux/CUDA path is unchanged.
“Cortexelus” added 14 commits July 15, 2026 16:19
The "base model not downloaded — run installer again" block in validateNewFt is
the TORCH base checkpoint (modelMeta.registered). Skip it when engine=mlx: the MLX
trainer brings its own base npz (auto-downloaded from HF / local in the MLX
checkout), so the torch download isn't required. Added an onchange on #new-ft-engine
so the error clears/appears when the engine is switched.

Also shorten the Apple engine label "MPS (PyTorch)" -> "MPS" to save UI width.

Dataset ENCODING still gates on the torch ckpt (validateNewDataset / launchEncoding)
— wiring the MLX pre-encode into the dashboard encoding flow is a separate follow-up.
The Encode Dataset flow launched dataset_processing/pre_encode.py (torch, reads the
torch base checkpoint) and gated on it being downloaded — so on Apple, where the
torch backend isn't installed, dataset encoding was blocked with "base model not
downloaded".

On Apple, encode with the MLX SAME encoder instead:
- mlx_engine.build_encode_cmd(input, output, model) -> pre_encode_mlx.py --audio-dir
  --output-dir --codec (small -> same-s, medium -> same-l). Encoder weights are the
  MLX SAME npz (auto-downloaded / local in the MLX checkout) — no torch checkpoint.
- server.py /api/datasets/encode: dispatch to the MLX encoder when platform == apple
  (no CUDA_VISIBLE_DEVICES); torch path unchanged elsewhere. Output layout (npy + json
  + details.json) matches pre_encode.py, so the existing completion check works as-is.
- index.html: skip the "not downloaded" gate (validateNewDataset + launchEncoding)
  when platform == apple.

Known gap: the MLX encoder does not yet honor the per-file exclude list (encodes the
full folder); logged when excludes are set.
pre_encode_mlx.py writes flat into --output-dir, but the dashboard expects latents
in output_dir/latents/<model> (latent_dir) — where the torch pre_encode.py writes and
where the encoding monitor looks for details.json + *.npy. Pointing the MLX encoder at
output_dir left latent_dir empty, so the monitor saw a finished-but-empty encode and
dropped the dataset. Point build_encode_cmd at latent_dir (+ mkdir it).
build_encode_cmd takes exclude_file and appends --exclude-file; the encode dispatch
passes the run's exclude.txt. Removes the earlier 'mlx encoder does not honor the
exclude list' note — per-file exclusions now work on the MLX path.
run_mlx_training now inherits the trainer's stdout/stderr instead of
capturing and re-emitting a synthetic step line. The trainer emits the
same tqdm output as underfit's torch loop, so writing it straight to
the run log preserves tqdm's carriage-return progress updates (the
dashboard collapses on them) and the grad_norm / lora_magnitude postfix
(the metric charts parse it). Drops the translation loop and the
now-unused _STEP_LINE_RE.

_build_demo_entries passes ARC entries through (arc=true, cfg=1, steps=8
defaults) instead of skipping them - the MLX trainer renders them now.
build_trainer_cmd forwards --gradient-clip-val (the dashboard passes
1.0) so the MLX trainer clips identically.
… model gate

(1) The /api/gradio gpu validation now only applies on CUDA. Apple/CPU have one
implicit accelerator (index 0) and nvidia-smi reports 0 GPUs, so the old
gpu >= _get_gpu_count() check rejected every Apple launch with "gpu must be
0--1". The MLX/MPS launch ignores the index anyway.

(2) The device-detail panel had no data on Apple: _query_gpu_mem returned {}
(no nvidia-smi) so the VRAM history never recorded ("Collecting VRAM data..."),
and _get_gpu_processes used nvidia-smi + /proc (empty on macOS → "No
processes"). Now _query_gpu_mem reports unified memory as GPU 0 (vm_stat), and
_apple_gpu_processes lists the managed training / gradio / encoding runs with
their whole process-tree RSS (ps/pgrep, no /proc).

(3) The "Base model not downloaded" popup (_warnUnregisteredModel) fired on
Apple for both MLX and MPS even though the MLX npz is present. Guarded with
_platform==='apple', matching the existing encode and validateNewFt guards.

(4) Resume now skips the torch-backend gate for engine=mlx, mirroring the
fresh-launch path — killing and reviving an MLX run no longer errors "requires
the sa3 backend".
When a base model's MLX weight pack (base + ARC + SAME codec + encoder + shared
t5gemma) isn't present, offer to download it instead of a dead-end "run
installer again" message.

- mlx_engine: shared pack helpers — mlx_pack_rel_paths / mlx_model_available /
  mlx_missing_pack (files + approx GB) / build_mlx_download_cmd (runs the MLX
  checkout's weights.ensure_local, the download source of truth, emitting
  DL/OK/ALL_DONE lines for progress). mlx_root_or_none resolves without raising.
- server: /api/models annotates each model with mlx_available + mlx_download_gb
  on Apple; POST /api/models/download starts a tracked background download and
  GET /api/models/download?model= reports {state, done/total files, current
  file, remaining GB}.
- index.html: New Finetune shows a "Download base + ARC (~X GB)" button when the
  selected model's MLX pack is missing (engine=mlx on Apple). It downloads with
  a live progress line, refreshes availability, and clears the gate — Next stays
  disabled until the pack lands.
- setup.py: on Apple, the model phase pre-downloads the MLX packs into the
  existing MLX checkout (reusing the same mlx_engine download) and skips the
  torch packs an MLX-only Mac won't use. Degrades gracefully if the checkout /
  venv isn't set up (weights then download lazily on first use).
_build_demo_entries dropped the per-demo `duration` field, so full-length demos
collapsed to the crop length in the MLX trainer. Forward it (like seed /
lora_strength / lora_interval_max) so each demo renders at its configured length.
… gate

On Apple the engine dropdown always offered "MPS" (torch) even with no torch
backend installed, so starting an MPS run greyed out Next with no warning — and
only a name change surfaced "not downloaded — run installer again".

- server: /api/gpu `engines` now reflects installed backends — mlx on Apple
  (its checkout brings its own stack) plus torch only where stable_audio_3 /
  stable_audio_tools is importable. An MLX-only Mac reports ["mlx"], so the UI
  omits the unusable MPS option entirely rather than showing a greyed dead-end.
- index.html: _applyEngineOptions hides/disables the torch (MPS) option when
  torch isn't in _engines (it previously only handled mlx).
- Fixed the greyed-silent gate itself: validateNewFt now flags a blocking error
  and updateCkptEstimate bails instead of overwriting it. The onchanges call
  both, so the size estimate was clobbering the "not downloaded" message, which
  then only reappeared on a name change. Any validation error shows immediately.
Launching an MLX gradio failed with "could not determine the --dit model":
resolve_dit_model reads 'base_model' from the run's _model.json (which doesn't
carry it) and the launch never passed --pretrained-name. The launch already
knows base_model from the run record — forward it so the MLX gradio picks the
--dit value (the torch path ignores --pretrained-name).
RF/Base demos are 50 steps (~8 min each at full length on MLX); ARC is 8 steps.
For the MLX engine the suggested default now drops the RF demos and keeps only
the ARC ones (all presets), falling back to RF only when the model has no ARC
variant. _defaultNewFtDemos / _defaultDemoDurationLatents / the
unconditional-prompt indices (_demoEmptyIdx) are engine-aware, and newFtGoStep4
rebuilds the default with the current engine on entry.
New Finetune → Demos shows a "Use SAME-S for demos (faster)" checkbox for
SA3-medium only — default ON for Apple/MLX, off elsewhere. When on, demos decode
with the small SAME-S autoencoder instead of the model's SAME-L (~1.6× faster,
near-identical: the two share the latent space, corr 0.99). Wired
payload.demo_decoder → training.demo.demo_decoder → the MLX trainer's
--demo-decoder (mlx_engine); the torch loop keeps its own decoder.
MLX demos are ARC-only, so a preset yields half the demos of the RF+ARC layout
(2→1, 4→2, 8→4). Relabel the preset buttons One/Two/Four on MLX to match the
actual ARC-demo count; non-MLX keeps Two/Four/Eight (half RF + half ARC). The
internal preset values (2/4/8) are unchanged — labels only.
_find_checkpoints included any *.safetensors, so the MLX ARC-demo scratch LoRA
(.arc_demo_tmp_*.safetensors) showed up in the Checkpoints box and the
Revive/launch checkpoint pickers. Skip dotfiles in the scan — hides that
scratch (and any hidden temp) from every checkpoint list.
@Cortexelus
Cortexelus merged commit ca67b7e into mps-support Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant