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.
178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
"""Benchmark: find_path() across grid sizes, obstacle densities, heuristics, weights,
|
|
and with/without collision-label entity blocking.
|
|
|
|
Kanban #37 coverage: pathfinding throughput at varying obstacle densities (10/30/50%)
|
|
plus an explicit with-vs-without collision-label comparison (10 / 100 entities tagged
|
|
'blocker' on a 100x100 grid).
|
|
|
|
Usage:
|
|
./mcrogueface --headless --exec ../tests/benchmarks/pathfinding_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_SIZES = [(100, 100), (500, 500)]
|
|
OBSTACLE_DENSITIES = [0.10, 0.30, 0.50]
|
|
HEURISTICS = [
|
|
("EUCLIDEAN", mcrfpy.Heuristic.EUCLIDEAN),
|
|
("MANHATTAN", mcrfpy.Heuristic.MANHATTAN),
|
|
("CHEBYSHEV", mcrfpy.Heuristic.CHEBYSHEV),
|
|
("DIAGONAL", mcrfpy.Heuristic.DIAGONAL),
|
|
("ZERO", mcrfpy.Heuristic.ZERO),
|
|
]
|
|
WEIGHTS = [1.0, 1.5, 2.0]
|
|
TRIALS_PER_CONFIG = 5
|
|
|
|
COLLIDE_GRID = (100, 100)
|
|
COLLIDE_DENSITY = 0.10
|
|
COLLIDE_BLOCKER_COUNTS = [0, 10, 100]
|
|
COLLIDE_TRIALS = 20
|
|
|
|
SEED = 0x315
|
|
|
|
|
|
def make_grid(w, h, density, seed):
|
|
rng = random.Random(seed)
|
|
g = mcrfpy.Grid(grid_size=(w, h))
|
|
for y in range(h):
|
|
for x in range(w):
|
|
c = g.at(x, y)
|
|
walkable = (x in (0, w - 1) or y in (0, h - 1)) or rng.random() > density
|
|
c.walkable = walkable
|
|
c.transparent = walkable
|
|
# Guarantee corners walkable.
|
|
g.at(1, 1).walkable = True
|
|
g.at(w - 2, h - 2).walkable = True
|
|
return g
|
|
|
|
|
|
def pick_endpoints(w, h, rng):
|
|
return (1, 1), (w - 2, h - 2)
|
|
|
|
|
|
def bench_one(g, start, end, heuristic, weight, collide, trials):
|
|
total_t = 0.0
|
|
hits = 0
|
|
length_sum = 0
|
|
for _ in range(trials):
|
|
t0 = time.perf_counter()
|
|
if collide:
|
|
p = g.find_path(start, end, heuristic=heuristic, weight=weight, collide=collide)
|
|
else:
|
|
p = g.find_path(start, end, heuristic=heuristic, weight=weight)
|
|
elapsed = time.perf_counter() - t0
|
|
total_t += elapsed
|
|
if p is not None:
|
|
steps = list(p)
|
|
if steps:
|
|
hits += 1
|
|
length_sum += len(steps)
|
|
return {
|
|
"mean_ms": (total_t / trials) * 1000.0,
|
|
"hits": hits,
|
|
"mean_length": length_sum / max(hits, 1),
|
|
}
|
|
|
|
|
|
def collide_block_section(rng):
|
|
"""100x100 grid, walkable arena, tag N entities with 'blocker' label.
|
|
|
|
Compares `find_path(..., collide='blocker')` (with) vs `find_path(...)` (without)
|
|
holding all other variables constant. The same grid is reused across N values,
|
|
walkable cells are unchanged; only the entity set differs.
|
|
"""
|
|
w, h = COLLIDE_GRID
|
|
g = make_grid(w, h, COLLIDE_DENSITY, rng.randrange(2**31))
|
|
start, end = pick_endpoints(w, h, rng)
|
|
|
|
runs = []
|
|
for n_blockers in COLLIDE_BLOCKER_COUNTS:
|
|
# Fresh entity set each iteration. Old entities are garbage-collected once
|
|
# the local list goes out of scope.
|
|
entities = []
|
|
for _ in range(n_blockers):
|
|
while True:
|
|
ex = rng.randrange(2, w - 2)
|
|
ey = rng.randrange(2, h - 2)
|
|
if g.at(ex, ey).walkable and (ex, ey) not in (start, end):
|
|
break
|
|
e = mcrfpy.Entity((ex, ey), grid=g)
|
|
e.add_label("blocker")
|
|
entities.append(e)
|
|
|
|
# WITHOUT collide arg (entities present but ignored).
|
|
wo = bench_one(g, start, end, mcrfpy.Heuristic.EUCLIDEAN, 1.0, None, COLLIDE_TRIALS)
|
|
# WITH collide arg.
|
|
wi = bench_one(g, start, end, mcrfpy.Heuristic.EUCLIDEAN, 1.0, "blocker", COLLIDE_TRIALS)
|
|
|
|
runs.append({
|
|
"grid": f"{w}x{h}",
|
|
"blockers": n_blockers,
|
|
"without_collide_ms": wo["mean_ms"],
|
|
"with_collide_ms": wi["mean_ms"],
|
|
"without_collide_path_len": wo["mean_length"],
|
|
"with_collide_path_len": wi["mean_length"],
|
|
"overhead_ms": wi["mean_ms"] - wo["mean_ms"],
|
|
})
|
|
print(f" collide n={n_blockers:<3} "
|
|
f"without={wo['mean_ms']:6.2f} ms (len={wo['mean_length']:.0f}) "
|
|
f"with={wi['mean_ms']:6.2f} ms (len={wi['mean_length']:.0f}) "
|
|
f"overhead={wi['mean_ms'] - wo['mean_ms']:+6.2f} ms")
|
|
|
|
# Drop entities from the grid before next iteration so the count is correct.
|
|
for e in entities:
|
|
e.die()
|
|
del entities
|
|
|
|
return runs
|
|
|
|
|
|
def main():
|
|
rng = random.Random(SEED)
|
|
out = {"config": {
|
|
"grid_sizes": GRID_SIZES,
|
|
"obstacle_densities": OBSTACLE_DENSITIES,
|
|
"heuristics": [h[0] for h in HEURISTICS],
|
|
"weights": WEIGHTS,
|
|
"trials": TRIALS_PER_CONFIG,
|
|
"collide_blocker_counts": COLLIDE_BLOCKER_COUNTS,
|
|
"collide_trials": COLLIDE_TRIALS,
|
|
}, "runs": []}
|
|
|
|
for (w, h) in GRID_SIZES:
|
|
for density in OBSTACLE_DENSITIES:
|
|
seed = rng.randrange(2**31)
|
|
g = make_grid(w, h, density, seed)
|
|
start, end = pick_endpoints(w, h, rng)
|
|
for (hname, heuristic) in HEURISTICS:
|
|
for weight in WEIGHTS:
|
|
r = bench_one(g, start, end, heuristic, weight, None, TRIALS_PER_CONFIG)
|
|
out["runs"].append({
|
|
"grid": f"{w}x{h}", "density": density,
|
|
"heuristic": hname, "weight": weight,
|
|
"collide": None, **r,
|
|
})
|
|
print(f" {w}x{h} d={density:.2f} h={hname:<9} w={weight:.1f} "
|
|
f"mean={r['mean_ms']:7.2f} ms len={r['mean_length']:.1f}")
|
|
|
|
print()
|
|
print(f"=== Collision-label comparison ({COLLIDE_GRID[0]}x{COLLIDE_GRID[1]}, "
|
|
f"{COLLIDE_TRIALS} trials/config) ===")
|
|
out["collide_runs"] = collide_block_section(rng)
|
|
|
|
print(json.dumps(out, indent=2))
|
|
_baseline.write("pathfinding_bench.json", out)
|
|
print("DONE")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
sys.exit(0)
|