From 211c3fcba4dccdf43312c1f8a55dbfe8c593ce3b Mon Sep 17 00:00:00 2001 From: Nik Nichols Date: Wed, 24 Jun 2026 16:29:09 -0500 Subject: [PATCH 1/3] feat(gpu): add device-aware routing for Apple Silicon MPS support - Add get_torch_device() helper in _shared.py: cuda > mps > cpu - Guard MPS detection for torch builds lacking torch.backends.mps - Check both is_built() and is_available() for MPS - Route load_diffusers_pipeline() to resolved device instead of hardcoded cuda - Use float32 on CPU (float16 is emulated/unreliable), float16 on MPS, bfloat16 on CUDA - Guard enable_model_cpu_offload() to CUDA-only; fall back to .to(device) on MPS - Enable attention slicing for MPS memory safety - Add inspect-based signature guard for device= arg on RealESRGANer/GFPGANer - Update install_instructions on all LOCAL_GPU tools to mention MPS/Apple Silicon --- tools/enhancement/face_restore.py | 49 ++++++++++++++-------- tools/enhancement/upscale.py | 50 +++++++++++++++------- tools/video/_shared.py | 70 +++++++++++++++++++++++++++---- 3 files changed, 129 insertions(+), 40 deletions(-) diff --git a/tools/enhancement/face_restore.py b/tools/enhancement/face_restore.py index 277091d..cc2408b 100644 --- a/tools/enhancement/face_restore.py +++ b/tools/enhancement/face_restore.py @@ -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}" diff --git a/tools/enhancement/upscale.py b/tools/enhancement/upscale.py index c87d045..0364840 100644 --- a/tools/enhancement/upscale.py +++ b/tools/enhancement/upscale.py @@ -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 diff --git a/tools/video/_shared.py b/tools/video/_shared.py index 762d0db..d4d679c 100644 --- a/tools/video/_shared.py +++ b/tools/video/_shared.py @@ -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"): From 576bb2f6f2eacbfb97fb7fddb41b18ed04deb069 Mon Sep 17 00:00:00 2001 From: Nik Nichols Date: Wed, 24 Jun 2026 16:30:29 -0500 Subject: [PATCH 2/3] test(gpu): add tests for Apple Silicon MPS device resolution and fallback --- tests/tools/test_mps_device.py | 337 +++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 tests/tools/test_mps_device.py diff --git a/tests/tools/test_mps_device.py b/tests/tools/test_mps_device.py new file mode 100644 index 0000000..844d633 --- /dev/null +++ b/tests/tools/test_mps_device.py @@ -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 From 7cd8fbf4b77ebab41907f87938d83865365cee26 Mon Sep 17 00:00:00 2001 From: Nik Nichols Date: Wed, 24 Jun 2026 16:30:30 -0500 Subject: [PATCH 3/3] docs(gpu): add Apple Silicon setup guide for MPS support --- docs/apple-silicon-mps.md | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/apple-silicon-mps.md diff --git a/docs/apple-silicon-mps.md b/docs/apple-silicon-mps.md new file mode 100644 index 0000000..f8ee157 --- /dev/null +++ b/docs/apple-silicon-mps.md @@ -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)