#!/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