diff --git a/packages/node/meshnet_node/hardware.py b/packages/node/meshnet_node/hardware.py index d490c4b..4f253fc 100644 --- a/packages/node/meshnet_node/hardware.py +++ b/packages/node/meshnet_node/hardware.py @@ -105,12 +105,57 @@ def _detect_windows_gpu_memory() -> dict | None: return best +def _detect_nvidia_smi_gpu_memory() -> dict | None: + """Return NVIDIA GPU memory metadata from nvidia-smi, if available.""" + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + line = result.stdout.strip().splitlines()[0] + parts = line.split(",", 1) + gpu_name = parts[0].strip() + vram_mb = int(parts[1].strip()) if len(parts) > 1 else 0 + return {"gpu_name": gpu_name, "vram_mb": max(0, vram_mb)} + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, IndexError): + pass + return None + + +def _torch_cuda_is_executable(torch_module) -> bool: + """Return True only if this Python process can execute a CUDA tensor op.""" + try: + if not torch_module.cuda.is_available(): + return False + probe = torch_module.empty((1,), device="cuda") + probe += 1 + torch_module.cuda.synchronize() + return True + except Exception: + return False + + +def _gpu_inventory_profile(gpu: dict | None, ram_mb: int) -> dict | None: + if gpu is None: + return None + return { + "device": "cpu", + "gpu_name": gpu["gpu_name"], + "vram_mb": gpu["vram_mb"], + "dedicated_vram_mb": gpu["vram_mb"], + "shared_vram_mb": max(0, ram_mb // 2), + "ram_mb": ram_mb, + "cuda_available": False, + } + + def detect_hardware() -> dict: """Detect GPU model and available VRAM. Returns hardware profile dict.""" ram_mb = _detect_ram_mb() try: import torch # type: ignore[import] - if torch.cuda.is_available(): + if _torch_cuda_is_executable(torch): idx = torch.cuda.current_device() name = torch.cuda.get_device_name(idx) props = torch.cuda.get_device_properties(idx) @@ -123,42 +168,18 @@ def detect_hardware() -> dict: "dedicated_vram_mb": vram_mb, "shared_vram_mb": shared_vram_mb, "ram_mb": ram_mb, + "cuda_available": True, } except ImportError: pass - try: - result = subprocess.run( - ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0 and result.stdout.strip(): - line = result.stdout.strip().splitlines()[0] - parts = line.split(",", 1) - gpu_name = parts[0].strip() - vram_mb = int(parts[1].strip()) if len(parts) > 1 else 0 - shared_vram_mb = max(0, ram_mb // 2) - return { - "device": "cuda", - "gpu_name": gpu_name, - "vram_mb": vram_mb, - "dedicated_vram_mb": vram_mb, - "shared_vram_mb": shared_vram_mb, - "ram_mb": ram_mb, - } - except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, IndexError): - pass + nvidia_gpu = _gpu_inventory_profile(_detect_nvidia_smi_gpu_memory(), ram_mb) + if nvidia_gpu is not None: + return nvidia_gpu - windows_gpu = _detect_windows_gpu_memory() + windows_gpu = _gpu_inventory_profile(_detect_windows_gpu_memory(), ram_mb) if windows_gpu is not None: - return { - "device": "cpu", - "gpu_name": windows_gpu["gpu_name"], - "vram_mb": windows_gpu["vram_mb"], - "dedicated_vram_mb": windows_gpu["vram_mb"], - "shared_vram_mb": max(0, ram_mb // 2), - "ram_mb": ram_mb, - } + return windows_gpu return { "device": "cpu", @@ -167,6 +188,7 @@ def detect_hardware() -> dict: "dedicated_vram_mb": 0, "shared_vram_mb": 0, "ram_mb": ram_mb, + "cuda_available": False, } diff --git a/packages/node/meshnet_node/startup.py b/packages/node/meshnet_node/startup.py index fa24fd7..37c3427 100644 --- a/packages/node/meshnet_node/startup.py +++ b/packages/node/meshnet_node/startup.py @@ -33,6 +33,14 @@ def _memory_budget(device: str, vram_mb: int, ram_mb: int, shared_vram_mb: int = return max(0, ram_mb), "RAM" +def _hardware_label(device: str, gpu_name: str | None = None) -> str: + if device == "cuda": + return "CUDA" + if gpu_name: + return "CPU (CUDA inactive)" + return "CPU" + + def _max_assignable_layers(memory_mb: int, total_layers: int | None) -> int: if total_layers is None or total_layers <= 0 or memory_mb <= 0: return 0 @@ -360,7 +368,10 @@ def run_startup( elif device == "cpu": gpu_suffix = "" if gpu_name and vram_mb > 0: - gpu_suffix = f"; detected {gpu_name} ({vram_mb / 1024:.1f} GB dedicated VRAM, {shared_vram_mb / 1024:.1f} GB shared)" + gpu_suffix = ( + f"; CUDA inactive; detected {gpu_name} " + f"({vram_mb / 1024:.1f} GB dedicated VRAM, {shared_vram_mb / 1024:.1f} GB shared)" + ) print(f" WARNING: No CUDA GPU detected — running in CPU mode ({ram_mb / 1024:.1f} GB RAM{gpu_suffix})", flush=True) else: shared_suffix = f", {shared_vram_mb / 1024:.1f} GB shared" if shared_vram_mb > 0 else "" @@ -497,7 +508,7 @@ def run_startup( f" Quantization: {quantization}\n" f" Endpoint: {endpoint}\n" f" Node ID: {tracker_node_id or 'unregistered'}\n" - f" Hardware: {device.upper()}\n" + f" Hardware: {_hardware_label(device, gpu_name)}\n" f" Benchmark: {bench_tps:,.0f} (throughput index)\n" f"{'=' * 32}", flush=True, @@ -590,7 +601,7 @@ def run_startup( f" Quantization: {quantization}\n" f" Endpoint: {endpoint}\n" f" Node ID: {tracker_node_id or 'unregistered'}\n" - f" Hardware: {device.upper()}\n" + f" Hardware: {_hardware_label(device, gpu_name)}\n" f" Benchmark: {bench_tps:,.0f} (throughput index)\n" f"{'=' * 32}", flush=True, diff --git a/tests/test_node_startup.py b/tests/test_node_startup.py index a177c2d..4070a11 100644 --- a/tests/test_node_startup.py +++ b/tests/test_node_startup.py @@ -12,6 +12,7 @@ import pytest from meshnet_node.downloader import download_shard, write_shard_archive from meshnet_node.hardware import detect_hardware, benchmark_throughput from meshnet_node.startup import ( + _hardware_label, _infer_relay_url_from_tracker, _memory_budget, _probationary_status_line, @@ -98,6 +99,29 @@ def test_windows_gpu_memory_fallback_preserves_cpu_execution(monkeypatch): assert hw["ram_mb"] == 80 * 1024 +def test_nvidia_smi_without_torch_cuda_keeps_cpu_execution(monkeypatch): + """nvidia-smi proves GPU inventory, not that this Python can execute CUDA.""" + import meshnet_node.hardware as hardware_mod + + class FakeResult: + returncode = 0 + stdout = "NVIDIA GeForce RTX 4060 Laptop GPU, 8188\n" + + fake_torch = types.SimpleNamespace(cuda=types.SimpleNamespace(is_available=lambda: False)) + + monkeypatch.setattr(hardware_mod, "_detect_ram_mb", lambda: 80 * 1024) + monkeypatch.setattr(hardware_mod.subprocess, "run", lambda *a, **kw: FakeResult()) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + hw = hardware_mod.detect_hardware() + + assert hw["device"] == "cpu" + assert hw["cuda_available"] is False + assert hw["gpu_name"] == "NVIDIA GeForce RTX 4060 Laptop GPU" + assert hw["vram_mb"] == 8188 + assert hw["ram_mb"] == 80 * 1024 + + def test_memory_budget_uses_ram_for_cpu_and_shared_memory_for_cuda(): assert _memory_budget("cpu", vram_mb=8192, ram_mb=80 * 1024, shared_vram_mb=40 * 1024) == ( 80 * 1024, @@ -109,6 +133,12 @@ def test_memory_budget_uses_ram_for_cpu_and_shared_memory_for_cuda(): ) +def test_hardware_label_marks_inventory_only_gpu_as_cuda_inactive(): + assert _hardware_label("cpu", "NVIDIA GeForce RTX 4060 Laptop GPU") == "CPU (CUDA inactive)" + assert _hardware_label("cpu", None) == "CPU" + assert _hardware_label("cuda", "NVIDIA GeForce RTX 4060 Laptop GPU") == "CUDA" + + def test_benchmark_throughput_cpu_returns_positive(): """CPU benchmark returns a positive float greater than the 1.0 error fallback.""" result = benchmark_throughput("cpu")