Skip to content

MLX LoRA training for SA3 (Apple Silicon)#72

Merged
Cortexelus merged 28 commits into
mainfrom
mlx-lora-training
Jul 16, 2026
Merged

MLX LoRA training for SA3 (Apple Silicon)#72
Cortexelus merged 28 commits into
mainfrom
mlx-lora-training

Conversation

@Cortexelus

Copy link
Copy Markdown
Collaborator

Builds on #51 (@betweentwomidnights — the MLX LoRA training + audio-encoding primitives) and carries commits by @brxs. This completes them into a full MLX-native LoRA training runtime for SA3 that replicates underfit's conventions and can run as its Apple-Silicon backend — same behaviors, same defaults, same checkpoint format.

What's here (optimized/mlx)

  • Trainer scripts/lora_train_mlx.py — step-driven loop; AdamW (betas/eps/weight-decay + InverseLR warmup schedule); uniform / logit_normal / trunc_logit_normal / log_snr(_uniform) samplers; the SA3 "full" distribution shift with per-sample effective-length; CFG dropout 0.1; signal-only masked rectified-flow loss (per-sample-then-mean); checkpoint-every + underfit {run}-step=S-epoch=E.safetensors filename/metadata; resume-offset ladder; loss_by_timestep.bin telemetry; mx.compile'd step; per-prompt T5 conditioning cache; --grad-checkpoint.
  • Adapters models/defs/lora.py — all 9 LoRA-family types with the no-weight-materialization DoRA/BoRA reformulation (--bora-mode speed|memory).
  • Dataset models/defs/latent_dataset.py — PreEncodedLatentDataset (crop/pad, random-crop, oversample-with-replacement for tiny sets, underfit prompt-template augmentation; seconds_total kept full after crop).
  • Pre-encode scripts/pre_encode_mlx.py — torch-free audio→SAME-latent CLI, underfit npy+json field parity.
  • Demos models/defs/demo_mlx.py — training-time RF Euler+CFG inference on the base model + trained LoRA → peak-normalized mp3 + json (underfit on-disk format), baseline@0 + cadence + final, idempotent.
  • Gradio scripts/sa3_gradio.py--lora preload (×3 slots, active on launch) + --port.
  • Parity harness scripts/parity_forward_{torch,mlx}.py — controlled fp32 forward+backward comparison vs the torch model.
  • Docs TRAINING_CONVENTIONS.md — the full underfit-convention inventory + parity results.

Parity vs the torch (MPS) trainer — controlled fp32, sm-music base

  • pretransform.scale == 1.0 verified against the real model (the softnorm no-scale convention was previously only assumed)
  • forward 79.0 dB, backward (∂loss/∂noised) 77.5 dB — the cross-framework fp32 reduction-order floor through 20 layers (a convention/autograd bug reads ≪40 dB)
  • conditioning 88.5 / 77.6 dB, end-to-end loss Δ 6.1e-4
  • conventions bit-exact: InverseLR Δ=0, signal-only loss identical, effective-length identical
  • the DoRA/BoRA reformulated forwards+backwards match the naive materialized path to ≤2.4e-7 (within-framework)
  • ~2.5× faster than the MPS torch loop at seq=256

Follow-ups (not blocking)

  • ship a base-ckpt→npz converter (base weights are currently produced out-of-band)
  • fold TRAINING_CONVENTIONS.md into the README; add a macOS-latest CI job for the MLX test suites
  • wire pre_encode_mlx.py into underfit's dashboard pre-encode step for engine=mlx (today a dataset pre-encoded by either encoder feeds both engines, since the npy+json format is identical)

Please preserve @betweentwomidnights and @brxs authorship on merge (a merge/rebase merge, or co-author trailers if squashed).

betweentwomidnights and others added 19 commits July 14, 2026 02:14
Add pure-MLX LoRA, DoRA, BoRA, and XS adapter injection, checkpoint interoperability, fixed-strength inference support, waveform-to-SAME-latent encoding, SA3 timestep sampling, distribution shifting, rectified-flow loss, and focused parity tests.
Complete inventory of every training convention and default in underfit
(adapter config, checkpoint key roots and metadata, loss/timesteps/CFG
dropout, optimizer and loop mechanics, pre-encode and dataset semantics,
prompt augmentation, demos), cross-referenced to source, plus the gap
analysis against the MLX primitives this branch starts from and the
build order. Working reference for the branch — fold into README before
the PR finalizes.
…apter, defaults

