Files
ds4/tests/test_server_batching.py
T
Salvatore Sanfilippo ab38bc6b7a Serve Qwen batched MTP with safe session state and output limits
Keep exact sampled requests on ordinary batches. Clear stale verification snapshots, honor logical context limits, and reset speculative timing estimates when batch width changes. Exercise forced rejection, mixed decode, snapshot restore, disk failures and arena replacement.
2026-09-16 15:17:25 +02:00

332 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Concurrent API correctness/load check for ds4-server session batching.
Each case is submitted twice with the same non-zero seed. The pairs run in one
cold concurrent wave and must return identical output, even though prompt sizes
and output limits differ across cases. A fresh nonce avoids accidental reuse of
an earlier run's in-memory or disk checkpoint.
"""
import argparse
import concurrent.futures
import json
import math
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request
CASES = (
("short-greedy", 0, 80, 0.0, 101),
("medium-sampled", 384, 72, 0.7, 202),
("long-greedy", 2304, 48, 0.0, 303),
("short-sampled", 32, 96, 0.65, 404),
("very-long-sampled", 3600, 32, 0.75, 505),
("medium-greedy", 1024, 64, 0.0, 606),
("long-sampled", 2048, 40, 0.7, 707),
("tiny-sampled", 0, 96, 0.6, 808),
)
def percentile(values, fraction):
ordered = sorted(values)
return ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
def make_payload(case, case_number, nonce, stream):
name, filler_words, max_tokens, temperature, seed = case
filler = (" alpha beta gamma delta" * ((filler_words + 3) // 4))
filler = " ".join(filler.split()[:filler_words])
prompt = (
"This is batching test %s case %d (%s). Read the filler, then write "
"a concise explanation of why deterministic request isolation matters. "
"Do not quote the filler.\nFILLER:\n%s"
) % (nonce, case_number, name, filler)
payload = {
"model": "deepseek-chat",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": 0.9,
"seed": seed + case_number * 1000,
"stream": stream,
}
if stream:
payload["stream_options"] = {"include_usage": True}
return name, filler_words, payload
def parse_stream(raw):
content = []
reasoning = []
finish = None
usage = None
for line in raw.decode("utf-8", errors="replace").splitlines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
continue
event = json.loads(data)
if event.get("usage"):
usage = event["usage"]
choices = event.get("choices") or []
if not choices:
continue
choice = choices[0]
delta = choice.get("delta") or {}
if delta.get("content") is not None:
content.append(delta["content"])
if delta.get("reasoning_content") is not None:
reasoning.append(delta["reasoning_content"])
if choice.get("finish_reason") is not None:
finish = choice["finish_reason"]
return {
"content": "".join(content),
"reasoning": "".join(reasoning),
"finish": finish,
"completion_tokens": (usage or {}).get("completion_tokens"),
}
def post_chat(url, payload, timeout, start_event):
start_event.wait()
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
req = urllib.request.Request(
url.rstrip("/") + "/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
started = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError("HTTP %d: %s" % (exc.code, detail)) from exc
elapsed = time.monotonic() - started
if payload["stream"]:
result = parse_stream(raw)
else:
response = json.loads(raw)
choice = response["choices"][0]
message = choice.get("message") or {}
result = {
"content": message.get("content") or "",
"reasoning": message.get("reasoning_content") or "",
"finish": choice.get("finish_reason"),
"completion_tokens": (response.get("usage") or {}).get(
"completion_tokens"
),
}
result["elapsed"] = elapsed
return result
def cancel_stream(url, payload, timeout, start_event):
"""Close a streaming response after the first generated text fragment."""
start_event.wait()
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
req = urllib.request.Request(
url.rstrip("/") + "/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
started = time.monotonic()
with urllib.request.urlopen(req, timeout=timeout) as response:
while True:
line = response.readline()
if not line:
raise RuntimeError("stream ended before cancellation point")
if not line.startswith(b"data:"):
continue
data = line[5:].strip()
if not data or data == b"[DONE]":
continue
event = json.loads(data)
choices = event.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
if delta.get("content") or delta.get("reasoning_content"):
return time.monotonic() - started
def comparable(result):
return (
result["content"],
result["reasoning"],
result["finish"],
result["completion_tokens"],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8000")
parser.add_argument("--model", default="deepseek-chat")
parser.add_argument("--pairs", type=int, default=4)
parser.add_argument("--workers", type=int, default=0)
parser.add_argument("--timeout", type=float, default=1800.0)
parser.add_argument("--stream", action="store_true")
parser.add_argument("--nonce", default="")
parser.add_argument(
"--same-prompt", action="store_true",
help="send one identical prompt and seed in every request",
)
parser.add_argument(
"--max-tokens", type=int,
help="override each case's completion limit",
)
parser.add_argument(
"--case", choices=[case[0] for case in CASES],
help="repeat one case shape instead of cycling through mixed lengths",
)
parser.add_argument(
"--cancel-first", type=int, default=0,
help="abort this many concurrent streams before running the pair oracle",
)
args = parser.parse_args()
if args.pairs <= 0:
parser.error("--pairs must be positive")
if args.cancel_first < 0:
parser.error("--cancel-first must not be negative")
if args.max_tokens is not None and args.max_tokens < 0:
parser.error("--max-tokens must be non-negative")
nonce = args.nonce or "cold-%d" % time.time_ns()
cancelled = []
if args.cancel_first:
cancel_start = threading.Event()
cancel_payloads = []
for i in range(args.cancel_first):
_, _, payload = make_payload(
CASES[3], i, nonce + "-cancel", True
)
payload["max_tokens"] = max(args.max_tokens or 0, 128)
payload["model"] = args.model
cancel_payloads.append(payload)
with concurrent.futures.ThreadPoolExecutor(
max_workers=args.cancel_first
) as executor:
futures = [
executor.submit(
cancel_stream, args.url, payload, args.timeout, cancel_start
)
for payload in cancel_payloads
]
cancel_start.set()
cancelled = [future.result() for future in futures]
# Give the server time to observe closed sockets and recycle every slot.
time.sleep(0.5)
requests = []
metadata = []
for i in range(args.pairs):
case = next((c for c in CASES if c[0] == args.case), None)
if case is None:
case = CASES[i % len(CASES)]
case_number = 0 if args.same_prompt else i
name, filler_words, payload = make_payload(
case, case_number, nonce, args.stream
)
if args.max_tokens is not None:
payload["max_tokens"] = args.max_tokens
payload["model"] = args.model
for copy in range(2):
requests.append(payload)
metadata.append((i, copy, name, filler_words))
workers = args.workers or len(requests)
workers = max(1, min(workers, len(requests)))
start_event = threading.Event()
wall_start = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(post_chat, args.url, payload, args.timeout, start_event)
for payload in requests
]
start_event.set()
results = [future.result() for future in futures]
wall = time.monotonic() - wall_start
failures = 0
for i, result in enumerate(results):
if result["finish"] not in ("stop", "length") or (
requests[i]["max_tokens"] > 0 and not (result["content"] or result["reasoning"])
):
failures += 1
print("INVALID request=%d result=%s" % (i, result), file=sys.stderr)
count = result["completion_tokens"]
if count is not None and not 0 <= count <= requests[i]["max_tokens"]:
failures += 1
print("INVALID token budget request=%d result=%s" % (i, result), file=sys.stderr)
for i in range(args.pairs):
a = results[2 * i]
b = results[2 * i + 1]
if comparable(a) != comparable(b):
failures += 1
print("MISMATCH pair=%d case=%s" % (i, metadata[2 * i][2]), file=sys.stderr)
print(" A=%s" % (json.dumps(a, ensure_ascii=True)[:1000]), file=sys.stderr)
print(" B=%s" % (json.dumps(b, ensure_ascii=True)[:1000]), file=sys.stderr)
for label, result in (("A", a), ("B", b)):
matches = [
index for index, candidate in enumerate(results)
if index not in (2 * i, 2 * i + 1)
and comparable(candidate) == comparable(result)
]
if matches:
print(
" %s also returned by requests=%s" % (label, matches),
file=sys.stderr,
)
if args.same_prompt:
mismatches = [
index for index, result in enumerate(results[1:], start=1)
if comparable(result) != comparable(results[0])
]
if mismatches:
failures += 1
print(
"MISMATCH identical prompt requests=%s" % mismatches,
file=sys.stderr,
)
latencies = [result["elapsed"] for result in results]
known_tokens = [
result["completion_tokens"]
for result in results
if result["completion_tokens"] is not None
]
summary = {
"status": "PASS" if failures == 0 else "FAIL",
"pairs": args.pairs,
"requests": len(requests),
"workers": workers,
"stream": args.stream,
"wall_seconds": round(wall, 3),
"latency_p50_seconds": round(statistics.median(latencies), 3),
"latency_p95_seconds": round(percentile(latencies, 0.95), 3),
"completion_tokens": sum(known_tokens) if known_tokens else None,
"completion_tokens_per_second": (
round(sum(known_tokens) / wall, 2) if known_tokens else None
),
"cancelled_streams": len(cancelled),
"cancel_latency_max_seconds": (
round(max(cancelled), 3) if cancelled else None
),
"nonce": nonce,
}
print(json.dumps(summary, sort_keys=True))
return 0 if failures == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())