Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ jobs:
- 'invokeai/**'
- '!invokeai/frontend/web/**'
- 'tests/**'
# tests/ covers a few of these (e.g. test_check_pins.py, test_docs_json_export.py)
- 'scripts/**'
- 'pins.json'

- name: setup uv
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/uv-lock-checks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Check the `uv` lockfile for consistency with `pyproject.toml`.
# Check the `uv` lockfile and `pins.json` for consistency with `pyproject.toml`.
#
# If this check fails, you should run `uv lock` to update the lockfile.
# If the lockfile check fails, you should run `uv lock` to update the lockfile.
# If the pins check fails, update the torch index URLs in `pins.json` to match
# the `[[tool.uv.index]]` entries in `pyproject.toml` (see scripts/check_pins.py).

name: 'uv lock checks'

Expand Down Expand Up @@ -54,6 +56,8 @@ jobs:
uvlock-pyprojecttoml:
- 'pyproject.toml'
- 'uv.lock'
- 'pins.json'
- 'scripts/check_pins.py'

- name: setup uv
if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
Expand All @@ -66,3 +70,8 @@ jobs:
if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
run: uv lock --locked # this will exit with 1 if the lockfile is not consistent with pyproject.toml
shell: bash

- name: check pins.json
if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
run: python3 scripts/check_pins.py # pins.json is consumed by the launcher; keep its torch index URLs in sync with pyproject.toml
shell: bash
10 changes: 10 additions & 0 deletions docs/src/content/docs/start-here/manual.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ The following commands vary depending on the version of Invoke being installed a
```sh
--torch-backend=rocm7.1
```

