From 9ec4ca9ce1d8b0ae1104d337ebee9e2a561eaced Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Thu, 9 Jul 2026 08:19:15 +0300 Subject: [PATCH] -cpu flag --- MAIN_FEATURES.md | 12 +++--- packages/node/meshnet_node/cli.py | 8 ++++ packages/node/meshnet_node/hardware.py | 12 ++++++ packages/node/meshnet_node/model_backend.py | 11 +++++- packages/node/meshnet_node/startup.py | 20 +++++++++- packages/node/meshnet_node/torch_server.py | 7 +++- tests/test_mining_cli.py | 41 +++++++++++++++++++++ tests/test_node_startup.py | 21 +++++++++++ 8 files changed, 121 insertions(+), 11 deletions(-) diff --git a/MAIN_FEATURES.md b/MAIN_FEATURES.md index c53ad2c..c3d64af 100644 --- a/MAIN_FEATURES.md +++ b/MAIN_FEATURES.md @@ -60,13 +60,13 @@ Windows equivalent: `install.ps1` with the same flags. | Flag | Meaning | |------|---------| | *(none)* | Auto-detect hardware, print detected profile, proceed with best match (interactive confirm unless `--yes`) | -| `--cpu` | Skip GPU wheels entirely; install CPU PyTorch and register as a CPU node — even if a discrete GPU is present | -| `--gpu` | Install and verify a GPU runtime; **fail hard** if GPU execution cannot be confirmed after install | +| `--cpu` | Installer: CPU PyTorch wheel. **`meshnet-node --cpu` (implemented):** force CPU inference and CPU shard assignment even if a GPU is present | +| `--gpu` | Install and verify a GPU runtime; **fail hard** if GPU execution cannot be confirmed after install (installer only — not implemented on `meshnet-node` yet) | | `--yes` | Skip interactive confirm; for headless installs and future web GUI orchestration | -These flags set **intent** for the install environment. They do not replace runtime -hardware detection in `meshnet_node.hardware.detect_hardware()` — that profile still -feeds tracker registration and routing. +Installer flags set install-time intent. At runtime, `meshnet-node` auto-uses GPU when +CUDA works; pass `--cpu` to ignore it. Hardware metadata (GPU name/VRAM) is still +detected for diagnostics. ### v1 install pipeline @@ -243,7 +243,7 @@ Not in Ralph yet. See | Hardware-aware + learned routing | **Working** (telemetry cleanup open) | US-027+, AH-024 | | Zero port-forwarding (relay) | **Working** (streamed relay chat open) | US-017, US-029, US-036 | | OpenAI-compatible API | **Working** | US-005 | -| Mining-style node CLI + wizard | **Working** (installer not built) | US-016 | +| Mining-style node CLI + wizard | **Working** (`--cpu` forces CPU mode) | US-016 | | Billing + devnet USDT | **Working** | US-031…033, alpha-hardening | | Fraud / TOPLOC / reputation | **Engineering done** (calibration ops pending) | AH-006…010, AH-021 | | Sharded per-node KV cache | **Implemented — GPU verify pending** | AH-025, ADR-0022 | diff --git a/packages/node/meshnet_node/cli.py b/packages/node/meshnet_node/cli.py index af2b7d9..a7b4e40 100644 --- a/packages/node/meshnet_node/cli.py +++ b/packages/node/meshnet_node/cli.py @@ -69,6 +69,7 @@ def _run_node(cfg: dict) -> None: torch_threads=cfg.get("torch_threads"), torch_interop_threads=cfg.get("torch_interop_threads"), node_name=cfg.get("node_name"), + force_cpu=bool(cfg.get("force_cpu", False)), ) except Exception as exc: print(f"\nERROR: {exc}", file=sys.stderr, flush=True) @@ -174,6 +175,8 @@ def _cmd_default(args) -> int: overrides["torch_threads"] = args.torch_threads if getattr(args, "torch_interop_threads", None) is not None: overrides["torch_interop_threads"] = args.torch_interop_threads + if getattr(args, "cpu", False): + overrides["force_cpu"] = True if overrides: cfg = merge_cli_overrides(cfg, **overrides) @@ -276,6 +279,7 @@ def _cmd_start(args) -> int: torch_threads=getattr(args, "torch_threads", None), torch_interop_threads=getattr(args, "torch_interop_threads", None), node_name=cfg.get("node_name"), + force_cpu=getattr(args, "cpu", False), ) except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr, flush=True) @@ -332,6 +336,8 @@ def main() -> None: help="Set PyTorch intra-op CPU worker threads") parser.add_argument("--torch-interop-threads", type=int, metavar="N", help="Set PyTorch inter-op CPU worker threads") + parser.add_argument("--cpu", action="store_true", + help="Force CPU inference even when a GPU is available") parser.add_argument("--debug", action="store_true", help="Enable verbose node debug logging") parser.add_argument("--no-tui", action="store_true", help="Plain-text output (no rich dashboard)") parser.add_argument("--compact", action="store_true", help="Single-line status output") @@ -372,6 +378,8 @@ def main() -> None: help="Set PyTorch intra-op CPU worker threads") start_cmd.add_argument("--torch-interop-threads", type=int, metavar="N", help="Set PyTorch inter-op CPU worker threads") + start_cmd.add_argument("--cpu", action="store_true", + help="Force CPU inference even when a GPU is available") start_cmd.add_argument("--debug", action="store_true", help="Enable verbose node debug logging") start_cmd.add_argument("--tracker-source-disabled", action="store_true", help="Skip tracker/peer model-file sources and download from HuggingFace directly") diff --git a/packages/node/meshnet_node/hardware.py b/packages/node/meshnet_node/hardware.py index 6fdf9c6..7c97a06 100644 --- a/packages/node/meshnet_node/hardware.py +++ b/packages/node/meshnet_node/hardware.py @@ -171,6 +171,18 @@ def _gpu_inventory_profile(gpu: dict | None, ram_mb: int) -> dict | None: return profile +def with_forced_cpu(hw: dict) -> dict: + """Return a hardware profile forced to CPU execution. + + Keeps detected GPU metadata for diagnostics and tracker registration context, + but clears CUDA availability so startup and the model backend stay on CPU. + """ + forced = dict(hw) + forced["device"] = "cpu" + forced["cuda_available"] = False + return forced + + def detect_hardware() -> dict: """Detect GPU model and available VRAM. Returns hardware profile dict.""" ram_mb = _detect_ram_mb() diff --git a/packages/node/meshnet_node/model_backend.py b/packages/node/meshnet_node/model_backend.py index 063c309..c0e8e2f 100644 --- a/packages/node/meshnet_node/model_backend.py +++ b/packages/node/meshnet_node/model_backend.py @@ -212,6 +212,7 @@ class TorchModelShard: shard_end: int, quantization: Quantization = "auto", cache_dir: Path | None = None, + force_cpu: bool = False, ) -> None: if shard_start < 0 or shard_end < 0 or shard_start > shard_end: raise ValueError("shard_start must be <= shard_end and non-negative") @@ -229,7 +230,10 @@ class TorchModelShard: ) from exc self.torch = torch - self.device = torch.device("cuda" if _torch_cuda_is_executable(torch) else "cpu") + if force_cpu: + self.device = torch.device("cpu") + else: + self.device = torch.device("cuda" if _torch_cuda_is_executable(torch) else "cpu") load_source = str(cache_dir) if cache_dir is not None and (cache_dir / "config.json").exists() else model_id quant_config, dtype, uses_quantized_weights = _model_load_plan( AutoConfig, @@ -682,8 +686,11 @@ def load_torch_shard( shard_end: int, quantization: Quantization = "auto", cache_dir: Path | None = None, + force_cpu: bool = False, ) -> TorchModelShard: - return TorchModelShard(model_id, shard_start, shard_end, quantization, cache_dir) + return TorchModelShard( + model_id, shard_start, shard_end, quantization, cache_dir, force_cpu=force_cpu + ) def _total_layers_for_local_snapshot(auto_config: Any, load_source: str) -> int | None: diff --git a/packages/node/meshnet_node/startup.py b/packages/node/meshnet_node/startup.py index 3b62b1b..fb02857 100644 --- a/packages/node/meshnet_node/startup.py +++ b/packages/node/meshnet_node/startup.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any from .downloader import compute_shard_checksum, download_shard -from .hardware import detect_hardware, benchmark_throughput_checked +from .hardware import detect_hardware, benchmark_throughput_checked, with_forced_cpu from .model_catalog import model_metadata_for from .relay_bridge import RelayHttpBridge, peer_id_from_wallet from .server import StubNodeServer @@ -640,6 +640,7 @@ def run_startup( torch_threads: int | None = None, torch_interop_threads: int | None = None, node_name: str | None = None, + force_cpu: bool = False, ) -> StubNodeServer | TorchNodeServer: """Execute the full startup sequence and return a running node server. @@ -681,6 +682,8 @@ def run_startup( print("Detecting hardware...", flush=True) hw = detect_hardware() + if force_cpu: + hw = with_forced_cpu(hw) torch_thread_config = _configure_torch_threads(torch_threads, torch_interop_threads) if torch_thread_config: hw.update(torch_thread_config) @@ -697,6 +700,16 @@ def run_startup( vram_mb = vram_mb_override shared_vram_mb = 0 print(f" Memory budget overridden to {vram_mb / 1024:.1f} GB via --memory", flush=True) + elif force_cpu: + if gpu_name and vram_mb > 0: + print( + f" --cpu: ignoring {gpu_name} " + f"({vram_mb / 1024:.1f} GB VRAM); running in CPU mode " + f"({ram_mb / 1024:.1f} GB RAM)", + flush=True, + ) + else: + print(f" --cpu: running in CPU mode ({ram_mb / 1024:.1f} GB RAM)", flush=True) elif device == "cpu": gpu_suffix = "" if gpu_name and vram_mb > 0: @@ -718,7 +731,7 @@ def run_startup( print(f" Memory budget: {memory_budget_mb / 1024:.1f} GB {memory_budget_source}", flush=True) print("Benchmarking compute...", flush=True) - if device != "cuda" and gpu_name: + if device != "cuda" and gpu_name and not force_cpu: _cuda_score, cuda_ok, cuda_error = benchmark_throughput_checked("cuda") hw["cuda_benchmark_ok"] = cuda_ok if cuda_error: @@ -833,6 +846,7 @@ def run_startup( cache_dir=cache_dir, debug=debug, max_loaded_shards=max_loaded_shards, + force_cpu=force_cpu, ) _node_start_time = time.monotonic() actual_port = node.start() @@ -986,6 +1000,7 @@ def run_startup( cache_dir=cache_dir, debug=debug, max_loaded_shards=max_loaded_shards, + force_cpu=force_cpu, ) _node_start_time = time.monotonic() actual_port = node.start() @@ -1163,6 +1178,7 @@ def run_startup( cache_dir=shard_path, debug=debug, max_loaded_shards=max_loaded_shards, + force_cpu=force_cpu, ) actual_port = node.start() total_layers = getattr(getattr(node, "backend", None), "total_layers", None) or assigned_total_layers diff --git a/packages/node/meshnet_node/torch_server.py b/packages/node/meshnet_node/torch_server.py index f0ba1b4..03193da 100644 --- a/packages/node/meshnet_node/torch_server.py +++ b/packages/node/meshnet_node/torch_server.py @@ -1087,6 +1087,7 @@ class TorchNodeServer: cache_dir: Path | None = None, debug: bool = False, max_loaded_shards: int = 1, + force_cpu: bool = False, ) -> None: self._host = host self._requested_port = port @@ -1097,6 +1098,7 @@ class TorchNodeServer: shard_end, quantization, cache_dir, + force_cpu=force_cpu, ) self._backends: dict[str, TorchModelShard] = {self._backend.model_id: self._backend} # Auto-detect tracker mode: enabled when shard_start == 0 or explicitly set @@ -1245,12 +1247,15 @@ def _load_backend( shard_end: int, quantization: str, cache_dir: Path | None = None, + force_cpu: bool = False, ) -> TorchModelShard: from .model_backend import load_torch_shard quant = validate_quantization(quantization) try: - return load_torch_shard(model_id, shard_start, shard_end, quant, cache_dir) + return load_torch_shard( + model_id, shard_start, shard_end, quant, cache_dir, force_cpu=force_cpu + ) except MissingModelDependencyError: raise except InsufficientVRAMError as exc: diff --git a/tests/test_mining_cli.py b/tests/test_mining_cli.py index a27a098..fe700cf 100644 --- a/tests/test_mining_cli.py +++ b/tests/test_mining_cli.py @@ -610,3 +610,44 @@ def test_default_cli_passes_advertise_host(monkeypatch): assert captured["tracker_url"] == "http://192.168.0.179:8081" assert captured["advertise_host"] == "192.168.0.42" assert captured["debug"] is True + + +def test_default_cli_passes_force_cpu(monkeypatch): + """`meshnet-node --cpu` forwards force_cpu into run_startup.""" + from meshnet_node.cli import main + + captured = {} + + def fake_run_startup(*args, **kwargs): + captured.update(kwargs) + + class _FakeNode: + chat_completion_count = 0 + + def stop(self): + pass + + return _FakeNode() + + saved = { + "tracker_url": "http://localhost:8080", + "model_name": "stub-model", + "model_hf_repo": "", + "quantization": "auto", + "download_dir": "/tmp/models", + "wallet_path": "/tmp/wallet.json", + "port": 7000, + "host": "0.0.0.0", + } + + monkeypatch.setattr(sys, "argv", ["meshnet-node", "--cpu"]) + + with patch("meshnet_node.config.load_config", return_value=saved): + with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup): + with patch("meshnet_node.dashboard.run_dashboard", side_effect=KeyboardInterrupt): + try: + main() + except SystemExit as exc: + assert exc.code == 0 + + assert captured["force_cpu"] is True diff --git a/tests/test_node_startup.py b/tests/test_node_startup.py index d574391..cece94d 100644 --- a/tests/test_node_startup.py +++ b/tests/test_node_startup.py @@ -34,6 +34,27 @@ from meshnet_tracker.server import TrackerServer # --------------------------------------------------------------------------- +def test_with_forced_cpu_overrides_device_but_keeps_gpu_inventory(): + """--cpu should register and run on CPU while preserving detected GPU metadata.""" + import meshnet_node.hardware as hardware_mod + + hw = hardware_mod.with_forced_cpu( + { + "device": "cuda", + "gpu_name": "NVIDIA GeForce RTX 4060", + "vram_mb": 8192, + "dedicated_vram_mb": 8192, + "shared_vram_mb": 0, + "ram_mb": 32768, + "cuda_available": True, + } + ) + assert hw["device"] == "cpu" + assert hw["cuda_available"] is False + assert hw["gpu_name"] == "NVIDIA GeForce RTX 4060" + assert hw["vram_mb"] == 8192 + + def test_detect_hardware_returns_valid_profile(): """Hardware detection always returns a dict with required keys.""" hw = detect_hardware()