305 lines
11 KiB
GDScript
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
|