Checkpoint keys now use underfit's roots (model. prefix for DiT layers via
a checkpoint_prefix on inject; conditioners.seconds_total.embedder.
embedding.1 for the seconds conditioner) — verified key-for-key and
shape-for-shape against a real underfit checkpoint. include/exclude
filters match both bare runtime names and checkpoint names so dashboard
filter strings work verbatim. New: underfit_lora_config() (dora-rows,
rank 16, alpha=rank, the dashboard exclude list), inject_from_lora_config
(config-layer fallbacks rank 8/alpha=rank/lora), TrainableSecondsEmbedder
(bit-exact vs the inference embedder at init), load_trainable_lora_state
for resume (strict=False, DoRA magnitude squeeze). 24 tests.
…ntics

pre_encode_mlx.py: offline audio→latent CLI (whole files ≤600 s aligned to
4096, fp32 SAME encoders, chunked >30 s, no normalization; npy + json with
seconds_total/seconds_start/audio_samples/latent_shape/padding_mask and
sidecar tag extraction — underfit field parity).

latent_dataset.py: PreEncodedDataset port (latent_crop_length crop with
random_crop over the mask-valid region, silence/zero padding, seconds_total
kept at FULL duration after cropping, min/max-length rejection redraw) +
prompt_templates port (tag prompts with the 50/50 shuffle-vs-subset
augmentation, trigger token at 80%, path/fixed sources, legacy fallback)
+ iterate_batches with the oversample-with-replacement x100 tiny-dataset
behavior. 31 tests, all synthetic fixtures.
Step-driven LoRA training on Apple Silicon mirroring underfit's raw loop:
rectified-flow velocity target with signal-only masked MSE (per-sample
then mean), uniform timestep sampler + the SA3 'full' distribution shift
(256/4096), CFG dropout 0.1 (whole cross_attn to zeros per sample inside
the grad scope, global_cond kept), AdamW at torch defaults with required
--lr (weight decay explicitly 0.0 — MLX's default differs), fp32 adapters
over a frozen fp16 base, checkpoints every 1000 steps + final
({run}-step={s}-epoch={e}.safetensors with step/epoch/base_model
metadata), resume ladder (flags > metadata > filename), per-step
loss_by_timestep.bin telemetry, <save>/<name>/<uuid8>/checkpoints layout.

E2E verified on sm-music: pre-encode → 20 steps dora-rows r16 (140 DiT
layers = the underfit per-block set + conditioner, 9.2M trainable,
1.6 it/s at T=256) → checkpoint passes underfit's lora_validate contract →
resume restores 141 layers with metadata offsets → checkpoint loads
directly via sa3_mlx.py --lora (141 layers merged).
…ventions

--dit-weights flag: train on the BASE (rectified-flow) checkpoint like
underfit does — inference uses the ARC weights, training must not (loud
warning otherwise). Conversion recipe for the HF *-base safetensors into
the 441-key MLX npz documented in TRAINING_CONVENTIONS.md.

Training forward now feeds the diffusion_cond_inpaint pure-generation
conditioning (all-ones inpaint mask + zero context) instead of the
inference path's zeros — verified against underfit's torch loop with a
controlled forward: loss 3.9829 vs 3.9823, prediction PSNR 80.7 dB
(fp16-weight bound; torch MPS-vs-CPU 3e-6 rel). Using inference-style
zeros trains against the wrong conditioning regime (4x loss difference).
Cross-attention correctly runs over all 257 padding-embedded tokens at
training time (parity-tested; masking/slicing to valid tokens is wrong).

Benchmark (M4 Pro, 30 steps, identical setup, full underfit loop on MPS
via the mps-training/mps-support branches): MLX 1.65 it/s vs MPS 0.668
it/s — MLX 2.47x faster. Loss regimes match; both checkpoints pass
underfit validation with identical key sets.
dora-rows/cols and the -xs variants (and lora-xs) no longer build the full
[out,in] adapted weight per call. dora-rows is exactly a row-scale of the
(base + low-rank) output with the norm computed in closed form:
  y = (x@W0.T + s·(x@A.T)@B.T) ⊙ m/rownorm + bias
  rownorm² = Σ_row W0²  (cached const)  + 2s·rowsum((W0@A.T)⊙B) + s²·rowsum((B@AAᵀ)⊙B)
dora-cols is the input-feature-scale dual. One fp16 read of W0 through a
rank-r matmul replaces ~5 fp32 full-matrix passes + an fp32 GEMM; the base
matmul runs in native dtype. bora keeps the full-weight path (its nested
col-norm has no rank-r expansion without storing W0²);
SA3_LORA_NAIVE_DORA=1 restores the old path for ablation.

Equivalence proven: outputs ≤9e-6 rel fp32, gradients ≤2.4e-7 wrt
A/B/magnitude/M_xs vs the naive path; 55 tests. Layer micro-bench
(12288x1536 dora-rows fwd+bwd): 7.2x faster. Full training step:
sm-music 1.60→3.15 it/s, medium 0.52→~1.2 it/s, and peak training
memory 9.8→5.5 GB (small) / 26.8→12.9 GB (medium) since the fp32
weight copies are gone.
…d-checkpoint

Train step (loss+grad+optimizer update) is mx.compile'd with
[bundle.state, optimizer.state, mx.random.state] capture (--no-compile
for eager; optimizer.init up front so captured state is stable; the
frozen-seconds branch hoisted out of the step for purity). T5Gemma prompt
conditioning cached per exact prompt string (--no-t5-cache; bit-exact,
~100% hit rate in the tiny-dataset workflow). Wired memory limit raised
to the device recommendation. --grad-checkpoint ports mlx-lm's
grad_checkpoint onto the DiT blocks: bit-exact losses, −19%+ peak memory,
~1.4x step cost — for big crops on small Macs.

Compiled-vs-eager losses agree to 8e-7 rel; save/resume verified under
compile. Combined ablation (30 same-seed steps, crop 256): sm-music
1.60→3.78 it/s and medium 0.52→1.29 it/s at ≤3e-4 max loss deviation;
vs the MPS/underfit loop that is 5.7x (small) and 5.1x (medium).
BoRA/BoRA-XS training forwards join the no-materialization path:
  y = (((x⊙β) @ W0.T + s·(((x⊙β)@A.T)@B.T)) ⊙ α) + bias
α reuses the dora-rows closed-form rownorm; β's colnorm of the
row-rescaled intermediate expands into three cheap terms, the W0²-weighted
one via a cached native-dtype W0⊙W0 — the speed-mode memory cost (+1 copy
of adapted weights, e.g. ~2.6 GiB for a full medium bora injection; the
underfit default config adapts no convs and bora is rare, so usually 0).
--bora-mode memory (trainer flag, threaded through inject_*) keeps the
exact old full-weight path with no cache; SA3_LORA_NAIVE_DORA=1 still
forces naive everywhere for ablation.

Equivalence: fp32 forward ≤1.4e-6 abs, grads ≤8.5e-5 rel (incl.
magnitude_r/magnitude_c/M_xs); fp16 within the shared tolerances with no
loosening. Layer micro-bench (12288x1536 bora, fwd+bwd): 5.8x vs the
full-weight path. 71 tests.
Add underfit-style demos to lora_train_mlx.py: baseline at step 0 then every
--demo-every steps + a final render. models/defs/demo_mlx.py runs MLX inference
on the base rectified_flow model + the trained LoRA — plain Euler velocity
integration (x += dt*v, distinct from the distilled rf_denoiser pingpong), the
same conditioning/CFG as sa3_mlx (uncond = cross->zeros, denoised-space
guidance, local_add_cond=None), decode via SAME-S/SAME-L, peak-normalized int16
-> mp3 with a json sidecar, files demo_<i>_<step:08d>.mp3, idempotent per step.

Per-entry cfg/seed/steps/duration; lora_strength and lora_interval_max (sigma
gating) are applied by scaling lora_B (M_xs for -xs) pre-norm, which is exactly
underfit's lora_strength semantics for lora/DoRA and touches no forward code.

sa3_gradio.py: --lora PATH (repeatable, preloads a slot active-on-launch) and
--port, so underfit can open the MLX gradio with a trained checkpoint loaded.
… betas/wd, InverseLR)

