Files
qwen36_01/worklogs/formal_perf_bench.py

256 lines
9.6 KiB
Python
Raw Normal View History

import argparse
import concurrent.futures
import json
import math
import statistics
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
@dataclass
class RequestResult:
ok: bool
elapsed_sec: float
ttft_sec: float | None
prompt_tokens: int
cached_tokens: int
completion_tokens: int
reasoning_tokens: int
output_tps: float | None
error: str | None = None
def percentile(values, pct):
if not values:
return None
values = sorted(values)
if len(values) == 1:
return values[0]
pos = (len(values) - 1) * pct / 100.0
lo = math.floor(pos)
hi = math.ceil(pos)
if lo == hi:
return values[lo]
return values[lo] * (hi - pos) + values[hi] * (pos - lo)
def load_dataset(path):
text = Path(path).read_text(encoding="utf-8")
if path.endswith(".jsonl"):
data = [json.loads(line) for line in text.splitlines() if line.strip()]
else:
data = json.loads(text)
# Official-like format: each item is already an OpenAI chat completion
# request with messages/tools/tool_choice/stream/etc.
if data and isinstance(data[0], dict) and "messages" in data[0]:
return data
requests = []
for item in data:
system_prompt = item.get("system_prompt") or "You are a helpful assistant."
history = [{"role": "system", "content": system_prompt}]
for question in item.get("user_questions", []):
messages = history + [{"role": "user", "content": question}]
requests.append({"messages": messages})
# Synthetic assistant placeholder keeps later prompts multi-turn.
history = messages + [{"role": "assistant", "content": "好的,我们继续。"}]
return requests
def post_stream(url, model, request_item, max_tokens, timeout):
allowed_fields = {
"messages",
"tools",
"tool_choice",
"model",
"max_tokens",
"temperature",
"top_p",
"stop",
"presence_penalty",
"frequency_penalty",
"n",
"response_format",
}
payload = {k: v for k, v in dict(request_item).items() if k in allowed_fields}
payload["model"] = payload.get("model") or model
# The benchmark CLI controls output length, even if the synthetic dataset
# stores a larger official-like max_tokens value.
payload["max_tokens"] = max_tokens
payload["temperature"] = payload.get("temperature", 0)
payload["stream"] = True
payload["stream_options"] = {"include_usage": True}
req = urllib.request.Request(
url.rstrip("/") + "/v1/chat/completions",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
start = time.perf_counter()
first_token_at = None
usage = {}
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
for raw in resp:
line = raw.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
obj = json.loads(data)
if obj.get("usage"):
usage = obj["usage"]
for choice in obj.get("choices") or []:
delta = choice.get("delta") or {}
text = delta.get("content") or delta.get("reasoning_content") or ""
if text and first_token_at is None:
first_token_at = time.perf_counter()
elapsed = time.perf_counter() - start
ttft = first_token_at - start if first_token_at else None
completion_tokens = int(usage.get("completion_tokens") or 0)
prompt_tokens = int(usage.get("prompt_tokens") or 0)
reasoning_tokens = int(usage.get("reasoning_tokens") or 0)
details = usage.get("prompt_tokens_details") or {}
cached_tokens = int(details.get("cached_tokens") or 0)
decode_sec = elapsed - ttft if ttft is not None else elapsed
output_tps = completion_tokens / decode_sec if completion_tokens and decode_sec > 0 else None
return RequestResult(
ok=True,
elapsed_sec=elapsed,
ttft_sec=ttft,
prompt_tokens=prompt_tokens,
cached_tokens=cached_tokens,
completion_tokens=completion_tokens,
reasoning_tokens=reasoning_tokens,
output_tps=output_tps,
)
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception:
pass
elapsed = time.perf_counter() - start
return RequestResult(
ok=False,
elapsed_sec=elapsed,
ttft_sec=None,
prompt_tokens=0,
cached_tokens=0,
completion_tokens=0,
reasoning_tokens=0,
output_tps=None,
error=f"HTTPError {exc.code}: {body[:1000]}",
)
except Exception as exc:
elapsed = time.perf_counter() - start
return RequestResult(
ok=False,
elapsed_sec=elapsed,
ttft_sec=None,
prompt_tokens=0,
cached_tokens=0,
completion_tokens=0,
reasoning_tokens=0,
output_tps=None,
error=f"{type(exc).__name__}: {exc}",
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:1111")
parser.add_argument("--model", default="llm")
parser.add_argument("--dataset", required=True)
parser.add_argument("--concurrency", type=int, default=1)
parser.add_argument("--max-requests", type=int, default=20)
parser.add_argument("--max-tokens", type=int, default=128)
parser.add_argument("--timeout", type=int, default=600)
parser.add_argument("--out", default="/root/work/logs/formal_perf_result.json")
args = parser.parse_args()
reqs = load_dataset(args.dataset)
if not reqs:
raise SystemExit("empty dataset")
scheduled = [reqs[i % len(reqs)] for i in range(args.max_requests)]
wall_start = time.perf_counter()
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = [
pool.submit(post_stream, args.url, args.model, request_item, args.max_tokens, args.timeout)
for request_item in scheduled
]
for fut in concurrent.futures.as_completed(futures):
results.append(fut.result())
print(f"done {len(results)}/{len(scheduled)} ok={results[-1].ok}", flush=True)
wall_sec = time.perf_counter() - wall_start
ok_results = [r for r in results if r.ok]
success_rate = len(ok_results) / len(results) if results else 0.0
ttfts = [r.ttft_sec for r in ok_results if r.ttft_sec is not None]
output_tps_values = [r.output_tps for r in ok_results if r.output_tps is not None]
prompt_tokens = sum(r.prompt_tokens for r in ok_results)
cached_tokens = sum(r.cached_tokens for r in ok_results)
input_tokens_uncached = max(prompt_tokens - cached_tokens, 0)
output_tokens = sum(r.completion_tokens for r in ok_results)
aggregate_output_tps = output_tokens / wall_sec if wall_sec > 0 else 0.0
aggregate_input_tps = input_tokens_uncached / wall_sec if wall_sec > 0 else 0.0
aggregate_cache_tps = cached_tokens / wall_sec if wall_sec > 0 else 0.0
weighted = aggregate_output_tps * 16.796 + aggregate_input_tps * 2.799 + aggregate_cache_tps * 0.56
cache_hit_rate = cached_tokens / prompt_tokens if prompt_tokens else 0.0
summary = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"url": args.url,
"model": args.model,
"dataset": args.dataset,
"concurrency": args.concurrency,
"max_requests": args.max_requests,
"max_tokens": args.max_tokens,
"wall_sec": wall_sec,
"success_rate": success_rate,
"ttft_p90_sec": percentile(ttfts, 90),
"output_tps_p10_per_request": percentile(output_tps_values, 10),
"aggregate_output_tps": aggregate_output_tps,
"aggregate_input_tps_uncached": aggregate_input_tps,
"aggregate_cache_tps": aggregate_cache_tps,
"cache_hit_rate": cache_hit_rate,
"weighted_token_throughput": weighted,
"totals": {
"requests": len(results),
"success": len(ok_results),
"prompt_tokens": prompt_tokens,
"input_tokens_uncached": input_tokens_uncached,
"cached_tokens": cached_tokens,
"completion_tokens": output_tokens,
"reasoning_tokens": sum(r.reasoning_tokens for r in ok_results),
},
"targets": {
"output_tps_p10_per_request_gte_20": (percentile(output_tps_values, 10) or 0) >= 20,
"ttft_p90_lte_5": (percentile(ttfts, 90) or 999) <= 5,
"cache_hit_rate_gte_50pct": cache_hit_rate >= 0.5,
"success_rate_gte_99pct": success_rate >= 0.99,
"weighted_token_throughput_gte_8000": weighted >= 8000,
},
"results": [asdict(r) for r in results],
}
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({k: v for k, v in summary.items() if k != "results"}, ensure_ascii=False, indent=2))
print("RESULT_FILE", args.out)
if __name__ == "__main__":
main()