Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ npm run build

## Platform notes

- AMD GPUs are supported through ROCm on Linux and Windows: a Radeon card is detected
automatically and extensions are steered to ROCm PyTorch wheels, with no ROCm install
required. See [docs/running-on-amd-rocm.md](docs/running-on-amd-rocm.md).
- macOS support targets Apple Silicon only.
- macOS uses native window controls. Windows and Linux keep the existing custom controls.
- The top bar includes a live RAM indicator sourced from the main process.
Expand Down
166 changes: 151 additions & 15 deletions api/routers/extensions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import asyncio
import json
import os
import platform as platform_module
import re
import subprocess
import sys
from pathlib import Path
from fastapi import APIRouter, Body, HTTPException

router = APIRouter(tags=["extensions"])
Expand Down Expand Up @@ -47,15 +52,38 @@ async def setup_extension(ext_id: str):
# No setup.py → legacy extension, nothing to do
return {"status": "skipped", "reason": "no setup.py"}

# Detect GPU compute capability
gpu_sm = _detect_gpu_sm()
# Detect GPU compute capability. NVIDIA keeps detection priority, exactly
# like electron/main/gpu-detect.ts: a Ryzen APU beside an NVIDIA dGPU must
# resolve to CUDA on both code paths.
gpu_sm, cuda_version = _detect_nvidia_gpu()
gfx_target = "" if gpu_sm else _detect_gfx_target()
flavor = "cuda" if gpu_sm else ("rocm" if gfx_target else "cpu")

# Pass arguments as JSON so setup.py sees torch_flavor. The keys mirror
# runExtensionSetup in electron/main/ipc-handlers.ts exactly — setup.py
# scripts read the same contract whichever side launched them. Note this
# endpoint is a fallback: Electron normally runs setup.py itself, and only
# that path gets the ROCm index rewriting for extensions that ignore
# torch_flavor.
args = json.dumps({
"python_exe": sys.executable,
"ext_dir": str(ext_dir),
"gpu_sm": gpu_sm,
"cuda_version": cuda_version,
"accelerator": flavor,
"torch_flavor": flavor,
"gfx_target": gfx_target,
"torch_index_url": _rocm_index_url() if flavor == "rocm" else "",
"platform": sys.platform,
"arch": _node_arch(),
})

# Run setup.py using Modly's embedded Python (sys.executable)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: subprocess.run(
[sys.executable, str(setup_py), sys.executable, str(ext_dir), str(gpu_sm)],
[sys.executable, str(setup_py), args],
capture_output=True,
text=True,
)
Expand All @@ -65,9 +93,10 @@ async def setup_extension(ext_id: str):
raise HTTPException(500, f"setup.py failed:\n{result.stderr}")

return {
"status": "ok",
"gpu_sm": gpu_sm,
"output": result.stdout,
"status": "ok",
"gpu_sm": gpu_sm,
"gfx_target": gfx_target,
"output": result.stdout,
}


Expand All @@ -78,13 +107,120 @@ async def extension_errors():
return generator_registry.load_errors()


def _detect_gpu_sm() -> int:
"""Returns GPU compute capability as integer (e.g. 86 for SM 8.6), or 0 if no GPU."""
def _detect_nvidia_gpu() -> tuple[int, int]:
"""
Returns (compute capability, max CUDA version) — e.g. (86, 124) — or (0, 0)
when there is no NVIDIA GPU.

Asks nvidia-smi rather than torch: this process runs in Modly's main venv,
which has no torch at all (see api/requirements.txt), so a torch import
always failed here and silently reported every machine as CPU-only. Asking
the driver also sidesteps the ROCm ambiguity — PyTorch's HIP build answers
the whole torch.cuda API, reporting (12, 0) for a gfx1200 Radeon exactly
like an sm_120 Blackwell. Mirrors parseNvidiaSmi in
electron/main/gpu-detect.ts, including the driver → CUDA version table.
"""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=compute_cap,driver_version", "--format=csv,noheader"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return 0, 0
if result.returncode != 0:
return 0, 0

line = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
if not line:
return 0, 0
parts = [part.strip() for part in line.split(",")]

try:
sm = round(float(parts[0]) * 10)
except (ValueError, IndexError):
sm = 86
try:
driver_major = int((parts[1] if len(parts) > 1 else "0").split(".")[0])
except ValueError:
driver_major = 0