The underfit SA3 training templates set conventions the trainer didn't honor:
- use_effective_length_for_schedule=True: shift timesteps by the effective latent
  length ceil(int(seconds_total*44100)/4096) per sample, not the crop length
  (--use-effective-length).
- AdamW betas [0.9,0.95] + weight_decay 0.01 (--beta1/--beta2/--eps/--weight-decay;
  MLX AdamW's decoupled wd matches torch AdamW).
- InverseLR scheduler with warmup (--lr-scheduler inverse + --inv-gamma/--lr-power/
  --lr-warmup/--lr-final): lr(step)=(1-warmup^(step+1))*max(final,lr*(1+step/inv_gamma)^-power),
  stepped per optimizer step (last_epoch=raw_step, fresh on resume like torch). At the
  template's warmup=0.995 the step-0 LR is ~5e-7, not the nominal 1e-4 — omitting it
  diverges immediately. Set via optimizer.learning_rate each step; the compiled step
  reads it through inputs=state (verified: exact InverseLR values, no recompile).
Reusable controlled-forward comparison: parity_forward_torch.py (sa3 venv) builds
the fp32 base model, injects shared latents/noise/timesteps, replicates underfit's
training-step forward, saves prediction/loss/cross/global + pretransform.scale;
parity_forward_mlx.py (MLX venv) runs the same forward and reports three PSNRs that
localize any mismatch — DiT-only (inject torch's cross/global → isolates the forward),
conditioning (T5+seconds), end-to-end — plus asserts pretransform.scale==1.0.

Fresh sm-music result: scale=1.0 (verified against the real model, not just assumed),
DiT-only 79.0 dB, conditioning 88.5/77.6 dB, end-to-end 79.0 dB, loss Δ 6.1e-4. A
convention bug would read ≪40 dB; ~79 dB is the cross-framework fp32 floor. No bug —
the MLX and torch(MPS) training forwards agree to the floating-point limit.
Extend the parity harness to compare the full DiT backward sweep cross-framework:
torch saves d loss/d noised (autograd w.r.t. the injected noised input, the same
backward every interior/adapter gradient is built from); the MLX side computes
mx.grad of the same loss (torch cross/global injected to isolate the DiT backward)
and reports its PSNR. Result: 77.5 dB — the same fp32 floor as the forward (79 dB),
so forward AND backward agree to the floating-point limit. The local adapter-param
VJP was separately verified within-framework (DoRA reformulation, <=2.4e-7).
GitHub-hosted macos-latest runners are Apple Silicon, which MLX requires. Runs
the weight-free adapter-math suite (all 9 LoRA/DoRA/BoRA types, scripts/
test_lora_merge.py) + a core-module import smoke + lora_train_mlx.py/
pre_encode_mlx.py --help. Scoped via paths filter to optimized/mlx changes so it
doesn't spend macOS runner minutes on unrelated PRs. (test_all_configs.py needs
the shipped npz weights and isn't CI-runnable — left out.)
The old 'LoRA training primitives' section predated the trainer and said there
was 'no dataset or training CLI' — now stale. Replace it with the real end-to-end
workflow: pre_encode_mlx.py → lora_train_mlx.py (train on the BASE ckpt via
--dit-weights, underfit-default flags + the full-template flag set, demos) →
generate with --lora. Keep a short 'build your own loop' note for the primitives,
and list the training files + TRAINING_CONVENTIONS.md in the Files tree.
…onverter

Training uses the BASE checkpoint (not the shipped ARC weights). Ship the base
weights via HF so training works out of the box:
- weights.py: TRAINING_BASE manifest entries for dit_{sm-music,sm-sfx,medium}-base_f16.npz
  (MLX/… in stabilityai/stable-audio-3-optimized), added to FLAT_MANIFEST for lazy
  ensure_local download.
- lora_train_mlx.py: default to the base npz (ensure_local auto-download) when
  --dit-weights is omitted — drop the old 'warn + train on ARC' fallback. --dit-weights
  still overrides with a custom path.
- export_base_npz.py: torch-free converter (safetensors.numpy) from a *-base checkpoint
  → dit_<model>-base_f16.npz (DiT + baked conditioner). Validated to reproduce the
  known-good sm-music/medium npz within fp16 (419/441 bit-identical, rest ≤1 fp16 ULP);
  used to produce the sm-sfx base npz.
- README: training uses the auto-downloaded base npz (no flag needed).
“Cortexelus” added 9 commits July 15, 2026 16:30
Print "Engine: MLX  ·  device: Device(gpu, 0)" at the top of the encode run so the
dashboard log (and CLI) shows unambiguously that the MLX SAME encoder on the Metal
GPU is running — not the torch (MPS) pre_encode path.
…de parity)

Add --exclude-file (text file of relpaths relative to --audio-dir, one per line) —
same format/semantics as dataset_processing/pre_encode.py. Filters the discovered
files in both main() (banner shows N excluded) and run(); the dashboard passes its
exclude.txt through so per-file exclusions work on the MLX encode path.
Emit the same per-step tqdm line as underfit's torch loop (desc
"Step N, Epoch E"; postfix train/loss, train/lr, train/grad_norm,
train/lora_magnitude) instead of a plain print, so the dashboard
collapses the progress bar and charts grad-norm / lora-magnitude with
no translation layer. Both norms are one global L2 over the adapter
grads (post-clip) and params (pre-update), matching loop.py
_compute_grad_and_lora_norms. Add optional --gradient-clip-val (the
dashboard passes 1.0) via mlx clip_grad_norm; grad_norm is post-clip
like torch.

