Skip to content

Commit 84ebaae

Browse files
authored
Merge pull request #72 from Stability-AI/mlx-lora-training
MLX LoRA training for SA3 (Apple Silicon)
2 parents 217e853 + 0f5c31c commit 84ebaae

24 files changed

Lines changed: 6440 additions & 16 deletions

.github/workflows/mlx.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: MLX (Apple Silicon)
2+
3+
# GitHub-hosted `macos-latest` runners are Apple Silicon (arm64), which the MLX
4+
# runtime requires. Scoped to optimized/mlx changes so it doesn't spend macOS
5+
# runner minutes on unrelated PRs.
6+
on:
7+
pull_request:
8+
paths:
9+
- "optimized/mlx/**"
10+
- ".github/workflows/mlx.yml"
11+
12+
jobs:
13+
test:
14+
runs-on: macos-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.11"
20+
- name: Install MLX deps
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install -r optimized/mlx/requirements.txt pytest
24+
- name: Adapter math — all 9 LoRA/DoRA/BoRA types (weight-free)
25+
run: pytest optimized/mlx/scripts/test_lora_merge.py -q
26+
- name: Import + CLI smoke (no weights downloaded)
27+
working-directory: optimized/mlx
28+
run: |
29+
python -c "import mlx.core; from models.defs import lora, training, demo_mlx, latent_dataset, lora_merge, sa3_pipeline; print('core imports OK')"
30+
python scripts/lora_train_mlx.py --help > /dev/null
31+
python scripts/pre_encode_mlx.py --help > /dev/null
32+
echo "CLI smoke OK"

optimized/mlx/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ output/gradio/
1414

1515
# Local LoRA library scanned by the gradio UI (user adapters, never committed)
1616
loras/
17+
18+
# Trainer telemetry (written to cwd, underfit convention)
19+
loss_by_timestep.bin