cuda_version = 118 # safe minimum
for threshold, version in (
(570, 128), (560, 126), (555, 125), (550, 124),
(545, 123), (535, 122), (530, 121), (525, 120), (520, 118),
):
if driver_major >= threshold:
cuda_version = version
break
return sm, cuda_version


def _rocm_index_url() -> str:
"""The pip index a ROCm torch install must come from. Mirrors
resolveRocmTorchSpec in electron/main/gpu-detect.ts."""
override = os.environ.get("MODLY_ROCM_INDEX", "").strip()
if override:
return override
if sys.platform == "win32":
return "https://repo.amd.com/rocm/whl-multi-arch/"
return "https://download.pytorch.org/whl/rocm7.2"


def _node_arch() -> str:
"""platform.machine() mapped onto Node's process.arch vocabulary, so
setup.py sees the same values whichever side launched it."""
machine = platform_module.machine().lower()
if machine in ("x86_64", "amd64"):
return "x64"
if machine in ("aarch64", "arm64"):
return "arm64"
return machine


def _detect_gfx_target() -> str:
"""
Returns the ROCm compute target (e.g. "gfx1200"), or "" when there is no AMD GPU.

Reads the kernel's KFD topology rather than asking torch: this process runs
in Modly's main venv, which has no torch at all (see api/requirements.txt).
The amdgpu driver publishes the target on its own, so no ROCm install is
needed either. Mirrors electron/main/gpu-detect.ts.
"""
kfd_nodes = Path("/sys/class/kfd/kfd/topology/nodes")
if not Path("/dev/kfd").exists() or not kfd_nodes.is_dir():
return ""

def _prop(text: str, key: str) -> int:
match = re.search(rf"^{key}\s+(\d+)\s*$", text, re.M)
return int(match.group(1)) if match else 0

try:
import torch
if torch.cuda.is_available():
major, minor = torch.cuda.get_device_capability(0)
return major * 10 + minor
except Exception:
pass
return 0
nodes = sorted(kfd_nodes.iterdir(), key=lambda p: int(p.name) if p.name.isdigit() else 0)
except OSError:
return ""

# Among GPU nodes the largest simd_count wins, mirroring parseKfdGfxTarget
# in electron/main/gpu-detect.ts: on an APU + dGPU machine the APU commonly
# gets the lower node number, and the discrete card has more SIMDs.
best_target, best_simd = "", 0
for node in nodes:
try:
text = (node / "properties").read_text()
except OSError:
continue
# Node 0 is the CPU node (simd_count 0) and carries no compute target.
simd_count = _prop(text, "simd_count")
if simd_count <= 0:
continue
# major*10000 + minor*100 + step, minor and step read as hex digits.
version = _prop(text, "gfx_target_version")
if version <= 0:
continue
major, minor, step = version // 10000, (version % 10000) // 100, version % 100
if major <= 0 or minor > 15 or step > 15:
continue
if simd_count > best_simd:
best_target, best_simd = f"gfx{major}{minor:x}{step:x}", simd_count
return best_target
100 changes: 100 additions & 0 deletions arch/decisions/AMD-ROCM-SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# AMD-ROCM-SUPPORT

- Status: proposed
- Date: 2026-08-17

## Decision

Modly supports AMD Radeon GPUs through ROCm on Linux and Windows. Detection is
automatic and requires no ROCm installation on the user's machine.

Scope and operating rules:

- Detection is centralised in `electron/main/gpu-detect.ts` and produces, for AMD
machines, a compute target plus the pip index and requirements an extension's
torch install must end up using.
- NVIDIA keeps detection priority. On a machine with both vendors the existing
CUDA behaviour is unchanged.
- AMD machines report `gpu_sm = 0` and `cuda_version = 0`, never a synthesised
compute capability.
- Extensions are told the flavour via a `torch_flavor` setup argument. Extensions
that ignore it are corrected by a rewrite shim in `electron/main/setup-launcher.ts`.
- The ROCm wheel source differs by platform: `download.pytorch.org/whl/rocm7.2`
on Linux, `repo.amd.com/rocm/whl-multi-arch/` on Windows.
- Every automatic choice has an environment-variable override. See
`docs/running-on-amd-rocm.md`.

## Context

Modly never installs PyTorch itself. Each extension ships a `setup.py` that
creates its own venv and installs torch from an index it hardcodes — and those
scripts are third-party code in separate GitHub repositories that Modly cannot
edit. Before this work `detectGpuInfo()` only probed `nvidia-smi`, so an AMD
machine was reported as `accelerator: 'cpu'` with `gpu_sm: 0`, which sent every
extension down its legacy CUDA 11.8 branch and installed a torch that cannot see
the GPU at all.