Demos: entries tagged arc=true now render on the shipped rf_denoiser
weights (train-on-base / demo-on-ARC). The trained LoRA is merged into
a fresh copy of dit_<dit>_f16.npz (auto-downloaded, or --arc-weights)
and sampled with the pingpong integrator; the seconds conditioner is
model-independent so the trained embedder is reused. The ARC model is
loaded once per demo round and freed after. RF/base entries keep the
Euler path. Documents both in TRAINING_CONVENTIONS section 11.
Demo entries carry a `duration` (seconds) when they aren't at the crop length
(the dashboard's full-length demos set it, crop-length ones omit it). The
trainer was ignoring it and rendering every demo at the crop length. Now each
demo computes its own T_lat from `duration` (falling back to the crop) for both
the RF/base and ARC paths.

For that to work on a model loaded at the training crop length, the DiT is now
length-agnostic: the local-add-cond zeros in the text-to-audio path
(local_add_cond=None) are built at the INPUT length instead of the baked
self.T_lat. Identical for inference (which always loads T_lat == the gen length)
and unused by training (which passes the all-ones inpaint cond), so this only
unlocks reusing one loaded model across demo lengths. Both DiT sizes
(dit_mlx_medium, dit_mlx) updated; attention is full (no mask) and RoPE is
dynamic, so length is otherwise free.

Verified: crop 128 + a 256-latent demo (via duration) render at 11.9 s / 23.8 s.
A full-length (4096-latent) demo is ~10 s/forward on MLX medium → ~8 min for a
50-step RF demo, and nothing was printed until it finished — so training looked
stuck at the step-0 baseline. Now each demo prints its length + step count
before running and shows a tqdm step bar (rf_euler_sample + pingpong_sample gain
a `desc`). Makes clear it's progressing, not hung.
Each demo's completion line now shows how long generation took, e.g.
"♪ demo 0 @ step 0: '...' → demo_0_00000000.mp3 (482s)" — useful given
full-length demos run minutes each on MLX.
When --demo-decoder differs from the model's default (SA3-medium's SAME-L),
print "demos: decoding with SAME-S instead of the model's SAME-L (faster demos)"
so the console shows the swap. Both RF and ARC demos use the chosen decoder.
The temp LoRA merged into the ARC DiT was written to the run's checkpoints/
dir (as a dotfile), so it could surface in the dashboard's checkpoint list
during the demo (or leak on a hard kill). Write it to the session dir instead
(ckpt_dir.parent, not the scanned checkpoints/ subdir); it's still a dotfile and
still removed after the demo.
@Cortexelus
Cortexelus merged commit 84ebaae into main Jul 16, 2026
1 of 2 checks passed
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.

2 participants