McRogueFace/tests/benchmarks/spatial_hash_bench.py
John McCardle 59e722166a Phase 5.2: performance benchmark suite for grid/entity/FOV/pathfinding
Adds 6 benchmark scripts in tests/benchmarks/ covering all 5 scenarios from
Kanboard #37, plus a shared baseline helper:

  grid_step_bench.py        100 ent / 100x100 grid / 1000 grid.step() rounds
                            mix of IDLE/NOISE8/SEEK/FLEE behaviors
  fov_opt_bench.py          100 ent / 1000x1000 grid; entity.update_visibility()
                            (with DiscreteMap perspective writeback) vs bare
                            grid.compute_fov() (no writeback) across FOV
                            algorithms BASIC/SHADOW/SYMMETRIC_SHADOWCAST and
                            radii 8/16/32
  spatial_hash_bench.py     entities_in_radius() at radii (1,5,10,50) x
                            entity counts (100,1k,10k); compares against
                            naive O(n) baseline with hit-count validation
  pathfinding_bench.py      A* across grid sizes/densities/heuristics/weights,
                            plus with-vs-without `collide=` collision-label
                            comparison (0/10/100 blockers on 100x100)
  gridview_render_bench.py  1/2/4 GridViews on shared grid; uses
                            automation.screenshot() to force real renders in
                            headless mode (mcrfpy.step alone is render-stubbed)
  dijkstra_bench.py         single-root, multi-root, mask, invert, descent
  _baseline.py              writes baseline JSON to baseline/phase5_2/

All scripts emit JSON to stdout and write a baseline copy under
tests/benchmarks/baseline/phase5_2/ for regression comparison. All run
headless; pure time.perf_counter() timing for compute benches, screenshot
wall-time for the render bench (start/end_benchmark would only capture the
no-op headless game loop, so direct timing is used).

Notable findings captured in baselines:
- spatial hash: 5x to >300x speedup over naive O(n), hits validated identical
- update_visibility: ~25-37 ms/entity perspective writeback overhead on
  1000x1000 grid (full-grid demote+promote loop in UIEntity::updateVisibility)
  dominates over the actual TCOD FOV cost (~3-24 ms). Worth a follow-up issue
  for sparse perspective updating.
- gridview render: per-view cost scales near-linearly down (~78ms total for
  1, 2, or 4 views) -- the multi-view system shares state efficiently.

Refs Kanboard #37.
2026-04-18 06:45:40 -04:00

163 lines
4.8 KiB
Python

"""Benchmark: spatial-hash query throughput (Phase 5.2 / card #37, scenario 3).
Per acceptance criteria: `queryRadius()` at radii (1, 5, 10, 50) with
(100, 1000, 10000) entities. The Python-facing call is `grid.entities_in_radius()`.
For each (count, radius) pair we measure:
- mean per-query time (us)
- O(n) baseline (manual scan of `grid.entities`)
- speedup factor
- mean hit count
Headless mode. Output: JSON to stdout.
Usage:
./mcrogueface --headless --exec ../tests/benchmarks/spatial_hash_bench.py
"""
import mcrfpy
import sys
import os
import time
import json
import random
sys.path.insert(0, os.path.dirname(__file__))
import _baseline
GRID_W, GRID_H = 200, 200
ENTITY_COUNTS = [100, 1000, 10000]
RADII = [1, 5, 10, 50]
QUERIES_PER_CONFIG = 200
SAMPLE_QUERY_LOCATIONS = 50 # how many distinct (x,y) sample positions
SEED = 0xCAFE
def build_grid(w, h):
g = mcrfpy.Grid(grid_size=(w, h))
for y in range(h):
for x in range(w):
c = g.at(x, y)
c.walkable = True
c.transparent = True
return g
def populate(g, n, rng):
ents = []
seen = set()
while len(ents) < n:
x = rng.randrange(GRID_W)
y = rng.randrange(GRID_H)
if (x, y) in seen:
continue
seen.add((x, y))
ents.append(mcrfpy.Entity((x, y), grid=g))
return ents
def sample_points(rng, n):
return [(rng.randrange(GRID_W), rng.randrange(GRID_H)) for _ in range(n)]
def bench_spatial(g, points, radius, queries):
"""Mean per-query time using SpatialHash-backed entities_in_radius."""
n_pts = len(points)
hits_total = 0
t0 = time.perf_counter()
for i in range(queries):
result = g.entities_in_radius(points[i % n_pts], radius)
hits_total += len(result)
elapsed = time.perf_counter() - t0
return elapsed / queries, hits_total / queries
def bench_naive(g, points, radius, queries):
"""O(n) baseline: enumerate grid.entities and check distance manually.
Note: `entity.x`/`entity.y` are pixel coordinates inherited from UIDrawable.
We compare against `entity.grid_pos`, which is the same coordinate frame
`entities_in_radius` uses.
"""
n_pts = len(points)
r2 = radius * radius
# Snapshot grid coordinates so the loop body has no Python attribute lookup
# cost beyond the unavoidable.
coords = [(e.grid_pos.x, e.grid_pos.y) for e in g.entities]
hits_total = 0
t0 = time.perf_counter()
for i in range(queries):
cx, cy = points[i % n_pts]
n = 0
for ex, ey in coords:
dx = ex - cx
dy = ey - cy
if dx * dx + dy * dy <= r2:
n += 1
hits_total += n
elapsed = time.perf_counter() - t0
return elapsed / queries, hits_total / queries
def main():
rng = random.Random(SEED)
runs = []
for n_ent in ENTITY_COUNTS:
scene = mcrfpy.Scene(f"spatial_{n_ent}")
mcrfpy.current_scene = scene
g = build_grid(GRID_W, GRID_H)
scene.children.append(g)
ents = populate(g, n_ent, rng)
pts = sample_points(rng, SAMPLE_QUERY_LOCATIONS)
for radius in RADII:
# Warmup the spatial hash for this entity set.
for p in pts:
g.entities_in_radius(p, radius)
sp_t, sp_hits = bench_spatial(g, pts, radius, QUERIES_PER_CONFIG)
nv_t, nv_hits = bench_naive(g, pts, radius, QUERIES_PER_CONFIG)
speedup = (nv_t / sp_t) if sp_t > 0 else float("inf")
entry = {
"entities": n_ent,
"radius": radius,
"queries": QUERIES_PER_CONFIG,
"spatial_per_query_us": sp_t * 1e6,
"naive_per_query_us": nv_t * 1e6,
"speedup": speedup,
"spatial_mean_hits": sp_hits,
"naive_mean_hits": nv_hits,
}
runs.append(entry)
print(f" n={n_ent:>5} r={radius:<3} "
f"spatial={sp_t * 1e6:9.2f} us "
f"naive={nv_t * 1e6:10.2f} us "
f"speedup={speedup:7.2f}x "
f"hits={sp_hits:6.1f} (naive={nv_hits:6.1f})")
# Tear down entities so they don't leak into the next iteration's grid.
for e in ents:
e.die()
del ents
del g
out = {
"config": {
"grid": f"{GRID_W}x{GRID_H}",
"entity_counts": ENTITY_COUNTS,
"radii": RADII,
"queries_per_config": QUERIES_PER_CONFIG,
"sample_query_locations": SAMPLE_QUERY_LOCATIONS,
"seed": SEED,
},
"runs": runs,
}
print(json.dumps(out, indent=2))
_baseline.write("spatial_hash_bench.json", out)
print("DONE")
if __name__ == "__main__":
main()
sys.exit(0)