node metrics
This commit is contained in:
@@ -8,6 +8,11 @@ metadata:
|
|||||||
|
|
||||||
# Project Status (2026-07-13)
|
# Project Status (2026-07-13)
|
||||||
|
|
||||||
|
## Selected-node model placement (2026-07-14)
|
||||||
|
|
||||||
|
- Admin Model placement now opens a node selector for load and release; the control-plane accepts optional `node_id` and targets only that registry assignment. Multi-model serving remains supported through `ADD_SHARD` and `max_loaded_shards`.
|
||||||
|
- Total node pool resource values are rendered from `/v1/network/map`'s `node.capacity` contract. Route selection remains assignment/capability/throughput/queue based; capacity is used for placement and falls back to tracker defaults only if a node truly omits it.
|
||||||
|
|
||||||
## Distributed inference performance (2026-07-14)
|
## Distributed inference performance (2026-07-14)
|
||||||
|
|
||||||
`DIP-001` is done in `.scratch/distributed-inference-performance/`: the deterministic two-node Route Session stub benchmark covers direct/relay plus cached/stateless prefill and decode. Its JSON and concise summary explicitly attribute model execution, activation encode/decode, compression, connection setup, relay queueing, local HTTP forwarding, and end-to-end seam latency. `PYTHONPATH=packages/node pytest -q tests/test_route_session_benchmark.py` passed (7); the fixture assertion checks output-token identity and connection attempts.
|
`DIP-001` is done in `.scratch/distributed-inference-performance/`: the deterministic two-node Route Session stub benchmark covers direct/relay plus cached/stateless prefill and decode. Its JSON and concise summary explicitly attribute model execution, activation encode/decode, compression, connection setup, relay queueing, local HTTP forwarding, and end-to-end seam latency. `PYTHONPATH=packages/node pytest -q tests/test_route_session_benchmark.py` passed (7); the fixture assertion checks output-token identity and connection attempts.
|
||||||
|
|||||||
@@ -16,12 +16,9 @@
|
|||||||
|
|
||||||
|
|
||||||
.\.venv\Scripts\meshnet-node.exe start http://192.168.0.179:8081 --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
.\.venv\Scripts\meshnet-node.exe start http://192.168.0.179:8081 --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
||||||
.\.venv\Scripts\meshnet-node.exe start --tracker http://ai.neuron.d-popov.com --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
.\.venv\Scripts\meshnet-node.exe start --tracker http://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
||||||
|
|
||||||
we .\.venv\Scripts\meshnet-node.exe start `
|
we .\.venv\Scripts\meshnet-node.exe start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct
|
||||||
--tracker http://192.168.0.179:8081 `
|
|
||||||
--model Qwen/Qwen2.5-0.5B-Instruct `
|
|
||||||
--advertise-host 192.168.0.20
|
|
||||||
# trackers:
|
# trackers:
|
||||||
https://meshnet.2.d-popov.com
|
https://meshnet.2.d-popov.com
|
||||||
https://ai.neuron.d-popov.com
|
https://ai.neuron.d-popov.com
|
||||||
|
|||||||
@@ -44,12 +44,15 @@
|
|||||||
.empty { color:var(--dim); font-style:italic; }
|
.empty { color:var(--dim); font-style:italic; }
|
||||||
.pill { display:inline-block; padding:0 7px; border-radius:9px;
|
.pill { display:inline-block; padding:0 7px; border-radius:9px;
|
||||||
border:1px solid var(--border); font-size:11px; }
|
border:1px solid var(--border); font-size:11px; }
|
||||||
input, button { font:inherit; color:var(--fg); background:var(--bg);
|
input, button, select { font:inherit; color:var(--fg); background:var(--bg);
|
||||||
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
|
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
|
||||||
input { width:100%; margin-bottom:6px; }
|
input { width:100%; margin-bottom:6px; }
|
||||||
button { cursor:pointer; color:var(--accent); }
|
button { cursor:pointer; color:var(--accent); }
|
||||||
button:hover { border-color:var(--accent); }
|
button:hover { border-color:var(--accent); }
|
||||||
button.small { font-size:11px; padding:1px 7px; }
|
button.small { font-size:11px; padding:1px 7px; }
|
||||||
|
dialog { color:var(--fg); background:var(--panel); border:1px solid var(--border); border-radius:8px; min-width:min(420px,calc(100vw - 32px)); }
|
||||||
|
dialog::backdrop { background:rgba(0,0,0,.55); }
|
||||||
|
.placement-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }
|
||||||
.form-row { display:flex; gap:8px; }
|
.form-row { display:flex; gap:8px; }
|
||||||
.form-row button { white-space:nowrap; }
|
.form-row button { white-space:nowrap; }
|
||||||
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
|
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
|
||||||
@@ -324,6 +327,14 @@
|
|||||||
<div id="testing-log" class="console empty">no test output yet</div>
|
<div id="testing-log" class="console empty">no test output yet</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<dialog id="model-placement-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<div id="model-placement-dialog-title"></div>
|
||||||
|
<label for="model-placement-node">Node</label>
|
||||||
|
<select id="model-placement-node"></select>
|
||||||
|
<div class="placement-dialog-actions"><button value="cancel">Cancel</button><button value="confirm">Confirm</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
@@ -1801,16 +1812,16 @@ async function requestSelectedModelLoad() {
|
|||||||
$("chat-status").textContent = `load queued on ${short(assignment.node_id || "node")} for layers ${assignment.shard_start}-${assignment.shard_end}`;
|
$("chat-status").textContent = `load queued on ${short(assignment.node_id || "node")} for layers ${assignment.shard_start}-${assignment.shard_end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestAdminModelLoad(model) {
|
async function requestAdminModelLoad(model, nodeId) {
|
||||||
const result = await apiCall("/v1/models/load", "POST", { model, force: true });
|
const result = await apiCall("/v1/models/load", "POST", { model, node_id: nodeId, force: true });
|
||||||
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model load request failed", true);
|
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model load request failed", true);
|
||||||
const assignment = result.data.assignment || {};
|
const assignment = result.data.assignment || {};
|
||||||
showAdminModelPlacementStatus(`Load queued on ${short(assignment.node_id || "node")} for ${model}.`);
|
showAdminModelPlacementStatus(`Load queued on ${short(assignment.node_id || "node")} for ${model}.`);
|
||||||
await refreshActiveTab(true);
|
await refreshActiveTab(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function releaseAdminModel(model) {
|
async function releaseAdminModel(model, nodeId) {
|
||||||
const result = await apiCall("/v1/models/release", "POST", { model });
|
const result = await apiCall("/v1/models/release", "POST", { model, node_id: nodeId });
|
||||||
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model release request failed", true);
|
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model release request failed", true);
|
||||||
showAdminModelPlacementStatus(`Release queued for ${result.data.released || 0} node(s) serving ${model}.`);
|
showAdminModelPlacementStatus(`Release queued for ${result.data.released || 0} node(s) serving ${model}.`);
|
||||||
await refreshActiveTab(true);
|
await refreshActiveTab(true);
|
||||||
@@ -1836,6 +1847,9 @@ function renderAdminNodePool(map) {
|
|||||||
html += table(["node", "assignment", "state / slots", "RAM", "GPU / VRAM", "model drive"], nodes.map(node => {
|
html += table(["node", "assignment", "state / slots", "RAM", "GPU / VRAM", "model drive"], nodes.map(node => {
|
||||||
const hw = node.hardware_profile || {};
|
const hw = node.hardware_profile || {};
|
||||||
const cap = node.capacity || {};
|
const cap = node.capacity || {};
|
||||||
|
// The network map keeps reported resource capacity under `capacity`.
|
||||||
|
node.ram_bytes = cap.ram_bytes ?? node.ram_bytes;
|
||||||
|
node.vram_bytes = cap.vram_bytes ?? node.vram_bytes;
|
||||||
const disk = hw.model_drive_free_bytes ?? hw.model_path_free_bytes ?? hw.disk_free_bytes;
|
const disk = hw.model_drive_free_bytes ?? hw.model_path_free_bytes ?? hw.disk_free_bytes;
|
||||||
const gpu = hw.gpu_name || (hw.cuda_available ? "CUDA GPU" : "CPU only");
|
const gpu = hw.gpu_name || (hw.cuda_available ? "CUDA GPU" : "CPU only");
|
||||||
return [nodeDisplayCell(node), esc(node.hf_repo || node.model || "unassigned"),
|
return [nodeDisplayCell(node), esc(node.hf_repo || node.model || "unassigned"),
|
||||||
@@ -1866,10 +1880,26 @@ function renderAdminModelPlacement(models, map) {
|
|||||||
$("admin-model-placement").addEventListener("click", event => {
|
$("admin-model-placement").addEventListener("click", event => {
|
||||||
const load = event.target.closest("[data-admin-model-load]");
|
const load = event.target.closest("[data-admin-model-load]");
|
||||||
const release = event.target.closest("[data-admin-model-release]");
|
const release = event.target.closest("[data-admin-model-release]");
|
||||||
if (load) void requestAdminModelLoad(load.dataset.adminModelLoad);
|
if (load) void chooseModelPlacementNode("load", load.dataset.adminModelLoad);
|
||||||
if (release) void releaseAdminModel(release.dataset.adminModelRelease);
|
if (release) void chooseModelPlacementNode("release", release.dataset.adminModelRelease);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function chooseModelPlacementNode(action, model) {
|
||||||
|
const dialog = $("model-placement-dialog");
|
||||||
|
const select = $("model-placement-node");
|
||||||
|
const aliases = new Set([model].filter(Boolean));
|
||||||
|
const nodes = (lastNetworkMap?.nodes || []).filter(node => action === "load" || aliases.has(node.model) || aliases.has(node.hf_repo));
|
||||||
|
if (!nodes.length) return showAdminModelPlacementStatus(`No node can ${action} ${model}.`, true);
|
||||||
|
$("model-placement-dialog-title").textContent = `${action === "load" ? "Load" : "Release"} ${model} on a node`;
|
||||||
|
select.innerHTML = nodes.map(node => `<option value="${esc(node.node_id)}">${esc(short(node.friendly_name || node.node_id, 20))} — ${esc(node.hf_repo || node.model || "unassigned")}</option>`).join("");
|
||||||
|
dialog.onclose = () => {
|
||||||
|
if (dialog.returnValue !== "confirm") return;
|
||||||
|
if (action === "load") void requestAdminModelLoad(model, select.value);
|
||||||
|
else void releaseAdminModel(model, select.value);
|
||||||
|
};
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
function chatAuthToken() {
|
function chatAuthToken() {
|
||||||
if (accountApiKeys.length) return accountApiKeys[0];
|
if (accountApiKeys.length) return accountApiKeys[0];
|
||||||
return null;
|
return null;
|
||||||
@@ -2512,6 +2542,7 @@ async function fetchAdminTab() {
|
|||||||
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
|
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
const [raft, consoleData, summary, wallets, models, map, adminResp] = results;
|
const [raft, consoleData, summary, wallets, models, map, adminResp] = results;
|
||||||
|
if (map) lastNetworkMap = map;
|
||||||
renderIfChanged("hive", raft, renderHive);
|
renderIfChanged("hive", raft, renderHive);
|
||||||
renderIfChanged("console", consoleData, renderConsole);
|
renderIfChanged("console", consoleData, renderConsole);
|
||||||
renderIfChanged("billing-summary", summary, data => renderBilling(data));
|
renderIfChanged("billing-summary", summary, data => renderBilling(data));
|
||||||
|
|||||||
@@ -1494,7 +1494,9 @@ def _scale_demanded_models_locked(server: "_TrackerHTTPServer") -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def _request_model_load_locked(server: "_TrackerHTTPServer", model_key: str) -> dict | None:
|
def _request_model_load_locked(
|
||||||
|
server: "_TrackerHTTPServer", model_key: str, node_id: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
"""Queue an explicitly requested model on the best available joined node."""
|
"""Queue an explicitly requested model on the best available joined node."""
|
||||||
resolved_name, preset = _resolve_model_preset(server.model_presets, model_key)
|
resolved_name, preset = _resolve_model_preset(server.model_presets, model_key)
|
||||||
if preset is None or not preset.get("hf_repo"):
|
if preset is None or not preset.get("hf_repo"):
|
||||||
@@ -1510,6 +1512,8 @@ def _request_model_load_locked(server: "_TrackerHTTPServer", model_key: str) ->
|
|||||||
continue
|
continue
|
||||||
host_nodes = [server.registry[item["node_id"]] for item in host["loaded"] if item["node_id"] in server.registry]
|
host_nodes = [server.registry[item["node_id"]] for item in host["loaded"] if item["node_id"] in server.registry]
|
||||||
placeable = [node for node in host_nodes if _has_usable_quantization(node)]
|
placeable = [node for node in host_nodes if _has_usable_quantization(node)]
|
||||||
|
if node_id is not None:
|
||||||
|
placeable = [node for node in placeable if node.node_id == node_id]
|
||||||
if not placeable:
|
if not placeable:
|
||||||
continue
|
continue
|
||||||
anchor = max(placeable, key=lambda node: node.benchmark_tokens_per_sec)
|
anchor = max(placeable, key=lambda node: node.benchmark_tokens_per_sec)
|
||||||
@@ -1556,13 +1560,17 @@ def _force_model_load_locked(server: "_TrackerHTTPServer", model_key: str) -> di
|
|||||||
"shard_start": start, "shard_end": shard_end, "replaced_model": replaced}
|
"shard_start": start, "shard_end": shard_end, "replaced_model": replaced}
|
||||||
|
|
||||||
|
|
||||||
def _release_model_locked(server: "_TrackerHTTPServer", model_key: str) -> int:
|
def _release_model_locked(
|
||||||
|
server: "_TrackerHTTPServer", model_key: str, node_id: str | None = None,
|
||||||
|
) -> int:
|
||||||
"""Queue DROP_SHARD for every served shard and remove it from routing immediately."""
|
"""Queue DROP_SHARD for every served shard and remove it from routing immediately."""
|
||||||
resolved_name, preset = _resolve_model_preset(server.model_presets, model_key)
|
resolved_name, preset = _resolve_model_preset(server.model_presets, model_key)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
return 0
|
return 0
|
||||||
released = 0
|
released = 0
|
||||||
for node in server.registry.values():
|
for node in server.registry.values():
|
||||||
|
if node_id is not None and node.node_id != node_id:
|
||||||
|
continue
|
||||||
if not _node_matches_preset(node, resolved_name, preset) or node.shard_start is None or node.shard_end is None:
|
if not _node_matches_preset(node, resolved_name, preset) or node.shard_start is None or node.shard_end is None:
|
||||||
continue
|
continue
|
||||||
node.pending_directives.append(_drop_directive(node, str(preset.get("hf_repo") or resolved_name), node.shard_start, node.shard_end, node.quantization or "bfloat16"))
|
node.pending_directives.append(_drop_directive(node, str(preset.get("hf_repo") or resolved_name), node.shard_start, node.shard_end, node.quantization or "bfloat16"))
|
||||||
@@ -4880,10 +4888,14 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
if not isinstance(model, str) or not model.strip():
|
if not isinstance(model, str) or not model.strip():
|
||||||
self._send_json(400, {"error": "model is required"})
|
self._send_json(400, {"error": "model is required"})
|
||||||
return
|
return
|
||||||
|
node_id = body.get("node_id")
|
||||||
|
if node_id is not None and (not isinstance(node_id, str) or not node_id):
|
||||||
|
self._send_json(400, {"error": "node_id must be a non-empty string"})
|
||||||
|
return
|
||||||
with server.lock:
|
with server.lock:
|
||||||
self._purge_expired_nodes()
|
self._purge_expired_nodes()
|
||||||
assignment = _request_model_load_locked(server, model)
|
assignment = _request_model_load_locked(server, model, node_id)
|
||||||
if assignment is None and body.get("force") is True:
|
if assignment is None and node_id is None and body.get("force") is True:
|
||||||
assignment = _force_model_load_locked(server, model)
|
assignment = _force_model_load_locked(server, model)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
self._send_json(409, {"error": "no ready joined node has an available model slot and sufficient capacity"})
|
self._send_json(409, {"error": "no ready joined node has an available model slot and sufficient capacity"})
|
||||||
@@ -4901,9 +4913,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
if not isinstance(model, str) or not model.strip():
|
if not isinstance(model, str) or not model.strip():
|
||||||
self._send_json(400, {"error": "model is required"})
|
self._send_json(400, {"error": "model is required"})
|
||||||
return
|
return
|
||||||
|
node_id = body.get("node_id")
|
||||||
|
if node_id is not None and (not isinstance(node_id, str) or not node_id):
|
||||||
|
self._send_json(400, {"error": "node_id must be a non-empty string"})
|
||||||
|
return
|
||||||
with server.lock:
|
with server.lock:
|
||||||
self._purge_expired_nodes()
|
self._purge_expired_nodes()
|
||||||
released = _release_model_locked(server, model)
|
released = _release_model_locked(server, model, node_id)
|
||||||
if not released:
|
if not released:
|
||||||
self._send_json(404, {"error": "no served shards found for model"})
|
self._send_json(404, {"error": "no served shards found for model"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ def test_dashboard_exposes_admin_model_inventory_and_release_controls():
|
|||||||
assert 'id="admin-node-pool"' in html
|
assert 'id="admin-node-pool"' in html
|
||||||
assert "renderAdminNodePool" in html
|
assert "renderAdminNodePool" in html
|
||||||
assert "model drive" in html
|
assert "model drive" in html
|
||||||
|
# RAM and VRAM live in the network-map capacity object, not at node top level.
|
||||||
|
assert "node.ram_bytes = cap.ram_bytes" in html
|
||||||
|
assert "node.vram_bytes = cap.vram_bytes" in html
|
||||||
|
assert 'id="model-placement-dialog"' in html
|
||||||
|
assert "chooseModelPlacementNode" in html
|
||||||
|
assert "node_id: nodeId" in html
|
||||||
|
|
||||||
|
|
||||||
def test_network_map_includes_node_friendly_name():
|
def test_network_map_includes_node_friendly_name():
|
||||||
|
|||||||
@@ -2869,6 +2869,43 @@ def test_same_endpoint_can_register_multiple_models():
|
|||||||
tracker.stop()
|
tracker.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_model_placement_targets_only_the_selected_node():
|
||||||
|
"An admin can add and release a model on one chosen multi-model node.\n\nTags: http, routing, tracker"
|
||||||
|
from meshnet_tracker.server import _release_model_locked, _request_model_load_locked
|
||||||
|
|
||||||
|
tracker = _tracker(model_presets={
|
||||||
|
"model-a": {"total_layers": 4, "bytes_per_layer": {"bfloat16": 1_000}, "hf_repo": "org/ModelA"},
|
||||||
|
"model-b": {"total_layers": 4, "bytes_per_layer": {"bfloat16": 1_000}, "hf_repo": "org/ModelB"},
|
||||||
|
})
|
||||||
|
tracker_port = tracker.start()
|
||||||
|
try:
|
||||||
|
registrations = []
|
||||||
|
for port in (9062, 9063):
|
||||||
|
registrations.append(_post_json(
|
||||||
|
f"http://127.0.0.1:{tracker_port}/v1/nodes/register",
|
||||||
|
{"endpoint": f"http://127.0.0.1:{port}", "model": "model-a", "hf_repo": "org/ModelA",
|
||||||
|
"num_layers": 4, "shard_start": 0, "shard_end": 3, "max_loaded_shards": 2,
|
||||||
|
"vram_bytes": 20_000, "ram_bytes": 20_000, "quantizations": ["bfloat16"],
|
||||||
|
"benchmark_tokens_per_sec": 1.0, "hardware_profile": {}, "score": 1.0},
|
||||||
|
))
|
||||||
|
selected, other = (item["node_id"] for item in registrations)
|
||||||
|
|
||||||
|
with tracker._lock:
|
||||||
|
assignment = _request_model_load_locked(tracker._server, "model-b", selected) # type: ignore[arg-type]
|
||||||
|
assert assignment is not None
|
||||||
|
assert assignment["node_id"] == selected
|
||||||
|
assert tracker._registry[selected].pending_new_assignment is not None
|
||||||
|
assert tracker._registry[other].pending_new_assignment is None
|
||||||
|
|
||||||
|
with tracker._lock:
|
||||||
|
released = _release_model_locked(tracker._server, "model-a", selected) # type: ignore[arg-type]
|
||||||
|
assert released == 1
|
||||||
|
assert len(tracker._registry[selected].pending_directives) == 2
|
||||||
|
assert tracker._registry[other].pending_directives == []
|
||||||
|
finally:
|
||||||
|
tracker.stop()
|
||||||
|
|
||||||
|
|
||||||
def test_scale_demanded_models_queues_add_shard_on_spare_host():
|
def test_scale_demanded_models_queues_add_shard_on_spare_host():
|
||||||
"Scale demanded models queues add shard on spare host\n\nTags: http, routing, tracker"
|
"Scale demanded models queues add shard on spare host\n\nTags: http, routing, tracker"
|
||||||
tracker = _tracker(model_presets={
|
tracker = _tracker(model_presets={
|
||||||
|
|||||||
Reference in New Issue
Block a user