node stats and benchmark, dynamic realocation working

This commit is contained in:
Dobromir Popov
2026-07-01 10:02:17 +03:00
parent b6272db93d
commit 278be49539
6 changed files with 279 additions and 14 deletions

View File

@@ -94,6 +94,19 @@ def _make_bar(pct: float, width: int = 10) -> str:
return "" * filled + "" * (width - filled) return "" * filled + "" * (width - filled)
def _node_stats(node) -> dict:
total = int(getattr(node, "total_requests", getattr(node, "chat_completion_count", 0)) or 0)
failed = int(getattr(node, "failed_requests", 0) or 0)
queue_depth = int(getattr(node, "queue_depth", 0) or 0)
success_rate = ((total - failed) / total * 100.0) if total else 100.0
return {
"total_requests": total,
"failed_requests": failed,
"queue_depth": queue_depth,
"success_rate": success_rate,
}
def run_dashboard(node, config: dict, start_time: float) -> None: def run_dashboard(node, config: dict, start_time: float) -> None:
"""Start the live dashboard. Blocks until Ctrl-C. Returns cleanly.""" """Start the live dashboard. Blocks until Ctrl-C. Returns cleanly."""
if not is_interactive_tty(): if not is_interactive_tty():
@@ -117,7 +130,8 @@ def _build_rich_renderable(
from rich.text import Text # type: ignore[import] from rich.text import Text # type: ignore[import]
uptime = time.monotonic() - start_time uptime = time.monotonic() - start_time
req_count = getattr(node, "chat_completion_count", 0) stats = _node_stats(node)
req_count = stats["total_requests"]
# Tokens/sec EMA (approximate: 20 tokens per request heuristic when no real counter) # Tokens/sec EMA (approximate: 20 tokens per request heuristic when no real counter)
delta_req = req_count - prev_req[0] delta_req = req_count - prev_req[0]
@@ -163,6 +177,7 @@ def _build_rich_renderable(
stats_lines = [ stats_lines = [
f"Tokens/sec {tps_bar} {tps:.1f} t/s (EMA)", f"Tokens/sec {tps_bar} {tps:.1f} t/s (EMA)",
f"Requests {req_count:,} served", f"Requests {req_count:,} served",
f"Success {stats['success_rate']:.1f}% failed {stats['failed_requests']:,} queue {stats['queue_depth']}",
f"Peers 0 connected (gossip: US-017)", f"Peers 0 connected (gossip: US-017)",
f"TAI earned 0.00 TAI (payments: US-006)", f"TAI earned 0.00 TAI (payments: US-006)",
f"Uptime {_format_uptime(uptime)}", f"Uptime {_format_uptime(uptime)}",
@@ -205,14 +220,17 @@ def _run_plain_loop(node, config: dict, start_time: float) -> None:
try: try:
while True: while True:
uptime = time.monotonic() - start_time uptime = time.monotonic() - start_time
req = getattr(node, "chat_completion_count", 0) stats = _node_stats(node)
req = stats["total_requests"]
gpu_stats = _gpu_stats() gpu_stats = _gpu_stats()
vram_str = "" vram_str = ""
if gpu_stats: if gpu_stats:
g = gpu_stats[0] g = gpu_stats[0]
vram_str = f" VRAM{g['used_gb']:.1f}GB" vram_str = f" VRAM{g['used_gb']:.1f}GB"
print( print(
f"[{model_name} req{req}{vram_str} up{_format_uptime(uptime)}]", f"[{model_name} req{req} ok{stats['success_rate']:.1f}% "
f"fail{stats['failed_requests']} q{stats['queue_depth']}"
f"{vram_str} up{_format_uptime(uptime)}]",
flush=True, flush=True,
) )
time.sleep(2) time.sleep(2)

View File

@@ -1,10 +1,23 @@
"""GPU hardware detection with graceful CPU fallback.""" """GPU hardware detection with graceful CPU fallback."""
import os
import subprocess import subprocess
import time
def _detect_ram_mb() -> int:
"""Return host physical RAM in MB, or 0 if unavailable."""
try:
pages = os.sysconf("SC_PHYS_PAGES")
page_size = os.sysconf("SC_PAGE_SIZE")
return int((pages * page_size) // (1024 * 1024))
except (AttributeError, OSError, ValueError):
return 0
def detect_hardware() -> dict: def detect_hardware() -> dict:
"""Detect GPU model and available VRAM. Returns hardware profile dict.""" """Detect GPU model and available VRAM. Returns hardware profile dict."""
ram_mb = _detect_ram_mb()
try: try:
import torch # type: ignore[import] import torch # type: ignore[import]
if torch.cuda.is_available(): if torch.cuda.is_available():
@@ -12,7 +25,7 @@ def detect_hardware() -> dict:
name = torch.cuda.get_device_name(idx) name = torch.cuda.get_device_name(idx)
props = torch.cuda.get_device_properties(idx) props = torch.cuda.get_device_properties(idx)
vram_mb = props.total_memory // (1024 * 1024) vram_mb = props.total_memory // (1024 * 1024)
return {"device": "cuda", "gpu_name": name, "vram_mb": vram_mb} return {"device": "cuda", "gpu_name": name, "vram_mb": vram_mb, "ram_mb": ram_mb}
except ImportError: except ImportError:
pass pass
@@ -26,8 +39,54 @@ def detect_hardware() -> dict:
parts = line.split(",", 1) parts = line.split(",", 1)
gpu_name = parts[0].strip() gpu_name = parts[0].strip()
vram_mb = int(parts[1].strip()) if len(parts) > 1 else 0 vram_mb = int(parts[1].strip()) if len(parts) > 1 else 0
return {"device": "cuda", "gpu_name": gpu_name, "vram_mb": vram_mb} return {"device": "cuda", "gpu_name": gpu_name, "vram_mb": vram_mb, "ram_mb": ram_mb}
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, IndexError): except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, IndexError):
pass pass
return {"device": "cpu", "gpu_name": None, "vram_mb": 0} return {"device": "cpu", "gpu_name": None, "vram_mb": 0, "ram_mb": ram_mb}
def benchmark_throughput(device_str: str = "cpu") -> float:
"""
Estimate compute throughput via a synthetic transformer GEMM benchmark.
Runs hidden_size × (hidden_size*4) matmul — the dominant op in FFN layers —
and returns iterations/second as a relative speed index. Higher = faster.
The value is used as benchmark_tokens_per_sec in tracker registration for
routing tiebreaks; it is not an absolute token rate.
Falls back to 1.0 if torch is unavailable.
"""
try:
import torch # type: ignore[import]
device = torch.device(device_str)
# bfloat16 on CUDA matches real inference dtype; float32 on CPU avoids
# precision-downcast surprises on older hardware without bfloat16 support.
dtype = torch.bfloat16 if device_str == "cuda" else torch.float32
# hidden_size=2048 is representative of a mid-sized model; large enough
# that BLAS finds an efficient kernel on both GPU and CPU.
hidden_size = 2048
a = torch.randn(1, hidden_size, dtype=dtype, device=device)
b = torch.randn(hidden_size, hidden_size * 4, dtype=dtype, device=device)
def _sync() -> None:
if device_str == "cuda":
torch.cuda.synchronize()
# Warmup: prime caches and JIT compilation.
for _ in range(10):
torch.matmul(a, b)
_sync()
n_iters = 50
t0 = time.perf_counter()
for _ in range(n_iters):
torch.matmul(a, b)
_sync()
elapsed = time.perf_counter() - t0
return round(n_iters / max(elapsed, 1e-9), 2)
except Exception:
return 1.0

View File

@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Any from typing import Any
from .downloader import compute_shard_checksum, download_shard from .downloader import compute_shard_checksum, download_shard
from .hardware import detect_hardware from .hardware import detect_hardware, benchmark_throughput
from .relay_bridge import RelayHttpBridge, peer_id_from_wallet from .relay_bridge import RelayHttpBridge, peer_id_from_wallet
from .server import StubNodeServer from .server import StubNodeServer
from .torch_server import TorchNodeServer from .torch_server import TorchNodeServer
@@ -359,12 +359,18 @@ def run_startup(
print(f" GPU: {gpu_name} ({vram_mb / 1024:.1f} GB VRAM, {ram_mb / 1024:.1f} GB RAM)", flush=True) print(f" GPU: {gpu_name} ({vram_mb / 1024:.1f} GB VRAM, {ram_mb / 1024:.1f} GB RAM)", flush=True)
memory_budget_mb, memory_budget_source = _memory_budget(vram_mb, ram_mb) memory_budget_mb, memory_budget_source = _memory_budget(vram_mb, ram_mb)
print(f" Memory budget: {memory_budget_mb} MB {memory_budget_source}", flush=True) print(f" Memory budget: {memory_budget_mb / 1024:.1f} GB {memory_budget_source}", flush=True)
print("Benchmarking compute...", flush=True)
bench_tps = benchmark_throughput(device)
device_label = "GPU" if device == "cuda" else "CPU"
print(f" {device_label} throughput index: {bench_tps:,.0f}", flush=True)
registration_capabilities = { registration_capabilities = {
"vram_bytes": max(0, int(vram_mb)) * 1024 * 1024, "vram_bytes": max(0, int(vram_mb)) * 1024 * 1024,
"ram_bytes": max(0, int(ram_mb)) * 1024 * 1024, "ram_bytes": max(0, int(ram_mb)) * 1024 * 1024,
"max_loaded_shards": max_loaded_shards, "max_loaded_shards": max_loaded_shards,
"benchmark_tokens_per_sec": bench_tps,
} }
# 2. Wallet # 2. Wallet
print("Loading wallet...", flush=True) print("Loading wallet...", flush=True)
@@ -479,6 +485,7 @@ def run_startup(
f" Endpoint: {endpoint}\n" f" Endpoint: {endpoint}\n"
f" Node ID: {tracker_node_id or 'unregistered'}\n" f" Node ID: {tracker_node_id or 'unregistered'}\n"
f" Hardware: {device.upper()}\n" f" Hardware: {device.upper()}\n"
f" Benchmark: {bench_tps:,.0f} (throughput index)\n"
f"{'=' * 32}", f"{'=' * 32}",
flush=True, flush=True,
) )
@@ -571,6 +578,7 @@ def run_startup(
f" Endpoint: {endpoint}\n" f" Endpoint: {endpoint}\n"
f" Node ID: {tracker_node_id or 'unregistered'}\n" f" Node ID: {tracker_node_id or 'unregistered'}\n"
f" Hardware: {device.upper()}\n" f" Hardware: {device.upper()}\n"
f" Benchmark: {bench_tps:,.0f} (throughput index)\n"
f"{'=' * 32}", f"{'=' * 32}",
flush=True, flush=True,
) )
@@ -671,6 +679,7 @@ def run_startup(
f" Endpoint: {endpoint}\n" f" Endpoint: {endpoint}\n"
f" Node ID: {node_id}\n" f" Node ID: {node_id}\n"
f" Hardware: {hw_str}\n" f" Hardware: {hw_str}\n"
f" Benchmark: {bench_tps:,.0f} (throughput index)\n"
f"{'=' * 32}", f"{'=' * 32}",
flush=True, flush=True,
) )

View File

@@ -429,24 +429,38 @@ def _node_quantization(node: _NodeEntry, preset: dict) -> str:
return next(iter(bytes_per_layer)) return next(iter(bytes_per_layer))
def _node_memory_budget_bytes(node: _NodeEntry) -> tuple[int, str]:
"""Return the memory pool used for shard-capacity planning."""
if node.vram_bytes > 0:
return node.vram_bytes, "vram"
if node.ram_bytes > 0:
return node.ram_bytes, "ram"
return DEFAULT_RAM_BYTES, "ram-default"
def _node_layer_capacity(node: _NodeEntry, preset: dict) -> int: def _node_layer_capacity(node: _NodeEntry, preset: dict) -> int:
bytes_per_layer = _preset_bytes_per_layer(preset) bytes_per_layer = _preset_bytes_per_layer(preset)
quantization = _node_quantization(node, preset) quantization = _node_quantization(node, preset)
layer_bytes = bytes_per_layer[quantization] layer_bytes = bytes_per_layer[quantization]
if layer_bytes <= 0: if layer_bytes <= 0:
return 0 return 0
return int((node.vram_bytes * 0.8) // layer_bytes) memory_budget_bytes, _ = _node_memory_budget_bytes(node)
return int((memory_budget_bytes * 0.8) // layer_bytes)
def _node_capacity_summary(node: _NodeEntry, preset: dict | None = None) -> dict: def _node_capacity_summary(node: _NodeEntry, preset: dict | None = None) -> dict:
"""Operator-facing capacity fields for inspection endpoints.""" """Operator-facing capacity fields for inspection endpoints."""
memory_budget_bytes, memory_budget_source = _node_memory_budget_bytes(node)
summary = { summary = {
"vram_bytes": node.vram_bytes, "vram_bytes": node.vram_bytes,
"ram_bytes": node.ram_bytes, "ram_bytes": node.ram_bytes,
"memory_budget_bytes": memory_budget_bytes,
"memory_budget_source": memory_budget_source,
"max_loaded_shards": node.max_loaded_shards, "max_loaded_shards": node.max_loaded_shards,
"quantizations": list(node.quantizations), "quantizations": list(node.quantizations),
"quantization": node.quantization, "quantization": node.quantization,
"benchmark_tokens_per_sec": node.benchmark_tokens_per_sec, "benchmark_tokens_per_sec": node.benchmark_tokens_per_sec,
"effective_throughput": round(_effective_throughput(node), 4),
} }
if preset is not None: if preset is not None:
summary["max_assignable_layers"] = _node_layer_capacity(node, preset) summary["max_assignable_layers"] = _node_layer_capacity(node, preset)
@@ -1154,6 +1168,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"tracker_mode": node.tracker_mode, "tracker_mode": node.tracker_mode,
"last_heartbeat": node.last_heartbeat, "last_heartbeat": node.last_heartbeat,
"capacity": capacity_for(node), "capacity": capacity_for(node),
"stats": _node_health(node, server.heartbeat_timeout),
} }
for node in nodes for node in nodes
], ],
@@ -1567,8 +1582,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
shard_info = f"layers {shard_start}-{shard_end}" if shard_start is not None else "unsharded" shard_info = f"layers {shard_start}-{shard_end}" if shard_start is not None else "unsharded"
repo_info = f" [{hf_repo}]" if hf_repo else "" repo_info = f" [{hf_repo}]" if hf_repo else ""
budget_bytes, budget_source = _node_memory_budget_bytes(entry)
budget_gb = budget_bytes / (1024 ** 3)
print( print(
f"[tracker] node registered: {node_id} {endpoint} {model}{repo_info} {shard_info}", f"[tracker] node registered: {node_id} {endpoint} {model}{repo_info} {shard_info} "
f"capacity={budget_gb:.1f}GB {budget_source} slots={max_loaded_shards}",
flush=True, flush=True,
) )
@@ -1707,6 +1725,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
model — model preset name (default: first preset) model — model preset name (default: first preset)
device — "cuda" | "cpu" device — "cuda" | "cpu"
vram_mb — integer VRAM in MB (0 for CPU) vram_mb — integer VRAM in MB (0 for CPU)
ram_mb — integer system RAM in MB, used when vram_mb=0
The greedy strategy: find the first gap in current layer coverage The greedy strategy: find the first gap in current layer coverage
and assign it. If no gap exists, assign the full model range so the and assign it. If no gap exists, assign the full model range so the
@@ -1745,8 +1764,16 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
vram_mb = int(params.get("vram_mb", ["0"])[0]) vram_mb = int(params.get("vram_mb", ["0"])[0])
except ValueError: except ValueError:
vram_mb = 0 vram_mb = 0
try:
ram_mb = int(params.get("ram_mb", ["0"])[0])
except ValueError:
ram_mb = 0
max_layers = required_end - required_start + 1 max_layers = required_end - required_start + 1
if device != "cuda" or vram_mb < 8192: memory_mb = vram_mb if vram_mb > 0 else ram_mb
if memory_mb > 0:
layer_bytes = _preset_bytes_per_layer(preset).get("bfloat16", 30 * 1024 * 1024)
max_layers = min(max_layers, max(1, int(((memory_mb * 1024 * 1024) * 0.8) // layer_bytes)))
elif device != "cuda" or vram_mb < 8192:
max_layers = min(max_layers, 16) max_layers = min(max_layers, 16)
# Collect covered intervals sorted by start layer. # Collect covered intervals sorted by start layer.
@@ -1798,6 +1825,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
Query params: Query params:
vram_mb — integer VRAM in MB (0 = CPU-only node) vram_mb — integer VRAM in MB (0 = CPU-only node)
ram_mb — integer system RAM in MB, used when vram_mb=0
device — "cuda" | "cpu" device — "cuda" | "cpu"
hf_repo — optional; if set, restrict search to this repo only hf_repo — optional; if set, restrict search to this repo only
@@ -1811,6 +1839,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
vram_mb = int(params.get("vram_mb", ["0"])[0]) vram_mb = int(params.get("vram_mb", ["0"])[0])
except ValueError: except ValueError:
vram_mb = 0 vram_mb = 0
try:
ram_mb = int(params.get("ram_mb", ["0"])[0])
except ValueError:
ram_mb = 0
device = params.get("device", ["cpu"])[0] device = params.get("device", ["cpu"])[0]
filter_repo = params.get("hf_repo", [None])[0] # optional repo filter filter_repo = params.get("hf_repo", [None])[0] # optional repo filter
@@ -1894,9 +1926,15 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
best_gap_start = 0 best_gap_start = 0
best_num_layers = repo_layers[best_repo] best_num_layers = repo_layers[best_repo]
# Capacity: CPU nodes get at most half the layers; CUDA nodes based on VRAM. # Capacity: use the same 80%-of-memory rule as registered node planning.
total_l = best_num_layers total_l = best_num_layers
if device == "cuda" and vram_mb >= 8192: memory_mb = vram_mb if vram_mb > 0 else ram_mb
if memory_mb > 0:
max_layers = min(
total_l,
max(1, int(((memory_mb * 1024 * 1024) * 0.8) // (30 * 1024 * 1024))),
)
elif device == "cuda" and vram_mb >= 8192:
max_layers = total_l max_layers = total_l
else: else:
max_layers = max(1, total_l // 2) max_layers = max(1, total_l // 2)

19
tests/conftest.py Normal file
View File

@@ -0,0 +1,19 @@
"""Shared pytest fixtures for the meshnet test suite."""
import pytest
@pytest.fixture(autouse=True)
def _stub_benchmark_throughput(monkeypatch):
"""Replace the GEMM benchmark with a fixed value in all tests.
The benchmark runs 60 matmuls (warmup + measure) which adds ~100ms per test
on CPU. Tests verify registration flow, not hardware speed — stub it out.
Tests that specifically exercise benchmark_throughput import it directly from
meshnet_node.hardware and are not affected by this patch.
"""
try:
import meshnet_node.startup as startup_mod
monkeypatch.setattr(startup_mod, "benchmark_throughput", lambda _device: 999.0)
except ImportError:
pass

View File

@@ -10,7 +10,7 @@ from pathlib import Path
import pytest import pytest
from meshnet_node.downloader import download_shard, write_shard_archive from meshnet_node.downloader import download_shard, write_shard_archive
from meshnet_node.hardware import detect_hardware from meshnet_node.hardware import detect_hardware, benchmark_throughput
from meshnet_node.startup import ( from meshnet_node.startup import (
_infer_relay_url_from_tracker, _infer_relay_url_from_tracker,
_probationary_status_line, _probationary_status_line,
@@ -31,6 +31,8 @@ def test_detect_hardware_returns_valid_profile():
hw = detect_hardware() hw = detect_hardware()
assert hw["device"] in {"cuda", "cpu"} assert hw["device"] in {"cuda", "cpu"}
assert isinstance(hw.get("vram_mb"), int) assert isinstance(hw.get("vram_mb"), int)
assert isinstance(hw.get("ram_mb"), int)
assert hw["ram_mb"] > 0
if hw["device"] == "cpu": if hw["device"] == "cpu":
assert hw["gpu_name"] is None assert hw["gpu_name"] is None
assert hw["vram_mb"] == 0 assert hw["vram_mb"] == 0
@@ -39,6 +41,62 @@ def test_detect_hardware_returns_valid_profile():
assert hw["vram_mb"] > 0 assert hw["vram_mb"] > 0
def test_benchmark_throughput_cpu_returns_positive():
"""CPU benchmark returns a positive float greater than the 1.0 error fallback."""
result = benchmark_throughput("cpu")
assert isinstance(result, float)
assert result > 1.0, f"expected benchmark > 1.0, got {result}"
def test_benchmark_throughput_fallback_on_bad_device():
"""benchmark_throughput returns 1.0 (not raises) when device is invalid."""
result = benchmark_throughput("invalid_device_xyz")
assert result == 1.0
def test_benchmark_throughput_is_registered_in_payload(monkeypatch, tmp_path):
"""benchmark_tokens_per_sec from the benchmark is included in the tracker registration."""
import meshnet_node.startup as startup_mod
captured: dict = {}
class FakeNode:
backend = None
tracker_node_id = None
def start(self):
return 7099
def stop(self):
pass
def apply_tracker_directives(self, directives):
return None
monkeypatch.setattr(startup_mod, "detect_hardware",
lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0, "ram_mb": 16384})
monkeypatch.setattr(startup_mod, "benchmark_throughput", lambda _device: 42.5)
monkeypatch.setattr(startup_mod, "TorchNodeServer", lambda **_kw: FakeNode())
monkeypatch.setattr(startup_mod, "_detect_num_layers", lambda _model_id: 24)
monkeypatch.setattr(startup_mod, "RelayHttpBridge", None)
monkeypatch.setattr(startup_mod, "_get_json",
lambda _url, timeout=10.0: {"relay_url": None, "nodes": []})
monkeypatch.setattr(startup_mod, "_post_json",
lambda _url, payload, timeout=10.0: (captured.update(payload) or {"node_id": "x"}))
monkeypatch.setattr(startup_mod, "_start_heartbeat", lambda *a, **kw: None)
node = run_startup(
tracker_url="http://localhost:8080",
model_id="Qwen/Qwen2.5-0.5B-Instruct",
shard_start=0,
shard_end=23,
wallet_path=tmp_path / "wallet.json",
)
node.stop()
assert captured.get("benchmark_tokens_per_sec") == 42.5
def test_wallet_generates_new_keypair(tmp_path): def test_wallet_generates_new_keypair(tmp_path):
"""A new wallet is created when none exists, saved to disk.""" """A new wallet is created when none exists, saved to disk."""
wallet_file = tmp_path / "wallet.json" wallet_file = tmp_path / "wallet.json"
@@ -490,6 +548,70 @@ def test_real_model_startup_summary_shows_total_layers(tmp_path, monkeypatch, ca
assert "Node ID: node-test-123" in output assert "Node ID: node-test-123" in output
def test_real_model_startup_autodetects_cpu_memory_budget_and_logs_shard_budget(
tmp_path,
monkeypatch,
capsys,
):
"""Without --memory, startup reports RAM-backed capacity to the tracker and operator."""
import meshnet_node.startup as startup_mod
captured_registration = {}
class FakeBackend:
total_layers = 24
class FakeTorchNodeServer:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.backend = FakeBackend()
self.port = None
self.total_requests = 0
self.failed_requests = 0
self.queue_depth = 0
def start(self):
self.port = 8001
return self.port
def stop(self):
pass
monkeypatch.setattr(
startup_mod,
"detect_hardware",
lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0, "ram_mb": 16384},
)
monkeypatch.setattr(startup_mod, "TorchNodeServer", FakeTorchNodeServer)
monkeypatch.setattr(
startup_mod,
"_post_json",
lambda _url, _payload, timeout=10.0: (
captured_registration.update(_payload) or {"node_id": "node-auto-mem"}
),
)
node = run_startup(
tracker_url="http://127.0.0.1:8080",
model_id="Qwen/Qwen2.5-0.5B-Instruct",
shard_start=0,
shard_end=23,
wallet_path=tmp_path / "wallet.json",
)
try:
pass
finally:
node.stop()
assert captured_registration["vram_bytes"] == 0
assert captured_registration["ram_bytes"] == 16384 * 1024 * 1024
assert captured_registration["max_loaded_shards"] == 1
output = capsys.readouterr().out
assert "Memory budget: 16.0 GB RAM" in output
assert "Shard budget: up to 24/24 layers at bfloat16" in output
assert "GB remaining after full load" in output
assert "Node ID: node-auto-mem" in output
def test_public_tracker_model_node_registers_relay_metadata_from_tracker_url_only( def test_public_tracker_model_node_registers_relay_metadata_from_tracker_url_only(
tmp_path, tmp_path,
monkeypatch, monkeypatch,