From 5a536c493a0055bf80bc68e9e584b56a24ea1be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 12:07:29 -0400 Subject: [PATCH 01/19] Add MLX engine selector (torch|mlx) for training + gradio 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). --- dashboard/index.html | 12 +- dashboard/server.py | 26 +- defaults.ini | 8 + lora_train.py | 22 ++ run_gradio.py | 14 + underfit/backends/mlx_engine.py | 438 ++++++++++++++++++++++++++++++++ 6 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 underfit/backends/mlx_engine.py diff --git a/dashboard/index.html b/dashboard/index.html index 81a39db..76720e5 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1270,7 +1270,7 @@

Select GPU

New Finetune

Launch a new LoRA training run from scratch
-
+
@@ -1281,6 +1281,13 @@

New Finetune

+
+ + +
+ @@ -3811,7 +3811,7 @@

Download Audio Selection

o.disabled = !hasMlx; o.textContent = isApple ? 'MLX' : 'mlx'; } else if (o.value === 'torch') { - o.textContent = isApple ? 'MPS (PyTorch)' : 'torch'; + o.textContent = isApple ? 'MPS' : 'torch'; } }); const cur = sel.options[sel.selectedIndex]; @@ -6201,9 +6201,13 @@

Download Audio Selection

const btn = document.getElementById('new-ft-next-btn'); const baseModelKey = document.getElementById('new-ft-base-model').value; const baseModelMeta = _remoteModels[baseModelKey] || {}; + // The 'registered' check is the torch base checkpoint. The MLX engine brings + // its own base npz (auto-downloaded / local in the MLX checkout), so the torch + // download isn't required for an mlx run. + const _ftEngine = (document.getElementById('new-ft-engine') || {}).value || 'torch'; let error = ''; if (_newFtNoDatasets) error = 'Create a dataset first'; - else if (baseModelKey && baseModelMeta.registered === false) + else if (baseModelKey && baseModelMeta.registered === false && _ftEngine !== 'mlx') error = `Base model "${baseModelMeta.label || baseModelKey}" not downloaded — please run installer again`; else if (!name) error = '_name_empty'; else if (runsData.some(r => r.display_name === name)) error = `Run "${name}" already exists`; From 4492c40a9e52a29c76718595d2c545beeb543112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 16:24:06 -0400 Subject: [PATCH 07/19] dashboard: MLX dataset encoding on Apple (torch-free) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard/index.html | 6 ++-- dashboard/server.py | 49 ++++++++++++++++++++------------- underfit/backends/mlx_engine.py | 17 ++++++++++++ 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 9800ef3..7d36cea 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -7670,7 +7670,9 @@

Download Audio Selection

