Files
jundot--omlx/omlx/admin/accuracy_benchmark.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

606 lines
21 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Accuracy benchmark execution logic for oMLX admin panel.
Orchestrates MMLU, HellaSwag, TruthfulQA, GSM8K, and LiveCodeBench
evaluations with real-time progress reporting via SSE events.
Supports server-side queue and persistent result accumulation.
Results survive browser close and persist until explicitly reset.
"""
import asyncio
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Literal, Optional
from pydantic import BaseModel, field_validator, model_validator
from .external_api import (
ExternalAPIClient,
ExternalChatAdapter,
ExternalEndpointConfig,
)
logger = logging.getLogger(__name__)
# Module-level storage for active benchmark runs
_accuracy_runs: dict[str, "AccuracyBenchmarkRun"] = {}
# Accumulated results — persists until explicit reset
_accumulated_results: list[dict] = []
# Server-side queue
_queue: list["AccuracyBenchmarkRequest"] = []
_queue_running: bool = False
_current_run_id: Optional[str] = None
_current_model: Optional[str] = None
_engine_pool_ref: Any = None
VALID_BENCHMARKS = [
"mmlu", "mmlu_pro", "kmmlu", "cmmlu", "jmmlu",
"hellaswag", "truthfulqa", "arc_challenge", "winogrande",
"gsm8k", "mathqa", "humaneval", "mbpp", "livecodebench",
"bbq", "safetybench",
]
# Sampling profile for an accuracy run. "deterministic" (default) runs greedy
# (temperature 0) so saved scores stay reproducible; "model_settings" opts in to
# the model's configured sampling (temperature, top_p, …) for a real-world score.
SamplingProfile = Literal["deterministic", "model_settings"]
class AccuracyBenchmarkRequest(BaseModel):
"""Request model for starting an accuracy benchmark."""
model_id: str
benchmarks: dict[str, int] # name -> sample_size (0 = full dataset)
batch_size: int = 1
enable_thinking: bool = False
sampling_profile: SamplingProfile = "deterministic"
# When set, the benchmark runs against a remote OpenAI-compatible
# endpoint instead of a local engine and model_id is the remote
# model name (not validated against the local catalog).
external: Optional[ExternalEndpointConfig] = None
@model_validator(mode="after")
def _force_thinking_off_for_external(self) -> "AccuracyBenchmarkRequest":
# enable_thinking is a local chat-template kwarg; external requests
# never send it, so keep the stored flag honest.
if self.external is not None:
self.enable_thinking = False
return self
@field_validator("batch_size")
@classmethod
def validate_batch_size(cls, v: int) -> int:
if v not in (1, 2, 4, 8, 16, 32):
raise ValueError("batch_size must be 1, 2, 4, 8, 16, or 32")
return v
@field_validator("benchmarks")
@classmethod
def validate_benchmarks(cls, v: dict[str, int]) -> dict[str, int]:
if not v:
raise ValueError("At least one benchmark is required")
for name, size in v.items():
if name not in VALID_BENCHMARKS:
raise ValueError(
f"Invalid benchmark '{name}'. Must be one of {VALID_BENCHMARKS}"
)
if size < 0:
raise ValueError(f"Sample size for '{name}' must be >= 0")
return v
@dataclass
class AccuracyBenchmarkRun:
"""Tracks the state of a running accuracy benchmark.
SSE delivery model mirrors `BenchmarkRun`: append-only `events`
log + `cond` for live notification + `terminal` flag set on the
final event. See benchmark.py for the rationale.
"""
bench_id: str
request: AccuracyBenchmarkRequest
status: str = "running" # running, completed, cancelled, error
events: list[dict] = field(default_factory=list)
cond: asyncio.Condition = field(default_factory=asyncio.Condition)
terminal: bool = False
task: Optional[asyncio.Task] = None
results: list[dict] = field(default_factory=list)
error_message: str = ""
last_progress: Optional[dict] = None # last progress event for reconnect
# Finer-grained lifecycle than `status` — surfaces the difference between
# "still scoring questions" and "cleaning up after the last result was
# emitted". The serialization gate (_queue_running) stays True across
# both, but a UI rendering the running row wants to hide it once
# phase=="unloading" so the user isn't told "still running" when the
# result card has already appeared on screen. Transitions:
# pending → loading → evaluating → unloading → completed
# (cancelled / error replace the terminal phase on those branches.)
phase: str = "pending"
# Accuracy stream closes on `done` (run finished) or `error`. Unlike the
# throughput bench there's no separate upload phase to ride out.
_ACCURACY_TERMINAL_TYPES = frozenset({"done", "error"})
# --- Run management ---
def get_run(bench_id: str) -> Optional[AccuracyBenchmarkRun]:
"""Get an accuracy benchmark run by ID."""
return _accuracy_runs.get(bench_id)
def create_run(request: AccuracyBenchmarkRequest) -> AccuracyBenchmarkRun:
"""Create a new accuracy benchmark run."""
bench_id = str(uuid.uuid4())[:8]
run = AccuracyBenchmarkRun(bench_id=bench_id, request=request)
_accuracy_runs[bench_id] = run
return run
def cleanup_old_runs() -> None:
"""Remove completed/errored runs to prevent memory leaks."""
to_remove = []
for bid, run in _accuracy_runs.items():
if run.status in ("completed", "cancelled", "error"):
to_remove.append(bid)
for bid in to_remove:
del _accuracy_runs[bid]
# --- Accumulated results ---
def get_accumulated_results() -> list[dict]:
"""Get all accumulated benchmark results."""
return _accumulated_results
def reset_accumulated_results() -> None:
"""Clear all accumulated results."""
_accumulated_results.clear()
# --- Queue management ---
def add_to_queue(request: AccuracyBenchmarkRequest) -> None:
"""Add a benchmark request to the queue."""
_queue.append(request)
def get_queue_status() -> dict:
"""Get current queue status."""
last_progress = None
phase = None
if _current_run_id:
run = get_run(_current_run_id)
if run:
last_progress = run.last_progress
phase = run.phase
return {
"running": _queue_running,
"current_model": _current_model,
"current_bench_id": _current_run_id,
"last_progress": last_progress,
# Finer-grained than `running`: distinguishes "still scoring" from
# "cleaning up after the last result emitted". Polling UIs hide
# the running row once phase becomes "unloading" / "completed" so
# the result card alone tells the story.
"phase": phase,
"queue": [
{
"model_id": r.model_id,
"benchmarks": list(r.benchmarks.keys()),
"external": r.external is not None,
}
for r in _queue
],
}
def remove_from_queue(idx: int) -> bool:
"""Remove an item from the queue by index."""
if 0 <= idx < len(_queue):
_queue.pop(idx)
return True
return False
def start_next_from_queue(engine_pool: Any) -> Optional[str]:
"""Pop next item from queue, create run, start background task.
Returns bench_id if a run was started, None if already running or queue empty.
This is synchronous so the caller gets the bench_id immediately.
"""
global _queue_running, _current_run_id, _current_model, _engine_pool_ref
_engine_pool_ref = engine_pool
if _queue_running:
return None
if not _queue:
return None
request = _queue.pop(0)
_queue_running = True
_current_model = request.model_id
cleanup_old_runs()
run = create_run(request)
_current_run_id = run.bench_id
logger.info(
f"Queue: starting {request.model_id} "
f"benchmarks={list(request.benchmarks.keys())}"
)
async def _run_and_continue():
try:
await run_accuracy_benchmark(run, engine_pool)
except Exception as e:
logger.error(f"Queue: error running {request.model_id}: {e}")
# Auto-continue with next in queue
await _continue_queue(engine_pool)
run.task = asyncio.create_task(_run_and_continue())
return run.bench_id
async def _continue_queue(engine_pool: Any) -> None:
"""Continue processing the queue after a run completes."""
global _queue_running, _current_run_id, _current_model
if not _queue:
_queue_running = False
_current_run_id = None
_current_model = None
return
request = _queue.pop(0)
_current_model = request.model_id
cleanup_old_runs()
run = create_run(request)
_current_run_id = run.bench_id
logger.info(
f"Queue: continuing with {request.model_id} "
f"benchmarks={list(request.benchmarks.keys())}"
)
try:
await run_accuracy_benchmark(run, engine_pool)
except Exception as e:
logger.error(f"Queue: error running {request.model_id}: {e}")
await _continue_queue(engine_pool)
async def cancel_queue() -> None:
"""Cancel the current run and clear the queue."""
global _queue_running, _current_run_id, _current_model
_queue.clear()
if _current_run_id:
run = get_run(_current_run_id)
if run and run.status == "running":
run.status = "cancelled"
if run.task and not run.task.done():
run.task.cancel()
_queue_running = False
_current_run_id = None
_current_model = None
# --- SSE ---
async def _send_event(run: AccuracyBenchmarkRun, event: dict) -> None:
"""Append an event to the run's log and wake subscribers.
Updates `last_progress` (used by the REST `queue/status` endpoint
for reconnect hints) and sets `run.terminal` on the final event.
"""
if event.get("type") == "progress":
run.last_progress = event
async with run.cond:
run.events.append(event)
if event.get("type") in _ACCURACY_TERMINAL_TYPES:
run.terminal = True
run.cond.notify_all()
# --- Benchmark execution ---
async def run_accuracy_benchmark(
run: AccuracyBenchmarkRun, engine_pool: Any
) -> None:
"""Execute accuracy benchmark run.
Phases:
1. Unload all models
2. Load target model
3. For each selected benchmark: load data, evaluate, report
4. Unload model
5. Send done event
"""
from ..eval import BENCHMARKS
request = run.request
# Suppress TTL auto-unload during benchmark (local engines only)
if request.external is None:
engine_pool._suppress_ttl = True
start_time = time.time()
client: Optional[ExternalAPIClient] = None
try:
run.phase = "loading"
if request.external is not None:
# External endpoint: no local model lifecycle. The adapter owns
# the sampling-profile mapping, so sampling_kwargs stays empty
# (enable_thinking is already forced off by request validation).
await _send_event(run, {
"type": "progress",
"phase": "connect",
"model_id": request.model_id,
"benchmark": "",
"message": f"Connecting to {request.external.base_url}...",
"current": 0,
"total": len(request.benchmarks),
})
client = ExternalAPIClient(request.external)
engine = ExternalChatAdapter(client, request.sampling_profile)
# Fail fast on auth/URL/model errors so a wrong API key cannot
# silently produce a 0% score.
await engine.preflight()
sampling_kwargs = {}
else:
# Phase 1: Unload all models
loaded_ids = engine_pool.get_loaded_model_ids()
if loaded_ids:
await _send_event(run, {
"type": "progress",
"phase": "unload",
"model_id": request.model_id,
"benchmark": "",
"message": f"Unloading {len(loaded_ids)} model(s)...",
"current": 0,
"total": len(request.benchmarks),
})
for model_id in loaded_ids:
try:
await engine_pool._unload_engine(model_id)
except Exception as e:
logger.warning(f"Failed to unload {model_id}: {e}")
# Phase 2: Load target model
await _send_event(run, {
"type": "progress",
"phase": "load",
"model_id": request.model_id,
"benchmark": "",
"message": f"Loading {request.model_id}...",
"current": 0,
"total": len(request.benchmarks),
})
# Force LM engine for accuracy benchmarks — text-only tasks
# don't need VLM and the VLM adapter can produce empty responses.
engine = await engine_pool.get_engine(request.model_id, force_lm=True)
# Load model sampling settings. Under the default "deterministic"
# profile sampling params are not read — the benchmark runs greedy
# (temperature 0) so saved scores stay reproducible. Only the
# explicit "model_settings" opt-in honors the model's configured
# sampling. chat_template_kwargs is prompt construction, not
# sampling, so it is forwarded in both profiles.
sampling_kwargs = {}
if engine_pool._settings_manager is not None:
ms = engine_pool._settings_manager.get_settings(request.model_id)
if ms.chat_template_kwargs:
sampling_kwargs["chat_template_kwargs"] = ms.chat_template_kwargs
if request.sampling_profile == "model_settings":
if ms.temperature is not None:
sampling_kwargs["temperature"] = ms.temperature
if ms.top_p is not None:
sampling_kwargs["top_p"] = ms.top_p
if ms.top_k is not None:
sampling_kwargs["top_k"] = ms.top_k
if ms.min_p is not None:
sampling_kwargs["min_p"] = ms.min_p
if ms.repetition_penalty is not None:
sampling_kwargs["repetition_penalty"] = ms.repetition_penalty
if ms.presence_penalty is not None:
sampling_kwargs["presence_penalty"] = ms.presence_penalty
# Phase 3: Run each benchmark
run.phase = "evaluating"
completed = 0
for bench_name, sample_size in request.benchmarks.items():
if run.status == "cancelled":
break
bench_cls = BENCHMARKS.get(bench_name)
if bench_cls is None:
logger.warning(f"Unknown benchmark: {bench_name}")
continue
evaluator = bench_cls()
# Load dataset
await _send_event(run, {
"type": "progress",
"phase": "download",
"model_id": request.model_id,
"benchmark": bench_name,
"message": f"Loading {bench_name} dataset...",
"current": completed,
"total": len(request.benchmarks),
})
try:
items = await evaluator.load_dataset(sample_size=sample_size)
except Exception as e:
logger.error(f"Failed to load {bench_name} dataset: {e}")
await _send_event(run, {
"type": "error",
"message": f"Failed to load {bench_name} dataset: {e}",
})
run.status = "error"
run.error_message = str(e)
return
# Run evaluation with progress
total_items = len(items)
async def on_progress(current: int, total: int) -> None:
if run.status == "cancelled":
raise asyncio.CancelledError()
await _send_event(run, {
"type": "progress",
"phase": "eval",
"model_id": request.model_id,
"benchmark": bench_name,
"message": f"Evaluating {bench_name} ({current}/{total})...",
"current": completed,
"total": len(request.benchmarks),
"bench_current": current,
"bench_total": total,
})
await _send_event(run, {
"type": "progress",
"phase": "eval",
"model_id": request.model_id,
"benchmark": bench_name,
"message": f"Evaluating {bench_name} (0/{total_items})...",
"current": completed,
"total": len(request.benchmarks),
"bench_current": 0,
"bench_total": total_items,
})
try:
result = await evaluator.run(
engine, items, on_progress,
batch_size=request.batch_size,
sampling_kwargs=sampling_kwargs,
enable_thinking=request.enable_thinking,
)
except asyncio.CancelledError:
run.status = "cancelled"
await _send_event(run, {
"type": "error",
"message": "Benchmark cancelled",
})
return
except Exception as e:
logger.error(f"Error running {bench_name}: {e}")
await _send_event(run, {
"type": "error",
"message": f"Error running {bench_name}: {e}",
})
run.status = "error"
run.error_message = str(e)
return
# Build result
result_data = {
"model_id": request.model_id,
"external": request.external is not None,
"benchmark": result.benchmark_name,
"accuracy": round(result.accuracy, 4),
"thinking_used": result.thinking_used,
"total": result.total_questions,
"correct": result.correct_count,
"time_s": round(result.time_seconds, 1),
"question_results": [
{
"id": qr.question_id,
"correct": qr.correct,
"expected": qr.expected,
"predicted": qr.predicted,
"question": qr.question_text,
"raw_response": qr.raw_response,
"category": qr.category,
"time_s": round(qr.time_seconds, 3),
}
for qr in result.question_results
],
}
if result.category_scores:
result_data["category_scores"] = {
k: round(v, 4) for k, v in result.category_scores.items()
}
# Accumulate persistently
_accumulated_results.append(result_data)
run.results.append(result_data)
completed += 1
await _send_event(run, {
"type": "result",
"data": result_data,
})
# Phase 4: Unload model. The result(s) are already emitted by now,
# so flip phase so polling clients hide the running indicator
# (the result card has already appeared on screen — telling the
# user "still running" while we clean up reads as a bug).
run.phase = "unloading"
if request.external is None:
try:
await engine_pool._unload_engine(request.model_id)
except Exception:
pass
# Phase 5: Done
total_time = time.time() - start_time
run.status = "completed"
run.phase = "completed"
await _send_event(run, {
"type": "done",
"summary": {
"model_id": request.model_id,
"total_time": round(total_time, 1),
"benchmarks_completed": completed,
},
})
except asyncio.CancelledError:
run.status = "cancelled"
run.phase = "cancelled"
await _send_event(run, {
"type": "error",
"message": "Benchmark cancelled",
})
except Exception as e:
logger.exception(f"Accuracy benchmark error: {e}")
run.status = "error"
run.phase = "error"
run.error_message = str(e)
await _send_event(run, {
"type": "error",
"message": str(e),
})
finally:
# Re-enable TTL auto-unload
engine_pool._suppress_ttl = False
if client is not None:
await client.aclose()