From 6808a8818702373fa5f3284044e86541c57821e0 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 14:09:09 +0000 Subject: [PATCH 1/4] fix: honour the CPU offload setting on H3, and report training before the first step --- core/src/inline_core/training/models.py | 17 ++++++---- core/src/inline_core/training/trainer.py | 25 +++++++++++++++ core/tests/test_minimaxh3_training.py | 14 +++++++++ core/tests/test_training_models.py | 40 +++++++++++++++++++++--- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/core/src/inline_core/training/models.py b/core/src/inline_core/training/models.py index 0b24bd7..3131025 100644 --- a/core/src/inline_core/training/models.py +++ b/core/src/inline_core/training/models.py @@ -325,20 +325,25 @@ def resolve_offload( ) -> bool: """Whether to stream saved activations to host RAM this run. - Only meaningful for a full-precision (bf16) base: the point is to keep the 26GB Krea 2 base - resident and fit the ~21GB of 1024 activations elsewhere, rather than dropping the frozen base - to NF4. A quantized base already fits, so offload just adds PCIe traffic for nothing. ``auto`` - turns it on exactly when the bf16 plan would otherwise not fit VRAM; ``on``/``off`` force it.""" + ``auto`` was written for a full-precision base: keep the 26GB Krea 2 base resident and put the + ~21GB of 1024 activations elsewhere, rather than dropping the frozen base to NF4. Under a + quantized base ``auto`` stays off, because there the base is the whole story and offload would + buy PCIe traffic for nothing. + + ``on`` and ``off`` are the user's answer and win outright. The ordering matters: the quant test + used to sit above ``on``, so the control was silently dead for MiniMax H3, always 4-bit. H3 + breaks the "a quantized base already fits" assumption, because its base is only 11.7GB and it is + the clip activations that overflow a card, which is exactly when someone reaches for this.""" from ..device.policy import Quantization if pref == "off": return False - if quant is not Quantization.NONE: - return False # a quantized base already fits; offload would only slow it down if pref == "on": return True if pref not in ("auto", ""): raise RuntimeError(f"Unknown offload preference {pref!r}.") + if quant is not Quantization.NONE: + return False # auto only: a quantized base already fits, so do not pay for offload import torch diff --git a/core/src/inline_core/training/trainer.py b/core/src/inline_core/training/trainer.py index df542ba..df00633 100644 --- a/core/src/inline_core/training/trainer.py +++ b/core/src/inline_core/training/trainer.py @@ -119,6 +119,24 @@ def _peak_vram_gb() -> float | None: return round(torch.cuda.max_memory_allocated() / 1e9, 2) +def _vram_note(label: str) -> str: + """`label: allocated X.XGB, reserved Y.YGB` for the log. + + Both numbers, because they answer different questions and nvidia-smi only shows the second. + Reserved is what the card looks full of; allocated is what is actually live. A phase that frees + its weights but leaves reserved high is the allocator holding cache, which is fine. One that + leaves ALLOCATED high is a reference nobody dropped, which is a leak.""" + import torch + + if not torch.cuda.is_available(): + return label + gb = 1e9 + return ( + f"{label}: allocated {torch.cuda.memory_allocated() / gb:.1f}GB, " + f"reserved {torch.cuda.memory_reserved() / gb:.1f}GB" + ) + + def _activation_offload(enabled: bool) -> Any: """A context that streams saved activations to host RAM (pinned) for the forward, pulling them back on backward. Keeps a full-precision base resident on a card that could not otherwise hold @@ -205,9 +223,11 @@ def train(manifest: dict[str, Any]) -> str | None: ) plan = quant.value + (" + cpu offload" if offload else "") protocol.progress(0, steps, status=f"loading model ({plan})") + print(_vram_note("VRAM after caching, before the base loads"), flush=True) transformer = models.load_transformer( manifest["modelsDir"], arch.key, manifest["baseMode"], str(device), dtype, quant ) + print(_vram_note("VRAM after the base loaded"), flush=True) transformer.requires_grad_(False) # PEFT picks its bitsandbytes-aware LoRA layer off this one attribute. Without it, and because # bnb's Linear4bit subclasses nn.Linear, the generic dispatcher matches instead: grads still @@ -237,6 +257,11 @@ def train(manifest: dict[str, Any]) -> str | None: signal.signal(signal.SIGTERM, stop) transformer.train() + # Announce the phase BEFORE the first step, not after it. Progress was only emitted once a step + # finished, so the UI sat on "loading model (nf4)" for the whole of step one and a slow first + # step read as a hung loader. That cost a user and me a day of chasing the wrong component. + print(_vram_note("VRAM entering the training loop"), flush=True) + protocol.progress(start, steps, status="training") for step in range(start, steps): if stop.flagged: break diff --git a/core/tests/test_minimaxh3_training.py b/core/tests/test_minimaxh3_training.py index 4450386..10a385a 100644 --- a/core/tests/test_minimaxh3_training.py +++ b/core/tests/test_minimaxh3_training.py @@ -475,3 +475,17 @@ def test_clip_window_defaults_to_the_start(tmp_path) -> None: assert h3._clip_frames(clip, clip_frames=24)[0].getpixel((0, 0)) == ( h3._clip_frames(clip, clip_frames=24, window="start")[0].getpixel((0, 0)) ) + + +def test_the_loop_reports_training_before_the_first_step(monkeypatch) -> None: + """Progress used to be emitted only AFTER a step finished, so the UI sat on + "loading model (nf4)" for the whole of step one. A slow first step then read as a hung loader, + which sent a user and me chasing bitsandbytes for a day.""" + import inspect + + from inline_core.training import trainer + + source = inspect.getsource(trainer.train) + enters_loop = source.index("for step in range(start, steps):") + announces = source.index('status="training"') + assert announces < enters_loop, "the training status must be sent before the loop, not after" diff --git a/core/tests/test_training_models.py b/core/tests/test_training_models.py index b7623dc..83f26ad 100644 --- a/core/tests/test_training_models.py +++ b/core/tests/test_training_models.py @@ -97,8 +97,8 @@ def test_auto_quantization_accounts_for_resolution(monkeypatch, tmp_path) -> Non def test_offload_fits_a_bf16_base_that_would_not_otherwise(monkeypatch, tmp_path) -> None: """bf16 1024 on a 45GB card: base (26GB) + activations (~21GB) overflow, so auto-offload turns - on to keep the base full precision rather than dropping it to NF4. A quantized base already - fits, so offload stays off there no matter the preference.""" + on to keep the base full precision rather than dropping it to NF4. Under a quantized base AUTO + stays off, but an explicit on/off is the user's answer and wins.""" root = tmp_path / "models" (root / "diffusion_models").mkdir(parents=True) (root / "diffusion_models" / "krea2_raw_bf16.safetensors").write_bytes(b"") @@ -120,8 +120,14 @@ def test_offload_fits_a_bf16_base_that_would_not_otherwise(monkeypatch, tmp_path assert off("auto", Quantization.NONE, str(root), archs.KREA2, "raw", 512) is False assert off("on", Quantization.NONE, str(root), archs.KREA2, "raw", 512) is True assert off("off", Quantization.NONE, str(root), archs.KREA2, "raw", 1024) is False - # A quantized base already fits, so offload would only add PCIe traffic - never on. - assert off("on", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is False + # Under a quantized base, AUTO stays off: there the base is the whole story and offload would + # only add PCIe traffic. + assert off("auto", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is False + # But an explicit "on" wins. This used to return False, which made the control silently dead for + # MiniMax H3 (always 4-bit), the one arch where a user actually needs it: its base is 11.7GB and + # it is the clip activations that overflow the card. + assert off("on", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is True + assert off("off", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is False def test_zimage_has_no_four_bit_path_and_says_so(tmp_path) -> None: @@ -198,3 +204,29 @@ def test_fails_open_when_the_machine_cannot_be_read(monkeypatch) -> None: _fake_env(monkeypatch, mode=0, ram_gib=30, size_gib=62) monkeypatch.setattr(models, "_base_size", lambda *a: 0) models.check_base_mappable("/m", "minimax-h3", "raw") + + +def test_an_explicit_offload_choice_beats_the_quant_heuristic(monkeypatch, tmp_path) -> None: + """MiniMax H3 is always 4-bit, and the quant test used to sit above the explicit-preference + test, so the CPU offload control was silently dead for the one architecture whose users need + it: H3's base is 11.7GB and it is the clip activations that overflow a card.""" + from inline_core.device.policy import Quantization + + root = tmp_path / "models" + (root / "diffusion_models").mkdir(parents=True) + monkeypatch.setattr(models, "_base_size", lambda *a: 12 * 1024**3) + off = models.resolve_offload + + assert off("on", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is True + assert off("off", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is False + # auto stays conservative under a quantized base, which is the original intent. + assert off("auto", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is False + + +def test_an_unknown_offload_preference_still_raises(monkeypatch, tmp_path) -> None: + """The reorder must not let a typo fall through to the auto path and silently mean 'off'.""" + from inline_core.device.policy import Quantization + + monkeypatch.setattr(models, "_base_size", lambda *a: 12 * 1024**3) + with pytest.raises(RuntimeError): + models.resolve_offload("yes", Quantization.NF4, str(tmp_path), archs.MINIMAX_H3, "raw", 512) From 6ecb5a23280f6498de338a6072744894381aaf5b Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 14:41:48 +0000 Subject: [PATCH 2/4] upgrade hf hub --- core/pyproject.toml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index fd973f1..0521b3e 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -29,7 +29,10 @@ runtime = [ # Backs the Beta sigma schedule (diffusers gates use_beta_sigmas on scipy). "scipy>=1.11", # We call snapshot_download directly for the model popup, so pin it rather than rely on transit. - "huggingface_hub>=0.23", + # 0.32 is where hf_xet became a hard dependency rather than an extra. Below it, Hugging Face + # refuses the largest files outright ("too large to be downloaded using the regular download + # method"), which is every H3 transformer at 66GB. + "huggingface_hub>=0.32", # ControlNet preprocessors (the Apply ControlNet node): OpenPose/DWPose, MiDaS/Zoe depth, canny, # HED, lineart, MLSD, scribble, normal. DWPose runs its detector on ONNX Runtime. "controlnet-aux>=0.0.7", @@ -85,7 +88,10 @@ all = [ # Clip decode for MiniMax H3 LoRA training, and H3's reference node. "av>=12", "scipy>=1.11", - "huggingface_hub>=0.23", + # 0.32 is where hf_xet became a hard dependency rather than an extra. Below it, Hugging Face + # refuses the largest files outright ("too large to be downloaded using the regular download + # method"), which is every H3 transformer at 66GB. + "huggingface_hub>=0.32", "controlnet-aux>=0.0.7", "onnxruntime>=1.17", # server From 747598def64902a132999283223a811a5b56ce43 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 14:47:31 +0000 Subject: [PATCH 3/4] bump to 1.2.66 --- core/pyproject.toml | 2 +- package.json | 2 +- packages/frontend/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index 0521b3e..2eb4aa7 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -1,7 +1,7 @@ [project] # PyPI name; the import package is `inline_core` (src/inline_core). name = "inline-core" -version = "1.2.65" +version = "1.2.66" description = "The generation engine behind Inline Studio." readme = "README.md" license = "GPL-3.0-or-later" diff --git a/package.json b/package.json index 032ef07..d5d197d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inline-studio", - "version": "1.2.65", + "version": "1.2.66", "description": "AI filmmaking on a node canvas. Generate locally on your own GPU and train your own LoRAs on the same canvas, with the built-in Inline Core engine and hosted models. Every render is kept as a versioned take.", "keywords": [ "ai-filmmaking", diff --git a/packages/frontend/pyproject.toml b/packages/frontend/pyproject.toml index bbb38cc..0c52fcf 100644 --- a/packages/frontend/pyproject.toml +++ b/packages/frontend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inline-studio-frontend" -version = "1.2.65" +version = "1.2.66" description = "Prebuilt Inline Studio web UI (SPA), served by Inline Core. Mirrors comfyui-frontend-package." requires-python = ">=3.9" readme = "README.md" From ac5fbad922143d1709f43c32c289bc5f841297a2 Mon Sep 17 00:00:00 2001 From: ashish-aesthisia Date: Sat, 8 Aug 2026 14:59:21 +0000 Subject: [PATCH 4/4] updated comments --- CLAUDE.md | 5 ++++ core/CLAUDE.md | 16 ++++++++++++ core/src/inline_core/training/models.py | 6 ++--- core/src/inline_core/training/trainer.py | 13 +++------- core/tests/test_minimaxh3_training.py | 4 +-- core/tests/test_training_models.py | 11 ++------ core/webui.bat | 30 +++++++-------------- core/webui.sh | 33 +++++++----------------- 8 files changed, 49 insertions(+), 69 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 837603d..8bb725d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,11 @@ linking. Generation is Core nodes, installed-extension nodes, and fal nodes on t `VolumeIcon`); reuse or add to those rather than dropping in an emoji. - **Tests (Vitest).** Cover the logic that matters: fal node input/request resolution, frame-input and hero-take resolution, DB migrations. UI is verified by running the app - don't chase view coverage. +- **Arithmetic mirrored from Core lives in `src/shared/` and is pinned by a test.** `clipGrid.ts` + restates H3's frame grid so the Trainer can show what a setting resolves to; if the two drift the + UI promises a number the run will not honour. +- **Surface what a setting resolves to, not just what was typed.** A control that silently snaps + (H3 clip length rounds down onto its frame grid) reads as broken. - **Commits.** Conventional Commits (`feat:`, `fix:`, `chore:`), small and scoped. `lint` + `typecheck` run on pre-commit (husky + lint-staged). - **Never commit automatically.** Claude (or any AI agent) must **not** run `git commit`/`git push` diff --git a/core/CLAUDE.md b/core/CLAUDE.md index 8963052..4fc326f 100644 --- a/core/CLAUDE.md +++ b/core/CLAUDE.md @@ -126,6 +126,8 @@ between nodes and are never takes. every stop/start**, taking the models and leaving dangling symlinks in `models/`. This has cost two full re-downloads of MiniMax H3 at ~130 GB each. Weights go on the persistent root volume, or on an attached volume that survives a restart. Scratch is fine for logs and temporary output only. +- **`huggingface_hub>=0.32`** - below it `hf_xet` is only an extra, and files over ~50GB (every + H3 transformer) refuse to download at all. - **Models root** - `INLINE_MODELS_DIR`, else `./models`. **Bring your own weights; nothing is downloaded.** ComfyUI-style category subfolders (`diffusion_models/`, `vae/`, `text_encoders/`, `loras/`, `controlnet/`, `checkpoints/`, `clip_vision/`, `upscale_models/`, `embeddings/`). The @@ -233,6 +235,20 @@ real codec that moves tensors lives with the model runner. vision-language encoder loaded in place of a text one, an unnormalized latent, and a control context cast to a quantized weight's `uint8` storage dtype. A unit test cannot see a plausible-but-wrong image. Render something and look at it. +- **Launchers are twins.** `webui.sh` and `webui.bat` change together; only the `launcher` CI job + can prove the `.bat`, since it cannot run on a dev box. +- **Match CUDA arches by within-major compatibility, never exactly.** An `sm_8x` cubin runs on any + `sm_8y` where `y >= x`, so `sm_86` covers Ada's `sm_89`. +- **Pass the widest `uv` flag that works.** `--no-sources-package` needs uv 0.10+; `--no-sources` + works back to 0.4 and means the same while torch is the only `[tool.uv.sources]` entry. +- **An explicit user setting beats a heuristic.** Test `on`/`off` before any auto rule, or the + control is silently dead for whichever arch the rule excludes. +- **Report a phase before the slow work, not after.** Progress emitted only on completion makes a + slow step look like a hung previous phase. +- **In a `.bat`, `call` anything that might be a `.bat`.** `nvidia-smi` is sometimes a shim, and + without `call` it takes over the script and never returns. +- **Log allocated AND reserved VRAM.** `nvidia-smi` shows only reserved, so allocator cache and a + leaked reference look identical from outside. - **Tests (pytest).** Cover the logic that matters: graph validate/topo/executor/cache, the catalog scan, the run store + server contract, the device/memory policy, the parallel group + xfuser seam, and each model runner (import-guarded, no GPU needed). See `tests/`. diff --git a/core/src/inline_core/training/models.py b/core/src/inline_core/training/models.py index 3131025..35f9492 100644 --- a/core/src/inline_core/training/models.py +++ b/core/src/inline_core/training/models.py @@ -330,10 +330,8 @@ def resolve_offload( quantized base ``auto`` stays off, because there the base is the whole story and offload would buy PCIe traffic for nothing. - ``on`` and ``off`` are the user's answer and win outright. The ordering matters: the quant test - used to sit above ``on``, so the control was silently dead for MiniMax H3, always 4-bit. H3 - breaks the "a quantized base already fits" assumption, because its base is only 11.7GB and it is - the clip activations that overflow a card, which is exactly when someone reaches for this.""" + ``on``/``off`` are tested before the quant rule, or the control is dead for MiniMax H3 (always + 4-bit), whose base is small and whose clip activations are what overflow the card.""" from ..device.policy import Quantization if pref == "off": diff --git a/core/src/inline_core/training/trainer.py b/core/src/inline_core/training/trainer.py index df00633..9eeb51c 100644 --- a/core/src/inline_core/training/trainer.py +++ b/core/src/inline_core/training/trainer.py @@ -120,12 +120,8 @@ def _peak_vram_gb() -> float | None: def _vram_note(label: str) -> str: - """`label: allocated X.XGB, reserved Y.YGB` for the log. - - Both numbers, because they answer different questions and nvidia-smi only shows the second. - Reserved is what the card looks full of; allocated is what is actually live. A phase that frees - its weights but leaves reserved high is the allocator holding cache, which is fine. One that - leaves ALLOCATED high is a reference nobody dropped, which is a leak.""" + """Both numbers: nvidia-smi shows only reserved, so allocator cache and a leaked reference look + identical from outside.""" import torch if not torch.cuda.is_available(): @@ -257,9 +253,8 @@ def train(manifest: dict[str, Any]) -> str | None: signal.signal(signal.SIGTERM, stop) transformer.train() - # Announce the phase BEFORE the first step, not after it. Progress was only emitted once a step - # finished, so the UI sat on "loading model (nf4)" for the whole of step one and a slow first - # step read as a hung loader. That cost a user and me a day of chasing the wrong component. + # Before the first step, not after: emitting only on completion makes a slow step one look like + # the loader is still running. print(_vram_note("VRAM entering the training loop"), flush=True) protocol.progress(start, steps, status="training") for step in range(start, steps): diff --git a/core/tests/test_minimaxh3_training.py b/core/tests/test_minimaxh3_training.py index 10a385a..11bcf96 100644 --- a/core/tests/test_minimaxh3_training.py +++ b/core/tests/test_minimaxh3_training.py @@ -478,9 +478,7 @@ def test_clip_window_defaults_to_the_start(tmp_path) -> None: def test_the_loop_reports_training_before_the_first_step(monkeypatch) -> None: - """Progress used to be emitted only AFTER a step finished, so the UI sat on - "loading model (nf4)" for the whole of step one. A slow first step then read as a hung loader, - which sent a user and me chasing bitsandbytes for a day.""" + """Emitted only on completion, a slow step one reads as a hung loader.""" import inspect from inline_core.training import trainer diff --git a/core/tests/test_training_models.py b/core/tests/test_training_models.py index 83f26ad..6abc216 100644 --- a/core/tests/test_training_models.py +++ b/core/tests/test_training_models.py @@ -120,12 +120,8 @@ def test_offload_fits_a_bf16_base_that_would_not_otherwise(monkeypatch, tmp_path assert off("auto", Quantization.NONE, str(root), archs.KREA2, "raw", 512) is False assert off("on", Quantization.NONE, str(root), archs.KREA2, "raw", 512) is True assert off("off", Quantization.NONE, str(root), archs.KREA2, "raw", 1024) is False - # Under a quantized base, AUTO stays off: there the base is the whole story and offload would - # only add PCIe traffic. + # Auto stays off under a quantized base; an explicit choice wins. assert off("auto", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is False - # But an explicit "on" wins. This used to return False, which made the control silently dead for - # MiniMax H3 (always 4-bit), the one arch where a user actually needs it: its base is 11.7GB and - # it is the clip activations that overflow the card. assert off("on", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is True assert off("off", Quantization.NF4, str(root), archs.KREA2, "raw", 1024) is False @@ -207,9 +203,7 @@ def test_fails_open_when_the_machine_cannot_be_read(monkeypatch) -> None: def test_an_explicit_offload_choice_beats_the_quant_heuristic(monkeypatch, tmp_path) -> None: - """MiniMax H3 is always 4-bit, and the quant test used to sit above the explicit-preference - test, so the CPU offload control was silently dead for the one architecture whose users need - it: H3's base is 11.7GB and it is the clip activations that overflow a card.""" + """Dead for H3, always 4-bit, whose clip activations are what overflow the card.""" from inline_core.device.policy import Quantization root = tmp_path / "models" @@ -219,7 +213,6 @@ def test_an_explicit_offload_choice_beats_the_quant_heuristic(monkeypatch, tmp_p assert off("on", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is True assert off("off", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is False - # auto stays conservative under a quantized base, which is the original intent. assert off("auto", Quantization.NF4, str(root), archs.MINIMAX_H3, "raw", 512) is False diff --git a/core/webui.bat b/core/webui.bat index a1ecb93..3fd384c 100644 --- a/core/webui.bat +++ b/core/webui.bat @@ -162,14 +162,10 @@ if !CAP_MAJOR! GEQ 10 if !DRIVER_MAJOR! GTR 0 if !DRIVER_MAJOR! LSS 580 ( if /i "!TORCH_CHOICE!"=="cpu" goto install_cpu_forced set "TORCH_URL=https://download.pytorch.org/whl/!TORCH_CHOICE!" if /i "!TORCH_CHOICE:~0,4!"=="http" set "TORCH_URL=!TORCH_CHOICE!" -rem unsafe-best-match: torchao is on the CUDA index too, older there than our torchao>=0.14 pin on -rem some indexes; without this uv's first-index rule stops at that older copy instead of finding a -rem new enough one on PyPI. It also makes the +cuXXX local version outrank PyPI's plain one, which -rem is what pulls the CUDA build in rather than the CPU-only wheel PyPI serves on Windows. -rem no-sources: the pyproject [tool.uv.sources] pin names one fixed index, and the card decides -rem here. Deliberately the broad flag, not --no-sources-package torch: that one is too new for the -rem uv versions people actually have, and it hard-errored their install. torch is the only entry in -rem that table, so the two mean the same thing today. Adding another entry would change that. +rem unsafe-best-match: without it uv stops at the older torchao on the CUDA index, and PyPI's plain +rem torch outranks the +cuXXX build on Windows. +rem no-sources: the pyproject pin names one index, and the card decides here. The broad flag, not +rem --no-sources-package, which needs uv 0.10+; torch is the only entry so they are equivalent. set "TORCH_ARGS=--extra-index-url !TORCH_URL! --index-strategy unsafe-best-match --no-sources" echo NVIDIA GPU detected - installing the CUDA build of PyTorch (!TORCH_CHOICE!). goto install_pkgs @@ -216,10 +212,8 @@ if /i "!PROBE_STATUS!"=="uncovered" ( :install_run echo + uv pip install --python "!TARGET_PY!" !TORCH_ARGS! -e ".[!EXTRAS!]" uv pip install --python "!TARGET_PY!" !TORCH_ARGS! -e ".[!EXTRAS!]" || goto fail -rem Torch LAST, and through --index-url (exclusive), when the index was named or the installed wheel -rem is wrong. It cannot ride on the project install: [tool.uv.sources] pins torch to the cu126 index -rem on win32, and --extra-index-url with unsafe-best-match picks the highest version ACROSS indexes, -rem which lands back on PyPI's CPU wheel whenever PyPI leads. +rem Torch LAST and through --index-url (exclusive): [tool.uv.sources] pins it to cu126 on win32, +rem and --extra-index-url picks the highest version ACROSS indexes, so PyPI's CPU wheel can win. if "!TORCH_FORCE!"=="1" if defined TORCH_CHOICE ( set "TORCH_PINS=torch torchvision" rem cu128 is frozen, so the current pair does not exist there. Pin the last one it has. @@ -243,18 +237,14 @@ echo .\webui.bat --install --torch-index !TORCH_CHOICE! --recreate echo Installed extras: !EXTRAS!. Start with: .\webui.bat exit /b 0 -rem Reads compute_cap and driver_version in one query, keeping the highest capability seen. -rem set /a rather than a findstr guard: an old driver answers an unknown query with an error string, -rem and `if LSS` would STRING-compare it, so "Unknown" would rank above 10 and win. set /a reads a -rem bare word as an undefined variable and yields 0, which is exactly what we want here, so do not -rem "fix" it back into a guard later. +rem One query for both, keeping the highest capability. set /a not a findstr guard: `if LSS` would +rem string-compare an error string like "Unknown" above 10; set /a yields 0 for it, as intended. :read_gpu_probe set "CAP_MAJOR=0" set "DRIVER_MAJOR=0" set "GPU_PROBE_RAW=" -rem Split on the comma ONLY. Including "." here would cut "12.0, 610.88" into 12 / 0 / 610 / 88, so -rem token 2 would be the capability's minor rather than the driver, and the R580 floor could never -rem fire. The majors are taken off each field by the inner loops. +rem Comma ONLY: adding "." would cut "12.0, 610.88" into 12/0/610/88, making token 2 the minor +rem rather than the driver, so the R580 floor could never fire. for /f "usebackq tokens=1,2 delims=," %%c in (`nvidia-smi --query-gpu^=compute_cap^,driver_version --format^=csv^,noheader 2^>nul`) do ( rem Reset every iteration: set /a errors on garbage and would otherwise leave the previous line's rem value in place, double-counting a good line followed by a bad one. diff --git a/core/webui.sh b/core/webui.sh index e07b14c..d1f1ad6 100755 --- a/core/webui.sh +++ b/core/webui.sh @@ -200,17 +200,9 @@ read_gpu_probe() { #: | override | no-gpu. TORCH_INDEX_REASON="autodetect" -# No single index covers every card: Blackwell (sm_100/sm_120) exists only from cu128 on, while cu126 -# is the last index still built for Maxwell..Volta (sm_50..sm_70). Unknown cards get cu126, the one -# that covers the widest range of what people actually own. -# -# Verified 2026-02 and load-bearing: cu128 still SERVES but is frozen at torch 2.11.0, and it was the -# first index with sm_120. So a Blackwell card on a pre-R580 driver (CUDA 13's floor) gets cu128 -# rather than an error, because it has exactly one workable choice. Re-check that ceiling before -# trusting this comment in a year. -# Assigns TORCH_CHOICE and TORCH_INDEX_REASON rather than printing them. It must NOT be called -# through $(...): a command substitution is a subshell, so the probe globals and the reason would be -# discarded and only the index would survive. That is what the tests caught. +# cu126 is the last index built for Maxwell..Volta; sm_100/sm_120 need cu128 or newer. cu128 serves +# but is frozen at torch 2.11 (checked 2026-02), so it is only for Blackwell below CUDA 13's R580. +# Assigns rather than prints: through $(...) the probe globals would die in the subshell. pick_torch_index() { read_gpu_probe if [[ -z "$GPU_CAP_MAJOR" ]]; then TORCH_CHOICE="cu126"; return 0; fi @@ -340,14 +332,10 @@ if [[ "$RUN_INSTALL" -eq 1 ]]; then elif [[ -z "$TORCH_CHOICE" ]]; then echo "NVIDIA GPU detected - installing the CUDA build of PyTorch." else - # unsafe-best-match: torchao is on the CUDA index too but older there than our torchao>=0.14 pin - # on some indexes; without this uv's first-index rule stops at that older copy instead of - # finding a new enough one on PyPI. It also makes the +cuXXX local version outrank PyPI's plain - # one, which is what pulls the CUDA build in on Windows. - # no-sources: the pyproject [tool.uv.sources] pin names one fixed index, and the whole point - # here is that the card decides. Deliberately the broad flag, not --no-sources-package torch: - # that one is too new for the uv versions people actually have, and it hard-errored their - # install. torch is the only entry in that table, so the two mean the same thing today. + # unsafe-best-match: without it uv stops at the older torchao on the CUDA index, and PyPI's + # plain torch outranks the +cuXXX build on Windows. + # no-sources: the pyproject pin names one index, and the card decides here. The broad flag, not + # --no-sources-package, which needs uv 0.10+; torch is the only entry so they are equivalent. TORCH_INDEX=(--extra-index-url "$(torch_index_url "$TORCH_CHOICE")" \ --index-strategy unsafe-best-match --no-sources) echo "NVIDIA GPU detected - installing the CUDA build of PyTorch ($TORCH_CHOICE)." @@ -373,11 +361,8 @@ if [[ "$RUN_INSTALL" -eq 1 ]]; then echo "+ uv pip install --python $TARGET_PY ${TORCH_INDEX[*]} -e .[$EXTRAS]" uv pip install --python "$TARGET_PY" "${TORCH_INDEX[@]}" -e ".[$EXTRAS]" - # Torch LAST, and through --index-url (exclusive), when the index was named or the installed wheel - # is wrong. Two reasons it cannot ride on the project install: [tool.uv.sources] pins torch to the - # cu126 index on win32, and --extra-index-url with unsafe-best-match picks the highest version - # ACROSS indexes, which lands back on PyPI's CPU wheel whenever PyPI leads. Costs one possibly - # wasted download on a path that is rare and deliberate. + # Torch LAST and through --index-url (exclusive): [tool.uv.sources] pins it to cu126 on win32, + # and --extra-index-url picks the highest version ACROSS indexes, so PyPI's CPU wheel can win. if [[ "$TORCH_FORCE" -eq 1 && -n "$TORCH_CHOICE" ]]; then read -r -a TORCH_PINS <<<"$(torch_pins_for "$TORCH_CHOICE")" TORCH_URL="$(torch_index_url "$TORCH_CHOICE")"