const modelMeta = _remoteModels[modelKey] || {}; const errEl = document.getElementById('ds-encode-error'); const btn = document.getElementById('ds-encode-btn'); - if (modelKey && modelMeta.registered === false) { + // On Apple the dashboard encodes with the MLX SAME encoder (own npz), so the + // torch base checkpoint ('registered') isn't required. + if (modelKey && modelMeta.registered === false && _platform !== 'apple') { errEl.textContent = `Base model "${modelMeta.label || modelKey}" not downloaded — please run installer again`; btn.disabled = true; } else { @@ -7818,7 +7820,7 @@

Download Audio Selection

const modelMeta = _remoteModels[model] || {}; const gpus = Array.from(_dsSelectedGpus).sort(); const errEl = document.getElementById('ds-encode-error'); - if (model && modelMeta.registered === false) { + if (model && modelMeta.registered === false && _platform !== 'apple') { errEl.textContent = `Base model "${modelMeta.label || model}" not downloaded — please run installer again`; return; } diff --git a/dashboard/server.py b/dashboard/server.py index 6736828..84be81c 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -6285,25 +6285,36 @@ def _handle_datasets_encode(self, body): exclude_file = output_dir / "exclude.txt" exclude_file.write_text("\n".join(sorted(exclude_set)) + "\n") - encode_args = [ - str(VENV_PYTHON), - str(PRE_DIR / "pre_encode.py"), - "--input-dir", str(input_path), - "--model", model, - "--output-dir", str(output_dir), - "--num-gpus", str(len(gpus)), - ] - if half: - encode_args.append("--half") - if exclude_file is not None: - encode_args.extend(["--exclude-file", str(exclude_file)]) - - encode_env = os.environ.copy() - encode_env.update({ - "CUDA_VISIBLE_DEVICES": gpu_str, - "PYTHONUNBUFFERED": "1", - "MPLBACKEND": "Agg", - }) + if _detect_platform() == "apple": + # MLX SAME encoder (torch-free) — no torch base checkpoint, no CUDA. + # Writes the same npy+json (+ details.json) layout pre_encode.py does. + from underfit.backends import mlx_engine + encode_args = mlx_engine.build_encode_cmd(input_path, output_dir, model) + if exclude_set: + print(f"[encode] mlx encoder does not yet honor the exclude list " + f"({len(exclude_set)} file(s)) — encoding the full folder.") + encode_env = os.environ.copy() + encode_env.update({"PYTHONUNBUFFERED": "1"}) + else: + encode_args = [ + str(VENV_PYTHON), + str(PRE_DIR / "pre_encode.py"), + "--input-dir", str(input_path), + "--model", model, + "--output-dir", str(output_dir), + "--num-gpus", str(len(gpus)), + ] + if half: + encode_args.append("--half") + if exclude_file is not None: + encode_args.extend(["--exclude-file", str(exclude_file)]) + + encode_env = os.environ.copy() + encode_env.update({ + "CUDA_VISIBLE_DEVICES": gpu_str, + "PYTHONUNBUFFERED": "1", + "MPLBACKEND": "Agg", + }) log_handle = None try: diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index d4a600a..16416f1 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -435,6 +435,23 @@ def build_gradio_cmd(model, lora_ckpt_paths, share=False, port=None): return cmd +def build_encode_cmd(input_dir, output_dir, base_model): + """Map the dashboard's encode request onto pre_encode_mlx.py (the torch-free + MLX SAME encoder). base_model → codec: small (sm-music/sm-sfx) → same-s, + medium → same-l. The encoder weights (SAME npz) are auto-downloaded / local + in the MLX checkout — no torch base checkpoint needed.""" + mlx_root, mlx_python = resolve_mlx_paths() + script = os.path.join(mlx_root, "scripts", "pre_encode_mlx.py") + if not os.path.isfile(script): + raise FileNotFoundError( + f"MLX pre-encode script not found at {script!r}. Point " + f"UNDERFIT_MLX_ROOT at the stable-audio-3/optimized/mlx checkout." + ) + codec = "same-l" if map_model_name(base_model) == "medium" else "same-s" + return [mlx_python, script, "--audio-dir", str(input_dir), + "--output-dir", str(output_dir), "--codec", codec] + + # --------------------------------------------------------------------------- # # Runners # --------------------------------------------------------------------------- # From 25a37fd78850b8c998052792e8ed5fb760baaf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 16:37:02 -0400 Subject: [PATCH 08/19] dashboard: MLX encode writes into latent_dir, not output_dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre_encode_mlx.py writes flat into --output-dir, but the dashboard expects latents in output_dir/latents/ (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). --- dashboard/server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dashboard/server.py b/dashboard/server.py index 84be81c..d1bb0ee 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -6287,9 +6287,13 @@ def _handle_datasets_encode(self, body): if _detect_platform() == "apple": # MLX SAME encoder (torch-free) — no torch base checkpoint, no CUDA. - # Writes the same npy+json (+ details.json) layout pre_encode.py does. + # Write into latent_dir (output_dir/latents/) — the exact dir the + # torch pre_encode.py targets and the encoding monitor checks for + # details.json + *.npy. (pre_encode_mlx writes flat into --output-dir, + # so point it AT latent_dir rather than output_dir.) from underfit.backends import mlx_engine - encode_args = mlx_engine.build_encode_cmd(input_path, output_dir, model) + latent_dir.mkdir(parents=True, exist_ok=True) + encode_args = mlx_engine.build_encode_cmd(input_path, latent_dir, model) if exclude_set: print(f"[encode] mlx encoder does not yet honor the exclude list " f"({len(exclude_set)} file(s)) — encoding the full folder.") From 08f90f438fa25bf04735a18e50bae39f98e55824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 16:44:08 -0400 Subject: [PATCH 09/19] dashboard: pass exclude-file to the MLX encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard/server.py | 6 ++---- underfit/backends/mlx_engine.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dashboard/server.py b/dashboard/server.py index d1bb0ee..d9219f7 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -6293,10 +6293,8 @@ def _handle_datasets_encode(self, body): # so point it AT latent_dir rather than output_dir.) from underfit.backends import mlx_engine latent_dir.mkdir(parents=True, exist_ok=True) - encode_args = mlx_engine.build_encode_cmd(input_path, latent_dir, model) - if exclude_set: - print(f"[encode] mlx encoder does not yet honor the exclude list " - f"({len(exclude_set)} file(s)) — encoding the full folder.") + encode_args = mlx_engine.build_encode_cmd( + input_path, latent_dir, model, exclude_file=exclude_file) encode_env = os.environ.copy() encode_env.update({"PYTHONUNBUFFERED": "1"}) else: diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index 16416f1..7810b99 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -435,11 +435,12 @@ def build_gradio_cmd(model, lora_ckpt_paths, share=False, port=None): return cmd -def build_encode_cmd(input_dir, output_dir, base_model): +def build_encode_cmd(input_dir, output_dir, base_model, exclude_file=None): """Map the dashboard's encode request onto pre_encode_mlx.py (the torch-free MLX SAME encoder). base_model → codec: small (sm-music/sm-sfx) → same-s, medium → same-l. The encoder weights (SAME npz) are auto-downloaded / local - in the MLX checkout — no torch base checkpoint needed.""" + in the MLX checkout — no torch base checkpoint needed. exclude_file is a text + file of relpaths to skip (same format the torch pre_encode.py uses).""" mlx_root, mlx_python = resolve_mlx_paths() script = os.path.join(mlx_root, "scripts", "pre_encode_mlx.py") if not os.path.isfile(script): @@ -448,8 +449,11 @@ def build_encode_cmd(input_dir, output_dir, base_model): f"UNDERFIT_MLX_ROOT at the stable-audio-3/optimized/mlx checkout." ) codec = "same-l" if map_model_name(base_model) == "medium" else "same-s" - return [mlx_python, script, "--audio-dir", str(input_dir), - "--output-dir", str(output_dir), "--codec", codec] + cmd = [mlx_python, script, "--audio-dir", str(input_dir), + "--output-dir", str(output_dir), "--codec", codec] + if exclude_file: + cmd.extend(["--exclude-file", str(exclude_file)]) + return cmd # --------------------------------------------------------------------------- # From c1669ee8897439fc36a7f2a12eb34e3d3484cb2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 17:17:40 -0400 Subject: [PATCH 10/19] mlx-engine: pass ARC demos, forward grad-clip, inherit trainer stdout 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. --- underfit/backends/mlx_engine.py | 81 +++++++++++++++------------------ 1 file changed, 37 insertions(+), 44 deletions(-) diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index 7810b99..30560fd 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -35,12 +35,6 @@ _SUPPORTED_DITS = ("sm-music", "sm-sfx", "medium") -# Underfit's tqdm-postfix step line the MLX trainer emits, per its locked -# contract: "step {N} train/loss {L:.6f} train/lr {lr:.3e} epoch {E} (…)" -_STEP_LINE_RE = re.compile( - r"^step (\d+)\s+train/loss (\S+)\s+train/lr (\S+)\s+epoch (\d+)" -) - _STEP_TOKEN_RE = re.compile(r"step=(\d+)") _EPOCH_TOKEN_RE = re.compile(r"epoch=(\d+)") @@ -285,19 +279,29 @@ def _resolve_offsets(training, resume_path): def _build_demo_entries(demo): """Map underfit's `training.demo` block onto the MLX trainer's --demo-config, - which is a flat LIST of entries {prompt, cfg, seed, steps, lora_strength?, - lora_interval_max?}. ARC entries are skipped: the MLX trainer finetunes the - BASE model, and ARC demos need a weight-swap/second-model it doesn't do.""" + which is a flat LIST of entries {prompt, cfg, seed, steps, arc?, + lora_strength?, lora_interval_max?}. + + Both RF/base and ARC entries are passed through. ARC entries carry + ``arc: true`` and default to cfg=1 / steps=8 (the distilled rf_denoiser + convention, matching underfit's demo_step._run_one_arc_entry); the MLX + trainer renders them by loading the shipped ARC weights with the trained + LoRA merged in and sampling with the pingpong integrator. RF entries default + to the run's demo_cfg_scales[0] / demo_steps.""" cfg_scales = demo.get("demo_cfg_scales") or [7] default_cfg = cfg_scales[0] if cfg_scales else 7 default_steps = demo.get("demo_steps", 50) entries = [] for e in demo.get("demo_cond") or []: if e.get("arc"): - continue - entry = {"prompt": e.get("prompt", ""), - "cfg": e.get("cfg", default_cfg), - "steps": e.get("steps", default_steps)} + entry = {"prompt": e.get("prompt", ""), + "cfg": e.get("cfg", 1), # ARC: distilled, cfg=1 + "steps": e.get("steps", 8), # ARC: 8-step pingpong + "arc": True} + else: + entry = {"prompt": e.get("prompt", ""), + "cfg": e.get("cfg", default_cfg), + "steps": e.get("steps", default_steps)} for k in ("seed", "lora_strength", "lora_interval_max"): if e.get(k) is not None: entry[k] = e[k] @@ -376,6 +380,13 @@ def build_trainer_cmd(args, base_weights=None): _add(cmd, "--eps", opt_cfg.get("eps")) _add(cmd, "--weight-decay", opt_cfg.get("weight_decay")) + # gradient clipping: the dashboard passes --gradient-clip-val (1.0 by + # default, server.py restart_cmd) to lora_train.py; forward it so the MLX + # trainer clips identically and the grad_norm metric is post-clip like torch. + gcv = getattr(args, "gradient_clip_val", None) + if gcv: + _add(cmd, "--gradient-clip-val", gcv) + # LR scheduler: only InverseLR is supported (the SA3 templates' scheduler) sched = _scheduler_config(training) if isinstance(sched, dict) and sched.get("type") == "InverseLR": @@ -460,13 +471,18 @@ def build_encode_cmd(input_dir, output_dir, base_model, exclude_file=None): # Runners # --------------------------------------------------------------------------- # def run_mlx_training(args): - """Launch the MLX trainer, stream its stdout, and return its exit code. + """Launch the MLX trainer, inheriting its stdout/stderr, and return its exit + code. cwd is the current process cwd (the dashboard already cd's into the run's demo dir before invoking lora_train.py), so loss_by_timestep.bin and demo - files land where the dashboard expects. Every trainer step line is echoed - verbatim and ALSO re-emitted in the dashboard's tqdm-progress format so the - step/loss/lr parsers pick it up. + files land where the dashboard expects. The trainer emits the SAME tqdm + output as underfit's torch loop (desc "Step N, Epoch E"; postfix + train/loss, train/lr, train/grad_norm, train/lora_magnitude), so we let it + write straight through to lora_train.py's stdout (which the dashboard has + redirected to the run log). No line interception/translation — that would + break tqdm's \\r in-place updates (the dashboard collapses on them) and drop + the grad_norm / lora_magnitude postfix the charts parse. """ model_config = _load_json(args.model_config) dit = _dit_model_from_config(model_config) @@ -490,33 +506,10 @@ def run_mlx_training(args): env = dict(os.environ) env.setdefault("PYTHONUNBUFFERED", "1") - proc = subprocess.Popen( - cmd, - cwd=os.getcwd(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - env=env, - ) - try: - for line in proc.stdout: - line = line.rstrip("\n") - print(line, flush=True) - m = _STEP_LINE_RE.match(line) - if m: - n, loss, lr, ep = m.group(1), m.group(2), m.group(3), m.group(4) - # Format satisfies dashboard/server.py: - # _parse_latest_step (Epoch N: … a/b + "Step N," prefix) - # _HISTORY_RE / _LR_RE (train/loss= and train/lr=) - print( - f"Step {n}, Epoch {ep}: 100%|##########| 1/1 " - f"train/loss={loss} train/lr={lr}", - flush=True, - ) - finally: - if proc.stdout: - proc.stdout.close() + # Inherit stdout/stderr: the trainer's tqdm writes straight to the run log + # (via lora_train.py's redirected fd), preserving \r so the dashboard + # collapses the progress bar and parses every step's postfix metrics. + proc = subprocess.Popen(cmd, cwd=os.getcwd(), env=env) code = proc.wait() print(f"[mlx-engine] MLX trainer exited with code {code}", flush=True) return code From b7f6dfdf4fae21f12cdbadfba20285fe8ccf7a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 17:33:59 -0400 Subject: [PATCH 11/19] dashboard: fix Apple/MLX gaps in gradio launch, device panel, resume, model gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (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". --- dashboard/index.html | 5 ++ dashboard/server.py | 111 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 7d36cea..218b8eb 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5567,6 +5567,11 @@

Download Audio Selection

// disabled-Next/disabled-Encode logic still runs alongside this. window._warnUnregisteredModel = function(modelKey) { if (!modelKey) return; + // On Apple the MLX engine (New Finetune) and the MLX SAME encoder (Encode + // Dataset) bring their own base npz — auto-downloaded / local in the MLX + // checkout — so the torch 'registered' flag doesn't gate usage here. Matches + // the validateNewFt (engine!=mlx) and encode (_platform!=apple) guards. + if (_platform === 'apple') return; const meta = _remoteModels[modelKey] || {}; if (meta.registered !== false) return; const label = _escAttr(meta.label || modelKey); diff --git a/dashboard/server.py b/dashboard/server.py index d9219f7..916e6e9 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -2737,7 +2737,11 @@ def _get_gpu_count(force_refresh=False): def _query_gpu_mem(): - """Return dict of gpu_index -> used_mb from nvidia-smi.""" + """Return dict of gpu_index -> used_mb. CUDA: nvidia-smi per GPU. Apple: the + single unified-memory accelerator (index 0) via vm_stat, so the VRAM history + sampler and per-run VRAM chart populate on Apple Silicon too.""" + if _detect_platform() == "apple": + return {0: _apple_gpu_mem_used_mb()} try: out = subprocess.check_output( ["nvidia-smi", "--query-gpu=index,memory.used", @@ -3574,7 +3578,11 @@ def do_POST(self): self._json_response({"error": "missing checkpoint_path or gpu"}, status=400) return gpu = int(gpu) - if gpu < 0 or gpu >= _get_gpu_count(): + # On CUDA, validate against the nvidia-smi GPU count. On Apple/CPU + # there's a single implicit accelerator (index 0) and nvidia-smi + # reports 0 GPUs, so skip the check — the MLX/MPS launch ignores the + # index anyway (CUDA_VISIBLE_DEVICES is only set on CUDA). + if _detect_platform() == "cuda" and (gpu < 0 or gpu >= _get_gpu_count()): self._json_response({"error": f"gpu must be 0-{_get_gpu_count()-1}"}, status=400) return instance_id, err = gradio_manager.launch( @@ -4478,11 +4486,14 @@ def _handle_resume(self, run_id, body): gpu = body.get("gpu", run.get("gpu")) gpu_env = _cuda_env_prefix(gpu) # Refuse the resume up front if the run's declared backends aren't - # installed (see _missing_backend_error_for_model docstring). - backend_err = _missing_backend_error_for_model(run.get("base_model")) - if backend_err: - self._json_response({"error": backend_err}, status=400) - return + # installed (see _missing_backend_error_for_model docstring). engine=mlx + # trains in a separate MLX venv/checkout, so the in-venv torch backend + # need not be installed — skip the gate (mirrors the fresh-launch path). + if run.get("engine", "torch") != "mlx": + backend_err = _missing_backend_error_for_model(run.get("base_model")) + if backend_err: + self._json_response({"error": backend_err}, status=400) + return backend_env = _backend_env_for_model(run.get("base_model")) demo_dir = run.get("demo_source_dir", str(RUNS_DIR)) os.makedirs(demo_dir, exist_ok=True) @@ -5240,8 +5251,92 @@ def _get_gpu_info(self): resp["cuda_visible_devices"] = visible_gpus return resp + def _descendant_pids(self, pid): + """Set of all descendant PIDs of pid (recursive pgrep -P).""" + found = set() + frontier = [int(pid)] + while frontier: + p = frontier.pop() + try: + kids = subprocess.check_output( + ["pgrep", "-P", str(p)], stderr=subprocess.DEVNULL + ).decode().split() + except Exception: + kids = [] + for k in kids: + ki = int(k) + if ki not in found: + found.add(ki) + frontier.append(ki) + return found + + def _apple_gpu_processes(self): + """Apple unified-memory accelerator processes: the dashboard-managed + training / gradio / encoding runs that are alive, each reporting the RSS + of its whole process tree as used_mb (Apple has no per-process VRAM — + RSS on unified memory is the closest analog). Uses ps/pgrep, not + nvidia-smi or /proc, so it works on macOS.""" + def _alive(pid): + try: + os.kill(int(pid), 0) + return True + except Exception: + return False + + def _tree_rss_mb(pid): + try: + pids = self._descendant_pids(pid) | {int(pid)} + total_kb = 0 + for p in pids: + out = subprocess.check_output( + ["ps", "-o", "rss=", "-p", str(p)], + stderr=subprocess.DEVNULL, + ).decode().strip() + if out: + total_kb += int(out.split()[0]) + return total_kb // 1024 + except Exception: + return 0 + + procs = [] + for r in registry.list_runs(): + if r.get("status") in ("completed", "killed"): + continue + pid = r.get("pid") + if pid and _alive(pid): + procs.append({ + "pid": pid, "used_mb": _tree_rss_mb(pid), "type": "training", + "name": r.get("display_name", r["id"]), "run_id": r["id"], + }) + for inst in gradio_manager.list_instances(): + if inst["status"] not in ("starting", "ready"): + continue + pid = inst.get("pid") + if pid and _alive(pid): + procs.append({ + "pid": pid, "used_mb": _tree_rss_mb(pid), "type": "gradio", + "name": inst.get("title") or inst.get("checkpoint_name", "?"), + "run_id": inst.get("run_id"), + "checkpoint_path": inst.get("checkpoint_path"), + "instance_id": inst["id"], + }) + for ds in datasets_registry.list_datasets(): + if ds["status"] != "encoding": + continue + pid = ds.get("encoding_pid") + if pid and _alive(pid): + procs.append({ + "pid": pid, "used_mb": _tree_rss_mb(pid), "type": "encoding", + "name": ds["name"], "dataset_id": ds["id"], + }) + procs.sort(key=lambda p: p["used_mb"], reverse=True) + return procs + def _get_gpu_processes(self, gpu_idx): - """Return classified processes running on a specific GPU.""" + """Return classified processes running on a specific GPU (CUDA), or the + dashboard-managed accelerator processes on Apple unified memory.""" + if _detect_platform() == "apple": + return self._apple_gpu_processes() # Query nvidia-smi for compute processes try: out = subprocess.check_output( From f8e606fb7e096b0807202c7b5e12d4bd5748a4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 18:01:45 -0400 Subject: [PATCH 12/19] dashboard/installer: on-demand + install-time MLX model download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- dashboard/index.html | 83 +++++++++++++++++++++- dashboard/server.py | 121 +++++++++++++++++++++++++++++++- underfit/backends/mlx_engine.py | 82 ++++++++++++++++++++++ underfit/cli/setup.py | 61 ++++++++++++++++ 4 files changed, 345 insertions(+), 2 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 218b8eb..68b02ac 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1280,6 +1280,8 @@

New Finetune

+ +
@@ -5581,6 +5583,77 @@

Download Audio Selection

_showSeedDialog('Base model not downloaded', bodyHtml, [{label: 'OK', primary: true}]); }; +// ── On-demand MLX model-pack download (item 5) ────────────────────── +// When a base model's MLX weights (base + ARC + codec + encoder + t5) aren't +// present, offer to download them into the MLX checkout instead of a dead-end +// "run installer" message. Renders into `containerId`; `onDone()` re-runs once +// the pack lands so the gate clears. Apple + engine=mlx only. +const _mlxDlState = {}; // modelKey -> 'idle'|'downloading'|'done'|'error' +const _mlxDlCb = {}; // modelKey -> onDone callback + +window._renderMlxDownload = function(containerId, modelKey, meta, show, onDone) { + const el = document.getElementById(containerId); + if (!el) return; + if (!show) { el.style.display = 'none'; el.innerHTML = ''; return; } + el.style.display = 'block'; + _mlxDlCb[modelKey] = onDone; + if (_mlxDlState[modelKey] === 'downloading') return; // poller owns the panel + const label = _escAttr(meta.label || modelKey); + const gb = meta.mlx_download_gb ? ` (~${meta.mlx_download_gb} GB)` : ''; + el.innerHTML = + `
` + + `⚠ ${label} not downloaded for MLX${gb}` + + `` + + `
`; +}; + +window._startModelDownload = async function(modelKey, containerId) { + _mlxDlState[modelKey] = 'downloading'; + const el = document.getElementById(containerId); + if (el) el.innerHTML = `
Starting download…
`; + try { + const res = await fetch('/api/models/download', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({model: modelKey}), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'failed to start'); + } catch (e) { + _mlxDlState[modelKey] = 'error'; + if (el) el.innerHTML = `
Download failed: ${_escAttr(e.message)}
`; + return; + } + _pollModelDownload(modelKey, containerId); +}; + +window._pollModelDownload = async function(modelKey, containerId) { + const el = document.getElementById(containerId); + let data; + try { + const res = await fetch('/api/models/download?model=' + encodeURIComponent(modelKey)); + data = await res.json(); + } catch (e) { setTimeout(() => _pollModelDownload(modelKey, containerId), 2500); return; } + if (data.state === 'downloading') { + const cur = data.current_file ? ` — ${_escAttr(data.current_file)}` : ''; + const rem = data.remaining_gb ? `, ${data.remaining_gb} GB left` : ''; + if (el) el.innerHTML = `
` + + `Downloading ${data.done_files || 0}/${data.total_files || '?'} files${rem}${cur}…
`; + setTimeout(() => _pollModelDownload(modelKey, containerId), 2500); + return; + } + if (data.state === 'done' || data.available) { + _mlxDlState[modelKey] = 'done'; + if (el) { el.style.display = 'none'; el.innerHTML = ''; } + await _loadRemoteModels(); // refresh mlx_available + const cb = _mlxDlCb[modelKey]; + if (typeof cb === 'function') cb(); // re-validate → gate clears + return; + } + _mlxDlState[modelKey] = 'error'; + if (el) el.innerHTML = `
Download failed — see server log
`; +}; + // Open the file picker for the seed-LoRA upload. Always clear the input's // value first — otherwise re-picking the same file (after Cancel) doesn't // re-fire the change event and the picker appears to do nothing. @@ -6210,10 +6283,17 @@

Download Audio Selection

// its own base npz (auto-downloaded / local in the MLX checkout), so the torch // download isn't required for an mlx run. const _ftEngine = (document.getElementById('new-ft-engine') || {}).value || 'torch'; + // MLX engine: gate on the MLX pack (base+ARC+codec+encoder+t5), not the torch + // 'registered' flag. If missing, the download panel offers an on-demand fetch; + // Next stays disabled until it lands (item 5). + const _mlxUnavail = _platform === 'apple' && _ftEngine === 'mlx' + && baseModelMeta.mlx_available === false; + _renderMlxDownload('new-ft-download', baseModelKey, baseModelMeta, _mlxUnavail, validateNewFt); let error = ''; if (_newFtNoDatasets) error = 'Create a dataset first'; else if (baseModelKey && baseModelMeta.registered === false && _ftEngine !== 'mlx') error = `Base model "${baseModelMeta.label || baseModelKey}" not downloaded — please run installer again`; + else if (_mlxUnavail) error = '_mlx_download'; else if (!name) error = '_name_empty'; else if (runsData.some(r => r.display_name === name)) error = `Run "${name}" already exists`; else if (isNaN(steps) || steps < 100) error = 'Steps must be >= 100'; @@ -6230,13 +6310,14 @@

Download Audio Selection

// Show name errors next to the label; other errors in the estimate area const nameLabelEl = document.getElementById('new-ft-name').parentElement.querySelector('.modal-label'); const isNameError = error === '_name_empty' || error.startsWith('Run "'); + const isMlxDl = error === '_mlx_download'; // shown via the download panel if (nameLabelEl) { if (error === '_name_empty') nameLabelEl.innerHTML = 'Run name *'; else if (error.startsWith('Run "')) nameLabelEl.innerHTML = `Run name ${error}`; else nameLabelEl.innerHTML = 'Run name'; } btn.disabled = !!error; - if (error && !isNameError) { el.style.color = 'var(--red)'; el.textContent = error; } + if (error && !isNameError && !isMlxDl) { el.style.color = 'var(--red)'; el.textContent = error; } else { el.style.color = 'var(--yellow)'; updateCkptEstimate(); } updateLayerPreview(); }; diff --git a/dashboard/server.py b/dashboard/server.py index 916e6e9..2f7f467 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -2564,6 +2564,14 @@ def _query_gpu_compute_caps() -> dict: _gradio_vram = {"load_mb": 10000, "peak_mb": 12000, "n_samples": 0} _gradio_vram_baselines = {} # instance_id -> {"gpu": int, "before_mb": int, "measured": bool} +# MLX model-pack downloads (Apple on-demand, item 5). dit -> {proc, log, started}. +# Each is a subprocess running mlx_engine.build_mlx_download_cmd (weights. +# ensure_local for the base+arc+codec+t5 pack) with stdout tee'd to a log the +# status endpoint tails for progress. +_mlx_downloads = {} +_mlx_downloads_lock = threading.Lock() +_MLX_DOWNLOAD_DIR = STATE_FILES_DIR / "mlx_downloads" + def _load_gradio_vram_estimate(): global _gradio_vram @@ -3268,7 +3276,32 @@ def do_GET(self): self.send_response(404) self.end_headers() elif path == "/api/models": - self._json_response({"models": MODELS_UI_PAYLOAD}) + payload = MODELS_UI_PAYLOAD + # On Apple, annotate each model with whether its MLX weight pack + # (base + arc + codec + encoder + t5) is present, so the UI can + # offer an on-demand download instead of the torch 'registered' flag. + if _detect_platform() == "apple": + try: + from underfit.backends import mlx_engine + payload = {} + for k, v in MODELS_UI_PAYLOAD.items(): + vv = dict(v) + try: + vv["mlx_available"] = mlx_engine.mlx_model_available(k) + _miss, _gb = mlx_engine.mlx_missing_pack( + mlx_engine.map_model_name(k)) + vv["mlx_download_gb"] = _gb + except Exception: + vv["mlx_available"] = None + payload[k] = vv + except Exception: + payload = MODELS_UI_PAYLOAD + self._json_response({"models": payload}) + elif path == "/api/models/download": + # Status of an on-demand MLX pack download. ?model= + qs = parse_qs(parsed.query) + model = (qs.get("model") or [None])[0] + self._json_response(self._mlx_download_status(model)) elif path == "/api/runs": self._json_response(self._get_runs()) elif path == "/api/status": @@ -3598,6 +3631,10 @@ def do_POST(self): self._json_response({"error": err}, status=409) else: self._json_response({"id": instance_id}, status=201) + elif parsed.path == "/api/models/download": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + self._start_mlx_download(body.get("model")) elif parsed.path == "/api/runs/new": length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length else {} @@ -5251,6 +5288,88 @@ def _get_gpu_info(self): resp["cuda_visible_devices"] = visible_gpus return resp + def _start_mlx_download(self, model): + """Kick off (or report) an on-demand MLX pack download for `model`. + Apple-only; the pack (base + arc + codec + encoder + t5) is fetched into + the existing MLX checkout via the MLX venv's weights.ensure_local.""" + if _detect_platform() != "apple": + self._json_response( + {"error": "MLX model download is Apple-only."}, status=400) + return + try: + from underfit.backends import mlx_engine + dit = mlx_engine.map_model_name(model) + except Exception as e: + self._json_response({"error": str(e)}, status=400) + return + if mlx_engine.mlx_model_available(model): + self._json_response({"ok": True, "state": "available"}) + return + with _mlx_downloads_lock: + cur = _mlx_downloads.get(dit) + if cur and cur["proc"].poll() is None: + self._json_response({"ok": True, "state": "downloading", "dit": dit}) + return + try: + cmd = mlx_engine.build_mlx_download_cmd(dit) + mlx_root, _ = mlx_engine.resolve_mlx_paths() + except FileNotFoundError as e: + self._json_response({"error": str(e)}, status=400) + return + _MLX_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + log_path = _MLX_DOWNLOAD_DIR / f"{dit}.log" + try: + logf = open(log_path, "w") + except OSError as e: + self._json_response({"error": str(e)}, status=500) + return + env = dict(os.environ) + env.setdefault("PYTHONUNBUFFERED", "1") + proc = subprocess.Popen( + cmd, cwd=mlx_root, stdout=logf, stderr=subprocess.STDOUT, env=env) + _mlx_downloads[dit] = { + "proc": proc, "log": str(log_path), "started": time.time()} + self._json_response( + {"ok": True, "state": "downloading", "dit": dit}, status=201) + + def _mlx_download_status(self, model): + """Progress of an on-demand MLX pack download for `model`.""" + try: + from underfit.backends import mlx_engine + dit = mlx_engine.map_model_name(model) + except Exception as e: + return {"state": "error", "error": str(e), "available": False} + available = mlx_engine.mlx_model_available(model) + total = len(mlx_engine.mlx_pack_rel_paths(dit)) + with _mlx_downloads_lock: + cur = _mlx_downloads.get(dit) + state = "available" if available else "idle" + done_files, current = 0, None + if cur: + rc = cur["proc"].poll() + try: + log = Path(cur["log"]).read_text() + except Exception: + log = "" + oks = set(re.findall(r"OK (\S+)", log)) + dls = re.findall(r"DL (\S+)", log) + done_files = len(oks) + pending = [d for d in dls if d not in oks] + current = os.path.basename(pending[-1]) if pending else None + if rc is None: + state = "downloading" + elif "ALL_DONE" in log or available: + state = "done" + else: + state = "error" + return { + "state": state, "available": available, + "done_files": done_files, "total_files": total, + "current_file": current, + "remaining_gb": (mlx_engine.mlx_missing_pack(dit)[1] + if not available else 0), + } + def _descendant_pids(self, pid): """Set of all descendant PIDs of pid (recursive pgrep -P).""" found = set() diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index 30560fd..76aa47b 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -94,6 +94,88 @@ def map_model_name(base_model): return name +# --------------------------------------------------------------------------- # +# MLX model packs (weights) — availability + download +# --------------------------------------------------------------------------- # +# A model's MLX "pack": base npz (LoRA training) + arc npz (inference / ARC +# demos) + SAME decoder + SAME encoder (a2a / dataset encode) + the shared +# t5gemma conditioner. Mirrors scripts/weights.py (DIT_BUNDLES + SHARED + +# TRAINING_BASE) in the MLX checkout — that file stays the download source of +# truth (ensure_local); this list drives availability + size for the +# dashboard/installer WITHOUT importing the MLX venv. Sizes (GB) approximate. +_MLX_FILE_GB = { + "dit_medium-base_f16.npz": 2.70, "dit_medium_f16.npz": 2.70, + "same_l_decoder_f32.npz": 1.58, "same_l_encoder_f32.npz": 1.58, + "dit_sm-music-base_f16.npz": 0.85, "dit_sm-music_f16.npz": 0.85, + "dit_sm-sfx-base_f16.npz": 0.85, "dit_sm-sfx_f16.npz": 0.85, + "same_s_decoder_f32.npz": 0.20, "same_s_encoder_f32.npz": 0.20, + "t5gemma_f16.npz": 0.52, +} + + +def mlx_pack_rel_paths(dit): + """The models/mlx/*.npz a --dit model needs (base + arc + codec + t5).""" + codec = "same_l" if dit == "medium" else "same_s" + return [ + f"models/mlx/dit_{dit}-base_f16.npz", # base — LoRA training + f"models/mlx/dit_{dit}_f16.npz", # arc — inference / ARC demos + f"models/mlx/{codec}_decoder_f32.npz", # decode + f"models/mlx/{codec}_encoder_f32.npz", # encode (a2a / dataset) + "models/mlx/t5gemma_f16.npz", # shared conditioner + ] + + +def mlx_root_or_none(): + """MLX checkout root if it resolves on disk, else None (no raise) — for + availability checks before the checkout/venv are known to exist.""" + root = os.environ.get("UNDERFIT_MLX_ROOT") or os.path.join( + _SA3_LOCAL, "optimized", "mlx") + root = os.path.abspath(root) + return root if os.path.isdir(root) else None + + +def mlx_missing_pack(dit): + """(missing_rel_paths, approx_gb) for a --dit model's MLX pack. Everything + counts as missing if the checkout itself isn't present yet.""" + root = mlx_root_or_none() + paths = mlx_pack_rel_paths(dit) + missing = [p for p in paths + if not (root and os.path.isfile(os.path.join(root, p)))] + gb = round(sum(_MLX_FILE_GB.get(os.path.basename(p), 0.0) for p in missing), 1) + return missing, gb + + +def mlx_model_available(base_model): + """True if the model's MLX pack is fully present on disk (base + arc + codec + + encoder + t5). base_model may be 'sa3-medium' or 'medium'.""" + try: + dit = map_model_name(base_model) + except ValueError: + return False + missing, _ = mlx_missing_pack(dit) + return not missing + + +def build_mlx_download_cmd(dit): + """MLX-venv-python command that downloads a model's whole pack via the MLX + checkout's weights.ensure_local (the download source of truth). Emits + 'DL ' / 'OK ' per file and 'ALL_DONE' at the end so callers can + show progress. Run with cwd=.""" + _, mlx_python = resolve_mlx_paths() + paths = mlx_pack_rel_paths(dit) + prog = ( + "import sys; sys.path.insert(0, 'scripts')\n" + "from weights import ensure_local\n" + f"paths = {paths!r}\n" + "for p in paths:\n" + " print('DL ' + p, flush=True)\n" + " ensure_local(p, verbose=False)\n" + " print('OK ' + p, flush=True)\n" + "print('ALL_DONE', flush=True)\n" + ) + return [mlx_python, "-c", prog] + + def resolve_base_weights(dit_model): """Path to the MLX BASE-checkpoint DiT weights npz for a --dit value. diff --git a/underfit/cli/setup.py b/underfit/cli/setup.py index fc9b3db..d8a2668 100644 --- a/underfit/cli/setup.py +++ b/underfit/cli/setup.py @@ -826,6 +826,59 @@ def run_model_phase(args, backend: Backend) -> int: # ── ENTRY POINT ────────────────────────────────────────────────────────────── +def maybe_download_mlx_packs(args) -> int: + """Apple-only: pre-download the MLX weight packs (base + ARC + SAME codec + + encoder + shared t5gemma) into the existing MLX checkout so the first MLX + finetune / dataset-encode doesn't stall. Reuses underfit.backends.mlx_engine + — the same download the dashboard's on-demand button uses (item 5). Degrades + gracefully: if the MLX checkout / venv isn't set up, the weights just + download lazily on first use instead.""" + import subprocess as _sp + try: + from underfit.backends import mlx_engine + except Exception as e: + print(f"\n(could not import the MLX engine — {e}. MLX weights will " + f"download on first use.)") + return 0 + root = mlx_engine.mlx_root_or_none() + if not root: + print("\n▸ Apple Silicon detected, but the MLX checkout wasn't found.\n" + " Set UNDERFIT_MLX_ROOT (or clone stable-audio-3 as a sibling and\n" + " create its .venv). MLX weights will download on first use.") + return 0 + all_keys = [p.key for p in SA3_PACKS] + if args.models: + wanted = {s.strip() for s in args.models.split(",") if s.strip()} + keys = [k for k in all_keys if k in wanted] + else: + keys = all_keys + print(f"\n▸ MLX weight packs → {os.path.join(root, 'models', 'mlx')}") + rc = 0 + for key in keys: + try: + dit = mlx_engine.map_model_name(key) + except Exception: + continue + if mlx_engine.mlx_model_available(dit): + print(f" ✓ {dit}: already present") + continue + _missing, gb = mlx_engine.mlx_missing_pack(dit) + print(f" ↓ {dit}: downloading ~{gb} GB …", flush=True) + try: + cmd = mlx_engine.build_mlx_download_cmd(dit) + except FileNotFoundError as e: + print(f" skipped — {e}") + rc = 1 + continue + try: + _sp.run(cmd, cwd=root, check=True) + print(f" ✓ {dit}: done") + except Exception as e: + print(f" download failed ({e}) — will retry on first use") + rc = 1 + return rc + + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="underfit-setup", @@ -873,6 +926,14 @@ def main(argv: list[str] | None = None) -> int: print("\n(skipping model phase as requested)") return 0 + import platform as _pf + if _pf.system() == "Darwin" and _pf.machine() == "arm64": + # Apple Silicon: the MLX engine (Metal GPU) is the primary path, so + # download the MLX weight packs. The torch packs (run_model_phase) are + # only needed for the torch/MPS engine — skip them here to avoid pulling + # ~10 GB of torch weights an MLX-only Mac won't use. + return maybe_download_mlx_packs(args) + return run_model_phase(args, backend) From 08c0003576ff1a9ac96dff1d27c1687ac889b06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 18:26:25 -0400 Subject: [PATCH 13/19] mlx-engine: pass demo `duration` through to the MLX trainer _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. --- underfit/backends/mlx_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index 76aa47b..ecdfdb8 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -384,7 +384,7 @@ def _build_demo_entries(demo): entry = {"prompt": e.get("prompt", ""), "cfg": e.get("cfg", default_cfg), "steps": e.get("steps", default_steps)} - for k in ("seed", "lora_strength", "lora_interval_max"): + for k in ("seed", "lora_strength", "lora_interval_max", "duration"): if e.get(k) is not None: entry[k] = e[k] entries.append(entry) From ecc15e1e4ca5e592f0c8f73da8575c907a9a0bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 18:33:39 -0400 Subject: [PATCH 14/19] dashboard: don't offer MPS without a torch backend; fix greyed-silent gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard/index.html | 13 +++++++++++++ dashboard/server.py | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/dashboard/index.html b/dashboard/index.html index 68b02ac..38bdd1e 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3806,6 +3806,7 @@

Download Audio Selection

const sel = document.getElementById('new-ft-engine'); if (!sel) return; const hasMlx = _engines.includes('mlx'); + const hasTorch = _engines.includes('torch'); const isApple = _platform === 'apple'; Array.from(sel.options).forEach(o => { if (o.value === 'mlx') { @@ -3813,6 +3814,10 @@

Download Audio Selection

o.disabled = !hasMlx; o.textContent = isApple ? 'MLX' : 'mlx'; } else if (o.value === 'torch') { + // Hide MPS/torch when no torch backend is installed — an unusable engine + // option is worse than its absence (server omits it from _engines). + o.hidden = !hasTorch; + o.disabled = !hasTorch; o.textContent = isApple ? 'MPS' : 'torch'; } }); @@ -5833,6 +5838,10 @@

Download Audio Selection

}; function updateCkptEstimate() { + // Don't overwrite a validation error currently shown in this element — the + // onchanges call validateNewFt() then updateCkptEstimate(), so the error + // (set by validateNewFt) must win over the size estimate. + if (window._newFtHasBlockingErr) return; const el = document.getElementById('new-ft-ckpt-estimate'); const model = document.getElementById('new-ft-base-model').value; const type = document.getElementById('new-ft-lora-type').value; @@ -6317,6 +6326,10 @@

Download Audio Selection

else nameLabelEl.innerHTML = 'Run name'; } btn.disabled = !!error; + // Flag a blocking error shown in `el` so a following updateCkptEstimate() + // (onchanges call both) doesn't clobber the message — previously the error + // only surfaced on a name change, leaving Next greyed with no warning. + window._newFtHasBlockingErr = !!(error && !isNameError && !isMlxDl); if (error && !isNameError && !isMlxDl) { el.style.color = 'var(--red)'; el.textContent = error; } else { el.style.color = 'var(--yellow)'; updateCkptEstimate(); } updateLayerPreview(); diff --git a/dashboard/server.py b/dashboard/server.py index 2f7f467..c5c9417 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -2726,6 +2726,20 @@ def _cuda_env_prefix(gpu): return f"CUDA_VISIBLE_DEVICES={gpu} " +def _available_engines(plat): + """Training engines the dashboard can actually launch. 'mlx' on Apple (the + MLX checkout brings its own stack); 'torch' only where a torch backend + (stable_audio_3 / stable_audio_tools) is importable — never list an engine + whose backend isn't installed, since a greyed, unusable option (e.g. MPS on + an MLX-only Mac) is worse than its absence.""" + import importlib.util + torch_ok = any(importlib.util.find_spec(m) is not None + for m in ("stable_audio_3", "stable_audio_tools")) + if plat == "apple": + return ["mlx"] + (["torch"] if torch_ok else []) + return ["torch"] + + def _get_gpu_count(force_refresh=False): """Return number of CUDA GPUs from nvidia-smi (cached after the first *successful* call). Returns 0 if nvidia-smi is missing or fails — the UI @@ -5283,7 +5297,7 @@ def _get_gpu_info(self): resp = {"gpus": gpus, "gradio_estimate": _gradio_vram, "arc_info": arc_info, "platform": plat, - "engines": ["torch", "mlx"] if plat == "apple" else ["torch"]} + "engines": _available_engines(plat)} if visible_gpus is not None: resp["cuda_visible_devices"] = visible_gpus return resp From fba6445587221708496cf681596ce7666fe8ee96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 18:42:56 -0400 Subject: [PATCH 15/19] dashboard: pass base_model to the MLX gradio launch (--pretrained-name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- dashboard/server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dashboard/server.py b/dashboard/server.py index c5c9417..2f0ce00 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -1535,6 +1535,10 @@ def launch(self, checkpoint_path, gpu, run_id=None, checkpoint_name=None, title= f"--ckpt-path {_bash_quote(ckpt_path_model)} " f"{lora_args} " f"--engine {engine} " + # base_model name for engine=mlx: the run's _model.json has no + # top-level 'base_model', so resolve_dit_model needs it here to + # pick the --dit value (the torch path ignores --pretrained-name). + f"--pretrained-name {shlex.quote(base_model)} " f"--model-half " f"--title {shlex.quote(title)}" f"{default_prompt_arg}" From 054928026f9c17900f06f5aa96e589f15d348048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 19:01:26 -0400 Subject: [PATCH 16/19] dashboard: default MLX demos to ARC-only (skip the slow RF demos) 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. --- dashboard/index.html | 67 +++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 38bdd1e..a9ba442 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4758,23 +4758,34 @@

Download Audio Selection

let _newFtPreset = 8; let _newFtPresetUserSet = false; // true once the user clicks a Preset button +// True when the New Finetune engine is MLX (Apple). MLX RF/Base demos take ~50 +// forwards — ~8 min each at full length — so with MLX we suggest ARC-only demos +// (8 steps). Falls back to RF only when the model has no ARC variant. +function _engineIsMlx() { + return ((document.getElementById('new-ft-engine') || {}).value || 'torch') === 'mlx'; +} +function _modelHasArc() { + const mi = _arcInfo[(document.getElementById('new-ft-base-model') || {}).value] || {}; + return mi.arc_type === 'full_model' || mi.arc_type === 'lora'; +} + function _defaultNewFtDemos(preset) { const model = document.getElementById('new-ft-base-model').value || 'sa3'; const mi = _arcInfo[model] || {}; const hasArc = mi.arc_type === 'full_model' || mi.arc_type === 'lora'; const sampler = mi.diffusion_objective === 'v' ? 'V' : 'Base'; const baseSteps = mi.diffusion_objective === 'v' ? 100 : 50; + // MLX: drop the RF/Base demos (too slow) and suggest ARC-only. + const arcOnly = _engineIsMlx() && hasArc; if (preset === undefined || preset === null) preset = _newFtPreset; if (preset === 2) { - const demos = [{prompt: '', sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}]; + const demos = arcOnly ? [] : [{prompt: '', sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}]; if (hasArc) demos.push({prompt: '', sampler: 'ARC', cfg: 1, steps: 8, seed: _randomSeed()}); return demos; } if (preset === 4) { - // Order matters: indices used by _populateDemoPrompts to clear prompt for "no prompt" slots. - // idx 0: RF + prompt idx 1: RF + no prompt - // idx 2: ARC + prompt idx 3: ARC + no prompt - const demos = [ + // Order matters: indices used by _demoEmptyIdx to clear prompt for "no prompt" slots. + const demos = arcOnly ? [] : [ {prompt: '', sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}, {prompt: '', sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}, ]; @@ -4787,7 +4798,7 @@

Download Audio Selection

return demos; } // preset === 8 (default suite) - const demos = [ + const demos = arcOnly ? [] : [ {prompt: '', sampler: sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}, {prompt: '', sampler: sampler, cfg: 7, steps: baseSteps, seed: _randomSeed()}, {prompt: '', sampler: sampler, cfg: 1, steps: baseSteps, seed: _randomSeed()}, @@ -4805,19 +4816,25 @@

Download Audio Selection

} // "Unconditional" indices per preset — emptied by _populateDemoPrompts after the -// dataset-driven prompt fill. Two: none. Four: 2nd of each sampler. Eight: 4th -// RF + 3rd ARC (legacy positions). -const _DEMO_PRESET_EMPTY_IDX = {2: [], 4: [1, 3], 8: [3, 6]}; - -// Default per-demo duration depending on preset: -// Eight — demos 0 (first RF) and 4 (first ARC) default to the model's native -// length so the user always has a long-form reference; the rest use -// the training crop length. -// Two/Four — every demo defaults to the training crop length. These presets -// exist for quick previews on slow GPUs; full-length defeats the point. +// dataset-driven prompt fill. RF+ARC layout: Two none, Four 2nd of each sampler, +// Eight 4th RF + 3rd ARC. ARC-only (MLX) drops the RF slots, so the +// unconditional slot shifts down accordingly. +function _demoEmptyIdx(preset) { + const p = preset || _newFtPreset; + const arcOnly = _engineIsMlx() && _modelHasArc(); + const layout = arcOnly ? {2: [], 4: [1], 8: [2]} : {2: [], 4: [1, 3], 8: [3, 6]}; + return layout[p] || []; +} + +// Default per-demo duration: the first demo of each sampler gets the model's +// native (full) length as a long-form reference (preset 8 only); the rest use +// the training crop length. RF+ARC → idx 0 (RF) + 4 (ARC); ARC-only (MLX) → the +// first ARC is idx 0. function _defaultDemoDurationLatents(idx, cropLen, nativeLen) { - if (_newFtPreset === 8 && (idx === 0 || idx === 4)) return nativeLen; - return cropLen; + if (_newFtPreset !== 8) return cropLen; + const arcOnly = _engineIsMlx() && _modelHasArc(); + const nativeIdx = arcOnly ? [0] : [0, 4]; + return nativeIdx.includes(idx) ? nativeLen : cropLen; } let _newFtDemos = _defaultNewFtDemos(8); @@ -5084,13 +5101,13 @@

Download Audio Selection

}; window.newFtGoStep4 = function() { - // First entry (not cloned, user hasn't manually picked): auto-pick by GPU. + // First entry (not cloned, user hasn't manually picked): auto-pick by GPU and + // rebuild the default demos with the CURRENT engine (MLX → ARC-only). Always + // rebuild here (not only when the preset changes) so the engine-aware default + // applies even when the preset is unchanged; prompts are repopulated below. if (!_newFtDemosCloned && !_newFtPresetUserSet) { - const auto = _pickPresetForGpu(); - if (auto !== _newFtPreset) { - _newFtPreset = auto; - _newFtDemos = _defaultNewFtDemos(auto); - } + _newFtPreset = _pickPresetForGpu(); + _newFtDemos = _defaultNewFtDemos(_newFtPreset); } _updatePresetUI(); // Populate demo prompts from the prompt config heuristic (skip if cloned) @@ -5182,7 +5199,7 @@

Download Audio Selection

} // Clear prompts for the preset's "unconditional" slots — Two has none, // Four clears the second of each sampler, Eight clears idx 3 + 6. - const emptyIdx = _DEMO_PRESET_EMPTY_IDX[preset || _newFtPreset] || []; + const emptyIdx = _demoEmptyIdx(preset); for (const i of emptyIdx) { if (_newFtDemos.length > i) _newFtDemos[i].prompt = ''; } From d8a6a913d7175ecf727262e46ee29674e634c440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 19:10:51 -0400 Subject: [PATCH 17/19] dashboard: SA3-medium "Use SAME-S for demos (faster)" toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard/index.html | 33 +++++++++++++++++++++++++++++++++ dashboard/server.py | 5 +++++ underfit/backends/mlx_engine.py | 2 ++ 3 files changed, 40 insertions(+) diff --git a/dashboard/index.html b/dashboard/index.html index a9ba442..8d698ce 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1481,6 +1481,16 @@

New Finetune Demos

steps
+ +
@@ -4769,6 +4779,24 @@

Download Audio Selection

return mi.arc_type === 'full_model' || mi.arc_type === 'lora'; } +// SA3-medium only: the "Use SAME-S for demos (faster)" checkbox. Default on for +// Apple/MLX (SAME-L decode is a big share of demo time there), off elsewhere; +// _sameSUserSet preserves an explicit toggle for the rest of the modal session. +let _sameSUserSet = false; +function _updateSameSDemoOption() { + const row = document.getElementById('new-ft-same-s-row'); + const cb = document.getElementById('new-ft-same-s-demos'); + if (!row || !cb) return; + const isMedium = (document.getElementById('new-ft-base-model') || {}).value === 'sa3-medium'; + row.style.display = isMedium ? 'flex' : 'none'; + if (isMedium && !_sameSUserSet) cb.checked = (_platform === 'apple'); +} +function _useSameSForDemos() { + const cb = document.getElementById('new-ft-same-s-demos'); + return (document.getElementById('new-ft-base-model') || {}).value === 'sa3-medium' + && cb && cb.checked; +} + function _defaultNewFtDemos(preset) { const model = document.getElementById('new-ft-base-model').value || 'sa3'; const mi = _arcInfo[model] || {}; @@ -5117,6 +5145,7 @@

Download Audio Selection

document.getElementById('new-ft-step4').style.display = ''; document.getElementById('new-ft-demo-every-display').textContent = document.getElementById('new-ft-demo-every').value || '1000'; + _updateSameSDemoOption(); // SA3-medium: show + default the SAME-S demo toggle _renderDemoEntries(); }; @@ -6206,6 +6235,7 @@

Download Audio Selection

// Reset demos state — auto-pick preset will run when user reaches Step 4 _newFtPreset = 8; _newFtPresetUserSet = false; + _sameSUserSet = false; // re-default the SAME-S demo toggle per modal session _newFtDemos = _defaultNewFtDemos(8); _newFtDemosCloned = false; // Reset Step 2 state @@ -6420,6 +6450,9 @@

Download Audio Selection

} return entry; }); + // SA3-medium: decode demos with SAME-S instead of the model's SAME-L when the + // "faster demos" toggle is on (default on for Apple/MLX). + if (_useSameSForDemos()) payload.demo_decoder = 'same-s'; // Build ground_truth from source files used to generate each demo prompt payload.ground_truth = _newFtDemos.map(d => { const f = d.sourceFile; diff --git a/dashboard/server.py b/dashboard/server.py index 2f0ce00..75b52f8 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -4023,6 +4023,11 @@ def _handle_new_finetune(self, body): cfg["training"].setdefault("demo", {})["demo_every"] = demo_every cfg["training"]["demo"]["demo_mode"] = "lora_dashboard" cfg["training"]["demo"]["latent_crop_length"] = body.get("latent_crop_length", mi["latent_crop_length"]) + # SA3-medium (MLX): decode demos with SAME-S instead of SAME-L when + # the "faster demos" toggle is on — the MLX trainer honors this via + # --demo-decoder (mlx_engine); the torch loop keeps its own decoder. + if body.get("demo_decoder"): + cfg["training"]["demo"]["demo_decoder"] = body["demo_decoder"] # Apply custom demo_cond from frontend if provided if custom_demo_cond: # Compute seconds_total from the actual latent crop length diff --git a/underfit/backends/mlx_engine.py b/underfit/backends/mlx_engine.py index ecdfdb8..c05abb9 100644 --- a/underfit/backends/mlx_engine.py +++ b/underfit/backends/mlx_engine.py @@ -499,6 +499,8 @@ def build_trainer_cmd(args, base_weights=None): if entries: _add(cmd, "--demo-config", _write_demo_config(entries)) _add(cmd, "--demo-every", demo_every) + # SA3-medium "faster demos": decode with SAME-S instead of SAME-L. + _add(cmd, "--demo-decoder", demo.get("demo_decoder")) return cmd From 899dfc863a9692849b2f420d98c454485d3ad32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 19:44:12 -0400 Subject: [PATCH 18/19] dashboard: relabel demo presets One/Two/Four on MLX (ARC-only counts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard/index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dashboard/index.html b/dashboard/index.html index 8d698ce..12e08a8 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5105,9 +5105,16 @@

Download Audio Selection

} function _updatePresetUI() { + // MLX (ARC-only) demos drop the RF half, so a preset yields half the demos — + // relabel the buttons One/Two/Four to match the actual count. Non-MLX keeps + // Two/Four/Eight (half RF + half ARC). + const arcOnly = _engineIsMlx() && _modelHasArc(); + const labels = arcOnly ? {2: 'One', 4: 'Two', 8: 'Four'} + : {2: 'Two', 4: 'Four', 8: 'Eight'}; for (const k of [2, 4, 8]) { const btn = document.getElementById('new-ft-preset-' + k); if (!btn) continue; + btn.textContent = labels[k]; if (_newFtPreset === k) { btn.classList.remove('modal-cancel'); btn.classList.add('modal-launch'); From de117eab01ef944c02245ecabb5ac34c72ee2689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCortexelus=E2=80=9D?= <“emperorcj@gmail.com”> Date: Wed, 15 Jul 2026 19:47:32 -0400 Subject: [PATCH 19/19] dashboard: hide dotfile scratch from the checkpoint lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- dashboard/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dashboard/server.py b/dashboard/server.py index 75b52f8..425b4bc 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -6840,6 +6840,8 @@ def _find_checkpoints(ckpts_dir, run_id, dataset_history=None): if not os.path.isdir(ckpt_subdir): continue for f in os.scandir(ckpt_subdir): + if f.name.startswith("."): + continue # hidden scratch (e.g. .arc_demo_tmp — MLX ARC-demo temp LoRA) if f.name.endswith(".safetensors"): all_ckpts[f.path] = (Path(f.path), f.stat()) elif f.name.endswith(".ckpt"):