- docs/INSTALL_WINDOWS.md: step-by-step WSL2 + CUDA + meshnet-node install on Windows 11, including port-proxy setup and known issues - docs/TWO_MACHINE_TEST.md: two-machine LAN test procedure, start order, verification steps, latency reading, and Known Issues section - scripts/test_lan_inference.py: stdlib-only test script; sends 3 chat completions, validates OpenAI response format, prints tokens + latency, exits 0 on success; auto-discovers gateway from tracker if --gateway omitted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
End-to-end LAN inference test for meshnet distributed inference.
|
||
|
||
Sends 3 chat-completion requests to a meshnet node, validates OpenAI-format
|
||
responses, and prints token counts + latency per request.
|
||
|
||
Usage:
|
||
python scripts/test_lan_inference.py \\
|
||
--tracker http://192.168.1.10:8080 \\
|
||
--gateway http://192.168.1.10:8001
|
||
|
||
Exit 0 on success, 1 on any failure.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
|
||
PROMPTS = [
|
||
{"role": "user", "content": "What is 7 × 8? Answer in one word."},
|
||
{"role": "user", "content": "Name the capital of France in one word."},
|
||
{"role": "user", "content": "Complete the sequence: 1, 1, 2, 3, 5, ___. Answer in one word."},
|
||
]
|
||
|
||
MODEL = "microsoft/Phi-3-medium-128k-instruct"
|
||
|
||
|
||
def _get(url: str, timeout: float = 10.0) -> dict:
|
||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||
return json.loads(r.read())
|
||
|
||
|
||
def _post(url: str, payload: dict, timeout: float = 60.0) -> dict:
|
||
data = json.dumps(payload).encode()
|
||
req = urllib.request.Request(
|
||
url, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||
return json.loads(r.read())
|
||
|
||
|
||
def discover_gateway(tracker_url: str) -> str:
|
||
"""Return the first tracker-mode node endpoint for MODEL."""
|
||
nodes = _get(f"{tracker_url}/v1/nodes", timeout=5.0)
|
||
if isinstance(nodes, dict):
|
||
nodes = list(nodes.values())
|
||
tracker_nodes = [
|
||
n for n in nodes
|
||
if n.get("tracker_mode") and (
|
||
n.get("hf_repo") == MODEL or n.get("model") == MODEL.split("/")[-1]
|
||
)
|
||
]
|
||
if not tracker_nodes:
|
||
raise RuntimeError(
|
||
f"No tracker-mode nodes found for {MODEL!r}. "
|
||
"Is the first-shard node running and registered?"
|
||
)
|
||
endpoint: str = tracker_nodes[0]["endpoint"]
|
||
return endpoint.rstrip("/")
|
||
|
||
|
||
def check_route(tracker_url: str, gateway_url: str) -> list[str]:
|
||
"""Return the full inference route for MODEL."""
|
||
url = f"{tracker_url}/v1/route?model={urllib.parse.quote(MODEL)}"
|
||
try:
|
||
resp = _get(url, timeout=5.0)
|
||
return resp.get("route", [])
|
||
except Exception as exc:
|
||
print(f" Warning: could not fetch route: {exc}", file=sys.stderr)
|
||
return [gateway_url]
|
||
|
||
|
||
def run_inference(gateway_url: str, messages: list[dict]) -> tuple[str, int, float]:
|
||
"""Send one chat-completion request. Returns (content, tokens, elapsed_s)."""
|
||
t0 = time.monotonic()
|
||
resp = _post(
|
||
f"{gateway_url}/v1/chat/completions",
|
||
{"model": MODEL, "messages": messages, "stream": False},
|
||
timeout=120.0,
|
||
)
|
||
elapsed = time.monotonic() - t0
|
||
|
||
choices = resp.get("choices")
|
||
if not choices:
|
||
raise ValueError(f"No choices in response: {resp}")
|
||
content: str = choices[0].get("message", {}).get("content", "")
|
||
if not isinstance(content, str):
|
||
raise TypeError(f"Expected string content, got {type(content)}: {content}")
|
||
|
||
usage = resp.get("usage", {})
|
||
tokens: int = usage.get("completion_tokens", len(content.split()))
|
||
|
||
return content, tokens, elapsed
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
p = argparse.ArgumentParser(description=__doc__)
|
||
p.add_argument("--tracker", required=True, help="Tracker URL, e.g. http://192.168.1.10:8080")
|
||
p.add_argument(
|
||
"--gateway",
|
||
default=None,
|
||
help="Inference entry point URL. Auto-discovered from tracker if omitted.",
|
||
)
|
||
args = p.parse_args(argv)
|
||
|
||
tracker_url = args.tracker.rstrip("/")
|
||
|
||
print(f"Tracker: {tracker_url}")
|
||
|
||
# Resolve gateway
|
||
gateway_url = args.gateway.rstrip("/") if args.gateway else None
|
||
if gateway_url is None:
|
||
try:
|
||
gateway_url = discover_gateway(tracker_url)
|
||
print(f"Gateway (auto-discovered): {gateway_url}")
|
||
except Exception as exc:
|
||
print(f"ERROR: {exc}", file=sys.stderr)
|
||
return 1
|
||
else:
|
||
print(f"Gateway: {gateway_url}")
|
||
|
||
# Show route
|
||
route = check_route(tracker_url, gateway_url)
|
||
print(f"Route: {route}")
|
||
if len(route) < 2:
|
||
print(" Warning: only one node in route — is the second-shard node registered?")
|
||
print()
|
||
|
||
failures = 0
|
||
for i, msg in enumerate(PROMPTS, start=1):
|
||
print(f"[{i}] Q: {msg['content']}")
|
||
try:
|
||
content, tokens, elapsed = run_inference(gateway_url, [msg])
|
||
tps = tokens / elapsed if elapsed > 0 else 0.0
|
||
print(f" A: {content.strip()}")
|
||
print(f" {tokens} tokens {elapsed:.2f}s {tps:.1f} t/s")
|
||
except urllib.error.HTTPError as exc:
|
||
body = exc.read().decode(errors="replace")
|
||
print(f" ERROR {exc.code}: {body}", file=sys.stderr)
|
||
failures += 1
|
||
except Exception as exc:
|
||
print(f" ERROR: {exc}", file=sys.stderr)
|
||
failures += 1
|
||
print()
|
||
|
||
if failures == 0:
|
||
print(f"All {len(PROMPTS)} requests completed successfully.")
|
||
print("Exit code: 0")
|
||
return 0
|
||
else:
|
||
print(f"{failures}/{len(PROMPTS)} requests failed.", file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|