[FEAT] Support fast engine recovery through weight cache (#27139)

Signed-off-by: Michael Qiu <qiudayu.qdy@antgroup.com>
Co-authored-by: liusy58 <liusy58@linux.alibaba.com>
Co-authored-by: Alex Nails <alex.nails@radixark.ai>
This commit is contained in:
Mike Qiu
2026-07-25 14:31:21 +08:00
committed by GitHub
co-authored by liusy58 Alex Nails
parent bdd0698541
commit f9c14e6bd4
18 changed files with 3404 additions and 2 deletions
+377
View File
@@ -0,0 +1,377 @@
"""E2E test for WeightCacheDaemon with real model loading.
Launches TP daemons that load a real model, export IPC handles,
and verifies the client can fetch and import them.
Usage:
# With a small model (single GPU):
python test/manual/test_weight_cache_e2e.py --model-path /path/to/model --tp-size 1
# With a large model (multi-GPU):
python test/manual/test_weight_cache_e2e.py --model-path /path/to/model --tp-size 4
Requires GPUs and the model to be available on disk.
"""
import argparse
import multiprocessing as mp
import os
import socket
import sys
import time
def find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _global_rank(tp_size, pp_rank, tp_rank):
return tp_size * pp_rank + tp_rank
def _temp_path(tp_size, pp_rank, tp_rank, suffix):
return f"/tmp/sglang_weight_cache_rank{_global_rank(tp_size, pp_rank, tp_rank)}{suffix}"
def run_single_daemon(
model_path,
gpu_id,
tp_size,
tp_rank,
pp_size,
pp_rank,
dist_init_method,
socket_path,
ready_path,
done_path,
load_format,
dtype,
quantization,
trust_remote_code,
):
"""Run a single daemon process for one (pp_rank, tp_rank)."""
import traceback
from sglang.srt.weight_cache.daemon import WeightCacheDaemon
daemon = WeightCacheDaemon(
model_path=model_path,
gpu_id=gpu_id,
tp_size=tp_size,
tp_rank=tp_rank,
pp_size=pp_size,
pp_rank=pp_rank,
dp_size=1,
load_format=load_format,
dtype=dtype,
quantization=quantization,
trust_remote_code=trust_remote_code,
dist_init_method=dist_init_method,
)
daemon.socket_path = socket_path
daemon.ready_path = ready_path
try:
daemon.load()
except Exception as e:
tb = traceback.format_exc()
print(
f"[Daemon gpu={gpu_id} pp_rank={pp_rank} tp_rank={tp_rank}] "
f"LOAD ERROR: {e}\n{tb}",
flush=True,
)
with open(ready_path, "w") as f:
f.write(f"ERROR: {e}\n")
return
with open(ready_path, "w") as f:
f.write(f"pid={os.getpid()}\n")
f.write(f"num_entries={len(daemon.state_entries)}\n")
print(
f"[Daemon gpu={gpu_id} pp_rank={pp_rank} tp_rank={tp_rank}] Ready with "
f"{len(daemon.state_entries)} tensors",
flush=True,
)
from sglang.srt.weight_cache.protocol import CacheConfig, recv_msg, send_msg
if os.path.exists(socket_path):
os.unlink(socket_path)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(socket_path)
s.listen(5)
s.settimeout(60)
try:
while not os.path.exists(done_path):
try:
conn, _ = s.accept()
try:
req = recv_msg(conn)
if req.get("type") == "query_config":
send_msg(
conn, {"status": "ok", "config": daemon.config.to_dict()}
)
elif req.get("type") == "fetch_state":
engine_config = CacheConfig.from_dict(req["config"])
if daemon.config.matches(engine_config):
send_msg(
conn,
{
"status": "ok",
"config": daemon.config.to_dict(),
"entries": daemon.state_entries,
},
)
else:
send_msg(
conn,
{
"status": "mismatch",
"daemon_config": daemon.config.to_dict(),
},
)
elif req.get("type") == "ping":
send_msg(conn, {"status": "ok"})
except Exception:
pass
finally:
conn.close()
except socket.timeout:
continue
finally:
s.close()
if os.path.exists(socket_path):
os.unlink(socket_path)
print(
f"[Daemon gpu={gpu_id} pp_rank={pp_rank} tp_rank={tp_rank}] Exiting",
flush=True,
)
def main():
parser = argparse.ArgumentParser(description="E2E test for WeightCacheDaemon")
parser.add_argument("--model-path", required=True, help="Path to model weights")
parser.add_argument("--tp-size", type=int, default=1, help="Tensor parallel size")
parser.add_argument("--pp-size", type=int, default=1, help="Pipeline parallel size")
parser.add_argument("--load-format", default="auto", help="Weight load format")
parser.add_argument("--dtype", default="auto", help="Model dtype")
parser.add_argument("--quantization", default=None, help="Quantization method")
parser.add_argument("--trust-remote-code", action="store_true")
parser.add_argument("--timeout", type=int, default=1800, help="Timeout in seconds")
args = parser.parse_args()
tp_size = args.tp_size
pp_size = args.pp_size
total_ranks = tp_size * pp_size
print(f"=== E2E Test: WeightCacheDaemon TP={tp_size} PP={pp_size} ===")
print(f"Model: {args.model_path}")
dist_port = find_free_port()
dist_init_method = f"tcp://127.0.0.1:{dist_port}"
print(f"dist_init_method = {dist_init_method}")
# Clean up old files
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
for suffix in [".sock", ".ready", ".done"]:
p = _temp_path(tp_size, pp_rank, tp_rank, suffix)
if os.path.exists(p):
os.unlink(p)
mp.set_start_method("spawn", force=True)
# Launch all daemon processes
procs = []
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
gpu_id = pp_rank * tp_size + tp_rank
sock_path = _temp_path(tp_size, pp_rank, tp_rank, ".sock")
rdy_path = _temp_path(tp_size, pp_rank, tp_rank, ".ready")
done_path = _temp_path(tp_size, pp_rank, tp_rank, ".done")
p = mp.Process(
target=run_single_daemon,
args=(
args.model_path,
gpu_id,
tp_size,
tp_rank,
pp_size,
pp_rank,
dist_init_method,
sock_path,
rdy_path,
done_path,
args.load_format,
args.dtype,
args.quantization,
args.trust_remote_code,
),
name=f"daemon_gpu{gpu_id}",
)
p.start()
procs.append(p)
print(
f"Launched daemon gpu={gpu_id} pp_rank={pp_rank} "
f"tp_rank={tp_rank} pid={p.pid}"
)
# Wait for all daemons to be ready
print("Waiting for all daemons to load model...")
start = time.time()
error_found = False
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
ready_path = _temp_path(tp_size, pp_rank, tp_rank, ".ready")
while not os.path.exists(ready_path):
elapsed = time.time() - start
if elapsed > args.timeout:
print(
f"ERROR: Daemon pp_rank={pp_rank} tp_rank={tp_rank} "
f"timeout after {args.timeout}s"
)
error_found = True
break
for p in procs:
if not p.is_alive() and not os.path.exists(ready_path):
print(
f"ERROR: Daemon pid={p.pid} exited with code {p.exitcode}"
)
error_found = True
break
if error_found:
break
time.sleep(2)
if error_found:
break
with open(ready_path) as f:
content = f.read()
if content.startswith("ERROR:"):
print(
f"ERROR: Daemon pp_rank={pp_rank} tp_rank={tp_rank} "
f"failed: {content.strip()}"
)
error_found = True
break
print(
f"Daemon pp_rank={pp_rank} tp_rank={tp_rank} ready "
f"({time.time()-start:.0f}s)"
)
if error_found:
break
if error_found:
for p in procs:
if p.is_alive():
p.terminate()
sys.exit(1)
print(
f"\nAll {total_ranks} daemons ready! Total load time: {time.time()-start:.1f}s"
)
# Query config from daemon (pp_rank=0, tp_rank=0)
from sglang.srt.utils import MultiprocessingSerializer
from sglang.srt.weight_cache.protocol import recv_msg, send_msg
socket_path_0 = _temp_path(tp_size, 0, 0, ".sock")
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(10)
s.connect(socket_path_0)
send_msg(s, {"type": "query_config"})
result = recv_msg(s)
s.close()
daemon_config = result.get("config", {})
print(
f"\nDaemon (0,0) config: model={daemon_config.get('model_path')}, "
f"arch={daemon_config.get('model_arch')}, "
f"tp_size={daemon_config.get('tp_size')}, "
f"dtype={daemon_config.get('dtype')}"
)
# Fetch state from daemon (pp_rank=0, tp_rank=0)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(300)
s.connect(socket_path_0)
send_msg(s, {"type": "fetch_state", "config": daemon_config})
tic = time.perf_counter()
result = recv_msg(s)
fetch_time = time.perf_counter() - tic
s.close()
print(
f"\nFetch from daemon (0,0): status={result['status']}, time={fetch_time:.2f}s"
)
if result["status"] == "ok":
entries = result["entries"]
print(f"Received {len(entries)} IPC handles from daemon (0,0)")
sample_names = list(entries.keys())[:5]
for name in sample_names:
entry = entries[name]
imported = MultiprocessingSerializer.deserialize(entry["handle"])
print(
f" {name}: shape={tuple(imported.shape)}, "
f"dtype={imported.dtype}, device={imported.device}"
)
del imported
print("\nIPC import OK!")
else:
print(f"ERROR: fetch_state returned {result}")
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
done = _temp_path(tp_size, pp_rank, tp_rank, ".done")
with open(done, "w") as f:
f.write("done\n")
for p in procs:
if p.is_alive():
p.terminate()
sys.exit(1)
# Test config mismatch
mismatch_config = dict(daemon_config)
mismatch_config["tp_size"] = daemon_config.get("tp_size", 1) + 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(10)
s.connect(socket_path_0)
send_msg(s, {"type": "fetch_state", "config": mismatch_config})
result = recv_msg(s)
s.close()
if result["status"] == "mismatch":
print("Config mismatch detection: OK")
else:
print(f"WARNING: Expected mismatch, got {result['status']}")
# Signal all daemons done
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
done = _temp_path(tp_size, pp_rank, tp_rank, ".done")
with open(done, "w") as f:
f.write("done\n")
for p in procs:
p.join(timeout=15)
if p.is_alive():
p.terminate()
# Clean up
for pp_rank in range(pp_size):
for tp_rank in range(tp_size):
for suffix in [".sock", ".ready", ".done"]:
path = _temp_path(tp_size, pp_rank, tp_rank, suffix)
if os.path.exists(path):
os.unlink(path)
print("\n=== E2E Test Passed! ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,338 @@
import os
import subprocess
import sys
import time
import unittest
import requests
import torch
from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
# A ~1B model keeps the daemon->client IPC handoff cheap to exercise on every
# PR (fast download + load) while still covering the real block-load path; the
# test asserts the IPC path ran, not any particular model's quality.
DEFAULT_MODEL = "Qwen/Qwen3-0.6B"
# This file runs in two suites. The TP=2 class needs the 2-GPU runner (extra-a);
# the TP=1 smoke class is always-on (base-b / 1-gpu-small) so the daemon->client
# IPC handoff is exercised on every PR. Since the CI runner executes the whole
# file per suite, TestWeightCacheDaemonTP2 self-skips when fewer than 2 GPUs are
# visible (i.e. on the 1-gpu runner).
register_cuda_ci(est_time=100, stage="extra-a", runner_config="2-gpu-large")
register_cuda_ci(est_time=100, stage="base-b", runner_config="1-gpu-small")
# Capture the client server's logs so test_loaded_via_ipc can assert the IPC
# load path actually ran (and did not silently fall back to disk).
STDOUT_FILENAME = "/tmp/test_weight_cache_daemon_stdout.log"
STDERR_FILENAME = "/tmp/test_weight_cache_daemon_stderr.log"
SMOKE_STDOUT_FILENAME = "/tmp/test_weight_cache_daemon_smoke_stdout.log"
SMOKE_STDERR_FILENAME = "/tmp/test_weight_cache_daemon_smoke_stderr.log"
PROMPTS = [
"The capital of France is",
"Hello, my name is",
"The future of AI is",
]
@unittest.skipIf(
torch.cuda.device_count() < 2,
"TP=2 weight cache daemon test requires >=2 GPUs (skipped on the 1-gpu runner)",
)
class TestWeightCacheDaemonTP2(CustomTestCase):
"""E2E test: start weight cache daemons, then launch server in client mode with TP2."""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL
cls.base_url = DEFAULT_URL_FOR_TEST
cls.tp_size = 2
# Clean up stale ready/socket files from previous runs
for rank in range(cls.tp_size):
for suffix in (".ready", ".sock"):
path = f"/tmp/sglang_weight_cache_rank{rank}{suffix}"
if os.path.exists(path):
os.unlink(path)
# Step 1: Launch weight cache daemons (blocks until all ranks are ready,
# then monitors child processes)
cls.daemon_process = subprocess.Popen(
[
sys.executable,
"-m",
"sglang.srt.weight_cache.daemon",
"--model-path",
cls.model,
"--tp-size",
str(cls.tp_size),
]
)
# Step 2: Wait for all daemon ready files
timeout = DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
start = time.time()
for rank in range(cls.tp_size):
ready_path = f"/tmp/sglang_weight_cache_rank{rank}.ready"
while not os.path.exists(ready_path):
if time.time() - start > timeout:
kill_process_tree(cls.daemon_process.pid)
raise TimeoutError(
f"Weight cache daemon rank {rank} not ready within {timeout}s"
)
if cls.daemon_process.poll() is not None:
raise RuntimeError(
f"Weight cache daemon exited prematurely "
f"with code {cls.daemon_process.returncode}"
)
time.sleep(2)
# Step 3: Launch server in client mode — loads weights via IPC from daemons
cls.stdout = open(STDOUT_FILENAME, "w")
cls.stderr = open(STDERR_FILENAME, "w")
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--tp",
str(cls.tp_size),
"--weight-cache-mode",
"client",
],
return_stdout_stderr=(cls.stdout, cls.stderr),
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
if hasattr(cls, "daemon_process") and cls.daemon_process:
kill_process_tree(cls.daemon_process.pid)
for stream in (getattr(cls, "stdout", None), getattr(cls, "stderr", None)):
if stream is not None:
try:
stream.close()
except OSError:
pass
for path in (STDOUT_FILENAME, STDERR_FILENAME):
if os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
for rank in range(getattr(cls, "tp_size", 2)):
for suffix in (".ready", ".sock"):
path = f"/tmp/sglang_weight_cache_rank{rank}{suffix}"
if os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
def test_generate(self):
for prompt in PROMPTS:
resp = requests.post(
f"{self.base_url}/v1/completions",
json={
"model": self.model,
"prompt": prompt,
"max_tokens": 32,
"temperature": 0,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
text = data["choices"][0]["text"]
self.assertIsInstance(text, str)
self.assertGreater(len(text), 0, f"Empty output for prompt: {prompt}")
def test_chat(self):
resp = requests.post(
f"{self.base_url}/v1/chat/completions",
json={
"model": self.model,
"messages": [{"role": "user", "content": "What is 2+3?"}],
"max_tokens": 32,
"temperature": 0,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
content = data["choices"][0]["message"]["content"]
self.assertIsInstance(content, str)
self.assertGreater(len(content), 0)
def test_loaded_via_ipc(self):
"""Assert the server actually loaded weights over IPC.
Without this, the test would still pass if the IPC path silently
regressed to disk loading (the daemon would just sit unused), because
generation output looks identical either way. The daemon-side loader
logs "[IpcModelLoader] Loaded model via IPC" on every rank, so its
presence in the captured server logs is our proof the IPC path ran.
"""
for stream in (getattr(self, "stdout", None), getattr(self, "stderr", None)):
if stream is not None:
try:
stream.flush()
except OSError:
pass
logs = ""
for path in (STDOUT_FILENAME, STDERR_FILENAME):
if os.path.exists(path):
with open(path, errors="replace") as f:
logs += f.read()
self.assertIn(
"Loaded model via IPC",
logs,
"Expected the client server to load weights via IPC, but the IPC "
"load log line was not found — the loader likely fell back to disk.",
)
class TestWeightCacheDaemonTP1Smoke(CustomTestCase):
"""Always-on TP=1 smoke: start a single weight cache daemon, launch a server
in client mode, and confirm it loads weights via IPC and generates.
This is the fast single-GPU sanity check (small model) that runs on every PR
in the base-b / 1-gpu-small suite; the heavier TP=2 case above only runs on
the 2-GPU runner.
"""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL
cls.base_url = DEFAULT_URL_FOR_TEST
cls.tp_size = 1
# Clean up stale ready/socket files from previous runs.
for rank in range(cls.tp_size):
for suffix in (".ready", ".sock"):
path = f"/tmp/sglang_weight_cache_rank{rank}{suffix}"
if os.path.exists(path):
os.unlink(path)
# Step 1: Launch the weight cache daemon (blocks until the rank is
# ready, then monitors the child process).
cls.daemon_process = subprocess.Popen(
[
sys.executable,
"-m",
"sglang.srt.weight_cache.daemon",
"--model-path",
cls.model,
"--tp-size",
str(cls.tp_size),
]
)
# Step 2: Wait for the daemon ready file.
timeout = DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
start = time.time()
for rank in range(cls.tp_size):
ready_path = f"/tmp/sglang_weight_cache_rank{rank}.ready"
while not os.path.exists(ready_path):
if time.time() - start > timeout:
kill_process_tree(cls.daemon_process.pid)
raise TimeoutError(
f"Weight cache daemon rank {rank} not ready within {timeout}s"
)
if cls.daemon_process.poll() is not None:
raise RuntimeError(
f"Weight cache daemon exited prematurely "
f"with code {cls.daemon_process.returncode}"
)
time.sleep(2)
# Step 3: Launch server in client mode — loads weights via IPC.
cls.stdout = open(SMOKE_STDOUT_FILENAME, "w")
cls.stderr = open(SMOKE_STDERR_FILENAME, "w")
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--tp",
str(cls.tp_size),
"--weight-cache-mode",
"client",
],
return_stdout_stderr=(cls.stdout, cls.stderr),
)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
if hasattr(cls, "daemon_process") and cls.daemon_process:
kill_process_tree(cls.daemon_process.pid)
for stream in (getattr(cls, "stdout", None), getattr(cls, "stderr", None)):
if stream is not None:
try:
stream.close()
except OSError:
pass
for path in (SMOKE_STDOUT_FILENAME, SMOKE_STDERR_FILENAME):
if os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
for rank in range(getattr(cls, "tp_size", 1)):
for suffix in (".ready", ".sock"):
path = f"/tmp/sglang_weight_cache_rank{rank}{suffix}"
if os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
def test_generate(self):
resp = requests.post(
f"{self.base_url}/v1/completions",
json={
"model": self.model,
"prompt": "The capital of France is",
"max_tokens": 32,
"temperature": 0,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
text = data["choices"][0]["text"]
self.assertIsInstance(text, str)
self.assertGreater(len(text), 0, "Empty generation output")
def test_loaded_via_ipc(self):
"""Assert the server actually loaded weights over IPC (see the TP=2
variant for why this guard matters)."""
for stream in (getattr(self, "stdout", None), getattr(self, "stderr", None)):
if stream is not None:
try:
stream.flush()
except OSError:
pass
logs = ""
for path in (SMOKE_STDOUT_FILENAME, SMOKE_STDERR_FILENAME):
if os.path.exists(path):
with open(path, errors="replace") as f:
logs += f.read()
self.assertIn(
"Loaded model via IPC",
logs,
"Expected the client server to load weights via IPC, but the IPC "
"load log line was not found — the loader likely fell back to disk.",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,366 @@
"""
CPU-only unit tests for the weight cache protocol layer.
These cover the pure-Python logic that the GPU end-to-end test
(test_weight_cache_daemon.py) cannot exercise cheaply:
- length-prefixed socket framing (send_msg/recv_msg) over socketpair()
- CacheConfig fingerprint matching / (de)serialization
- quant-config hashing and method-name extraction
- the daemon rank formula and socket/ready path derivation
- the IPC quantization allowlist (the gate that keeps silently-wrong
quant methods off the zero-copy path)
- stale-vs-live daemon file cleanup
They intentionally require no CUDA, no model download, and no daemon
process, so they run in the fast CPU suite and would catch a regression
in any of these branches before it reaches the expensive GPU path.
"""
import os
import socket
import struct
import unittest
from sglang.srt.weight_cache.protocol import (
IPC_QUANT_ALLOWLIST,
CacheConfig,
UnsupportedQuantForIPCError,
check_ipc_quant_support,
cleanup_stale_daemon_files,
compute_global_rank,
compute_local_gpu_id,
get_quant_method_name,
get_ready_path,
get_socket_path,
hash_quant_config,
is_ipc_quant_supported,
recv_msg,
send_msg,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
def _make_cache_config(**overrides) -> CacheConfig:
base = dict(
model_path="/models/demo",
model_arch="LlamaForCausalLM",
tp_size=2,
tp_rank=0,
pp_size=1,
pp_rank=0,
dp_size=1,
ep_size=1,
quant_method="",
quant_config_hash="",
dtype="torch.float16",
revision="",
device_capability="8.0",
torch_version="2.5.1",
)
base.update(overrides)
return CacheConfig(**base)
class TestProtocolFraming(CustomTestCase):
"""Length-prefixed pickle framing over a real socket pair."""
def test_round_trip(self):
a, b = socket.socketpair()
try:
payload = {"handles": [1, 2, 3], "meta": ("x", 4.5), "flag": True}
send_msg(a, payload)
self.assertEqual(recv_msg(b), payload)
finally:
a.close()
b.close()
def test_multiple_messages_are_framed_independently(self):
a, b = socket.socketpair()
try:
send_msg(a, {"n": 1})
send_msg(a, {"n": 2})
self.assertEqual(recv_msg(b), {"n": 1})
self.assertEqual(recv_msg(b), {"n": 2})
finally:
a.close()
b.close()
def test_connection_closed_mid_header_raises(self):
a, b = socket.socketpair()
try:
# Peer sends only a partial header, then hangs up.
a.sendall(struct.pack("!I", 128)[:2])
a.close()
with self.assertRaises(ConnectionError):
recv_msg(b)
finally:
b.close()
def test_connection_closed_mid_body_raises(self):
a, b = socket.socketpair()
try:
# Full header promising 128 bytes, but no body follows.
a.sendall(struct.pack("!I", 128))
a.close()
with self.assertRaises(ConnectionError):
recv_msg(b)
finally:
b.close()
class TestCacheConfig(CustomTestCase):
def test_identical_configs_match(self):
self.assertTrue(_make_cache_config().matches(_make_cache_config()))
def test_any_field_difference_breaks_match(self):
base = _make_cache_config()
for field, value in (
("tp_rank", 1),
("dtype", "torch.bfloat16"),
("quant_method", "fp8"),
("model_path", "/models/other"),
("revision", "v2"),
("device_capability", "9.0"),
("torch_version", "2.4.0"),
):
self.assertFalse(
base.matches(_make_cache_config(**{field: value})),
msg=f"{field} difference should break match",
)
def test_dict_round_trip(self):
cfg = _make_cache_config(quant_method="fp8", quant_config_hash="abc123")
restored = CacheConfig.from_dict(cfg.to_dict())
self.assertTrue(cfg.matches(restored))
self.assertEqual(cfg.to_dict(), restored.to_dict())
class TestQuantConfigHashing(CustomTestCase):
def test_none_hashes_to_empty(self):
self.assertEqual(hash_quant_config(None), "")
def test_dict_hash_is_deterministic_and_order_insensitive(self):
h1 = hash_quant_config({"bits": 8, "group_size": 128})
h2 = hash_quant_config({"group_size": 128, "bits": 8})
self.assertEqual(h1, h2)
self.assertNotEqual(h1, hash_quant_config({"bits": 4, "group_size": 128}))
def test_hash_is_not_truncated(self):
# A correctness gate must use the full SHA-256 digest, not a 16-char prefix.
self.assertEqual(len(hash_quant_config({"bits": 8})), 64)
def test_hash_does_not_embed_object_address(self):
# Two distinct instances with identical public attrs must hash equal,
# otherwise configs would mismatch across processes (the bug the
# docstring warns about).
class _Q:
def __init__(self):
self.bits = 8
self.method = "fp8"
self.assertEqual(hash_quant_config(_Q()), hash_quant_config(_Q()))
def test_get_quant_method_name_variants(self):
self.assertEqual(get_quant_method_name(None), "")
self.assertEqual(get_quant_method_name("fp8"), "fp8")
class _WithGetName:
def get_name(self):
return "gptq_marlin"
class _WithName:
name = "awq"
self.assertEqual(get_quant_method_name(_WithGetName()), "gptq_marlin")
self.assertEqual(get_quant_method_name(_WithName()), "awq")
class TestGlobalRankAndPaths(CustomTestCase):
def test_compute_global_rank_formula(self):
self.assertEqual(compute_global_rank(tp_size=4, pp_rank=0, tp_rank=3), 3)
self.assertEqual(compute_global_rank(tp_size=4, pp_rank=1, tp_rank=0), 4)
self.assertEqual(compute_global_rank(tp_size=4, pp_rank=2, tp_rank=1), 9)
def test_socket_and_ready_paths_are_unique_per_rank(self):
self.assertNotEqual(get_socket_path(0), get_socket_path(1))
self.assertTrue(get_socket_path(3).endswith("rank3.sock"))
self.assertTrue(get_ready_path(3).endswith("rank3.ready"))
def test_compute_local_gpu_id_honors_base_and_step(self):
# Single-node TP=4: identity mapping rank -> gpu.
self.assertEqual(
compute_local_gpu_id(0, 2, pp_size_per_node=1, tp_size_per_node=4),
2,
)
# base_gpu_id offsets every rank; gpu_id_step strides between them.
self.assertEqual(
compute_local_gpu_id(
0, 2, pp_size_per_node=1, tp_size_per_node=4, base_gpu_id=4
),
6,
)
self.assertEqual(
compute_local_gpu_id(
0, 2, pp_size_per_node=1, tp_size_per_node=4, gpu_id_step=2
),
4,
)
class TestIpcQuantAllowlist(CustomTestCase):
def test_unquantized_is_supported(self):
self.assertTrue(is_ipc_quant_supported("", None))
def test_block_fp8_supported_but_per_tensor_fp8_rejected(self):
self.assertTrue(
is_ipc_quant_supported("fp8", {"weight_block_size": [128, 128]})
)
# Per-tensor FP8 (no weight_block_size) transposes the weight during
# post-processing -> not reproducible by the meta-init client.
self.assertFalse(is_ipc_quant_supported("fp8", {}))
self.assertFalse(is_ipc_quant_supported("fp8", None))
def test_unknown_method_rejected(self):
self.assertFalse(is_ipc_quant_supported("gptq_marlin", None))
self.assertFalse(is_ipc_quant_supported("awq", None))
def test_check_raises_on_unsupported(self):
with self.assertRaises(UnsupportedQuantForIPCError):
check_ipc_quant_support("awq", None, where="client")
# Per-tensor FP8 must also raise even though "fp8" is a known key.
with self.assertRaises(UnsupportedQuantForIPCError):
check_ipc_quant_support("fp8", {}, where="daemon")
def test_check_passes_on_supported(self):
# Should not raise.
check_ipc_quant_support("", None, where="daemon")
check_ipc_quant_support(
"fp8", {"weight_block_size": [128, 128]}, where="daemon"
)
def test_allowlist_registry_shape(self):
# Guard against accidentally widening the allowlist without review.
self.assertEqual(set(IPC_QUANT_ALLOWLIST), {"", "fp8"})
class TestCleanupStaleDaemonFiles(CustomTestCase):
# Use a rank far outside any realistic tp*pp layout so we never collide
# with a daemon that might actually be running on the test host.
RANK = 987654
def _paths(self):
return get_ready_path(self.RANK), get_socket_path(self.RANK)
def tearDown(self):
for path in self._paths():
if os.path.exists(path):
os.unlink(path)
def test_no_files_is_noop(self):
# Neither file present: must return quietly, not raise.
cleanup_stale_daemon_files(self.RANK)
def test_stale_files_without_live_pid_are_removed(self):
ready_path, socket_path = self._paths()
# A .ready file whose PID is unreadable is treated as a crashed-daemon
# leftover and cleaned up.
with open(ready_path, "w") as f:
f.write("stale contents, no pid line\n")
open(socket_path, "w").close()
cleanup_stale_daemon_files(self.RANK)
self.assertFalse(os.path.exists(ready_path))
self.assertFalse(os.path.exists(socket_path))
def test_live_daemon_pid_blocks_cleanup(self):
ready_path, socket_path = self._paths()
# Our own PID is alive -> cleanup must refuse and leave files intact.
with open(ready_path, "w") as f:
f.write(f"pid={os.getpid()}\n")
open(socket_path, "w").close()
with self.assertRaises(RuntimeError):
cleanup_stale_daemon_files(self.RANK)
self.assertTrue(os.path.exists(ready_path))
self.assertTrue(os.path.exists(socket_path))
def test_force_takes_over_from_live_pid(self):
ready_path, socket_path = self._paths()
# Spawn a real child we are allowed to kill, point the ready file at it,
# then force-takeover: the child must be killed and the files removed.
import subprocess
import sys
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
try:
with open(ready_path, "w") as f:
f.write(f"pid={child.pid}\n")
open(socket_path, "w").close()
cleanup_stale_daemon_files(self.RANK, force=True)
self.assertFalse(os.path.exists(ready_path))
self.assertFalse(os.path.exists(socket_path))
# The daemon holding the rank must have been killed.
self.assertEqual(child.wait(timeout=5), -9)
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=5)
class TestDaemonModeRefusesDiskLoad(CustomTestCase):
"""In daemon mode the engine and daemon share a GPU, so a missing daemon
must be a hard error — NOT a silent disk-load that would OOM the shared
device. This exercises that contract without a GPU or a live daemon by
pointing the loader at a socket path that does not exist.
"""
RANK = 987655
def _model_config(self):
from types import SimpleNamespace
# Minimal stand-in: the loader only reads hf_config.quantization_config,
# quantization, and (unreached here) hf_config.architectures.
hf_config = SimpleNamespace(
architectures=["LlamaForCausalLM"], quantization_config=None
)
return SimpleNamespace(
model_path="/models/demo",
hf_config=hf_config,
quantization=None,
revision=None,
dtype="torch.float16",
)
def test_daemon_mode_missing_daemon_raises_instead_of_disk_load(self):
from sglang.srt.configs.load_config import LoadConfig, LoadFormat
from sglang.srt.weight_cache.ipc_loader import IpcModelLoader
missing_socket = get_socket_path(self.RANK)
if os.path.exists(missing_socket):
os.unlink(missing_socket)
loader = IpcModelLoader(
load_config=LoadConfig(load_format=LoadFormat.IPC_CACHE),
socket_path=missing_socket,
weight_cache_mode="daemon",
fallback_load_format="auto",
)
with self.assertRaises(RuntimeError) as ctx:
loader.load_model(model_config=self._model_config(), device_config=None)
# The error must be about the missing daemon, proving we did not quietly
# fall through to a disk load.
self.assertIn("daemon", str(ctx.exception).lower())
if __name__ == "__main__":
unittest.main()