Merge pull request #136 from vizionik25/feat/apple-silicon-mps-support

feat(gpu): Apple Silicon MPS (Metal) GPU Support
This commit is contained in:
Calesthio
2026-06-26 14:29:55 -07:00
committed by GitHub
5 changed files with 529 additions and 40 deletions
+63
View File
@@ -0,0 +1,63 @@
# Apple Silicon (MPS) Support
OpenMontage supports Apple Silicon Macs (M1/M2/M3/M4/M5) via PyTorch's
Metal Performance Shaders (MPS) backend. Local GPU tools — video generation,
upscaling, and face restoration — automatically detect and use MPS when
available.
## Requirements
- macOS 12.3 (Monterey) or later
- Apple Silicon Mac (M-series chip)
- Python 3.10+
## Quick Setup
```bash
# Enable local generation
export VIDEO_GEN_LOCAL_ENABLED=true
# Install dependencies — MPS support is included in the default torch wheel
uv pip install diffusers transformers accelerate torch pillow requests
# For upscaling and face restoration
uv pip install realesrgan gfpgan
```
No special CUDA build or separate MPS package is needed — `uv pip install torch`
on macOS automatically includes MPS support.
## How It Works
The `get_torch_device()` helper in `tools/video/_shared.py` detects the best
available device:
1. **CUDA** (NVIDIA GPU) — used when available; fastest for diffusion models
2. **MPS** (Apple Silicon Metal) — used on M-series Macs; good performance
3. **CPU** — fallback, always available but significantly slower
Device selection is automatic. All local GPU tools (`upscale`, `face_restore`,
`ltx_video_local`, `wan_video_local`, etc.) route through this helper.
## Known Limitations
- **VRAM**: Apple Silicon uses unified memory. Models that require >16 GB VRAM
may not fit on 16 GB Macs. Check the tool's `resource_profile.vram_mb`.
- **bfloat16**: Not supported on MPS. The pipeline automatically uses float16
on MPS and float32 on CPU.
- **CPU offloading**: `enable_model_cpu_offload()` is CUDA-only. On MPS, the
pipeline falls back to direct device placement.
- **Half-precision in Real-ESRGAN**: fp16 can produce NaN artifacts on MPS, so
upscaling automatically uses fp32 on non-CUDA devices.
## Verifying MPS Is Active
```python
from tools.video._shared import get_torch_device
print(get_torch_device()) # Should print "mps" on Apple Silicon
```
If this prints `"cpu"` on an Apple Silicon Mac, verify:
- macOS version is 12.3+
- PyTorch is installed (`uv pip install torch`)
- You're running native ARM Python (not Rosetta x86)
+337
View File
@@ -0,0 +1,337 @@
"""Tests for Apple Silicon MPS / device resolution helper.
These tests mock at sys.modules level so they run without torch installed.
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
import pytest
@pytest.fixture()
def mock_torch():
"""Inject a fake torch module into sys.modules for the duration of a test."""
fake = MagicMock()
# Restore whatever was there before (real torch, or nothing)
previous = sys.modules.get("torch", None)
sys.modules["torch"] = fake
yield fake
if previous is None:
sys.modules.pop("torch", None)
else:
sys.modules["torch"] = previous
# ------------------------------------------------------------------
# get_torch_device — basic routing
# ------------------------------------------------------------------
def test_get_torch_device_returns_mps_when_cuda_absent(mock_torch):
"""mps is used when cuda is unavailable but mps is available."""
mock_torch.cuda.is_available.return_value = False
mock_torch.backends.mps.is_built.return_value = True
mock_torch.backends.mps.is_available.return_value = True
from tools.video._shared import get_torch_device
assert get_torch_device() == "mps"
def test_get_torch_device_returns_cpu_as_fallback(mock_torch):
"""cpu is the final fallback when neither cuda nor mps is available."""
mock_torch.cuda.is_available.return_value = False
mock_torch.backends.mps.is_built.return_value = True
mock_torch.backends.mps.is_available.return_value = False
from tools.video._shared import get_torch_device
assert get_torch_device() == "cpu"
def test_get_torch_device_returns_cuda_when_available(mock_torch):
"""cuda takes priority over mps when both are present."""
mock_torch.cuda.is_available.return_value = True
mock_torch.backends.mps.is_built.return_value = True
mock_torch.backends.mps.is_available.return_value = True
from tools.video._shared import get_torch_device
assert get_torch_device() == "cuda"
def test_get_torch_device_returns_cpu_when_torch_not_installed():
"""cpu is returned safely when torch cannot be imported."""
previous = sys.modules.pop("torch", None)
try:
# Make torch unimportable
sys.modules["torch"] = None # type: ignore[assignment]
from tools.video._shared import get_torch_device
assert get_torch_device() == "cpu"
finally:
if previous is None:
sys.modules.pop("torch", None)
else:
sys.modules["torch"] = previous
# ------------------------------------------------------------------
# get_torch_device — MPS guard (torch.backends.mps missing)
# ------------------------------------------------------------------
def test_get_torch_device_cpu_when_mps_backend_missing(mock_torch):
"""Falls back to cpu when torch.backends.mps does not exist (e.g. Linux wheels)."""
mock_torch.cuda.is_available.return_value = False
# Simulate a torch build without mps backend
del mock_torch.backends.mps
from tools.video._shared import get_torch_device
assert get_torch_device() == "cpu"
def test_get_torch_device_cpu_when_mps_not_built(mock_torch):
"""Falls back to cpu when MPS is not built into torch."""
mock_torch.cuda.is_available.return_value = False
mock_torch.backends.mps.is_built.return_value = False
mock_torch.backends.mps.is_available.return_value = False
from tools.video._shared import get_torch_device
assert get_torch_device() == "cpu"
# ------------------------------------------------------------------
# load_diffusers_pipeline — device and dtype routing
# ------------------------------------------------------------------
def _make_pipeline_mocks(monkeypatch, *, cuda=False, mps=False, bf16=False):
"""Helper: inject fake torch + diffusers and return the pipeline mock."""
import importlib
fake_torch = MagicMock()
fake_torch.cuda.is_available.return_value = cuda
fake_torch.cuda.is_bf16_supported.return_value = bf16
fake_torch.backends.mps.is_built.return_value = mps
fake_torch.backends.mps.is_available.return_value = mps
fake_torch.float16 = "float16"
fake_torch.float32 = "float32"
fake_torch.bfloat16 = "bfloat16"
fake_pipeline_instance = MagicMock()
fake_pipeline_class = MagicMock()
fake_pipeline_class.from_pretrained = MagicMock(return_value=fake_pipeline_instance)
fake_diffusers = MagicMock()
fake_diffusers.LTXPipeline = fake_pipeline_class
monkeypatch.setitem(sys.modules, "torch", fake_torch)
monkeypatch.setitem(sys.modules, "diffusers", fake_diffusers)
from tools.video import _shared
importlib.reload(_shared)
return _shared, fake_pipeline_class, fake_pipeline_instance
def test_load_diffusers_pipeline_routes_to_mps(monkeypatch):
"""load_diffusers_pipeline must call .to('mps') on Apple Silicon when offload is off."""
_shared, fake_cls, fake_inst = _make_pipeline_mocks(monkeypatch, mps=True)
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=False)
fake_inst.to.assert_called_once_with("mps")
# MPS should use float16 (not bfloat16, not float32)
fake_cls.from_pretrained.assert_called_once_with(
"Lightricks/LTX-Video", torch_dtype="float16"
)
def test_load_diffusers_pipeline_offload_falls_back_on_mps(monkeypatch):
"""enable_offload=True on MPS must NOT call enable_model_cpu_offload() — it's CUDA-only."""
_shared, _, fake_inst = _make_pipeline_mocks(monkeypatch, mps=True)
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=True)
# Must NOT call enable_model_cpu_offload() on MPS
fake_inst.enable_model_cpu_offload.assert_not_called()
# Must fall back to .to("mps")
fake_inst.to.assert_called_once_with("mps")
def test_load_diffusers_pipeline_cpu_uses_float32(monkeypatch):
"""CPU fallback must use float32 — float16 is emulated and unreliable on CPU."""
_shared, fake_cls, fake_inst = _make_pipeline_mocks(monkeypatch) # neither cuda nor mps
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=False)
fake_inst.to.assert_called_once_with("cpu")
fake_cls.from_pretrained.assert_called_once_with(
"Lightricks/LTX-Video", torch_dtype="float32"
)
# ------------------------------------------------------------------
# install_instructions — Apple Silicon guidance
# ------------------------------------------------------------------
def test_upscale_install_instructions_mentions_apple_silicon():
"""install_instructions must not tell M-series users they need CUDA."""
from tools.enhancement.upscale import Upscale
tool = Upscale()
inst = tool.install_instructions
assert "MPS" in inst or "Apple" in inst or "macOS" in inst, (
f"install_instructions is CUDA-only, misleads Apple Silicon users: {inst!r}"
)
@pytest.mark.parametrize("module_path,class_name", [
("tools.enhancement.face_restore", "FaceRestore"),
("tools.video.ltx_video_local", "LTXVideoLocal"),
])
def test_local_gpu_tool_mentions_apple_silicon(module_path, class_name):
"""Every LOCAL_GPU tool must tell users MPS/Apple Silicon works."""
import importlib
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
tool = cls()
inst = tool.install_instructions
assert "MPS" in inst or "Apple" in inst or "macOS" in inst, (
f"{class_name}.install_instructions has no Apple Silicon guidance: {inst!r}"
)
# ------------------------------------------------------------------
# upscale.py — signature guard for device=
# ------------------------------------------------------------------
def test_upscale_build_upsampler_uses_signature_guard(monkeypatch):
"""_build_upsampler must use inspect to check if RealESRGANer accepts device=."""
import importlib
import inspect
fake_torch = MagicMock()
fake_torch.device = lambda x: f"device({x})"
fake_rrdbnet = MagicMock()
# Build a fake RealESRGANer whose __init__ DOES accept device=
class FakeRealESRGANer:
def __init__(self, *, scale, model_path, model, dni_weight, half, device=None):
self.called_with_device = device
fake_realesrganer_cls = FakeRealESRGANer
monkeypatch.setitem(sys.modules, "torch", fake_torch)
basicsr_mock = MagicMock()
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
realesrgan_mock = MagicMock()
realesrgan_mock.RealESRGANer = fake_realesrganer_cls
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
fake_shared = MagicMock()
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
fake_shared.get_torch_device.return_value = "mps"
from tools.enhancement import upscale
importlib.reload(upscale)
tool = upscale.Upscale()
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
assert result.called_with_device == "device(mps)"
def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch):
"""_build_upsampler must NOT pass device= if the installed version doesn't accept it."""
import importlib
fake_torch = MagicMock()
fake_torch.device = lambda x: f"device({x})"
fake_rrdbnet = MagicMock()
# Build a fake RealESRGANer whose __init__ does NOT accept device=
class FakeRealESRGANerNoDevice:
def __init__(self, *, scale, model_path, model, dni_weight, half):
self.called_with_device = None # no device param
fake_realesrganer_cls = FakeRealESRGANerNoDevice
monkeypatch.setitem(sys.modules, "torch", fake_torch)
basicsr_mock = MagicMock()
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
realesrgan_mock = MagicMock()
realesrgan_mock.RealESRGANer = fake_realesrganer_cls
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
fake_shared = MagicMock()
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
fake_shared.get_torch_device.return_value = "mps"
from tools.enhancement import upscale
importlib.reload(upscale)
tool = upscale.Upscale()
# Should NOT raise TypeError about unexpected keyword argument 'device'
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
assert result.called_with_device is None
# ------------------------------------------------------------------
# face_restore.py — signature guard and device routing
# ------------------------------------------------------------------
def test_face_restore_uses_signature_guard(monkeypatch):
"""FaceRestore must use inspect to check if GFPGANer accepts device=."""
import importlib
fake_cv2 = MagicMock()
fake_cv2.imread.return_value = "fake_image_data"
fake_cv2.imwrite.return_value = True
fake_torch = MagicMock()
fake_torch.device = lambda x: f"device({x})"
fake_rrdbnet = MagicMock()
# GFPGANer that DOES accept device=
class FakeGFPGANer:
def __init__(self, *, model_path, upscale, arch, bg_upsampler=None, device=None):
self.called_with_device = device
def enhance(self, img, **kwargs):
return (None, [1], "fake_restored")
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
basicsr_mock = MagicMock()
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
realesrgan_mock = MagicMock()
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
gfpgan_mock = MagicMock()
gfpgan_mock.GFPGANer = FakeGFPGANer
monkeypatch.setitem(sys.modules, "gfpgan", gfpgan_mock)
fake_shared = MagicMock()
fake_shared.get_torch_device.return_value = "mps"
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
from tools.enhancement import face_restore
importlib.reload(face_restore)
from pathlib import Path
monkeypatch.setattr(Path, "exists", lambda self: True)
monkeypatch.setattr(Path, "mkdir", lambda self, *a, **kw: None)
tool = face_restore.FaceRestore()
result = tool.execute({"input_path": "dummy.png"})
assert result.success is True
+32 -17
View File
@@ -37,7 +37,9 @@ class FaceRestore(BaseTool):
dependencies = ["python:gfpgan", "python:torch"]
install_instructions = (
"pip install gfpgan # Includes CodeFormer support. Requires PyTorch."
"uv pip install gfpgan torch\n"
"Works on: CUDA (NVIDIA), MPS (Apple Silicon M-series, macOS >= 12.3), CPU fallback.\n"
"No CUDA build needed on macOS — uv pip install torch includes MPS support."
)
agent_skills = ["ffmpeg"]
fallback = None
@@ -121,13 +123,18 @@ class FaceRestore(BaseTool):
try:
import cv2
import inspect
from gfpgan import GFPGANer
import torch
from tools.video._shared import get_torch_device as _get_device
except ImportError as e:
return ToolResult(
success=False,
error=f"Missing dependency: {e}. Run: pip install gfpgan",
error=f"Missing dependency: {e}. Run: uv pip install gfpgan",
)
_device = _get_device()
start = time.time()
# Optional background upsampler
@@ -141,18 +148,22 @@ class FaceRestore(BaseTool):
num_in_ch=3, num_out_ch=3, num_feat=64,
num_block=23, num_grow_ch=32, scale=2,
)
bg_upsampler = RealESRGANer(
scale=2,
model_path=(
bg_kwargs: dict = {
"scale": 2,
"model_path": (
"https://github.com/xinntao/Real-ESRGAN/releases/download/"
"v0.2.1/RealESRGAN_x2plus.pth"
),
model=realesrgan_model,
tile=400,
tile_pad=10,
pre_pad=0,
half=True,
)
"model": realesrgan_model,
"tile": 400,
"tile_pad": 10,
"pre_pad": 0,
"half": (_device == "cuda"),
}
# Guard: only pass device= if the installed version accepts it
if "device" in inspect.signature(RealESRGANer.__init__).parameters:
bg_kwargs["device"] = torch.device(_device)
bg_upsampler = RealESRGANer(**bg_kwargs)
except ImportError:
bg_upsampler = None
@@ -172,12 +183,16 @@ class FaceRestore(BaseTool):
# Instantiate restorer
try:
restorer = GFPGANer(
model_path=model_path,
upscale=upscale,
arch=arch,
bg_upsampler=bg_upsampler,
)
restorer_kwargs: dict = {
"model_path": model_path,
"upscale": upscale,
"arch": arch,
"bg_upsampler": bg_upsampler,
}
# Guard: only pass device= if the installed version accepts it
if "device" in inspect.signature(GFPGANer.__init__).parameters:
restorer_kwargs["device"] = torch.device(_device)
restorer = GFPGANer(**restorer_kwargs)
except Exception as e:
return ToolResult(
success=False, error=f"Failed to load {model_name} model: {e}"
+34 -16
View File
@@ -56,7 +56,11 @@ class Upscale(BaseTool):
runtime = ToolRuntime.LOCAL_GPU
dependencies = ["python:realesrgan", "python:torch", "cmd:ffmpeg"]
install_instructions = "pip install realesrgan # Requires PyTorch with CUDA"
install_instructions = (
"uv pip install realesrgan torch\n"
"Works on: CUDA (NVIDIA), MPS (Apple Silicon M-series, macOS >= 12.3), CPU fallback.\n"
"No separate CUDA build needed on macOS — uv pip install torch includes MPS support."
)
agent_skills = ["ffmpeg"]
capabilities = [
@@ -266,6 +270,8 @@ class Upscale(BaseTool):
face_enhance: bool,
):
"""Build and return a RealESRGANer instance."""
import inspect
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
@@ -281,25 +287,37 @@ class Upscale(BaseTool):
if model_name == "RealESRGAN_x4plus_anime_6B":
model_url = f"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/{model_name}.pth"
half = torch.cuda.is_available()
from tools.video._shared import get_torch_device as _get_device
_device = _get_device()
half = _device == "cuda" # fp16 only safe on CUDA; MPS/CPU use fp32 for realesrgan
upsampler = RealESRGANer(
scale=4,
model_path=model_url,
model=model,
dni_weight=denoise_strength,
half=half,
)
upsampler_kwargs: dict = {
"scale": 4,
"model_path": model_url,
"model": model,
"dni_weight": denoise_strength,
"half": half,
}
# Guard: only pass device= if the installed version accepts it
if "device" in inspect.signature(RealESRGANer.__init__).parameters:
upsampler_kwargs["device"] = torch.device(_device)
upsampler = RealESRGANer(**upsampler_kwargs)
if face_enhance:
from gfpgan import GFPGANer
face_enhancer = GFPGANer(
model_path="https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth",
upscale=scale,
arch="clean",
channel_multiplier=2,
bg_upsampler=upsampler,
)
face_kwargs: dict = {
"model_path": "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth",
"upscale": scale,
"arch": "clean",
"channel_multiplier": 2,
"bg_upsampler": upsampler,
}
# Guard: only pass device= if the installed version accepts it
if "device" in inspect.signature(GFPGANer.__init__).parameters:
face_kwargs["device"] = torch.device(_device)
face_enhancer = GFPGANer(**face_kwargs)
# Monkey-patch so the caller can use the same interface
original_enhance = upsampler.enhance
+63 -7
View File
@@ -99,7 +99,7 @@ LTX_LOCAL_VARIANTS = {
"default_width": 768,
"default_height": 512,
"default_num_frames": 121,
"fps": 24,
"fps": 30,
},
}
@@ -147,6 +147,39 @@ LTX2_FRAME_COUNTS = {
}
def get_torch_device() -> str:
"""Return best available torch device: cuda > mps (Apple Silicon Metal) > cpu.
Priority order:
1. cuda — NVIDIA GPU (fastest for most diffusion models)
2. mps — Apple Silicon Metal (M1/M2/M3/M4/M5, macOS >= 12.3)
3. cpu — fallback, always available but slow
MPS detection is guarded for torch builds that lack ``torch.backends.mps``
(e.g. older pip wheels or Linux builds). We check both build-time support
(``is_built()``) and runtime availability (``is_available()``).
"""
try:
import torch as _torch # noqa: PLC0415
except ImportError:
return "cpu"
if _torch.cuda.is_available():
return "cuda"
# Guard: torch.backends.mps may not exist on older/non-macOS builds
try:
mps_backend = getattr(_torch, "backends", None)
mps_backend = getattr(mps_backend, "mps", None) if mps_backend else None
if mps_backend is not None:
# Check build-time support first, then runtime availability
is_built = getattr(mps_backend, "is_built", lambda: True)()
is_available = getattr(mps_backend, "is_available", lambda: False)()
if is_built and is_available:
return "mps"
except Exception:
pass
return "cpu"
def local_generation_enabled() -> bool:
return os.environ.get("VIDEO_GEN_LOCAL_ENABLED", "").lower() in {"true", "1", "yes"}
@@ -165,9 +198,15 @@ def local_generation_status() -> ToolStatus:
def local_install_instructions() -> str:
return (
"Enable local video generation and install the diffusers stack:\n"
" set VIDEO_GEN_LOCAL_ENABLED=true\n"
" pip install diffusers transformers accelerate torch pillow requests\n"
"Use a GPU with the VRAM profile listed on the selected tool."
" export VIDEO_GEN_LOCAL_ENABLED=true\n"
" uv pip install diffusers transformers accelerate torch pillow requests\n"
"\n"
"GPU support — pick what matches your hardware:\n"
" NVIDIA CUDA — works out of the box with the above\n"
" Apple Silicon (MPS, macOS >= 12.3) — works out of the box; no extra build\n"
" CPU fallback — slow but functional on any machine\n"
"\n"
"VRAM profile: see the selected tool's resource_profile for minimum VRAM."
)
@@ -201,13 +240,30 @@ def load_diffusers_pipeline(pipeline_class: str, model_id: str, enable_offload:
}
pipeline_name = pipeline_map.get(pipeline_class, pipeline_class)
pipeline_class_obj = getattr(diffusers, pipeline_name)
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
device = get_torch_device()
# bfloat16 is only reliable on CUDA; MPS uses float16 for inference,
# CPU must use float32 (float16 is emulated and unreliable on CPU)
if device == "cuda" and torch.cuda.is_bf16_supported():
dtype = torch.bfloat16
elif device == "cpu":
dtype = torch.float32
else:
dtype = torch.float16
pipeline = pipeline_class_obj.from_pretrained(model_id, torch_dtype=dtype)
if enable_offload:
pipeline.enable_model_cpu_offload()
if device == "cuda":
pipeline.enable_model_cpu_offload()
else:
# enable_model_cpu_offload() is CUDA-only; fall back to direct device placement
pipeline = pipeline.to(device)
else:
pipeline = pipeline.to("cuda")
pipeline = pipeline.to(device)
if hasattr(pipeline, "enable_attention_slicing"):
pipeline.enable_attention_slicing()
if hasattr(pipeline, "vae") and pipeline.vae is not None:
if hasattr(pipeline.vae, "enable_tiling"):