Files
snake-python/scripts/benchmark_snakes_from_db.py
T
daniel156161 5efab77248 feat!: adopt Python 3.15 and support BattlesnakeArena
- Require Python 3.15 in project metadata, runtime pin, and Docker stages.
- Use lazy imports for database backends and type-only dependencies.
- Defer benchmark dependencies until their code paths are used.
- Rely on deferred annotations and use Path.info for file discovery.
- Accept BattlesnakeArena alongside BattlesnakeEngine on game endpoints.
- Advance quart_common for Python 3.15 and multi-agent validation.
- Add pytest development dependencies and refresh the uv lockfile.
- Test lazy backend loading, import order, and fresh file discovery.
- Document the runtime requirements and locked uv workflow.

BREAKING CHANGE: Python 3.15 or newer is required; earlier runtimes cannot parse the lazy import syntax.
2026-11-03 10:02:40 +01:00

139 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Benchmark snake move latency against sampled states from gameplay SQLite."""
import argparse
from pathlib import Path
import sqlite3
from statistics import mean, median
import sys
from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.database.benchmark_states import (
_build_state, is_postgresql_source, load_postgresql_states,
)
lazy from server.GameBoard import GameBoard
lazy from snakes import SnakeBuilder
def percentile(values: list[float], quantile: float) -> float:
ordered = sorted(values)
index = min(len(ordered) - 1, round((len(ordered) - 1) * quantile))
return ordered[index]
def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
if is_postgresql_source(db_path):
return load_postgresql_states(db_path, samples, stride)
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
connection.execute("PRAGMA query_only = ON")
max_id = int(connection.execute("SELECT max(id) FROM turns").fetchone()[0] or 0)
if max_id == 0:
return []
states: list[tuple[dict, dict]] = []
next_id = max(1, max_id - (samples - 1) * stride)
query = """
SELECT t.id, t.board_state_json, t.you_json, t.food_json, t.hazards_json,
g.your_snake_id, g.your_snake_name, g.width, g.height,
g.game_id, g.source, g.map_name,
g.ruleset_name, g.ruleset_version, t.turn
FROM turns AS t
JOIN games AS g ON g.game_id = t.game_id
WHERE t.id >= ?
ORDER BY t.id
LIMIT 1
"""
snake_query = """
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name),
st.health, st.length, st.head_x, st.head_y, st.body_json,
COALESCE(gs.customizations_json, '{}')
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY st.id
"""
while len(states) < samples and next_id <= max_id:
row = connection.execute(query, (next_id,)).fetchone()
if row is None:
break
snake_rows = connection.execute(snake_query, (row[9], row[14])).fetchall()
state = _build_state(row, snake_rows)
if state is not None:
states.append(state)
next_id = int(row[0]) + stride
connection.close()
return states
def benchmark(snake_name: str, states: list[tuple[dict, dict]], repeat: int) -> dict:
durations: list[float] = []
moves = 0
for pass_number in range(repeat):
for board_data, metadata in states:
snake = SnakeBuilder.build(snake_name)
game_id = f"benchmark-{pass_number}-{metadata['game_id']}"
board = GameBoard(
game_id=game_id,
width=board_data["width"],
height=board_data["height"],
ruleset=metadata["ruleset"],
source=metadata["source"],
map=metadata["map"],
snake_class=snake,
)
state = {
"game": {
"id": game_id,
"ruleset": metadata["ruleset"],
"source": metadata["source"],
"map": metadata["map"],
"timeout": 500,
},
"turn": metadata["turn"],
"board": board_data,
"you": metadata["you"],
}
board.read_game_data(state)
started = perf_counter()
snake.choose_move(board)
durations.append((perf_counter() - started) * 1000)
moves += 1
return {
"snake": snake_name,
"moves": moves,
"mean_ms": mean(durations),
"median_ms": median(durations),
"p95_ms": percentile(durations, 0.95),
"max_ms": max(durations),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--database", required=True,
help="SQLite path or postgresql:// DSN",
)
parser.add_argument("--snake", action="append", default=[])
parser.add_argument("--samples", type=int, default=100)
parser.add_argument("--stride", type=int, default=997)
parser.add_argument("--repeat", type=int, default=1)
args = parser.parse_args()
states = load_states(args.database, max(1, args.samples), max(1, args.stride))
if not states:
raise SystemExit("No gameplay states found")
snake_names = args.snake or ["ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol"]
print(f"Loaded {len(states)} states from {args.database}")
for snake_name in snake_names:
result = benchmark(snake_name, states, max(1, args.repeat))
print(
f"{result['snake']}: {result['moves']} moves, "
f"mean={result['mean_ms']:.2f} ms, median={result['median_ms']:.2f} ms, "
f"p95={result['p95_ms']:.2f} ms, max={result['max_ms']:.2f} ms"
)
if __name__ == "__main__":
main()