315 lines
11 KiB
Python
315 lines
11 KiB
Python
|
|
import argparse
|
||
|
|
import concurrent.futures
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
from dataclasses import asdict, dataclass
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class RequestResult:
|
||
|
|
ok: bool
|
||
|
|
elapsed_sec: float
|
||
|
|
ttft_sec: float | None
|
||
|
|
completion_tokens: int
|
||
|
|
prompt_tokens: int
|
||
|
|
cached_tokens: int
|
||
|
|
reasoning_tokens: int
|
||
|
|
output_tps: float | None
|
||
|
|
chars: int
|
||
|
|
error: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def percentile(values, pct):
|
||
|
|
values = sorted(v for v in values if v is not None)
|
||
|
|
if not values:
|
||
|
|
return None
|
||
|
|
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 make_tools(count):
|
||
|
|
tools = []
|
||
|
|
for i in range(count):
|
||
|
|
tools.append({
|
||
|
|
"type": "function",
|
||
|
|
"function": {
|
||
|
|
"name": f"tool_{i:02d}_exec",
|
||
|
|
"description": "Run a deterministic diagnostic action.",
|
||
|
|
"parameters": {
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"command": {"type": "string"},
|
||
|
|
"path": {"type": "string"},
|
||
|
|
},
|
||
|
|
"required": ["command"],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
return tools
|
||
|
|
|
||
|
|
|
||
|
|
def make_request(args, idx):
|
||
|
|
if args.prompt_mode == "short":
|
||
|
|
user = (
|
||
|
|
"Do not explain. Output a comma-separated sequence of four digit "
|
||
|
|
"numbers starting at 0001. Continue until the token limit stops you."
|
||
|
|
)
|
||
|
|
messages = [{"role": "user", "content": user}]
|
||
|
|
elif args.prompt_mode == "tool":
|
||
|
|
messages = [
|
||
|
|
{"role": "system", "content": "You are a coding agent. Return concise tool-call-like JSON text."},
|
||
|
|
{"role": "user", "content": "Create a shell command to list Python files and print the answer as JSON."},
|
||
|
|
]
|
||
|
|
else:
|
||
|
|
raise ValueError(f"unknown prompt_mode: {args.prompt_mode}")
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
"model": args.model,
|
||
|
|
"messages": messages,
|
||
|
|
"max_tokens": args.max_tokens,
|
||
|
|
"temperature": 0,
|
||
|
|
"stream": True,
|
||
|
|
"stream_options": {"include_usage": True},
|
||
|
|
}
|
||
|
|
if args.with_tools:
|
||
|
|
payload["tools"] = make_tools(args.tool_count)
|
||
|
|
payload["tool_choice"] = "auto"
|
||
|
|
return payload
|
||
|
|
|
||
|
|
|
||
|
|
def post_stream(url, payload, timeout):
|
||
|
|
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_at = None
|
||
|
|
usage = {}
|
||
|
|
chars = 0
|
||
|
|
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 {}
|
||
|
|
parts = [
|
||
|
|
delta.get("content") or "",
|
||
|
|
delta.get("reasoning_content") or "",
|
||
|
|
]
|
||
|
|
for tool_call in delta.get("tool_calls") or []:
|
||
|
|
fn = tool_call.get("function") or {}
|
||
|
|
parts.append(fn.get("name") or "")
|
||
|
|
parts.append(fn.get("arguments") or "")
|
||
|
|
added = sum(len(p) for p in parts)
|
||
|
|
if added and first_at is None:
|
||
|
|
first_at = time.perf_counter()
|
||
|
|
chars += added
|
||
|
|
elapsed = time.perf_counter() - start
|
||
|
|
ttft = first_at - start if first_at is not None else None
|
||
|
|
details = usage.get("prompt_tokens_details") or {}
|
||
|
|
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||
|
|
decode_sec = elapsed - ttft if ttft is not None else elapsed
|
||
|
|
return RequestResult(
|
||
|
|
ok=True,
|
||
|
|
elapsed_sec=elapsed,
|
||
|
|
ttft_sec=ttft,
|
||
|
|
completion_tokens=completion_tokens,
|
||
|
|
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
||
|
|
cached_tokens=int(details.get("cached_tokens") or 0),
|
||
|
|
reasoning_tokens=int(usage.get("reasoning_tokens") or 0),
|
||
|
|
output_tps=completion_tokens / decode_sec if completion_tokens and decode_sec > 0 else None,
|
||
|
|
chars=chars,
|
||
|
|
)
|
||
|
|
except urllib.error.HTTPError as exc:
|
||
|
|
body = exc.read().decode("utf-8", errors="replace")[:1000]
|
||
|
|
return RequestResult(False, time.perf_counter() - start, None, 0, 0, 0, 0, None, chars, f"HTTP {exc.code}: {body}")
|
||
|
|
except Exception as exc:
|
||
|
|
return RequestResult(False, time.perf_counter() - start, None, 0, 0, 0, 0, None, chars, f"{type(exc).__name__}: {exc}")
|
||
|
|
|
||
|
|
|
||
|
|
def parse_ixsmi(raw):
|
||
|
|
samples = []
|
||
|
|
for line in raw.splitlines():
|
||
|
|
m = re.search(r"\|\s*\d+%\s+\d+C\s+\S+\s+(\d+)W\s*/\s*(\d+)W\s*\|\s*(\d+)MiB\s*/\s*(\d+)MiB\s*\|\s*(\d+)%", line)
|
||
|
|
if m:
|
||
|
|
samples.append({
|
||
|
|
"power_w": int(m.group(1)),
|
||
|
|
"power_cap_w": int(m.group(2)),
|
||
|
|
"mem_mib": int(m.group(3)),
|
||
|
|
"mem_total_mib": int(m.group(4)),
|
||
|
|
"util_pct": int(m.group(5)),
|
||
|
|
})
|
||
|
|
return samples
|
||
|
|
|
||
|
|
|
||
|
|
def monitor_ixsmi(stop_event, out_path, interval):
|
||
|
|
records = []
|
||
|
|
env = dict(os.environ)
|
||
|
|
env["LD_LIBRARY_PATH"] = ":".join([
|
||
|
|
"/usr/local/corex/lib64",
|
||
|
|
"/usr/local/corex/lib",
|
||
|
|
"/usr/local/iluvatar/lib64",
|
||
|
|
env.get("LD_LIBRARY_PATH", ""),
|
||
|
|
])
|
||
|
|
candidates = [
|
||
|
|
"/usr/local/corex/bin/ixsmi",
|
||
|
|
"/usr/local/iluvatar/bin/ixsmi",
|
||
|
|
"ixsmi",
|
||
|
|
]
|
||
|
|
while not stop_event.is_set():
|
||
|
|
ts = datetime.now().isoformat(timespec="seconds")
|
||
|
|
try:
|
||
|
|
last_error = None
|
||
|
|
raw = None
|
||
|
|
for binary in candidates:
|
||
|
|
try:
|
||
|
|
raw = subprocess.check_output([binary], text=True, stderr=subprocess.STDOUT, timeout=10, env=env)
|
||
|
|
break
|
||
|
|
except Exception as exc:
|
||
|
|
last_error = exc
|
||
|
|
if raw is None:
|
||
|
|
raise last_error or RuntimeError("ixsmi not found")
|
||
|
|
records.append({"ts": ts, "ok": True, "raw": raw, "parsed": parse_ixsmi(raw)})
|
||
|
|
except Exception as exc:
|
||
|
|
records.append({"ts": ts, "ok": False, "error": repr(exc)})
|
||
|
|
stop_event.wait(interval)
|
||
|
|
Path(out_path).write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
|
|
||
|
|
|
||
|
|
def summarize_monitor(path):
|
||
|
|
if not path or not Path(path).exists():
|
||
|
|
return None
|
||
|
|
records = json.loads(Path(path).read_text(encoding="utf-8"))
|
||
|
|
util = []
|
||
|
|
mem = []
|
||
|
|
power = []
|
||
|
|
for rec in records:
|
||
|
|
for gpu in rec.get("parsed") or []:
|
||
|
|
util.append(gpu["util_pct"])
|
||
|
|
mem.append(gpu["mem_mib"])
|
||
|
|
power.append(gpu["power_w"])
|
||
|
|
if not util:
|
||
|
|
return {"records": len(records), "parsed_samples": 0}
|
||
|
|
return {
|
||
|
|
"records": len(records),
|
||
|
|
"parsed_samples": len(util),
|
||
|
|
"avg_gpu_util_pct": sum(util) / len(util),
|
||
|
|
"max_gpu_util_pct": max(util),
|
||
|
|
"avg_mem_mib": sum(mem) / len(mem),
|
||
|
|
"max_mem_mib": max(mem),
|
||
|
|
"avg_power_w": sum(power) / len(power),
|
||
|
|
"max_power_w": max(power),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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("--label", required=True)
|
||
|
|
parser.add_argument("--prompt-mode", choices=["short", "tool"], default="short")
|
||
|
|
parser.add_argument("--with-tools", action="store_true")
|
||
|
|
parser.add_argument("--tool-count", type=int, default=16)
|
||
|
|
parser.add_argument("--concurrency", type=int, default=1)
|
||
|
|
parser.add_argument("--requests", type=int, default=4)
|
||
|
|
parser.add_argument("--max-tokens", type=int, default=256)
|
||
|
|
parser.add_argument("--timeout", type=int, default=900)
|
||
|
|
parser.add_argument("--monitor-out")
|
||
|
|
parser.add_argument("--monitor-interval", type=float, default=1.0)
|
||
|
|
parser.add_argument("--out", required=True)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
stop_event = threading.Event()
|
||
|
|
monitor_thread = None
|
||
|
|
if args.monitor_out:
|
||
|
|
monitor_thread = threading.Thread(
|
||
|
|
target=monitor_ixsmi,
|
||
|
|
args=(stop_event, args.monitor_out, args.monitor_interval),
|
||
|
|
daemon=True,
|
||
|
|
)
|
||
|
|
monitor_thread.start()
|
||
|
|
|
||
|
|
started = time.perf_counter()
|
||
|
|
results = []
|
||
|
|
try:
|
||
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
|
||
|
|
futures = [
|
||
|
|
pool.submit(post_stream, args.url, make_request(args, i), args.timeout)
|
||
|
|
for i in range(args.requests)
|
||
|
|
]
|
||
|
|
for fut in concurrent.futures.as_completed(futures):
|
||
|
|
result = fut.result()
|
||
|
|
results.append(result)
|
||
|
|
print(f"done {len(results)}/{args.requests} ok={result.ok} tps={result.output_tps}", flush=True)
|
||
|
|
finally:
|
||
|
|
stop_event.set()
|
||
|
|
if monitor_thread:
|
||
|
|
monitor_thread.join(timeout=15)
|
||
|
|
|
||
|
|
wall = time.perf_counter() - started
|
||
|
|
ok = [r for r in results if r.ok]
|
||
|
|
tps_values = [r.output_tps for r in ok if r.output_tps is not None]
|
||
|
|
ttft_values = [r.ttft_sec for r in ok if r.ttft_sec is not None]
|
||
|
|
completion = sum(r.completion_tokens for r in ok)
|
||
|
|
prompt = sum(r.prompt_tokens for r in ok)
|
||
|
|
cached = sum(r.cached_tokens for r in ok)
|
||
|
|
summary = {
|
||
|
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||
|
|
"label": args.label,
|
||
|
|
"url": args.url,
|
||
|
|
"model": args.model,
|
||
|
|
"prompt_mode": args.prompt_mode,
|
||
|
|
"with_tools": args.with_tools,
|
||
|
|
"tool_count": args.tool_count if args.with_tools else 0,
|
||
|
|
"concurrency": args.concurrency,
|
||
|
|
"requests": args.requests,
|
||
|
|
"max_tokens": args.max_tokens,
|
||
|
|
"wall_sec": wall,
|
||
|
|
"success_rate": len(ok) / len(results) if results else 0,
|
||
|
|
"ttft_p50_sec": percentile(ttft_values, 50),
|
||
|
|
"ttft_p90_sec": percentile(ttft_values, 90),
|
||
|
|
"output_tps_p10_per_request": percentile(tps_values, 10),
|
||
|
|
"output_tps_p50_per_request": percentile(tps_values, 50),
|
||
|
|
"aggregate_output_tps": completion / wall if wall > 0 else 0,
|
||
|
|
"prompt_tokens": prompt,
|
||
|
|
"cached_tokens": cached,
|
||
|
|
"completion_tokens": completion,
|
||
|
|
"reasoning_tokens": sum(r.reasoning_tokens for r in ok),
|
||
|
|
"chars": sum(r.chars for r in ok),
|
||
|
|
"monitor": summarize_monitor(args.monitor_out) if args.monitor_out else None,
|
||
|
|
"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()
|