:::caution[ROCm and torch 2.12]
The `rocm7.1` index currently defaults to torch 2.12.x, which breaks generation on
ROCm ([#9328](https://github.com/invoke-ai/InvokeAI/issues/9328)). Until that is
resolved, also constrain torch when installing:
```sh
uv pip install <PACKAGE_SPECIFIER>==<VERSION> "torch<2.12" --python 3.12 --python-preference only-managed --torch-backend=rocm7.1 --force-reinstall
```
This affects ROCm only — other backends work fine with torch 2.12.
:::
</TabItem>
<TabItem label="All other cases">
Do not use a torch backend.
Expand Down
1 change: 1 addition & 0 deletions pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"python": "3.12",
"torchIndexUrl": {
"win32": {
"cpu": "https://download.pytorch.org/whl/cpu",
"cuda": "https://download.pytorch.org/whl/cu128"
},
"linux": {
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ dependencies = [
# Loosely pinned, will respect requirement of `diffusers[torch]`. Split by platform: linux/win allow
# >=2.10 so the rocm extra can use torch 2.10.0+rocm7.1, while macOS stays on 2.7.x — newer macOS torch
# wheels exercise MPS on CI runners (no usable Metal GPU) and fail with MPS OOM.
# Deliberately NOT capped below 2.12 here. torch 2.12.x+rocm7.1 breaks generation (#9328), but
# this range is what every backend-agnostic install resolves against — manual installs use
# `--torch-backend=<x>` with no extra (see docs/start-here/manual), so a blanket cap would also
# reject torch>=2.12 on Windows/Linux CUDA, CPU and ARM64, where 2.12 has no known problem.
# The ROCm path is constrained where it can actually be targeted: the `rocm` extra below pins an
# exact version, and the manual docs carry the ROCm-specific caveat.
"torch>=2.7.0,<3.0; sys_platform != 'darwin'",
"torch>=2.7.0,<2.8.0; sys_platform == 'darwin'",
"torchsde", # diffusers needs this for SDE solvers, but it is not an explicit dep of diffusers
Expand Down
119 changes: 119 additions & 0 deletions scripts/check_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Check that pins.json is consistent with pyproject.toml.

``pins.json`` is not used anywhere in this repo — it is fetched (at the release
tag) by the Invoke Launcher (https://github.com/invoke-ai/launcher), which uses
its ``torchIndexUrl`` entries to pick the torch wheel index for legacy
(pre-6.14.0) installs. Because nothing in-repo consumes it, it can silently
drift from the ``[[tool.uv.index]]`` URLs in pyproject.toml — which is exactly
what happened when ROCm moved from 6.3 to 7.1 (issue #9328).

The launcher's schema makes every backend key optional and its install path only
passes ``--index`` when the selected entry exists, so a *missing* entry is just
as damaging as a stale one: the install silently falls back to the default PyPI
index and resolves wheels for the wrong backend. This script therefore checks
both halves:

1. pins.json carries exactly the platform/backend entries in REQUIRED_BACKENDS
— no more, no less.
2. Each of those URLs matches the corresponding ``torch-<backend>``
``[[tool.uv.index]]`` URL in pyproject.toml.

Run from anywhere: python scripts/check_pins.py
"""

import json
import sys
import tomllib
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent

# Every platform/backend combination pins.json must carry, and no others.
#
# The launcher offers the same four GPU choices on every OS and maps them to a
# torch backend (amd -> rocm, either nvidia option -> cuda, no GPU -> cpu), then
# looks up `torchIndexUrl[sys.platform][backend]`. Any combination it can look up
# and that Invoke actually supports needs an entry here, because a missing one
# degrades silently rather than failing loudly.
#
# - darwin is deliberately empty: macOS uses MPS, for which PyTorch publishes no
# separate index, so the launcher installs the default PyPI wheels.
# - win32 has no rocm entry: PyTorch publishes no ROCm wheels for Windows, and
# the `rocm` extra in pyproject.toml is marked `sys_platform == 'linux'`.
REQUIRED_BACKENDS: dict[str, set[str]] = {
"win32": {"cpu", "cuda"},
"linux": {"cpu", "cuda", "rocm"},
"darwin": set(),
}


def check_pins(pins: dict, pyproject: dict) -> list[str]:
"""Return a list of human-readable problems; empty means pins.json is fine."""

indexes = {i["name"]: i["url"] for i in pyproject["tool"]["uv"]["index"]}
torch_index_url = pins.get("torchIndexUrl", {})

errors: list[str] = []

for platform in sorted(set(REQUIRED_BACKENDS) - set(torch_index_url)):
errors.append(f"pins.json is missing the torchIndexUrl.{platform} section")
for platform in sorted(set(torch_index_url) - set(REQUIRED_BACKENDS)):
errors.append(
f"pins.json torchIndexUrl.{platform} is not a platform the launcher installs on; "
f"expected only {sorted(REQUIRED_BACKENDS)}"
)

for platform, required in sorted(REQUIRED_BACKENDS.items()):
backends = torch_index_url.get(platform)
if backends is None:
continue

for backend in sorted(required - set(backends)):
errors.append(
f"pins.json torchIndexUrl.{platform}.{backend} is missing; the launcher would omit "
f"--index for {backend} installs on {platform} and resolve torch from the default index"
)
for backend in sorted(set(backends) - required):
errors.append(
f"pins.json torchIndexUrl.{platform}.{backend} is not supported on {platform}; "
f"expected only {sorted(required)}"
)

for backend in sorted(required & set(backends)):
pinned_url = backends[backend]
index_name = f"torch-{backend}"
expected_url = indexes.get(index_name)
if expected_url is None:
errors.append(
f"pins.json torchIndexUrl.{platform}.{backend}: no [[tool.uv.index]] named '{index_name}' in pyproject.toml"
)
elif pinned_url != expected_url:
errors.append(
f"pins.json torchIndexUrl.{platform}.{backend} is '{pinned_url}' but pyproject.toml index '{index_name}' is '{expected_url}'"
)

return errors


def main(repo_root: Path = REPO_ROOT) -> int:
pins = json.loads((repo_root / "pins.json").read_text())
pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text())

errors = check_pins(pins, pyproject)

if errors:
print("pins.json is out of sync with pyproject.toml:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
print(
"\nUpdate pins.json to match the [[tool.uv.index]] URLs in pyproject.toml (or vice versa).",
file=sys.stderr,
)
return 1

print("pins.json is consistent with pyproject.toml")
return 0


if __name__ == "__main__":
sys.exit(main())
129 changes: 129 additions & 0 deletions tests/test_check_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

import importlib.util
import json
import shutil
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parent.parent


def _load_module(module_path: Path, module_name: str):
spec = importlib.util.spec_from_file_location(module_name, module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


check_pins = _load_module(REPO_ROOT / "scripts" / "check_pins.py", "check_pins")


@pytest.fixture
def repo_copy(tmp_path: Path) -> Path:
"""A throwaway directory holding copies of the real pins.json and pyproject.toml."""
for name in ("pins.json", "pyproject.toml"):
shutil.copyfile(REPO_ROOT / name, tmp_path / name)
return tmp_path


def _read_pins(repo: Path) -> dict:
return json.loads((repo / "pins.json").read_text())


def _write_pins(repo: Path, pins: dict) -> None:
(repo / "pins.json").write_text(json.dumps(pins, indent=2))


# Every (platform, backend) the checker must insist on, derived from the checker's own matrix so
# the two cannot drift apart.
REQUIRED_ENTRIES = [
(platform, backend)
for platform, backends in sorted(check_pins.REQUIRED_BACKENDS.items())
for backend in sorted(backends)
]


def test_required_matrix_covers_cpu_cuda_and_rocm():
"""Guard against the matrix being narrowed to the point where it checks nothing."""
assert ("linux", "rocm") in REQUIRED_ENTRIES
assert ("linux", "cuda") in REQUIRED_ENTRIES
assert ("linux", "cpu") in REQUIRED_ENTRIES
assert ("win32", "cuda") in REQUIRED_ENTRIES
assert ("win32", "cpu") in REQUIRED_ENTRIES
# macOS uses MPS, which has no dedicated torch index.
assert check_pins.REQUIRED_BACKENDS["darwin"] == set()


def test_repo_pins_are_consistent(repo_copy: Path):
"""The checked-in pins.json and pyproject.toml agree."""
assert check_pins.main(repo_copy) == 0


@pytest.mark.parametrize(("platform", "backend"), REQUIRED_ENTRIES)
def test_missing_entry_fails(repo_copy: Path, platform: str, backend: str):
"""Removing any required entry must fail, not silently pass."""
pins = _read_pins(repo_copy)
del pins["torchIndexUrl"][platform][backend]
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1


@pytest.mark.parametrize("platform", sorted(check_pins.REQUIRED_BACKENDS))
def test_missing_platform_section_fails(repo_copy: Path, platform: str):
"""Removing a whole platform section must fail, even for platforms with no required backends."""
pins = _read_pins(repo_copy)
del pins["torchIndexUrl"][platform]
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1


@pytest.mark.parametrize(("platform", "backend"), REQUIRED_ENTRIES)
def test_stale_url_fails(repo_copy: Path, platform: str, backend: str):
"""The original drift case: an entry that exists but points at the wrong index."""
pins = _read_pins(repo_copy)
pins["torchIndexUrl"][platform][backend] = "https://download.pytorch.org/whl/stale"
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1


def test_rocm_url_regression_is_reported(repo_copy: Path, capsys: pytest.CaptureFixture[str]):
"""Reverting the ROCm index to 6.3 (issue #9328) is caught and named."""
pins = _read_pins(repo_copy)
pins["torchIndexUrl"]["linux"]["rocm"] = "https://download.pytorch.org/whl/rocm6.3"
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1
assert "torchIndexUrl.linux.rocm" in capsys.readouterr().err


def test_unsupported_backend_fails(repo_copy: Path):
"""ROCm on Windows has no wheels; pinning an index for it must be rejected."""
pins = _read_pins(repo_copy)
pins["torchIndexUrl"]["win32"]["rocm"] = "https://download.pytorch.org/whl/rocm7.1"
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1


def test_unknown_platform_fails(repo_copy: Path):
pins = _read_pins(repo_copy)
pins["torchIndexUrl"]["freebsd"] = {"cpu": "https://download.pytorch.org/whl/cpu"}
_write_pins(repo_copy, pins)

assert check_pins.main(repo_copy) == 1


def test_missing_pyproject_index_fails(repo_copy: Path):
"""Dropping a [[tool.uv.index]] entry that pins.json references must fail."""
pyproject = (repo_copy / "pyproject.toml").read_text()
pyproject = pyproject.replace('name = "torch-rocm"', 'name = "torch-rocm-renamed"')
(repo_copy / "pyproject.toml").write_text(pyproject)

assert check_pins.main(repo_copy) == 1
Loading