Files
SoundGame/tools/stable-audio-3/optimized/mlx/TRAINING_CONVENTIONS.md
T
SoundGame Dev f0f0a9b50e 初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线
- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
2026-08-29 13:00:34 +08:00

228 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Underfit training conventions → MLX port reference
Working reference for the `mlx-lora-training` branch: every training convention and
default in [underfit](https://github.com/dada-bots/underfit) (the SA3 LoRA trainer),
inventoried from its source (2026-07-14), plus the gap analysis against the MLX
primitives this branch starts from (PR #51 by @betweentwomidnights). The goal:
the MLX runtime can run as an underfit backend — same behaviors, same defaults,
same checkpoint format. Trim/fold this file into README before the PR finalizes.
## 1. Adapter configuration
| Convention | Underfit value | Source |
|---|---|---|
| adapter_type (config-layer default) | `"lora"`; legacy `"dora"` → `"dora-rows"` | training/lora.py:34, resolve_adapter_type |
| adapter_type (product/dashboard default) | **`dora-rows`** | dashboard index.html:1336 |
| rank (config-layer default) | 8 | training/lora.py:32 |
| rank (product/dashboard default) | **16** | README.md:157 |
| alpha default | = rank | training/lora.py:33 |
| targets | every `nn.Linear` + `nn.Conv1d` under BOTH `model.model` (DiT) and `model.conditioner`, filtered by include/exclude | training/lora.py:53-64 |
| include/exclude | substring match + `[i-j]` numeric-range expansion; dashboard default EXCLUDES all conditioner + "other" modules (embedders/projections/convs) | dashboard index.html:5741-49 |
| adapter dropout | none | — |
| trainable dtype | LoRA params forced fp32; base stays fp16 (`base_precision`) | loop.py:344-347 |
## 2. Checkpoint format
- Keys: `{**get_lora_state_dict(model.model), **get_lora_state_dict(model.conditioner)}`
→ DiT keys come out `model.transformer.layers...` (inner `.model` on the DiT wrapper),
conditioner keys `conditioners.seconds_total.embedder.embedding.1...`; per-key shape
`<layer>.parametrizations.weight.<lora_index>.{lora_A,lora_B,magnitude,magnitude_r,magnitude_c,M_xs,U,V}`
(index 0 for the finetune adapter).
- Tensors saved **fp16** (`save_lora_safetensors` casts `.half()`); DoRA magnitudes
squeezed to 1-D on load (`prepare_dora_state_dict`).
- Metadata `lora_config` JSON: `rank, alpha, adapter_type, include, exclude` +
injected `step` (int), `epoch` (int), `base_model` (str, from model_config).
- Filename: `{run_label}-step={step}-epoch={epoch}.safetensors` (run_label = run name
with trailing 14-digit timestamp stripped); dir `<save_dir>/<name>/<uuid8>/checkpoints`.
- -xs: only `M_xs` (+ magnitudes) trained/saved; U/V bases precomputed OFFLINE
(compute_svd.py) from fp32 base weights into a separate `.pt`
(`{key: {"U": fp16, "V": fp16, "S": fp32, "shape"}}`, sign-canonicalized by max-abs
U-column entry), loaded via `svd_bases_path` from the model registry; per-layer SVD
at attach time is the fallback.
- Validation contract (lora_validate.py): `.safetensors` only, ≥1 parametrization key,
`lora_config` present with `rank` + `adapter_type` (alpha:=rank, include/exclude:=[]
back-filled), adapter type inferable from key fingerprints.
## 3. Loss (loop.py:561-613 + loss.py)
- Objective from the model: `rectified_flow` → `alphas, sigmas = 1−t, t`,
`noised = x·α + noise·σ`, `target = noise − x` (`"v"` objective also exists:
cos/sin schedule, `target = noise·α − x·σ`).
- MSE per-element; `loss_normalization` default `"none"` (timestep/sample/sample_channel
variance-normalization modes exist, eps 1e-6, detached variance).
- Masking: `mask_loss_weight` default 0.0 and `mask_padding_attention` default False →
**signal-only** loss; reduction is per-sample mean over valid positions×channels,
then batch mean. Inpainting further restricts the mask to the regenerated region.
- No sigma/logSNR loss weighting. Runs under fp16 autocast; no explicit casts.
## 4. Timesteps
- Sampler default **`uniform`** (`torch.rand`); also logit_normal,
trunc_logit_normal (left 0.075, flipped `1−t`), log_snr (mean −1.2, std 2.0),
log_snr_uniform (min −6, max 5).
- Distribution shift comes from the MODEL config: SA3 models ship
`{"type": "full", "min_length": 256, "max_length": 4096}` (base_shift 0.5,
max_shift 1.15 defaults) — applied as `t = dist_shift.shift(t, seq_len)` with
seq_len = the latent time dim (crop length) by default
(`use_effective_length_for_schedule` False; when True: `ceil(seconds_total·sr/4096)`).
## 5. CFG dropout (dit.py:441-450)
- `cfg_dropout_prob` default **0.1**, applied INSIDE the model forward when
`cfg_scale == 1.0`: per-sample bernoulli mask replaces the whole
`cross_attn_cond` (prompt+seconds concat) with **zeros**; `global_cond` is NOT
dropped. (Matches inference: the uncond branch zeroes cross_attn, keeps global.)
## 6. Optimizer / schedule / loop mechanics
| Convention | Underfit value |
|---|---|
| optimizer | AdamW, single param group (LoRA params only) |
| lr | **no default — must be supplied** (`--lr` or optimizer_configs) |
| betas/eps/weight_decay | torch defaults: (0.9, 0.999) / 1e-8 / 0.0 |
| LR schedule | none by default (InverseLR + any torch scheduler configurable) |
| grad clip | off by default (`gradient_clip_val = 0.0`); when set: clip_grad_norm_ |
| grad accumulation | none (defaults.ini key exists but inert) |
| EMA / validation / early stopping / save_top_k | none (inert keys) |
| precision | `"16-mixed"`: fp16 autocast + GradScaler; base fp16, LoRA fp32 |
| batch_size | 1 (defaults.ini) |
| seed | 42 |
| steps | step-driven; `max_steps` absolute global-step target (default ~1e10) |
| checkpoint cadence | every 1000 steps + final save; save BEFORE demos |
| resume offsets | explicit config > safetensors metadata `step/epoch` > filename `step=/epoch=` tokens > step//steps_per_epoch |
| logging | train/loss, train/lr, grad_norm + lora_magnitude (fp32, post-clip); `loss_by_timestep.bin` binary telemetry (step, t_mean, loss_mean) flushed every 10 steps |
## 7. Data pipeline
- **Pre-encode** (offline, once): whole files (no clip splitting), up to 600 s
(aligned down to ds_ratio=4096 samples), 44.1 kHz stereo (mono→stereo by channel
repeat, resample via torchaudio defaults, NO loudness/peak norm), encoder fp32,
`chunked=True` for >30 s. Saved per file: `<stem>.npy` latents `[D,T]` fp32
**unscaled** (training divides by pretransform scale) + `<stem>.json` with
`seconds_total` (full duration, rounded 3dp), `seconds_start: 0`,
`audio_samples`, `latent_shape`, latent-resolution 0/1 `padding_mask`, and all tags.
- **Training dataset** (PreEncodedDataset semantics): crop/pad every item to
`latent_crop_length` — **1300** latents (sm-music/sfx, ≈120 s) / **4096** (medium,
≈380 s); `random_crop=True` (dashboard default) choosing a start within the valid
region; short items padded with `silence.npy` latent if present else zeros, mask
extended with zeros. Fixed-length batches + padding mask (no bucketing).
**`seconds_total` stays the FULL song duration after cropping** (not recomputed).
- **Small datasets**: if `len(ds) < batch_size`, RandomSampler with replacement,
`num_samples = batch_size*100` (~100 steps/epoch) — the core underfit workflow.
- **Prompts** (prompt_templates.py, per-sample at train time): tag-based by default
(`use_tags`, tags→`Title:/Artist:/Album:/Genre:/Label:/Year:/Composer:/BPM:/Prompt:`
joined ", "); augmentation: 50% shuffle-all vs 50% random-subset (1..n tags);
optional trigger token prepended with prob **80%**; path/fixed prompt sources with
weighted balance (tags 50/paths 50/fixed 0); `lyrics=""` always. Tag priority:
JSON sidecar > `.txt` sidecar (whole content = `prompt`) > embedded ID3
(`title,artist,album,genre,label,date,composer,bpm`).
- **Demos**: `demo_every` 1000 (template; loop default 0=off) + baseline at step 0;
`demo_steps` 50 (ARC 8), cfg [7] (ARC 1), per-entry seed/duration/lora_strength/
`lora_interval_max` (σ-interval demo path uses an explicit Euler loop);
peak-normalized int16 → mp3 (`ffmpeg -q:a 0`), trimmed to seconds_total+5 s,
`demo_<i>_<step:08d>.mp3` + json sidecar, idempotent per step.
## 8. Gap analysis: PR #51 primitives vs the above
Already aligned: adapter math for all 9 types (torch-parity tested), rf loss target
+ masked per-sample-then-mean reduction, the 5 timestep samplers + full/flux/logsnr
shifts with matching defaults, fp16 checkpoint tensors + `lora_config` metadata,
`.safetensors`-only trust boundary, fp32 adapter params over a fp16 base, SVD sign
canonicalization, `[i-j]` include/exclude expansion, whole-file chunked encoding
with validity masks.
To build (roughly in order):
1. **lora.py**: save with underfit key roots (`model.` + `conditioners.`) instead of
bare DiT keys; adapter for the baked seconds-conditioner Linear
(`cond.seconds_total_weight` ↔ `conditioners.seconds_total.embedder.embedding.1`);
match include/exclude against checkpoint-convention names so dashboard filter
strings work verbatim; config-layer defaults rank 8 / alpha=rank / lora;
metadata `step/epoch/base_model` injection helpers; accept `svd_bases_path`
`.pt`-equivalent (or keep recompute fallback, which underfit also has).
2. **conditioning + dropout**: training-time conditioning builder (T5Gemma frozen,
prompt padding, seconds token; MLX runtime pieces exist) + per-sample
cross_attn→zeros dropout at prob 0.1 (keep global_cond).
3. **trainer loop** (`lora_train`-equivalent CLI): AdamW (torch-default betas/eps/wd,
lr required), no schedule, no clip, batch 1, seed 42, step-driven with
checkpoint-every-1000 + final save, underfit filename convention, resume-offset
ladder, loss/lr/grad_norm logging, `loss_by_timestep.bin`.
4. **dataset**: PreEncodedDataset-equivalent (npy+json pairs, latent_crop_length
1300/4096 defaults, random_crop, silence/zero pad, mask handling, seconds_total
NOT recomputed, oversample-with-replacement ×100 for tiny sets) +
prompt_templates port (50/50 shuffle/subset, trigger 80%, display-name map).
5. **pre-encode CLI** on top of `audio_encoding.py` (600 s cap, fp32, unscaled fp32
npy + json sidecar with the exact metadata fields).
6. **demos** (optional, last): pipeline-based demo step with underfit cadence/format.
## 9. Training-forward conventions discovered by parity testing (2026-07-14)
**Harness** (reusable, 2026-07-15): `scripts/parity_forward_torch.py` (sa3 venv —
builds the fp32 base model, injects shared latents/noise/timesteps, saves
prediction/loss/cross/global/scale) + `scripts/parity_forward_mlx.py` (MLX venv —
same forward, reports three bug-localizing PSNRs). Fresh run (sm-music base, B=3,
t={0.25,0.5,0.75}): **pretransform.scale=1.0 asserted against the real model**
(confirms the softnorm no-scale convention — a prime hidden-bug candidate),
DiT-only forward (torch cross/global injected → isolates the DiT) **79.0 dB**,
conditioning cross/global **88.5/77.6 dB**, end-to-end **79.0 dB**, loss torch
4.716201 vs MLX 4.716814 (**Δ 6.1e-4**). **Backward also checked**: ∂loss/∂noised
through the full DiT (torch cross/global injected) agrees at **77.5 dB** — the same
floor as the forward, so the backward sweep every adapter gradient is built from
matches too (the local adapter-param VJP was separately verified within-framework to
≤2.4e-7 MLX / ≤4.4e-7 torch during the DoRA reformulation). A convention/autograd bug
(RoPE/attn-mask/adaLN/local_add_cond layout/scale) reads ≪40 dB; ~78 dB is the
cross-framework fp32 reduction-order floor through 20 layers. Confirms fwd+bwd parity,
no bug.
Verified by a controlled forward (identical latents crop, numpy-seeded noise,
t=0.5, fixed prompt, fp32) through underfit's torch loop on MPS vs the MLX
trainer — final agreement **loss 3.9829 vs 3.9823, prediction PSNR 80.7 dB**
(bounded by the fp16 npz weights; torch MPS-vs-CPU agrees at 3e-6 rel):
- **Train on the BASE checkpoint** (`stabilityai/stable-audio-3-*-base`,
rectified_flow), NOT the shipped ARC (rf_denoiser) weights that inference
uses. `lora_train_mlx.py --dit-weights` + a 441-key npz conversion
(strip `model.model.`, `.gamma`→`.weight`, conv (out,in,k)→(out,k,in),
`to_local_embed.{0,2}`→`.seq.{0,2}`, conditioner keys → `cond.*`).
- **local_add_cond during training = all-ONES inpaint mask + zero context**
(the models are the diffusion_cond_inpaint variant). The inference path's
`local_add_cond=None` (≡ zeros) is an inference-only convention — using it
in training changes the loss by ~4x (wrong conditioning regime).
- **Cross-attention runs over all 257 tokens at training time too**: the
torch conditioner substitutes the learned padding_embedding into padded
positions (exactly like the optimized runtimes) and the attention mask is
effectively pass-through. Do NOT mask/slice to valid tokens.
## 10. MPS vs MLX benchmark (M4 Pro 48 GB, sm-music base, 30 steps,
## dora-rows r16 α16 = 141 layers / 9.2M params, batch 1, crop 256, fp16 base)
| | MPS (underfit loop, torch 2.13) | MLX (lora_train_mlx.py) |
|---|---|---|
| steady it/s | 0.668 (1498 ms/step) | **1.65 (2.47x faster)** |
| peak memory | 3.67 GiB driver-allocated | (not instrumented; comparable class) |
| loss regime | 1–43, spikes at low t | same (0.9–35, spikes at low t) |
| checkpoint | underfit-valid, 423 keys | underfit-valid, identical key set |
## 11. Dashboard integration (implements §6/§7 for the underfit dashboard)
- **Progress output is byte-identical to underfit's torch loop** (loop.py:516):
one `tqdm` per epoch, `desc="Step N, Epoch E"`, `mininterval=0, miniters=1,
file=sys.stdout`, postfix `{train/loss:.6f, train/lr:.3e, train/grad_norm:.6f,
train/lora_magnitude:.6f}`. The dashboard collapses the progress bar on tqdm's
`\r` and parses those four keys per step (server.py `_HISTORY_RE`/`_LR_RE`/
`_GN_RE`/`_LM_RE`) — so `underfit.backends.mlx_engine.run_mlx_training` just
inherits stdout (no line translation, which would break `\r` and drop the
postfix). grad_norm/lora_magnitude are one global L2 over the adapter grads
(post-clip) / params (pre-update), matching `_compute_grad_and_lora_norms`.
- **`--gradient-clip-val`** (dashboard passes 1.0) clips the global adapter-grad
norm via `mlx.optimizers.clip_grad_norm` before the step; grad_norm is reported
post-clip like torch. Default 0 = off.
- **ARC demos** (entries `arc=true`): the trained LoRA is merged into a fresh copy
of the shipped rf_denoiser weights (`dit_<dit>_f16.npz`, auto-downloaded, or
`--arc-weights`) and sampled with the pingpong integrator — underfit's
train-on-base / demo-on-ARC story (its torch loop swaps the full ARC model in
place; the MLX path loads it with the LoRA merged at load, once per demo round,
freed after). The seconds conditioner is model-independent, so the trained
`bundle.secs` is reused for both RF and ARC conditioning. RF/base entries keep
the plain-Euler path. cfg/steps default to 1/8 for ARC, `demo_cfg_scales[0]`/
`demo_steps` for RF.