269 lines
9.1 KiB
Python
269 lines
9.1 KiB
Python
"""Omnigent user-journey benchmark runner.
|
|
|
|
Boots a real ``omnigent server`` against a SQLite DB (no runner, no LLM),
|
|
drives the selected HTTP journeys under load, prints per-journey latency /
|
|
throughput tables, and writes a versioned JSON report. Exits non-zero if any
|
|
supplied threshold is breached.
|
|
|
|
Runs in the project venv — it imports ``omnigent`` and ``tests._helpers`` and
|
|
spawns the real server, so it is NOT a standalone PEP 723 script. Invoke with
|
|
``--no-sync`` so ``uv`` uses the existing environment instead of rebuilding the
|
|
project (which triggers a web-UI build that fails in a worktree)::
|
|
|
|
uv run --no-sync dev/benchmarks/omnigent/run.py
|
|
uv run --no-sync dev/benchmarks/omnigent/run.py --journeys list_sessions,get_session
|
|
uv run --no-sync dev/benchmarks/omnigent/run.py --requests 500 --concurrency 25 --runs 3
|
|
uv run --no-sync dev/benchmarks/omnigent/run.py --output bench.json --max-p50-ms 25
|
|
|
|
The JSON is the contract consumed by the workspace Databricks ETL notebook —
|
|
see ``README.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import datetime
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Allow ``uv run <path>`` (no package context) to import the sibling modules.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
|
|
from dev.benchmarks.omnigent.environment import BenchEnvironment
|
|
from dev.benchmarks.omnigent.journeys import (
|
|
ALL_JOURNEYS,
|
|
Journey,
|
|
resolve_journeys,
|
|
run_latency,
|
|
run_throughput,
|
|
)
|
|
from dev.benchmarks.omnigent.measure import (
|
|
RunResult,
|
|
aggregate,
|
|
check_thresholds,
|
|
console,
|
|
print_results,
|
|
)
|
|
from dev.benchmarks.omnigent.schema import build_report
|
|
|
|
# Harness label stamped in the report: HTTP/DB journeys drive no agent turn;
|
|
# runner journeys drive turns through the in-process openai-agents SDK harness.
|
|
_HTTP_HARNESS = "http-only"
|
|
_RUNNER_HARNESS = "openai-agents"
|
|
|
|
|
|
def _backend_of(database_uri: str | None) -> str:
|
|
"""Classify the DB URI into a coarse backend label for the report.
|
|
|
|
``None`` is the harness's throwaway SQLite temp file. Otherwise key off the
|
|
URI scheme so the report (and the workspace dashboard) can group by backend.
|
|
"""
|
|
if database_uri is None or database_uri.startswith("sqlite"):
|
|
return "sqlite"
|
|
if database_uri.startswith("postgres"):
|
|
return "postgres"
|
|
if database_uri.startswith("mysql"):
|
|
return "mysql"
|
|
return "other"
|
|
|
|
|
|
def _effective_iterations(journey: Journey, requested: int) -> int:
|
|
"""Clamp *requested* iterations down to the journey's ``max_iterations``.
|
|
|
|
Full-turn journeys cost ~1s+ per op and cap themselves so a large
|
|
``--iterations`` (tuned for the millisecond HTTP journeys) doesn't overrun
|
|
the CI time budget. The cap only ever lowers the count, never raises it.
|
|
"""
|
|
if journey.max_iterations is not None:
|
|
return min(requested, journey.max_iterations)
|
|
return requested
|
|
|
|
|
|
async def _run_journey(
|
|
journey: Journey, env: BenchEnvironment, args: argparse.Namespace
|
|
) -> tuple[str, list[RunResult]]:
|
|
"""Run one journey's timed runs, returning its report kind + per-run results.
|
|
|
|
A journey runs as throughput when ``--concurrency > 1`` and it is
|
|
concurrency-safe; otherwise as sequential latency.
|
|
"""
|
|
as_throughput = args.concurrency > 1 and journey.concurrency_safe
|
|
iterations = _effective_iterations(journey, args.iterations)
|
|
results: list[RunResult] = []
|
|
for _ in range(args.runs):
|
|
if as_throughput:
|
|
results.append(
|
|
await run_throughput(
|
|
journey,
|
|
env,
|
|
requests=args.requests,
|
|
concurrency=args.concurrency,
|
|
warmup=args.warmup,
|
|
)
|
|
)
|
|
else:
|
|
results.append(
|
|
await run_latency(journey, env, iterations=iterations, warmup=args.warmup)
|
|
)
|
|
return ("throughput" if as_throughput else "latency"), results
|
|
|
|
|
|
async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bool]:
|
|
"""Run all selected journeys and build the report.
|
|
|
|
:returns: ``(report, passed)`` where *passed* is ``False`` if any journey
|
|
breached a supplied threshold.
|
|
"""
|
|
journeys = resolve_journeys(args.journeys)
|
|
journey_results: dict[str, dict[str, object]] = {}
|
|
passed = True
|
|
backend = _backend_of(args.database_uri)
|
|
|
|
# Any full-turn journey needs the runner + mock LLM. A full env is a
|
|
# superset — HTTP journeys still run against it — so a mixed selection just
|
|
# boots with_runner=True. The harness label reflects what drove the turns.
|
|
with_runner = any(j.needs_runner for j in journeys)
|
|
harness = _RUNNER_HARNESS if with_runner else _HTTP_HARNESS
|
|
|
|
async with BenchEnvironment(with_runner=with_runner, database_uri=args.database_uri) as env:
|
|
for journey in journeys:
|
|
console.print(f"\n[bold]Benchmarking[/bold] {journey.name} [dim]({backend})[/dim]")
|
|
kind, results = await _run_journey(journey, env, args)
|
|
print_results(journey.name, results)
|
|
block = aggregate(results)
|
|
block["kind"] = kind
|
|
block["backend"] = backend
|
|
journey_results[journey.name] = block
|
|
if not check_thresholds(
|
|
results,
|
|
min_rps=args.min_rps,
|
|
max_p50_ms=args.max_p50_ms,
|
|
max_p99_ms=args.max_p99_ms,
|
|
):
|
|
passed = False
|
|
|
|
config = {
|
|
"iterations": args.iterations,
|
|
"requests": args.requests,
|
|
"concurrency": args.concurrency,
|
|
"runs": args.runs,
|
|
"warmup": args.warmup,
|
|
"with_runner": with_runner,
|
|
"backend": backend,
|
|
}
|
|
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
report = build_report(
|
|
journey_results,
|
|
generated_at=generated_at,
|
|
config=config,
|
|
harness=harness,
|
|
)
|
|
return report, passed
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="omnigent-benchmark",
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--journeys",
|
|
type=lambda s: [p.strip() for p in s.split(",") if p.strip()],
|
|
default=None,
|
|
metavar="A,B,C",
|
|
help=f"Comma-separated journeys to run. Default: all ({', '.join(ALL_JOURNEYS)}).",
|
|
)
|
|
parser.add_argument(
|
|
"--database-uri",
|
|
default=None,
|
|
metavar="URI",
|
|
help="DB the server boots against — a pre-seeded SQLite file, a "
|
|
"postgresql+psycopg://… instance, or a mysql+mysqldb://… instance "
|
|
"(see seed.py). Default: a fresh throwaway SQLite DB (empty — "
|
|
"best-case numbers). The report's `backend` field is derived from this.",
|
|
)
|
|
parser.add_argument(
|
|
"--iterations",
|
|
type=int,
|
|
default=100,
|
|
metavar="N",
|
|
help="Sequential operations per latency run (default: 100).",
|
|
)
|
|
parser.add_argument(
|
|
"--requests",
|
|
type=int,
|
|
default=500,
|
|
metavar="N",
|
|
help="Total operations per throughput run — used when --concurrency>1 (default: 500).",
|
|
)
|
|
parser.add_argument(
|
|
"--concurrency",
|
|
type=int,
|
|
default=1,
|
|
metavar="N",
|
|
help="Max in-flight operations. >1 runs concurrency-safe journeys as "
|
|
"throughput (default: 1 = sequential latency).",
|
|
)
|
|
parser.add_argument(
|
|
"--runs",
|
|
type=int,
|
|
default=3,
|
|
metavar="N",
|
|
help="Timed runs per journey; results are per-run and averaged (default: 3).",
|
|
)
|
|
parser.add_argument(
|
|
"--warmup",
|
|
type=int,
|
|
default=10,
|
|
metavar="N",
|
|
help="Warmup operations discarded before each run (default: 10).",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
metavar="FILE",
|
|
help="Write the JSON report to FILE (for CI artifact upload).",
|
|
)
|
|
parser.add_argument(
|
|
"--min-rps",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Exit 1 if any journey's avg throughput falls below N req/s.",
|
|
)
|
|
parser.add_argument(
|
|
"--max-p50-ms",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Exit 1 if any journey's avg P50 latency exceeds N ms.",
|
|
)
|
|
parser.add_argument(
|
|
"--max-p99-ms",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Exit 1 if any journey's avg P99 latency exceeds N ms.",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
report, passed = asyncio.run(run_benchmark(args))
|
|
if args.output is not None:
|
|
args.output.write_text(json.dumps(report, indent=2))
|
|
console.print(f"\n Results written to [cyan]{args.output}[/cyan]")
|
|
if not passed:
|
|
console.print("\n[red]One or more thresholds failed.[/red]")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|