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
81 changes: 75 additions & 6 deletions api/routers/extensions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import asyncio
import json
import re
import subprocess
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException

router = APIRouter(tags=["extensions"])
Expand Down Expand Up @@ -43,14 +46,29 @@ async def setup_extension(ext_id: str):
return {"status": "skipped", "reason": "no setup.py"}

# Detect GPU compute capability
gpu_sm = _detect_gpu_sm()
gpu_sm = _detect_gpu_sm()
gfx_target = _detect_gfx_target()

# Pass arguments as JSON so setup.py sees torch_flavor. 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": 0,
"accelerator": "rocm" if gfx_target else ("cuda" if gpu_sm else "cpu"),
"torch_flavor": "rocm" if gfx_target else ("cuda" if gpu_sm else "cpu"),
"gfx_target": gfx_target,
"platform": sys.platform,
})

# 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 @@ -60,9 +78,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 @@ -74,12 +93,62 @@ async def extension_errors():


def _detect_gpu_sm() -> int:
"""Returns GPU compute capability as integer (e.g. 86 for SM 8.6), or 0 if no GPU."""
"""
Returns GPU compute capability as integer (e.g. 86 for SM 8.6), or 0 if no GPU.

Returns 0 on ROCm too. PyTorch's HIP build answers the whole torch.cuda API,
so get_device_capability() happily reports (12, 0) for a gfx1200 Radeon —
indistinguishable from an sm_120 Blackwell, which would send setup.py to the
CUDA 12.8 index. AMD cards are identified by _detect_gfx_target() instead.
"""
try:
import torch
if torch.version.hip:
return 0
if torch.cuda.is_available():
major, minor = torch.cuda.get_device_capability(0)
return major * 10 + minor
except Exception:
pass
return 0


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:
nodes = sorted(kfd_nodes.iterdir(), key=lambda p: int(p.name) if p.name.isdigit() else 0)
except OSError:
return ""

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.
if _prop(text, "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
return f"gfx{major}{minor:x}{step:x}"
return ""
99 changes: 99 additions & 0 deletions arch/decisions/AMD-ROCM-SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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` guards
on `torch.version.hip` for this reason.
- **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