sskj-h3/throughput/sglang-base/scripts/ref2va_feishu_bench.py
2026-08-31 15:57:13 +08:00

333 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Run the eight Feishu MiniMax-H3 Ref2VA cases against SGLang servers."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import statistics
import time
from pathlib import Path
from typing import Any
import requests
REFERENCE_RE = re.compile(
r"(图|音色)([一二三四五六七八九十\d]+)\s*=\s*([^(,\n]+)"
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def load_cases(records_path: Path, assets_root: Path) -> list[dict[str, Any]]:
envelope = json.loads(records_path.read_text(encoding="utf-8"))
payload = envelope["data"]
rows = payload["data"]
record_ids = payload["record_id_list"]
fields = payload["fields"]
if len(rows) != 8 or len(record_ids) != 8:
raise ValueError(f"expected exactly 8 records, got {len(rows)} rows")
field_index = {name: index for index, name in enumerate(fields)}
required_fields = {"提示词", "参考音频", "参考图像"}
if not required_fields.issubset(field_index):
raise ValueError(f"missing fields: {sorted(required_fields - set(field_index))}")
cases: list[dict[str, Any]] = []
for zero_index, (record_id, row) in enumerate(zip(record_ids, rows, strict=True)):
case_number = zero_index + 1
case_dir = assets_root / f"case{case_number:02d}_{record_id}"
prompt = row[field_index["提示词"]]
audio_attachments = row[field_index["参考音频"]] or []
image_attachments = row[field_index["参考图像"]] or []
attachment_by_name: dict[str, tuple[str, dict[str, Any]]] = {}
for kind, attachments in (("audio", audio_attachments), ("image", image_attachments)):
for attachment in attachments:
name = str(attachment["name"]).strip()
if name in attachment_by_name:
raise ValueError(f"case {case_number}: duplicate attachment name {name!r}")
attachment_by_name[name] = (kind, attachment)
references = []
seen_names: set[str] = set()
for match in REFERENCE_RE.finditer(prompt.splitlines()[0]):
label_kind, ordinal, name = match.groups()
name = name.strip()
expected_kind = "image" if label_kind == "" else "audio"
if name not in attachment_by_name:
raise ValueError(
f"case {case_number}: mapped {label_kind}{ordinal} file {name!r} "
f"not present in attachments {sorted(attachment_by_name)}"
)
actual_kind, attachment = attachment_by_name[name]
if actual_kind != expected_kind:
raise ValueError(
f"case {case_number}: {name!r} is {actual_kind}, expected {expected_kind}"
)
path = case_dir / name
if not path.is_file():
raise ValueError(f"case {case_number}: missing downloaded file {path}")
references.append(
{
"label": f"{label_kind}{ordinal}",
"type": expected_kind,
"name": name,
"path": str(path),
"file_token": attachment["file_token"],
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
seen_names.add(name)
if not references:
raise ValueError(f"case {case_number}: no reference mapping found in first line")
if seen_names != set(attachment_by_name):
raise ValueError(
f"case {case_number}: unmapped attachments "
f"{sorted(set(attachment_by_name) - seen_names)}"
)
cases.append(
{
"case_number": case_number,
"case_id": f"case{case_number:02d}_{record_id}",
"record_id": record_id,
"prompt": prompt,
"seed": 1100 + case_number,
"references": references,
}
)
return cases
def make_payload(args: argparse.Namespace, case: dict[str, Any], steps: int) -> dict[str, Any]:
conditions = [
{"type": ref["type"], "uri": ref["path"], "role": "reference"}
for ref in case["references"]
]
return {
"model": args.model,
"prompt": case["prompt"],
"num_outputs_per_prompt": 1,
"num_inference_steps": steps,
"flow_shift": args.flow_shift,
"audio_flow_shift": args.audio_flow_shift,
"seed": case["seed"],
"task": "ref2va",
"conditions": conditions,
"target": {
"short_edge": args.short_edge,
"aspect_ratio": args.aspect_ratio,
"duration_seconds": args.duration_seconds,
},
}
def request_video(
session: requests.Session,
args: argparse.Namespace,
case: dict[str, Any],
steps: int,
) -> dict[str, Any]:
started_epoch = time.time()
started = time.monotonic()
result: dict[str, Any] = {
"case_number": case["case_number"],
"case_id": case["case_id"],
"record_id": case["record_id"],
"seed": case["seed"],
"port": args.port,
"replica_index": args.replica_index,
"num_inference_steps": steps,
"short_edge": args.short_edge,
"aspect_ratio": args.aspect_ratio,
"duration_seconds": args.duration_seconds,
"references": case["references"],
"started_at_epoch": started_epoch,
"success": False,
"error": None,
}
try:
response = session.post(
f"http://{args.host}:{args.port}/v1/videos",
json=make_payload(args, case, steps),
timeout=args.submit_timeout,
)
if response.status_code != 200:
raise RuntimeError(f"submit HTTP {response.status_code}: {response.text[:2000]}")
status = response.json()
video_id = status.get("id")
if not video_id:
raise RuntimeError(f"submit response has no id: {status}")
result["video_id"] = video_id
deadline = time.monotonic() + args.request_timeout
while status.get("status") not in {"completed", "failed"}:
if time.monotonic() >= deadline:
raise TimeoutError(f"video job {video_id} exceeded {args.request_timeout}s")
time.sleep(args.poll_interval)
poll = session.get(
f"http://{args.host}:{args.port}/v1/videos/{video_id}",
timeout=args.poll_timeout,
)
if poll.status_code != 200:
raise RuntimeError(f"poll HTTP {poll.status_code}: {poll.text[:2000]}")
status = poll.json()
if status.get("status") != "completed":
raise RuntimeError(f"job failed: {status.get('error') or status}")
result["success"] = True
result["inference_time_s"] = status.get("inference_time_s")
result["peak_memory_mb"] = status.get("peak_memory_mb")
result["file_path"] = status.get("file_path")
result["status"] = status
except Exception as exc:
result["error"] = f"{type(exc).__name__}: {exc}"
result["latency_s"] = time.monotonic() - started
result["finished_at_epoch"] = time.time()
return result
def run_command(args: argparse.Namespace) -> int:
cases = load_cases(args.records, args.assets_root)
case = cases[args.case_number - 1]
args.output.parent.mkdir(parents=True, exist_ok=True)
with requests.Session() as session:
if args.warmup_steps > 0:
warmup = request_video(session, args, case, args.warmup_steps)
print(
f"warmup case={case['case_id']} success={warmup['success']} "
f"latency={warmup['latency_s']:.2f}s error={warmup['error']}",
flush=True,
)
if not warmup["success"]:
args.output.write_text(
json.dumps({"warmup": warmup}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return 1
result = request_video(session, args, case, args.num_inference_steps)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(
f"request case={case['case_id']} success={result['success']} "
f"latency={result['latency_s']:.2f}s file={result.get('file_path')} "
f"error={result['error']}",
flush=True,
)
return int(not result["success"])
def summarize_command(args: argparse.Namespace) -> int:
rows = []
for path in sorted(args.result_root.glob("batch_*/client_*/result.json")):
row = json.loads(path.read_text(encoding="utf-8"))
if "warmup" not in row:
rows.append(row)
batches: dict[str, Any] = {}
for batch_dir in sorted(args.result_root.glob("batch_*")):
batch_rows = []
for path in sorted(batch_dir.glob("client_*/result.json")):
row = json.loads(path.read_text(encoding="utf-8"))
if "warmup" not in row:
batch_rows.append(row)
successful = [row for row in batch_rows if row.get("success")]
starts = [float(row["started_at_epoch"]) for row in batch_rows]
finishes = [float(row["finished_at_epoch"]) for row in batch_rows]
wall_s = max(finishes) - min(starts) if starts and finishes else 0.0
batches[batch_dir.name] = {
"requests": len(batch_rows),
"completed": len(successful),
"failed": len(batch_rows) - len(successful),
"machine_wall_s": wall_s,
"machine_qps": len(successful) / wall_s if wall_s else 0.0,
"latency_mean_s": statistics.fmean(
float(row["latency_s"]) for row in successful
) if successful else 0.0,
}
successful = [row for row in rows if row.get("success")]
measured_wall_s = sum(batch["machine_wall_s"] for batch in batches.values())
summary = {
"method": "base",
"task": "ref2va",
"tp": 2,
"replicas": 4,
"expected_requests": 8,
"requests_recorded": len(rows),
"completed": len(successful),
"failed": len(rows) - len(successful),
"measured_batch_wall_s": measured_wall_s,
"machine_qps": len(successful) / measured_wall_s if measured_wall_s else 0.0,
"latency_mean_s": statistics.fmean(
float(row["latency_s"]) for row in successful
) if successful else 0.0,
"batches": batches,
"cases": rows,
}
args.output.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
per_case_path = args.output.with_name("per_case.tsv")
with per_case_path.open("w", encoding="utf-8") as output:
output.write(
"case_number\tcase_id\trecord_id\tseed\tsuccess\t"
"end_to_end_latency_s\tserver_inference_time_s\tfile_path\terror\n"
)
for row in sorted(rows, key=lambda item: int(item["case_number"])):
fields = [
row.get("case_number"),
row.get("case_id"),
row.get("record_id"),
row.get("seed"),
row.get("success"),
f"{float(row.get('latency_s') or 0.0):.6f}",
f"{float(row.get('inference_time_s') or 0.0):.6f}",
row.get("file_path") or "",
str(row.get("error") or "").replace("\t", " ").replace("\n", " "),
]
output.write("\t".join(str(field) for field in fields) + "\n")
print(json.dumps({key: value for key, value in summary.items() if key != "cases"}, ensure_ascii=False, indent=2))
return int(len(rows) != 8 or len(successful) != 8)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
run = subparsers.add_parser("run")
run.add_argument("--host", default="127.0.0.1")
run.add_argument("--port", type=int, required=True)
run.add_argument("--replica-index", type=int, required=True)
run.add_argument("--case-number", type=int, choices=range(1, 9), required=True)
run.add_argument("--records", type=Path, required=True)
run.add_argument("--assets-root", type=Path, required=True)
run.add_argument("--model", default="/data/hf_models/MiniMax-H3")
run.add_argument("--num-inference-steps", type=int, default=20)
run.add_argument("--warmup-steps", type=int, default=0)
run.add_argument("--short-edge", type=int, default=768)
run.add_argument("--aspect-ratio", default="9:16")
run.add_argument("--duration-seconds", type=float, default=15.0)
run.add_argument("--flow-shift", type=float, default=12.0)
run.add_argument("--audio-flow-shift", type=float, default=3.0)
run.add_argument("--submit-timeout", type=float, default=120.0)
run.add_argument("--poll-timeout", type=float, default=30.0)
run.add_argument("--poll-interval", type=float, default=1.0)
run.add_argument("--request-timeout", type=float, default=7200.0)
run.add_argument("--output", type=Path, required=True)
summarize = subparsers.add_parser("summarize")
summarize.add_argument("--result-root", type=Path, required=True)
summarize.add_argument("--output", type=Path, required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.command == "run":
return run_command(args)
return summarize_command(args)
if __name__ == "__main__":
raise SystemExit(main())