McRogueFace/tests/benchmarks/gridview_render_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

123 lines
3.6 KiB
Python

"""Benchmark: 1/2/4 GridView instances viewing shared Grid data (Phase 5.2).
A `mcrfpy.step()` in headless mode does not actually render -- the render path
is stubbed. To force a real render we use `automation.screenshot()`, which
flushes the current scene to a PNG via the off-screen render target. Each
screenshot is one full render of all currently-mounted children.
We measure mean wall time per screenshot for view counts {1, 2, 4} on the same
underlying grid, with grid cells populated to mimic a real overworld scene.
Usage:
./mcrogueface --headless --exec ../tests/benchmarks/gridview_render_bench.py
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
import time
import json
import tempfile
sys.path.insert(0, os.path.dirname(__file__))
import _baseline
GRID_W, GRID_H = 80, 80
N_FRAMES = 60
WARMUP_FRAMES = 5
VIEW_COUNTS = [1, 2, 4]
VIEW_PIXEL_SIZE = (320, 320)
def populate_grid(g):
for y in range(GRID_H):
for x in range(GRID_W):
c = g.at(x, y)
c.walkable = True
c.transparent = True
def make_scene(view_count, tmpdir):
scene = mcrfpy.Scene(f"bench_views_{view_count}")
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
populate_grid(grid)
views = []
for i in range(view_count):
# Stack views in a row; tolerate going off-screen, the renderer clips.
v = mcrfpy.GridView(
grid=grid,
pos=(i * 100, 0),
size=VIEW_PIXEL_SIZE,
)
scene.children.append(v)
views.append(v)
return scene, grid, views
def bench(view_count, tmpdir):
scene, grid, views = make_scene(view_count, tmpdir)
mcrfpy.current_scene = scene
# Warmup: a few screenshots so any first-time texture loads / shader
# compilations are amortised away.
for i in range(WARMUP_FRAMES):
automation.screenshot(os.path.join(tmpdir, f"warm_{view_count}_{i}.png"))
times = []
for i in range(N_FRAMES):
path = os.path.join(tmpdir, f"frame_{view_count}_{i}.png")
t0 = time.perf_counter()
automation.screenshot(path)
times.append(time.perf_counter() - t0)
times.sort()
total = sum(times)
mean = total / len(times)
p95 = times[int(0.95 * len(times))]
return {
"views": view_count,
"frames": N_FRAMES,
"warmup_frames": WARMUP_FRAMES,
"total_sec": total,
"mean_frame_ms": mean * 1000.0,
"p95_frame_ms": p95 * 1000.0,
"implied_fps": (1.0 / mean) if mean > 0 else float("inf"),
"per_view_frame_ms": mean * 1000.0 / view_count,
}
def main():
runs = []
with tempfile.TemporaryDirectory(prefix="mcrf_bench_") as tmpdir:
for n in VIEW_COUNTS:
r = bench(n, tmpdir)
runs.append(r)
print(f" views={r['views']} frames={r['frames']} "
f"mean={r['mean_frame_ms']:7.2f} ms "
f"p95={r['p95_frame_ms']:7.2f} ms "
f"fps~{r['implied_fps']:6.1f} "
f"per-view={r['per_view_frame_ms']:6.2f} ms")
base = runs[0]["mean_frame_ms"]
print()
for r in runs[1:]:
ratio = r["mean_frame_ms"] / base if base > 0 else 0
print(f" views={r['views']}: total frame time vs 1-view = {ratio:.2f}x")
out = {"runs": runs, "config": {
"grid": f"{GRID_W}x{GRID_H}",
"frames": N_FRAMES,
"warmup_frames": WARMUP_FRAMES,
"view_counts": VIEW_COUNTS,
"view_pixel_size": VIEW_PIXEL_SIZE,
}}
print(json.dumps(out, indent=2))
_baseline.write("gridview_render_bench.json", out)
print("DONE")
if __name__ == "__main__":
main()
sys.exit(0)