From 05007887d03de6503f18ecbec472df73f05fcfbc Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 2 Jun 2026 23:03:17 -0400 Subject: [PATCH 1/2] Auto-select sidecar in connect on macOS --- python/examples/tensor.py | 15 +++++++--- python/lupine/__init__.py | 28 ++++++++++++++++-- python/tests/test_lupine_adapter.py | 45 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/python/examples/tensor.py b/python/examples/tensor.py index fddfcb0..532b97b 100644 --- a/python/examples/tensor.py +++ b/python/examples/tensor.py @@ -22,11 +22,18 @@ def prompt_endpoint() -> str: with lupine.connect(host=prompt_endpoint()) as session: device = session.device() - props = torch.cuda.get_device_properties(device) + info = getattr(session, "info", None) x = torch.arange(8, device=device, dtype=torch.float32) y = (x * 2).cpu() - print("cuda available:", torch.cuda.is_available()) + if info is None: + props = torch.cuda.get_device_properties(device) + info = { + "cuda_available": torch.cuda.is_available(), + "device_count": torch.cuda.device_count(), + "gpu": props.name, + } + print("cuda available:", info["cuda_available"]) print("device:", device) - print("count:", torch.cuda.device_count()) - print("gpu:", props.name) + print("count:", info["device_count"]) + print("gpu:", info["gpu"]) print("result:", y.tolist()) diff --git a/python/lupine/__init__.py b/python/lupine/__init__.py index b19e12d..6250291 100644 --- a/python/lupine/__init__.py +++ b/python/lupine/__init__.py @@ -7,6 +7,7 @@ import os import ctypes +import sys from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -34,6 +35,13 @@ def _cuda_initialized() -> bool: return False +def _has_native_cuda_backend() -> bool: + try: + return _torch().version.cuda is not None + except LupineError: + return False + + def _require_mutable_config() -> None: if _cuda_initialized(): raise LupineError("connect to LUPINE before PyTorch initializes CUDA") @@ -170,7 +178,7 @@ def connect( port: int | None = None, require_available: bool = False, libcuda: str | os.PathLike[str] | None = None, -) -> Session: +) -> Any: """Create a LUPINE session for one or more remote GPU hosts. Use the session before any PyTorch CUDA operation: @@ -178,10 +186,26 @@ def connect( ``with lupine.connect(host=["a:14833", "b:14833"]) as s:`` ``s.devices()`` then returns ``[torch.device("cuda:0"), torch.device("cuda:1")]``. + + On macOS with a CPU-only PyTorch build, ``connect()`` automatically returns + a sidecar session backed by Apple's container runtime. """ + servers = _normalize_hosts(host, port) + if not _has_native_cuda_backend(): + if sys.platform != "darwin": + raise LupineError( + "PyTorch is not compiled with CUDA and automatic LUPINE sidecar " + "fallback is only supported on macOS." + ) + if len(servers) != 1: + raise LupineError("automatic LUPINE sidecar fallback supports one host") + if libcuda is not None: + raise LupineError("libcuda is only supported with native CUDA PyTorch") + return sidecar(server=servers[0]) + return Session( - servers=_normalize_hosts(host, port), + servers=servers, require_available=require_available, libcuda=libcuda, ) diff --git a/python/tests/test_lupine_adapter.py b/python/tests/test_lupine_adapter.py index a49ab86..9fcaa41 100644 --- a/python/tests/test_lupine_adapter.py +++ b/python/tests/test_lupine_adapter.py @@ -48,6 +48,7 @@ class FakeTorch(types.SimpleNamespace): def __init__(self): super().__init__() self.cuda = FakeCuda() + self.version = types.SimpleNamespace(cuda="fake-cuda") def device(self, kind, index=None): return FakeDevice(kind, index) @@ -96,6 +97,50 @@ def test_connect_accepts_multiple_hosts_in_order(lupine_module): assert session.device(1) == FakeDevice("cuda", 1) +def test_connect_uses_sidecar_when_torch_has_no_cuda_backend(lupine_module, monkeypatch): + lupine, fake_torch = lupine_module + fake_torch.version.cuda = None + sentinel = object() + calls = [] + + monkeypatch.setattr(lupine.sys, "platform", "darwin") + monkeypatch.setattr( + lupine, + "sidecar", + lambda **kwargs: calls.append(kwargs) or sentinel, + ) + + assert lupine.connect(host="host-a") is sentinel + assert calls == [{"server": "host-a:14833"}] + + +def test_connect_sidecar_fallback_rejects_multiple_hosts(lupine_module, monkeypatch): + lupine, fake_torch = lupine_module + fake_torch.version.cuda = None + monkeypatch.setattr(lupine.sys, "platform", "darwin") + + with pytest.raises(lupine.LupineError, match="supports one host"): + lupine.connect(host=["host-a:14833", "host-b:14833"]) + + +def test_connect_sidecar_fallback_rejects_libcuda(lupine_module, monkeypatch, tmp_path): + lupine, fake_torch = lupine_module + fake_torch.version.cuda = None + monkeypatch.setattr(lupine.sys, "platform", "darwin") + + with pytest.raises(lupine.LupineError, match="libcuda"): + lupine.connect(host="host-a", libcuda=tmp_path / "libcuda.so.1") + + +def test_connect_requires_cuda_backend_off_macos(lupine_module, monkeypatch): + lupine, fake_torch = lupine_module + fake_torch.version.cuda = None + monkeypatch.setattr(lupine.sys, "platform", "linux") + + with pytest.raises(lupine.LupineError, match="automatic LUPINE sidecar"): + lupine.connect(host="host-a") + + def test_connect_restores_env_when_cuda_was_not_initialized(lupine_module, monkeypatch): lupine, _ = lupine_module From e2403431aeb80950753381c8e16bcee3f2c01cfe Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 2 Jun 2026 23:06:42 -0400 Subject: [PATCH 2/2] Bump Python package version to 0.1.2 --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 49df00b..1b0013c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lupine" -version = "0.1.1" +version = "0.1.2" description = "Small PyTorch adapter helpers for LUPINE-backed CUDA devices" readme = "README.md" requires-python = ">=3.9"