md rework. new code

This commit is contained in:
Dobromir Popov
2026-07-08 17:59:08 +02:00
parent 194fa1d926
commit e06969fcb5
5 changed files with 563 additions and 582 deletions

View File

@@ -42,7 +42,7 @@ Historical handoff note: `/mnt/c/Users/popov/Downloads/neuron-tai-alpha-handoff-
- Verification: downloader/startup targeted subset passes (`pytest tests/test_node_startup.py -k "download_shard or same_shard"`). Full `tests/test_node_startup.py` has 46 passed and 4 unrelated Windows chmod/path separator failures. - Verification: downloader/startup targeted subset passes (`pytest tests/test_node_startup.py -k "download_shard or same_shard"`). Full `tests/test_node_startup.py` has 46 passed and 4 unrelated Windows chmod/path separator failures.
- Live Windows confirmation: `meshnet-node start --tracker http://192.168.0.179:8080 --model Qwen3.6-35B-A3B` reuses `F:\_STORAGE\models\qwen3.6-35b-a3b`, prints `Cached at`, registers, and reaches ready as node `5gMLrmyB-26b1f8a4204a`. - Live Windows confirmation: `meshnet-node start --tracker http://192.168.0.179:8080 --model Qwen3.6-35B-A3B` reuses `F:\_STORAGE\models\qwen3.6-35b-a3b`, prints `Cached at`, registers, and reaches ready as node `5gMLrmyB-26b1f8a4204a`.
- Follow-up fix: preset-model startup now starts the heartbeat thread after registration; without this, the node appeared briefly on the dashboard and was purged on first inference/route after heartbeat expiry. Tracker dashboard now has a "Console output" panel backed by `/v1/console` for node register/expiry, routing failures, and proxy events. - Follow-up fix: preset-model startup now starts the heartbeat thread after registration; without this, the node appeared briefly on the dashboard and was purged on first inference/route after heartbeat expiry. Tracker dashboard now has a "Console output" panel backed by `/v1/console` for node register/expiry, routing failures, and proxy events.
- Qwen3.6-35B-A3B reserve-based split is expected: an 79 GB CPU node may be assigned layers 0-36, and a second node fills 37-39. Do not "fix" this by bypassing the 20% assignment reserve unless the shard-planning policy changes. - Qwen3.6-35B-A3B CPU runtime cap (2026-07-08): the old reserve-based split could assign an 79 GB CPU node layers 0-36, but real partial loading can exceed that budget and die without a Python traceback. Node startup now clips oversized CPU auto-assignments before loading, and tracker CPU assignment uses a stricter runtime headroom factor; do not revert this to the old 20% reserve-only policy.
- Route hardening: tracker chat proxy and `/v1/route` diagnostics now use alias-aware preset node matching for split Qwen3.6 routes; dashboard derives grouped inference history from proxy route/complete console events and shows observed TPS after completion. - Route hardening: tracker chat proxy and `/v1/route` diagnostics now use alias-aware preset node matching for split Qwen3.6 routes; dashboard derives grouped inference history from proxy route/complete console events and shows observed TPS after completion.
- Live proxy hardening: model lookup trims outer whitespace before alias matching (`qwen3.6-35b-a3b ` resolves), and tracker route logs/dashboard queue depth combine heartbeat queue with tracker-local proxy in-flight counts so Postman-style bursts no longer show every selected route as queue `0`. - Live proxy hardening: model lookup trims outer whitespace before alias matching (`qwen3.6-35b-a3b ` resolves), and tracker route logs/dashboard queue depth combine heartbeat queue with tracker-local proxy in-flight counts so Postman-style bursts no longer show every selected route as queue `0`.
- Split-shard streaming hardening: Qwen3.6-style distributed generation now emits SSE chunks token-by-token from the head node instead of buffering all generated text until completion. Tracker direct/relay stream proxy logs `proxy progress` with live tokens/TPS, dashboard Inference history shows currently processing requests with live TPS/tokens/queue, and relay stream completion no longer references an undefined `session_id`. - Split-shard streaming hardening: Qwen3.6-style distributed generation now emits SSE chunks token-by-token from the head node instead of buffering all generated text until completion. Tracker direct/relay stream proxy logs `proxy progress` with live tokens/TPS, dashboard Inference history shows currently processing requests with live TPS/tokens/queue, and relay stream completion no longer references an undefined `session_id`.

File diff suppressed because it is too large Load Diff

View File

