feat(v2): T2 运维层 hw_profile + llama_server 进程管理
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""测试辅助:获取空闲 TCP 端口。"""
|
||||
import socket
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
|
||||
|
||||
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
|
||||
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
|
||||
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
|
||||
- 进程被终止时正常退出
|
||||
"""
|
||||
import argparse
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="fake-llama-server")
|
||||
parser.add_argument("--port", type=int, default=8901)
|
||||
parser.add_argument("-m", dest="model", default="")
|
||||
parser.add_argument("-ngl", dest="ngl", default="0")
|
||||
parser.add_argument("-c", dest="ctx", default="8192")
|
||||
parser.add_argument("-ctk", dest="ctk", default="")
|
||||
parser.add_argument("-ctv", dest="ctv", default="")
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
marker = os.environ.get("FAKE_MARKER")
|
||||
if marker:
|
||||
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
|
||||
with open(marker, "w", encoding="utf-8") as f:
|
||||
json.dump({"pid": os.getpid(), "port": args.port,
|
||||
"model": args.model, "args": sys.argv[1:]}, f)
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/health"):
|
||||
body = json.dumps({"status": "ok", "server": "fake-llama"}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
srv.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""T2 硬件档位检测单测(封闭,纯逻辑 + 注入 runner)。"""
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from runtime.hw_profile import (
|
||||
TIER_SPECS,
|
||||
detect,
|
||||
nvidia_vram_gb,
|
||||
pick_tier,
|
||||
tier_spec,
|
||||
vulkan_present,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRun:
|
||||
"""注入 subprocess runner:按命令返回预置输出。"""
|
||||
|
||||
def __init__(self, mapping):
|
||||
self.mapping = mapping # {关键子串: CompletedProcess}
|
||||
|
||||
def __call__(self, cmd, timeout):
|
||||
joined = " ".join(cmd)
|
||||
for key, cp in self.mapping.items():
|
||||
if key in joined:
|
||||
return cp
|
||||
raise FileNotFoundError(cmd)
|
||||
|
||||
|
||||
def _cp(stdout="", rc=0):
|
||||
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr="")
|
||||
|
||||
|
||||
def test_pick_tier_thresholds():
|
||||
assert pick_tier(None) == "cpu"
|
||||
assert pick_tier(6.0) == "cpu"
|
||||
assert pick_tier(8.0) == "gpu8"
|
||||
assert pick_tier(11.9) == "gpu8"
|
||||
assert pick_tier(12.0) == "gpu12"
|
||||
assert pick_tier(24.0) == "gpu12"
|
||||
|
||||
|
||||
def test_tier_specs_have_required_fields():
|
||||
for tier, spec in TIER_SPECS.items():
|
||||
assert spec["tier"] == tier
|
||||
assert "ngl" in spec and "ctx" in spec and "kv_quant" in spec
|
||||
# gpu12 ngl 全量卸载,cpu ngl 0
|
||||
assert TIER_SPECS["gpu12"]["ngl"] == 99
|
||||
assert TIER_SPECS["cpu"]["ngl"] == 0
|
||||
|
||||
|
||||
def test_tier_spec_unknown_raises():
|
||||
with pytest.raises(ValueError):
|
||||
tier_spec("nonexistent")
|
||||
|
||||
|
||||
def test_nvidia_vram_gb_parses():
|
||||
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
|
||||
assert nvidia_vram_gb(runner=fake) == 24.0
|
||||
|
||||
|
||||
def test_nvidia_vram_gb_multi_gpu_takes_max():
|
||||
fake = _FakeRun({"nvidia-smi": _cp("8192\n12288\n")})
|
||||
assert nvidia_vram_gb(runner=fake) == 12.0
|
||||
|
||||
|
||||
def test_nvidia_vram_gb_missing_tool_returns_none(monkeypatch):
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
assert nvidia_vram_gb() is None
|
||||
|
||||
|
||||
def test_vulkan_present_true():
|
||||
fake = _FakeRun({"vulkaninfo": _cp("deviceName : NVIDIA GeForce RTX 4090")})
|
||||
assert vulkan_present(runner=fake) is True
|
||||
|
||||
|
||||
def test_vulkan_present_software_false():
|
||||
fake = _FakeRun({"vulkaninfo": _cp("deviceName : llvmpipe (LLVM)")})
|
||||
assert vulkan_present(runner=fake) is False
|
||||
|
||||
|
||||
def test_detect_nvidia_gpu12(monkeypatch):
|
||||
# 让 shutil.which 只对 nvidia-smi 生效
|
||||
real_which = __import__("shutil").which
|
||||
def fake_which(name):
|
||||
return "C:/x/nvidia-smi.exe" if name == "nvidia-smi" else None
|
||||
monkeypatch.setattr(__import__("shutil"), "which", fake_which)
|
||||
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
|
||||
spec = detect(runner=fake)
|
||||
assert spec["tier"] == "gpu12"
|
||||
assert spec["ngl"] == 99 and spec["ctx"] == 32768
|
||||
assert spec["probe"] == "nvidia"
|
||||
|
||||
|
||||
def test_detect_cpu_fallback(monkeypatch):
|
||||
monkeypatch.setattr(__import__("shutil"), "which", lambda name: None)
|
||||
spec = detect()
|
||||
assert spec["tier"] == "cpu"
|
||||
assert spec["ngl"] == 0
|
||||
assert spec["probe"] == "cpu"
|
||||
|
||||
|
||||
def test_detect_override_tier():
|
||||
spec = detect(override={"tier": "gpu8"})
|
||||
assert spec["tier"] == "gpu8"
|
||||
assert spec["ngl"] == 14 and spec["ctx"] == 16384
|
||||
assert spec["probe"] == "override"
|
||||
|
||||
|
||||
def test_detect_override_field():
|
||||
spec = detect(override={"tier": "cpu", "ctx": 16384})
|
||||
assert spec["ctx"] == 16384
|
||||
assert spec["tier"] == "cpu"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""T2 llama-server 进程管理单测(封闭:假二进制 + 注入,D11)。"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from runtime.llama_server import LlamaServerManager, LlamaServerError, build_llama_server
|
||||
from tests._ports import free_port
|
||||
|
||||
FAKE_SCRIPT = Path(__file__).parent / "fixtures" / "fake_llama_server.py"
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
def _make_fake_binary(tmp: Path) -> Path:
|
||||
"""生成一个 .cmd 包装器:把 venv python + 假脚本当作"二进制"启动。"""
|
||||
cmd = tmp / "fake-llama-server.cmd"
|
||||
cmd.write_text(
|
||||
f'@echo off\r\n"{PYTHON}" "{FAKE_SCRIPT}" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
def _make_manager(tmp, binary, port, model, **kw):
|
||||
marker = tmp / "marker.json"
|
||||
env = dict(os.environ)
|
||||
env["FAKE_MARKER"] = str(marker)
|
||||
return LlamaServerManager(
|
||||
binary=str(binary),
|
||||
model=str(model),
|
||||
port=port,
|
||||
hw={"tier": "cpu"},
|
||||
health_timeout_s=15.0,
|
||||
poll_interval_s=0.2,
|
||||
log_dir=str(tmp / "runs"),
|
||||
env=env,
|
||||
**kw,
|
||||
), marker
|
||||
|
||||
|
||||
def test_build_command_uses_hw_tier():
|
||||
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=8901,
|
||||
hw={"tier": "gpu12"})
|
||||
cmd = m._build_command()
|
||||
assert Path(cmd[0]) == Path("bin/x.exe")
|
||||
assert cmd[1] == "-m" and Path(cmd[2]) == Path("models/m.gguf")
|
||||
assert "--port" in cmd and "8901" in cmd
|
||||
assert cmd[cmd.index("-ngl") + 1] == "99"
|
||||
assert cmd[cmd.index("-c") + 1] == "32768"
|
||||
|
||||
|
||||
def test_build_command_extra_args_appended():
|
||||
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=1,
|
||||
hw={"tier": "cpu"}, extra_args=["--cache-reuse", "256"])
|
||||
cmd = m._build_command()
|
||||
assert cmd[-2:] == ["--cache-reuse", "256"]
|
||||
|
||||
|
||||
def test_start_missing_binary_raises(tmp_path):
|
||||
m = LlamaServerManager(binary=str(tmp_path / "nope.exe"), model=str(tmp_path / "m.gguf"),
|
||||
port=free_port())
|
||||
with pytest.raises(LlamaServerError):
|
||||
m.start()
|
||||
|
||||
|
||||
def test_start_missing_model_raises(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
m = LlamaServerManager(binary=str(binary), model=str(tmp_path / "missing.gguf"),
|
||||
port=free_port())
|
||||
with pytest.raises(LlamaServerError):
|
||||
m.start()
|
||||
|
||||
|
||||
def test_full_lifecycle(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
model = tmp_path / "model.gguf"
|
||||
model.write_bytes(b"fake")
|
||||
port = free_port()
|
||||
m, marker = _make_manager(tmp_path, binary, port, model)
|
||||
|
||||
assert m.running is False
|
||||
assert m.health() is False # 无进程时不健康
|
||||
|
||||
ok = m.start()
|
||||
assert ok is True
|
||||
assert m.running is True
|
||||
assert m.health() is True
|
||||
|
||||
# 假脚本确实收到了参数
|
||||
data = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert data["port"] == port
|
||||
assert data["model"] == str(model)
|
||||
|
||||
# 再 start 幂等(已运行返回健康)
|
||||
assert m.start() is True
|
||||
|
||||
m.stop()
|
||||
assert m.running is False
|
||||
assert m.health() is False
|
||||
|
||||
|
||||
def test_stop_idempotent(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
model = tmp_path / "model.gguf"
|
||||
model.write_bytes(b"fake")
|
||||
m, _ = _make_manager(tmp_path, binary, free_port(), model)
|
||||
m.stop() # 未启动时 stop 不抛
|
||||
assert m.running is False
|
||||
|
||||
|
||||
def test_ensure_alive_healthy_no_restart(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
model = tmp_path / "model.gguf"
|
||||
model.write_bytes(b"fake")
|
||||
m, _ = _make_manager(tmp_path, binary, free_port(), model)
|
||||
m.start()
|
||||
assert m.ensure_alive() is True
|
||||
# 不应触发重启
|
||||
assert m._restart_count == 0
|
||||
m.stop()
|
||||
|
||||
|
||||
def test_ensure_alive_restart_exhausted(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
model = tmp_path / "model.gguf"
|
||||
model.write_bytes(b"fake")
|
||||
m, _ = _make_manager(tmp_path, binary, free_port(), model, max_restarts=0)
|
||||
# 未启动:restart 上限 0 -> False
|
||||
assert m.ensure_alive() is False
|
||||
|
||||
|
||||
def test_endpoint_format():
|
||||
m = LlamaServerManager(binary="x", model="m", port=8901, hw={"tier": "cpu"})
|
||||
assert m.endpoint() == "http://127.0.0.1:8901"
|
||||
|
||||
|
||||
def test_build_from_config(tmp_path):
|
||||
binary = _make_fake_binary(tmp_path)
|
||||
cfg = {
|
||||
"binary": str(binary),
|
||||
"model": str(tmp_path / "m.gguf"),
|
||||
"port": 8999,
|
||||
"hw": {"tier": "cpu"},
|
||||
"extra_args": ["-fa"],
|
||||
}
|
||||
m = build_llama_server(cfg)
|
||||
assert m.port == 8999
|
||||
assert "-fa" in m.extra_args
|
||||
Reference in New Issue
Block a user