That leaves two distinct problems, and both have to be solved:

- Extensions that *do* understand AMD were never told. The official
`modly-hunyuan3d-mini-extension` has accepted a `torch_flavor: "rocm"` argument
for some time; Modly simply never sent it.
- Extensions that don't understand AMD — `triposg`, `trellis2`, and the rest —
hardcode `--index-url .../whl/cu124` and have no branch to select. Passing an
argument achieves nothing for them.

The wheel sources are also not symmetric across platforms. `download.pytorch.org`
publishes no ROCm wheels for Windows at all; AMD's own multi-arch index does, but
there the compute target is selected by a pip extra (`torch[device-gfx1200]`)
rather than by the index URL, which means Windows needs the compute target
*before* the install, not after.

## Consequences

- **A rewrite shim is unavoidable.** Correcting extensions we cannot edit means
intercepting their pip calls. The launcher already patched `subprocess` for two
other compatibility fixes, so ROCm redirection joins those rather than
introducing a new mechanism.
- **The decision is made in TypeScript, applied in Python.** The launcher is an
inline Python string that cannot be unit-tested in isolation, so index and
requirement resolution lives in `gpu-detect.ts` and reaches the launcher as
environment variables. The launcher is separately exercised end-to-end by
`setup-launcher.test.mjs`, which runs it against the command shapes the
official extensions actually use.
- **`gpu_sm = 0` is load-bearing, not a placeholder.** Extensions written before
`torch_flavor` branch on that number, and 0 selects their most conservative
path. It also keeps them off `rembg[gpu]`, whose `onnxruntime-gpu` is
CUDA-only. Reporting a synthesised capability instead would break both.
PyTorch's HIP build answers the whole `torch.cuda` API, so
`get_device_capability()` reports `(12, 0)` for a gfx1200 Radeon —
indistinguishable from an sm_120 Blackwell. `api/routers/extensions.py` asks
`nvidia-smi` rather than torch for this reason (and because Modly's main venv
carries no torch at all).
- **Compute-target discovery is platform-specific.** Linux reads
`gfx_target_version` from the kernel's KFD topology, which needs no ROCm
install and no external binary. Windows has no equivalent, so it maps PCI
device ids from `Win32_VideoController` through a table keyed by silicon. That
table is a maintenance surface: new AMD silicon needs an entry, and an unmapped
AMD card falls back to CPU with an actionable message rather than guessing a
wheel.
- **The Linux and Windows torch versions diverge.** Linux gets unpinned wheels
from the pytorch.org ROCm index (currently torch 2.11+); Windows gets a pinned
pair from AMD's index. Extension code written against torch 2.6/2.7 may not
survive that jump, which is why `MODLY_ROCM_INDEX` and `MODLY_ROCM_TORCH_SPEC`
exist as first-class escape hatches rather than debug affordances.
- **Linux is verified, Windows is not.** On a Radeon RX 9060 XT (gfx1200),
`torch 2.13.0+rocm7.2` loads, rocBLAS and MIOpen kernels execute, 14 GB of the
card's 16 GB allocates and reads back cleanly ([ROCm #6295](https://github.com/ROCm/ROCm/issues/6295),
which reports this card capped near 8 GB, did not reproduce), and a full
image-to-3D generation completes through the normal `ExtensionProcess` path in
221 s. For Windows the wheel URLs, `cp311` availability and index layout were
checked, but no end-to-end run has been performed.
- **This work also required fixing an unrelated AppImage bug** to be verifiable
at all. `ensureStableEmbeddedPython()` copied the bundled runtime with
`fs.cp`, which rewrites relative symlinks into absolute paths pointing back at
the ephemeral `/tmp/.mount_Modly-XXXXXX/` mount — so the "stable" copy was not
stable, and every extension venv built from it died on the next launch with a
misleading `No module named 'PIL'`. See `electron/main/copy-runtime.ts`.
- **Texture generation is out of scope.** `api/texture_baker` already carries a
HIP build path, but those native extensions are not built as part of standard
extension setup, so texture generation is not covered by this ADR.
1 change: 1 addition & 0 deletions arch/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ single reviewable document.

Current ADRs:
- [APPLE-SILICON-SUPPORT](./APPLE-SILICON-SUPPORT.md)
- [AMD-ROCM-SUPPORT](./AMD-ROCM-SUPPORT.md)
Loading