@@ -197,12 +197,43 @@ def _max_assignable_layers(
memory_mb: int, memory_mb: int,
total_layers: int | None, total_layers: int | None,
bytes_per_layer: int | None = None, bytes_per_layer: int | None = None,
*,
safety_fraction: float = 0.8,
) -> int: ) -> int:
if total_layers is None or total_layers <= 0 or memory_mb <= 0: if total_layers is None or total_layers <= 0 or memory_mb <= 0:
return 0 return 0
budget_bytes = memory_mb * 1024 * 1024 budget_bytes = memory_mb * 1024 * 1024
layer_bytes = bytes_per_layer or _DEFAULT_BYTES_PER_LAYER layer_bytes = bytes_per_layer or _DEFAULT_BYTES_PER_LAYER
return min(total_layers, int((budget_bytes * 0.8) // layer_bytes)) return min(total_layers, int((budget_bytes * safety_fraction) // layer_bytes))
def _runtime_shard_safety_fraction(device: str) -> float:
"""CPU partial loads need room for model skeletons, tokenizer, and allocator peaks."""
return 0.55 if device != "cuda" else 0.8
def _cap_auto_assigned_shard(
shard_start: int,
shard_end: int,
total_layers: int | None,
memory_mb: int,
bytes_per_layer: int | None,
device: str,
) -> tuple[int, bool, int]:
if bytes_per_layer is None or total_layers is None:
return shard_end, False, 0
max_layers = _max_assignable_layers(
memory_mb,
total_layers,
bytes_per_layer=bytes_per_layer,
safety_fraction=_runtime_shard_safety_fraction(device),
)
if max_layers <= 0:
return shard_end, False, max_layers
assigned_layers = shard_end - shard_start + 1
if assigned_layers <= max_layers:
return shard_end, False, max_layers
return min(total_layers - 1, shard_start + max_layers - 1), True, max_layers
def _format_shard_label( def _format_shard_label(
@@ -226,13 +257,19 @@ def _shard_budget_line(
total_layers: int | None, total_layers: int | None,
quantization: str, quantization: str,
bytes_per_layer: int | None = None, bytes_per_layer: int | None = None,
safety_fraction: float = 0.8,
) -> str: ) -> str:
memory_gb = memory_mb / 1024 memory_gb = memory_mb / 1024
gb_str = f"{memory_gb:.1f} GB" gb_str = f"{memory_gb:.1f} GB"
budget_quantization = "bfloat16" if quantization == "auto" else quantization budget_quantization = "bfloat16" if quantization == "auto" else quantization
if total_layers is None or total_layers <= 0: if total_layers is None or total_layers <= 0:
return f"Memory budget: {gb_str} {memory_source}; shard budget: unknown model layer count" return f"Memory budget: {gb_str} {memory_source}; shard budget: unknown model layer count"
max_layers = _max_assignable_layers(memory_mb, total_layers, bytes_per_layer=bytes_per_layer) max_layers = _max_assignable_layers(
memory_mb,
total_layers,
bytes_per_layer=bytes_per_layer,
safety_fraction=safety_fraction,
)
# Remaining capacity after one full model load (rough estimate) # Remaining capacity after one full model load (rough estimate)
shard_bytes = max_layers * (bytes_per_layer or _DEFAULT_BYTES_PER_LAYER) shard_bytes = max_layers * (bytes_per_layer or _DEFAULT_BYTES_PER_LAYER)
remaining_gb = (memory_mb * 1024 * 1024 - shard_bytes) / (1024 ** 3) remaining_gb = (memory_mb * 1024 * 1024 - shard_bytes) / (1024 ** 3)
@@ -729,6 +766,25 @@ def run_startup(
if net_asgn.get("hf_repo") == model_id and net_asgn.get("gap_found"): if net_asgn.get("hf_repo") == model_id and net_asgn.get("gap_found"):
shard_start = net_asgn["shard_start"] shard_start = net_asgn["shard_start"]
shard_end = net_asgn["shard_end"] shard_end = net_asgn["shard_end"]
asgn_total_layers = int(net_asgn.get("num_layers") or detected)
asgn_bytes_per_layer = _assignment_bytes_per_layer(net_asgn, quantization)
capped_shard_end, was_capped, max_runtime_layers = _cap_auto_assigned_shard(
shard_start,
shard_end,
asgn_total_layers,
memory_budget_mb,
asgn_bytes_per_layer,
device,
)
if was_capped:
original_end = shard_end
shard_end = capped_shard_end
print(
f" WARNING: tracker assigned layers {shard_start}-{original_end}, "
f"but CPU-safe runtime budget fits {max_runtime_layers}/{asgn_total_layers} layers; "
f"loading layers {shard_start}-{shard_end} instead.",
flush=True,
)
full_sources = ( full_sources = (
[] if tracker_source_disabled [] if tracker_source_disabled
else _full_model_sources(net_asgn.get("model_sources", [])) else _full_model_sources(net_asgn.get("model_sources", []))
@@ -744,7 +800,7 @@ def run_startup(
) )
print( print(
f" Tracker found uncovered shard: " f" Tracker found uncovered shard: "
f"layers {shard_start}{shard_end} (of {detected})", f"layers {shard_start}-{shard_end} (of {detected})",
flush=True, flush=True,
) )
except Exception: except Exception:
@@ -859,18 +915,36 @@ def run_startup(
assigned_shard_start: int = net_assignment["shard_start"] assigned_shard_start: int = net_assignment["shard_start"]
assigned_shard_end: int = net_assignment["shard_end"] assigned_shard_end: int = net_assignment["shard_end"]
assigned_num_layers: int = net_assignment["num_layers"] assigned_num_layers: int = net_assignment["num_layers"]
assigned_bytes_per_layer = _assignment_bytes_per_layer(net_assignment, quantization)
capped_shard_end, was_capped, max_runtime_layers = _cap_auto_assigned_shard(
assigned_shard_start,
assigned_shard_end,
assigned_num_layers,
memory_budget_mb,
assigned_bytes_per_layer,
device,
)
if was_capped:
original_end = assigned_shard_end
assigned_shard_end = capped_shard_end
print(
f" WARNING: tracker assigned layers {assigned_shard_start}-{original_end}, "
f"but CPU-safe runtime budget fits {max_runtime_layers}/{assigned_num_layers} layers; "
f"loading layers {assigned_shard_start}-{assigned_shard_end} instead.",
flush=True,
)
assigned_model_sources: list[dict] = net_assignment.get("model_sources", []) assigned_model_sources: list[dict] = net_assignment.get("model_sources", [])
if _gap_found: if _gap_found:
print( print(
f" Assigned gap: {assigned_hf_repo} " f" Assigned gap: {assigned_hf_repo} "
f"layers {assigned_shard_start}{assigned_shard_end} " f"layers {assigned_shard_start}-{assigned_shard_end} "
f"(of {assigned_num_layers})", f"(of {assigned_num_layers})",
flush=True, flush=True,
) )
else: else:
print( print(
f" Assigned redundant copy: {assigned_hf_repo} " f" Assigned redundant copy: {assigned_hf_repo} "
f"layers {assigned_shard_start}{assigned_shard_end} " f"layers {assigned_shard_start}-{assigned_shard_end} "
f"(of {assigned_num_layers})", f"(of {assigned_num_layers})",
flush=True, flush=True,
) )
@@ -956,7 +1030,7 @@ def run_startup(
f" Wallet: {address}\n" f" Wallet: {address}\n"
f" Model ID: {assigned_hf_repo}\n" f" Model ID: {assigned_hf_repo}\n"
f" Shard: {shard_label}\n" f" Shard: {shard_label}\n"
f" {_shard_budget_line(memory_budget_mb, memory_budget_source, assigned_num_layers, quantization)}\n" f" {_shard_budget_line(memory_budget_mb, memory_budget_source, assigned_num_layers, quantization, bytes_per_layer=assigned_bytes_per_layer, safety_fraction=_runtime_shard_safety_fraction(device))}\n"
f" Quantization: {quantization}\n" f" Quantization: {quantization}\n"
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"
@@ -1022,6 +1096,7 @@ def run_startup(
memory_budget_mb, memory_budget_mb,
assigned_total_layers, assigned_total_layers,
assignment_bytes_per_layer, assignment_bytes_per_layer,
safety_fraction=_runtime_shard_safety_fraction(device),
) )
if pinned_layers > max_layers: if pinned_layers > max_layers:
raise ValueError( raise ValueError(
@@ -1115,7 +1190,7 @@ def run_startup(
f" Wallet: {address}\n" f" Wallet: {address}\n"
f" Model ID: {hf_repo}\n" f" Model ID: {hf_repo}\n"
f" Shard: {shard_label}\n" f" Shard: {shard_label}\n"
f" {_shard_budget_line(memory_budget_mb, memory_budget_source, total_layers, quantization, bytes_per_layer=assignment_bytes_per_layer)}\n" f" {_shard_budget_line(memory_budget_mb, memory_budget_source, total_layers, quantization, bytes_per_layer=assignment_bytes_per_layer, safety_fraction=_runtime_shard_safety_fraction(device))}\n"
f" Quantization: {quantization}\n" f" Quantization: {quantization}\n"
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"
@@ -1192,7 +1267,7 @@ def run_startup(
f"meshnet-node ready\n" f"meshnet-node ready\n"
f" Wallet: {address}\n" f" Wallet: {address}\n"
f" Shard: {shard_label}\n" f" Shard: {shard_label}\n"
f" {_shard_budget_line(memory_budget_mb, memory_budget_source, assigned_total_layers, quantization, bytes_per_layer=assignment_bytes_per_layer)}\n" f" {_shard_budget_line(memory_budget_mb, memory_budget_source, assigned_total_layers, quantization, bytes_per_layer=assignment_bytes_per_layer, safety_fraction=_runtime_shard_safety_fraction(device))}\n"
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"

View File

@@ -1275,16 +1275,23 @@ def _deployment_summary(nodes: list[_NodeEntry], preset: dict | None) -> dict:
} }
def _max_layers_for_memory(memory_mb: int, total_layers: int, preset: dict | None = None) -> int: def _max_layers_for_memory(
memory_mb: int,
total_layers: int,
preset: dict | None = None,
*,
device: str | None = None,
) -> int:
if total_layers <= 0: if total_layers <= 0:
return 0 return 0
if memory_mb <= 0: if memory_mb <= 0:
return max(1, total_layers // 2) return max(1, total_layers // 2)
memory_bytes = memory_mb * 1024 * 1024 memory_bytes = memory_mb * 1024 * 1024
bytes_per_layer = next(iter(_preset_bytes_per_layer(preset).values())) if preset is not None else 30 * 1024 * 1024 bytes_per_layer = next(iter(_preset_bytes_per_layer(preset).values())) if preset is not None else 30 * 1024 * 1024
safety_fraction = 0.55 if device == "cpu" else 0.8
return min( return min(
total_layers, total_layers,
max(1, int((memory_bytes * 0.8) // bytes_per_layer)), max(1, int((memory_bytes * safety_fraction) // bytes_per_layer)),
) )
@@ -5273,7 +5280,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
required_start, required_end = _preset_layer_bounds(preset) required_start, required_end = _preset_layer_bounds(preset)
total_l = required_end - required_start + 1 total_l = required_end - required_start + 1
memory_mb = vram_mb if (device == "cuda" and vram_mb > 0) else ram_mb memory_mb = vram_mb if (device == "cuda" and vram_mb > 0) else ram_mb
max_layers = _max_layers_for_memory(memory_mb, total_l, preset) max_layers = _max_layers_for_memory(memory_mb, total_l, preset, device=device)
shard_start = required_start shard_start = required_start
shard_end = min(required_end, shard_start + max_layers - 1) shard_end = min(required_end, shard_start + max_layers - 1)
self._send_json(200, { self._send_json(200, {
@@ -5373,11 +5380,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
memory_mb = vram_mb if (device == "cuda" and vram_mb > 0) else ram_mb memory_mb = vram_mb if (device == "cuda" and vram_mb > 0) else ram_mb
resolved_name, best_preset = _resolve_model_preset(server.model_presets, str(best_repo)) resolved_name, best_preset = _resolve_model_preset(server.model_presets, str(best_repo))
if memory_mb > 0: if memory_mb > 0:
max_layers = _max_layers_for_memory(memory_mb, total_l, best_preset) max_layers = _max_layers_for_memory(memory_mb, total_l, best_preset, device=device)
elif device == "cuda" and vram_mb >= 8192: elif device == "cuda" and vram_mb >= 8192:
max_layers = total_l max_layers = total_l
else: else:
max_layers = _max_layers_for_memory(memory_mb, total_l, best_preset) max_layers = _max_layers_for_memory(memory_mb, total_l, best_preset, device=device)
shard_start = best_gap_start shard_start = best_gap_start
shard_end = min(total_l - 1, shard_start + max_layers - 1) shard_end = min(total_l - 1, shard_start + max_layers - 1)

View File

@@ -1680,6 +1680,66 @@ def test_preset_startup_rejects_pinned_shard_above_memory_budget(tmp_path, monke
tracker.stop() tracker.stop()
def test_network_auto_join_clips_oversized_cpu_assignment(tmp_path, monkeypatch, capsys):
"""Old trackers may assign too many CPU layers; node clips before model load."""
import meshnet_node.startup as startup_mod
torch_calls: list[dict] = []
registrations: list[dict] = []
class FakeBackend:
total_layers = 40
class FakeTorchNodeServer:
def __init__(self, **kwargs):
torch_calls.append(kwargs)
self.backend = FakeBackend()
self.tracker_node_id = None
def start(self):
return 7000
def stop(self):
pass
oversized_assignment = {
"hf_repo": "unsloth/Qwen3.6-35B-A3B",
"model": "qwen3.6-35b-a3b",
"shard_start": 0,
"shard_end": 36,
"num_layers": 40,
"gap_found": False,
"bytes_per_layer": {"bfloat16": 1_797_594_419},
"model_sources": [],
}
monkeypatch.setattr(
startup_mod,
"detect_hardware",
lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0, "ram_mb": 79 * 1024},
)
monkeypatch.setattr(startup_mod, "TorchNodeServer", FakeTorchNodeServer)
monkeypatch.setattr(startup_mod, "_get_json", lambda *_args, **_kwargs: oversized_assignment)
monkeypatch.setattr(startup_mod, "_post_json", lambda _url, payload: registrations.append(payload) or {"node_id": "n1"})
monkeypatch.setattr(startup_mod, "_start_heartbeat", lambda *_args, **_kwargs: None)
monkeypatch.setattr(startup_mod, "model_metadata_for", lambda *_args, **_kwargs: {"num_layers": 40})
node = run_startup(
tracker_url="http://127.0.0.1:8080",
wallet_path=tmp_path / "wallet.json",
tracker_source_disabled=True,
)
try:
assert torch_calls[0]["shard_start"] == 0
assert torch_calls[0]["shard_end"] == 24
assert registrations[0]["shard_end"] == 24
output = capsys.readouterr().out
assert "CPU-safe runtime budget fits 25/40 layers" in output
assert "layers 0-24" in output
finally:
node.stop()
def test_preset_model_with_hf_repo_loads_torch_backend(tmp_path, monkeypatch, capsys): def test_preset_model_with_hf_repo_loads_torch_backend(tmp_path, monkeypatch, capsys):
"""Named presets that advertise hf_repo must load TorchNodeServer, not the stub server.""" """Named presets that advertise hf_repo must load TorchNodeServer, not the stub server."""
import meshnet_node.startup as startup_mod import meshnet_node.startup as startup_mod
@@ -1996,6 +2056,55 @@ def test_network_assign_gap_found_field():
tracker.stop() tracker.stop()
def test_network_assign_uses_conservative_cpu_runtime_budget():
"""CPU assignments leave headroom for partial-load overhead, not just raw weights."""
import json as _json
import urllib.request as _ur
tracker = TrackerServer(model_presets={
"qwen3.6-35b-a3b": {
"hf_repo": "unsloth/Qwen3.6-35B-A3B",
"aliases": ["unsloth/Qwen3.6-35B-A3B"],
"layers_start": 0,
"layers_end": 39,
"recommended": True,
"bytes_per_layer": {"bfloat16": 1_797_594_419},
},
})
port = tracker.start()
try:
data = _json.dumps({
"endpoint": "http://127.0.0.1:9200",
"model": "qwen3.6-35b-a3b",
"hf_repo": "unsloth/Qwen3.6-35B-A3B",
"num_layers": 40,
"shard_start": 0,
"shard_end": 39,
"hardware_profile": {},
"score": 1.0,
}).encode()
req = _ur.Request(
f"http://127.0.0.1:{port}/v1/nodes/register",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with _ur.urlopen(req) as r:
r.read()
resp = _get_json(
f"http://127.0.0.1:{port}/v1/network/assign"
"?device=cpu&vram_mb=0&ram_mb=80896"
"&hf_repo=unsloth/Qwen3.6-35B-A3B"
)
assert resp["gap_found"] is False
assert resp["shard_start"] == 0
assert resp["shard_end"] == 24
finally:
tracker.stop()
def test_route_finds_hf_model_across_two_nodes(): def test_route_finds_hf_model_across_two_nodes():
"""Tracker /v1/route returns ordered route for HF model even without a preset.""" """Tracker /v1/route returns ordered route for HF model even without a preset."""
import json as _json import json as _json