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

15 KiB
Raw Blame History

Underfit training conventions → MLX port reference

Working reference for the mlx-lora-training branch: every training convention and default in 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.