""" Usage: python3 -m sglang.test.run_eval --port 30000 --eval-name mmlu --num-examples 10 """ import argparse import json import os import statistics import subprocess import time import uuid from pathlib import Path from sglang.test.simple_eval_common import ( ChatCompletionSampler, CompletionSampler, Eval, make_report, set_ulimit, ) def get_thinking_kwargs(args): thinking_mode = getattr(args, "thinking_mode", None) if thinking_mode in THINKING_MODE_CHOICES: if thinking_mode in ["deepseek-v3", "kimi-k2"]: thinking_param = "thinking" else: # All models other than dpsk v3/kimi_k2 thinking_param = "enable_thinking" return {thinking_param: True} return {} def parse_json_object(value: str) -> dict: try: parsed = json.loads(value) except json.JSONDecodeError as e: raise argparse.ArgumentTypeError("must be a valid JSON object string") from e if not isinstance(parsed, dict): raise argparse.ArgumentTypeError("must be a JSON object") return parsed def run_eval_once(args, base_url: str, eval_obj: Eval) -> dict: chat_template_kwargs = getattr(args, "chat_template_kwargs", None) if isinstance(chat_template_kwargs, str): chat_template_kwargs = parse_json_object(chat_template_kwargs) elif chat_template_kwargs is None: chat_template_kwargs = {} elif not isinstance(chat_template_kwargs, dict): raise ValueError("chat_template_kwargs must be a dict or a JSON object string") chat_template_kwargs = {**get_thinking_kwargs(args), **chat_template_kwargs} extra_body = {} if chat_template_kwargs: extra_body["chat_template_kwargs"] = chat_template_kwargs for param_name in ("top_k", "min_p"): value = getattr(args, param_name, None) if value is not None: extra_body[param_name] = value common_kwargs = dict( model=getattr(args, "model", None), max_tokens=getattr(args, "max_tokens", 2048), top_p=getattr(args, "top_p", 1.0), base_url=base_url, temperature=getattr(args, "temperature", 0.0), ) api_mode = getattr(args, "api", "chat") if api_mode == "completion": # Default stop tokens for completion API (matches few_shot_gsm8k behavior) stop = getattr(args, "stop", ["Question", "Assistant:", "<|separator|>"]) sampler = CompletionSampler( **common_kwargs, stop=stop, ) else: sampler = ChatCompletionSampler( **common_kwargs, reasoning_effort=getattr(args, "reasoning_effort", None), extra_body=extra_body if extra_body else None, record_meta_info=True, ) # Run eval tic = time.perf_counter() result = eval_obj(sampler) latency = time.perf_counter() - tic return result, latency, sampler def _run_sgl_eval(eval_name, args) -> dict: # Returns a metrics dict (score, latency, output_throughput) so the # existing write_results_to_json + threshold gate keep working. from sglang.test.test_utils import dump_metric base_url = ( f"{args.base_url}/v1" if args.base_url else f"http://{args.host}:{args.port}/v1" ) out_parent = Path( getattr(args, "sgl_eval_out_dir", None) or (Path.home() / ".sgl_eval" / "sglang_run_eval" / uuid.uuid4().hex) ).expanduser() out_parent.mkdir(parents=True, exist_ok=True) cmd = [ "sgl-eval", "run", eval_name, "--base-url", base_url, "--num-threads", str(getattr(args, "num_threads", 64)), "--temperature", str(getattr(args, "temperature", 0.0)), "--out-dir", str(out_parent), ] if getattr(args, "model", None): cmd += ["--model", args.model] if getattr(args, "num_examples", None) is not None: cmd += ["--num-examples", str(args.num_examples)] # Bound generation length so long-reasoning models don't stall the eval. if getattr(args, "max_tokens", None) is not None: cmd += ["--max-tokens", str(args.max_tokens)] else: cmd += ["--max-tokens", "2048"] # Reasoning models (e.g. Qwen3.5) put their answer in the reasoning channel; # without --thinking their message.content is empty and sgl-eval scores 0. if getattr(args, "sgl_eval_thinking", None) is None: model_l = (getattr(args, "model", None) or "").lower() if "qwen3.5" in model_l or "qwen3-thinking" in model_l: cmd += ["--thinking"] elif args.sgl_eval_thinking: cmd += ["--thinking"] try: completed = subprocess.run( cmd, text=True, capture_output=True, check=False, timeout=getattr(args, "sgl_eval_timeout", None), ) except subprocess.TimeoutExpired as e: raise TimeoutError( f"sgl-eval timed out after {e.timeout}s: {' '.join(cmd)}\n" f"stdout:\n{e.stdout or ''}\nstderr:\n{e.stderr or ''}" ) from e if completed.returncode != 0: raise RuntimeError( f"sgl-eval failed with exit code {completed.returncode}: " f"{' '.join(cmd)}\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" ) metrics_files = sorted(out_parent.glob(f"sgl_eval_{eval_name}_*/metrics.json")) if len(metrics_files) != 1: raise FileNotFoundError( f"Expected exactly one metrics.json under {out_parent}, " f"found {len(metrics_files)}" ) payload = json.loads(metrics_files[0].read_text()) aggregate = payload.get("aggregate") if not isinstance(aggregate, dict) or "score" not in aggregate: raise KeyError(f"{metrics_files[0]} missing aggregate.score") metrics = dict(aggregate) metrics["latency"] = payload.get("latency_seconds", 0.0) metrics["output_throughput"] = payload.get("output_throughput_tps", 0.0) metrics["sgl_eval_metrics_path"] = str(metrics_files[0]) model = payload.get("model") or getattr(args, "model", None) dump_metric( f"{eval_name}_score", metrics["score"], labels={"model": model, "eval": eval_name}, ) dump_metric( f"{eval_name}_latency", metrics["latency"], labels={"model": model, "eval": eval_name}, ) print(f"Score: {metrics['score']:.3f}") print(f"Total latency: {metrics['latency']:.3f} s") print(f"Output throughput: {metrics['output_throughput']:.3f} token/s") print(f"sgl-eval metrics: {metrics_files[0]}") return metrics def print_accept_length_summary(samplers: list) -> None: accept_lengths = [ m["spec_accept_length"] for sampler in samplers for m in getattr(sampler, "_meta_infos", []) if m.get("spec_accept_length") is not None ] print("=" * 20) if not accept_lengths: print( "Speculative decoding: no per-request spec_accept_length in responses " "(non-speculative server, or --api completion which lacks return_meta_info)." ) else: print( f"Speculative accept length (per-request, from meta_info): " f"n={len(accept_lengths)} " f"mean={statistics.fmean(accept_lengths):.4f} " f"min={min(accept_lengths):.4f} " f"max={max(accept_lengths):.4f}" ) print("=" * 20) def run_eval(args): # Lazy import to avoid circular dependency with test_utils from sglang.test.test_utils import dump_metric set_ulimit() if "OPENAI_API_KEY" not in os.environ: os.environ["OPENAI_API_KEY"] = "EMPTY" base_url = ( f"{args.base_url}/v1" if args.base_url else f"http://{args.host}:{args.port}/v1" ) if args.eval_name == "mmlu": from sglang.test.simple_eval_mmlu import MMLUEval filename = "https://openaipublic.blob.core.windows.net/simple-evals/mmlu.csv" eval_obj = MMLUEval(filename, args.num_examples, args.num_threads) elif args.eval_name == "math": from sglang.test.simple_eval_math import MathEval equality_checker = ChatCompletionSampler(model="gpt-4-turbo") filename = ( "https://openaipublic.blob.core.windows.net/simple-evals/math_test.csv" ) eval_obj = MathEval( filename, equality_checker, args.num_examples, args.num_threads ) elif args.eval_name == "mgsm": from sglang.test.simple_eval_mgsm import MGSMEval eval_obj = MGSMEval(args.num_examples, args.num_threads) elif args.eval_name == "mgsm_en": from sglang.test.simple_eval_mgsm import MGSMEval eval_obj = MGSMEval(args.num_examples, args.num_threads, languages=["en"]) elif args.eval_name == "gpqa": from sglang.test.simple_eval_gpqa import GPQAEval filename = ( "https://openaipublic.blob.core.windows.net/simple-evals/gpqa_diamond.csv" ) eval_obj = GPQAEval(filename, args.num_examples, args.num_threads) elif args.eval_name == "humaneval": from sglang.test.simple_eval_humaneval import HumanEval eval_obj = HumanEval(args.num_examples, args.num_threads) elif args.eval_name == "longbench_v2": from sglang.test.simple_eval_longbench_v2 import LongBenchV2Eval # Default to HuggingFace dataset, can be overridden with --dataset-path data_source = args.dataset_path categories = args.categories.split(",") if args.categories else None eval_obj = LongBenchV2Eval( model=getattr(args, "model", None), data_source=data_source, num_examples=args.num_examples, num_threads=args.num_threads, categories=categories, max_context_length=getattr(args, "max_context_length", None), min_context_length=getattr(args, "min_context_length", None), ) elif args.eval_name == "mmmu": # VLM MMMU evaluation with fixed 100 examples by default from sglang.test.simple_eval_mmmu_vlm import MMMUVLMEval eval_obj = MMMUVLMEval( args.num_examples, args.num_threads, response_answer_regex=getattr(args, "response_answer_regex", None), ) elif args.eval_name == "aime25": from sglang.test.simple_eval_aime25 import AIME25Eval eval_obj = AIME25Eval(args.num_examples, args.num_threads) elif args.eval_name == "gsm8k": if getattr(args, "api", None) == "sgl_eval": # Only the nightly correctness eval opts into sgl-eval (zero-shot # chat, \boxed{}, math_verify). Every other gsm8k caller — spec # decoding perf/accuracy, disaggregation, quant, model e2e — uses # the 5-shot completion last-number scorer and relies on # max_tokens/throughput behavior sgl-eval cannot provide. return _run_sgl_eval("gsm8k", args) from sglang.test.simple_eval_mixed_prefix_gsm8k import GSM8KEval eval_obj = GSM8KEval( num_examples=args.num_examples, num_threads=args.num_threads, num_shots=getattr(args, "num_shots", 5), data_path=getattr(args, "gsm8k_data_path", None), ) elif args.eval_name == "mixed_prefix_gsm8k": from sglang.test.simple_eval_mixed_prefix_gsm8k import MixedPrefixGSM8KEval eval_obj = MixedPrefixGSM8KEval( num_examples=args.num_examples, num_threads=args.num_threads, num_shots=args.num_shots, secondary_pool_size=args.mixed_prefix_gsm8k_secondary_pool_size, data_path=args.gsm8k_data_path, seed=args.mixed_prefix_gsm8k_seed, ) else: raise ValueError(f"Invalid eval name: {args.eval_name}") if getattr(args, "repeat", 1) == 1: result, latency, sampler = run_eval_once(args, base_url, eval_obj) samplers = [sampler] metrics = result.metrics | {"score": result.score} metrics["latency"] = latency print(f"Total latency: {latency:.3f} s") print(f"Score: {metrics['score']:.3f}") # Compute output throughput from accumulated completion tokens total_completion_tokens = sum(sampler._completion_tokens) if total_completion_tokens > 0 and latency > 0: metrics["output_throughput"] = total_completion_tokens / latency print(f"Output throughput: {metrics['output_throughput']:.3f} token/s") # Report metrics to unified collection framework dump_metric( f"{args.eval_name}_score", metrics["score"], labels={"model": sampler.model, "eval": args.eval_name}, ) dump_metric( f"{args.eval_name}_latency", latency, labels={"model": sampler.model, "eval": args.eval_name}, ) else: from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=args.repeat) futures = [ executor.submit(run_eval_once, args, base_url, eval_obj) for _ in range(args.repeat) ] scores_repeat = [] latencies = [] total_completion_tokens = 0 samplers = [] for f in futures: result, latency, sampler = f.result() samplers.append(sampler) scores_repeat.append(result.score) latencies.append(latency) total_completion_tokens += sum(sampler._completion_tokens) mean_score = sum(scores_repeat) / len(scores_repeat) mean_latency = sum(latencies) / len(latencies) total_latency = sum(latencies) scores_repeat = [f"{s:.3f}" for s in scores_repeat] print("=" * 20) print(f"Repeat: {args.repeat}, mean: {mean_score:.3f}") print(f"Scores: {scores_repeat}") print(f"Mean latency: {mean_latency:.3f} s") print("=" * 20) metrics = result.metrics | {"scores": scores_repeat} metrics = metrics | {"mean_score": mean_score} metrics["latency"] = mean_latency if total_completion_tokens > 0 and total_latency > 0: metrics["output_throughput"] = total_completion_tokens / total_latency print(f"Output throughput: {metrics['output_throughput']:.3f} token/s") # Report metrics to unified collection framework dump_metric( f"{args.eval_name}_mean_score", mean_score, labels={ "model": sampler.model, "eval": args.eval_name, "repeat": args.repeat, }, ) executor.shutdown() print_accept_length_summary(samplers) # Dump reports file_stem = f"{args.eval_name}_{sampler.model.replace('/', '_')}" report_filename = f"/tmp/{file_stem}.html" print(f"Writing report to {report_filename}") with open(report_filename, "w") as fh: fh.write(make_report(result)) print(metrics) result_filename = f"/tmp/{file_stem}.json" with open(result_filename, "w") as f: f.write(json.dumps(metrics, indent=2)) print(f"Writing results to {result_filename}") if getattr(args, "return_latency", False): return metrics, latency return metrics THINKING_MODE_CHOICES = ["deepseek-v3", "qwen-3", "glm-45", "kimi-k2"] if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument( "--base-url", type=str, default=None, help="Server or API base url if not using http host and port.", ) parser.add_argument( "--host", type=str, default="0.0.0.0", help="Default host is 0.0.0.0." ) parser.add_argument( "--port", type=int, help="If not set, the default port is configured according to its default value for different LLM Inference Engines.", ) parser.add_argument( "--model", type=str, help="Name or path of the model. If not set, the default model will request /v1/models for conf.", ) parser.add_argument( "--repeat", type=int, default=1, help="repeat the evaluation n times" ) parser.add_argument("--eval-name", type=str, default="mmlu") parser.add_argument( "--api", type=str, default="chat", choices=["chat", "completion"], help="API mode: 'chat' for /v1/chat/completions, 'completion' for /v1/completions", ) parser.add_argument("--num-examples", type=int) parser.add_argument("--num-threads", type=int, default=512) parser.add_argument("--max-tokens", type=int, default=2048) parser.add_argument("--temperature", type=float, default=0.0) parser.add_argument("--top-p", type=float, default=1.0) parser.add_argument( "--top-k", type=int, default=None, help="Top-k sampling parameter" ) parser.add_argument( "--min-p", type=float, default=None, help="Min-p sampling parameter" ) parser.add_argument( "--chat-template-kwargs", type=parse_json_object, default=None, help="JSON object string for chat_template_kwargs, e.g. '{\"enable_thinking\": true}'", ) parser.add_argument("--reasoning-effort", type=str) parser.add_argument( "--thinking-mode", default=None, type=str, choices=THINKING_MODE_CHOICES, help="Enable thinking mode in Deepseek V3.1/3.2, or Qwen3.--reasoning-parser must be set when launching the server.", ) # LongBench-v2 specific arguments parser.add_argument( "--dataset-path", type=str, default="THUDM/LongBench-v2", help="Path to dataset file or HuggingFace dataset name for LongBench-v2", ) parser.add_argument( "--categories", type=str, default=None, help="Comma-separated list of categories to evaluate for LongBench-v2", ) parser.add_argument( "--max-context-length", type=int, help="Maximum context length in characters for LongBench-v2", ) parser.add_argument( "--min-context-length", type=int, help="Minimum context length in characters for LongBench-v2", ) parser.add_argument( "--num-shots", type=int, default=5, help="Number of few-shot examples for GSM8K (default: 5)", ) parser.add_argument( "--gsm8k-data-path", type=str, default=None, help="Path to GSM8K data file (e.g., test.jsonl)", ) parser.add_argument( "--mixed-prefix-gsm8k-secondary-pool-size", type=int, default=15, help="Size of secondary example pool for eval_name=mixed_prefix_gsm8k (default: 15)", ) parser.add_argument( "--mixed-prefix-gsm8k-seed", type=int, default=42, help="Seed for per-question random sampling in mixed_prefix_gsm8k (default: 42)", ) args = parser.parse_args() run_eval(args)