Files
tactical-shooter/tests/benchmarks/bench_128hz.gd
T

305 lines
11 KiB
GDScript

#!/usr/bin/env godot
# tests/benchmarks/bench_128hz.gd
# 128Hz Load Test Benchmark for GDExtension Simulation Core
#
# Tests SimulationServer at N=8,16,32,64 bots running at 128Hz for
# BENCH_DURATION virtual-seconds each. Measures achieved tick rate,
# frame time (GDScript + tick overhead), C++ tick time (from get_stats()),
# memory delta, and missed ticks.
#
# Usage:
# godot --headless --script tests/benchmarks/bench_128hz.gd
#
# Exit code: 0 = benchmark complete, 1 = GDExtension not loadable
extends SceneTree
const TICK_HZ := 128
const BENCH_DURATION_SEC := 10.0
const POPULATION_LEVELS := [8, 16, 32, 64]
const TICK_INTERVAL := 1.0 / TICK_HZ
const EXPECTED_TICKS := int(TICK_HZ * BENCH_DURATION_SEC)
var _results := []
# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------
func _init() -> void:
print("")
print("=".repeat(70))
print(" 128Hz Load Test Benchmark — GDExtension Simulation Server")
print("=".repeat(70))
print("")
var vi = Engine.get_version_info()
print(" Engine: Godot %d.%d.%d" % [vi.major, vi.minor, vi.patch])
print(" Platform: %s" % OS.get_name())
print(" Headless: %s" % str(DisplayServer.get_name() == "headless"))
print(" Tick Rate: %d Hz" % TICK_HZ)
print(" Duration: %.1f sec per level" % BENCH_DURATION_SEC)
print(" Expected ticks: %d" % EXPECTED_TICKS)
print("")
# ---- Diagnostics: is the GDExtension loadable? ----
if not ClassDB.class_exists("SimulationServer"):
printerr("")
printerr(" [FATAL] SimulationServer class is NOT registered in ClassDB.")
printerr("")
printerr(" Diagnostic:")
printerr(" The GDExtension shared library could not be loaded.")
printerr("")
printerr(" Expected configuration:")
printerr(" .gdextension: res://gdextension/simulation.gdextension")
printerr(" Linux .so: res://gdextension/simulation/gdextension/bin/linux/libsimulation.so")
printerr("")
printerr(" Possible causes:")
printerr(" 1. The .so has not been compiled — run: cd gdextension/simulation && scons -j$(nproc)")
printerr(" 2. The .gdextension file points to the wrong path")
printerr(" 3. The .so is incompatible with this Godot version (need 4.2+ godot-cpp)")
printerr(" 4. Missing symbol — rebuild with a clean build")
printerr("")
quit(1)
return
print(" [OK] SimulationServer class is registered in ClassDB")
print("")
# Run benchmarks for each population level
for pop in POPULATION_LEVELS:
_run_benchmark(pop)
# Print summary table
_print_summary()
quit(0)
# ---------------------------------------------------------------------------
# Single population-level benchmark
# ---------------------------------------------------------------------------
func _run_benchmark(pop: int) -> void:
print("".repeat(70))
print(" Benchmark: %d bots" % pop)
print("".repeat(70))
# Create a fresh SimulationServer for each population level
# Use ClassDB.instantiate because SimulationServer is a GDExtension
# class that isn't known to the parser at compile time.
var server = ClassDB.instantiate("SimulationServer")
if server == null:
printerr(" [ERROR] Failed to create SimulationServer instance!")
return
# Configure and populate
server.set_tick_rate(TICK_HZ)
server.populate_bots(pop)
server.start()
server.reset_stats()
var entity_count = server.get_entity_count()
print(" Entities spawned: %d" % entity_count)
assert(entity_count == pop, "Entity count should match requested population")
# ---- Warm-up: a few ticks to stabilise caches / allocators ----
for _i in range(10):
if server.can_tick(TICK_INTERVAL):
server.tick()
server.reset_stats()
# Memory snapshot BEFORE
var mem_before := OS.get_static_memory_usage()
# ---- Main benchmark loop (EXPECTED_TICKS iterations) ----
var tick_count := 0
var frame_times := []
var bench_start := Time.get_ticks_usec()
for i in range(EXPECTED_TICKS):
var frame_start := Time.get_ticks_usec()
if server.can_tick(TICK_INTERVAL):
var snap = server.tick()
tick_count += 1
var frame_end := Time.get_ticks_usec()
frame_times.append(frame_end - frame_start)
var bench_end := Time.get_ticks_usec()
# Memory snapshot AFTER
var mem_after := OS.get_static_memory_usage()
var mem_delta := mem_after - mem_before
# Internal C++ timing (from get_stats)
var stats = server.get_stats()
var cpp_avg_usec := float(stats["avg_tick_usec"])
var cpp_peak_usec := float(stats["peak_tick_usec"])
var cpp_tick_count := int(stats["tick_count"])
# Wall-clock timing
var wall_usec := bench_end - bench_start
var wall_sec := wall_usec / 1_000_000.0
var achieved_hz := float(tick_count) / wall_sec if wall_sec > 0.0 else 0.0
# Frame-time statistics
var avg_frame_usec := 0.0
var min_frame_usec := 0
var max_frame_usec := 0
if frame_times.size() > 0:
var total := 0
min_frame_usec = frame_times[0]
max_frame_usec = frame_times[0]
for ft in frame_times:
total += ft
if ft < min_frame_usec:
min_frame_usec = ft
if ft > max_frame_usec:
max_frame_usec = ft
avg_frame_usec = float(total) / float(frame_times.size())
# Missed ticks (process fewer than expected)
var missed_ticks: int = max(0, EXPECTED_TICKS - tick_count)
# ---- Print per-level results ----
print("")
print(" ┌─ Results ──────────────────────────────────────────────┐")
print(" │ Wall clock: %8.3f sec │" % wall_sec)
print(" │ Total ticks: %d / %d (expected) │" % [tick_count, EXPECTED_TICKS])
print(" │ Missed ticks: %d" % missed_ticks)
print(" │ Achieved tick rate: %.1f Hz │" % achieved_hz)
print(" │ │")
print(" │ Frame time (GDScript + tick overhead): │")
print(" │ Avg: %8.1f µs │" % avg_frame_usec)
print(" │ Min: %8d µs │" % min_frame_usec)
print(" │ Max: %8d µs │" % max_frame_usec)
print(" │ │")
print(" │ Tick time (C++ internal, from get_stats()): │")
print(" │ Avg: %8.1f µs │" % cpp_avg_usec)
print(" │ Peak: %8.1f µs │" % cpp_peak_usec)
print(" │ Count: %d" % cpp_tick_count)
print(" │ │")
print(" │ Memory: │")
print(" │ Before: %s" % _pad(_fmt_bytes(mem_before), 12))
print(" │ After: %s" % _pad(_fmt_bytes(mem_after), 12))
print(" │ Delta: %s" % _pad(_fmt_bytes(mem_delta), 12))
print(" └─────────────────────────────────────────────────────────┘")
print("")
# Save for summary
_results.append({
"pop": pop,
"achieved_hz": achieved_hz,
"avg_frame_usec": avg_frame_usec,
"min_frame_usec": min_frame_usec,
"max_frame_usec": max_frame_usec,
"cpp_avg_usec": cpp_avg_usec,
"cpp_peak_usec": cpp_peak_usec,
"wall_sec": wall_sec,
"total_ticks": tick_count,
"expected_ticks": EXPECTED_TICKS,
"missed_ticks": missed_ticks,
"mem_before": mem_before,
"mem_after": mem_after,
"mem_delta": mem_delta,
})
# Cleanup
server.stop()
server = null
# ---------------------------------------------------------------------------
# Summary table
# ---------------------------------------------------------------------------
func _print_summary() -> void:
print("")
print("=".repeat(70))
print(" BENCHMARK SUMMARY")
print("=".repeat(70))
print("")
# Table header
print(" %-8s %-12s %-10s %-10s %-8s %-10s" % [
"Players", "Tick Rate", "Frame Avg", "C++ Tick", "Missed", "Mem Δ",
])
print(" %-8s %-12s %-10s %-10s %-8s %-10s" % [
"", "(Hz)", "(µs)", "Avg (µs)", "Ticks", "",
])
print(" " + "".repeat(60))
for r in _results:
var rate_str := "%.1f/%d" % [r["achieved_hz"], TICK_HZ]
var missed_str := "%d/%d" % [r["missed_ticks"], r["expected_ticks"]]
print(" %-8s %-12s %-10.1f %-10.1f %-8s %-10s" % [
str(r["pop"]),
rate_str,
r["avg_frame_usec"],
r["cpp_avg_usec"],
missed_str,
_fmt_bytes(r["mem_delta"]),
])
print(" " + "".repeat(60))
print("")
print(" Legend:")
print(" Players: number of simulated bots")
print(" Tick Rate: achieved Hz / target Hz (128)")
print(" Frame Avg: average frame time incl. GDScript + tick overhead")
print(" C++ Tick: average tick processing time inside C++ (get_stats)")
print(" Missed: ticks short of expected / total expected ticks")
print(" Mem Δ: static memory delta before/after the benchmark")
print("")
if _results.size() > 0:
var all_zero_missed := true
var all_above_target := true
for r in _results:
if r["missed_ticks"] > 0:
all_zero_missed = false
if r["achieved_hz"] < TICK_HZ:
all_above_target = false
if all_zero_missed and all_above_target:
print(" ✓ ALL BENCHMARKS PASSED — simulation maintains 128 Hz at all population levels")
elif all_above_target:
print(" ⚠ All levels achieved ≥128 Hz but some missed ticks recorded (check timing)")
else:
print(" ✗ Some levels FAILED to maintain 128 Hz — simulation may need optimisation")
print(" for the higher population levels.")
print("")
print("=".repeat(70))
print(" Benchmark complete.")
print("=".repeat(70))
print("")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
static func _fmt_bytes(bytes: int) -> String:
if bytes < 0:
return "-" + _fmt_bytes(-bytes)
if bytes < 1024:
return "%d B" % bytes
elif bytes < 1024 * 1024:
return "%.1f KB" % (float(bytes) / 1024.0)
else:
return "%.2f MB" % (float(bytes) / (1024.0 * 1024.0))
# Left-pad a string to at least `width` characters by adding spaces.
# Godot 4 GDScript doesn't have rjust(), so we roll our own.
static func _pad(s: String, width: int) -> String:
var needed := width - s.length()
if needed <= 0:
return s
var buf := ""
for _i in range(needed):
buf += " "
return buf + s