optimized/mlx/README.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,70 @@ Sample run on **M4 Pro / 48 GB**:
191191
└───────────┴─────────┴─────────┴───────────┴───────────┴────────────┘
192192
```
193193

194+
## LoRA training
195+
196+
Finetune SA3 on your own audio, entirely in MLX (no PyTorch) — a full training
197+
CLI that replicates [underfit](https://github.com/dada-bots/underfit)'s
198+
conventions, defaults, and checkpoint format (so it can run as underfit's
199+
Apple-Silicon backend). Three steps: **pre-encode → train → generate**. See
200+
`TRAINING_CONVENTIONS.md` for the complete convention inventory and the
201+
torch(MPS)-vs-MLX forward+backward parity results.
202+
203+
**1. Pre-encode** your audio to SAME latents (once, offline — torch-free):
204+
205+
```bash
206+
uv run python scripts/pre_encode_mlx.py \
207+
--audio-dir ~/my-clips --output-dir ~/my-latents --codec same-s
208+
```
209+
210+
Each file becomes `<stem>.npy` latents `[D, T]` + a `<stem>.json` sidecar
211+
(duration, padding mask, tags) — the exact format underfit's dataset uses.
212+
Use `same-s` for sm-music/sm-sfx, `same-l` for medium.
213+
214+
**2. Train.** Training uses the **BASE** checkpoint (`stabilityai/stable-audio-3-*-base`,
215+
rectified_flow), *not* the shipped ARC weights inference uses. The base-model npz is
216+
**auto-downloaded** from HuggingFace on first run — no flag needed (override with
217+
`--dit-weights <path>`; regenerate one yourself from a `-base` repo with
218+
`scripts/export_base_npz.py`):
219+
220+
```bash
221+
uv run python scripts/lora_train_mlx.py \
222+
--dit sm-music --latents-dir ~/my-latents --lr 1e-4 --name my-lora \
223+
--adapter-type dora-rows --rank 16 --max-steps 2000
224+
```
225+
226+
Defaults mirror underfit (dora-rows / rank 16, AdamW, uniform sampler + the
227+
"full" distribution shift, CFG-dropout 0.1, signal-only masked rectified-flow
228+
loss, checkpoint every 1000 steps). To match the full dashboard template config,
229+
add `--timestep-sampler trunc_logit_normal --use-effective-length --beta2 0.95
230+
--weight-decay 0.01 --lr-scheduler inverse --lr-warmup 0.995`. Checkpoints land at
231+
`output/runs/<name>/<uuid>/checkpoints/<name>-step=S-epoch=E.safetensors` with
232+
`lora_config` metadata; resume with `--lora-ckpt-path <ckpt>`.
233+
234+
Optional training-time demos (RF-Euler inference → mp3, underfit's on-disk
235+
format): `--demo-every 1000 --demo-config demos.json`, where `demos.json` is a
236+
list of `{"prompt", "cfg", "seed", "steps", "lora_strength"?, "lora_interval_max"?}`.
237+
238+
**3. Generate** with your adapter — the checkpoint applies to the shipped ARC
239+
model at inference via `--lora` (see "Apply a LoRA finetune" above; also
240+
`scripts/sa3_gradio.py --lora <ckpt>` to open the web UI with it preloaded):
241+
242+
```bash
243+
./sa3 --dit sm-music --decoder same-s --prompt "..." --out finetuned.wav \
244+
--lora "output/runs/my-lora/"*"/checkpoints/my-lora-step=2000-epoch="*.safetensors
245+
```
246+
247+
### Building your own loop
248+
249+
The CLI is assembled from reusable pieces: `inject_trainable_lora` /
250+
`save_lora_checkpoint` / `apply_lora_checkpoint` (`models/defs/lora.py` — all 9
251+
LoRA/DoRA/BoRA/-XS types with the no-weight-materialization forward), the RF
252+
loss + samplers + distribution shift (`models/defs/training.py`), the
253+
pre-encoded dataset + prompt templates (`models/defs/latent_dataset.py`), and
254+
`encode_audio` (`models/defs/audio_encoding.py`). Checkpoints use the same
255+
safetensors keys + `lora_config` metadata as the PyTorch trainer, so adapters
256+
are interchangeable in either direction.
257+
194258
## Flag reference
195259

196260
| Flag | Default | Notes |
@@ -221,6 +285,7 @@ sa3_mlx/
221285
├── sa3 ← shell wrapper (use this)
222286
├── install.sh ← uv bootstrap (run once)
223287
├── README.md
288+
├── TRAINING_CONVENTIONS.md ← LoRA-training conventions + MPS-vs-MLX parity
224289
├── requirements.txt
225290
├── output/ ← default landing zone for generated WAVs
226291
├── scripts/
@@ -229,15 +294,26 @@ sa3_mlx/
229294
│ ├── examples.py ← shared examples block (--help + post-install)
230295
│ ├── install.py ← install.sh's Python half (bundle picker)
231296
│ ├── test_all_configs.py ← npz + CLI config sanity tests
232-
│ └── benchmark.py ← wall-time + peak-RAM matrix across model × duration
297+
│ ├── benchmark.py ← wall-time + peak-RAM matrix across model × duration
298+
│ ├── lora_train_mlx.py ← LoRA training CLI (underfit conventions)
299+
│ ├── pre_encode_mlx.py ← audio → SAME-latent pre-encode (torch-free)
300+
│ ├── sa3_gradio.py ← web UI (invoked by ./sa3-gradio; --lora preload)
301+
│ ├── test_lora_merge.py ← adapter-math tests (weight-free; run in CI)
302+
│ └── parity_forward_{torch,mlx}.py ← torch(MPS)-vs-MLX fwd+bwd parity harness
233303
└── models/
234304
├── defs/
235305
│ ├── sa3_pipeline.py ← sampler + conditioner + unpatch
236306
│ ├── t5gemma_mlx.py ← T5Gemma encoder + SentencePiece wrapper
237307
│ ├── dit_mlx.py ← small DiT (sm-music + sm-sfx)
238308
│ ├── dit_mlx_medium.py ← medium DiT (differential attention)
239309
│ ├── same_s_{encoder,decoder}.py ← small codec
240-
│ └── same_l_{encoder,decoder}.py ← large codec
310+
│ ├── same_l_{encoder,decoder}.py ← large codec
311+
│ ├── lora.py ← trainable LoRA/DoRA/BoRA adapters (9 types)
312+
│ ├── lora_merge.py ← inference-time LoRA merge + per-step gating
313+
│ ├── training.py ← RF loss + timestep samplers + distribution shift
314+
│ ├── latent_dataset.py ← pre-encoded dataset + underfit prompt templates
315+
│ ├── audio_encoding.py ← waveform → SAME latents (pre-encode bridge)
316+
│ └── demo_mlx.py ← training-time RF-Euler demos → mp3
241317
└── mlx/ ← .npz weights (auto-downloaded; ~8.4 GB total)
242318
├── t5gemma_f16.npz 541 MB text encoder + tokenizer
243319
├── dit_sm-music_f16.npz 877 MB DiT + conditioner baked in

0 commit comments

Comments
 (0)