Files
gamedev-the-steward/tools/benchmark_simulation_scaling.gd
T
2026-07-16 13:07:01 +02:00

223 lines
7.3 KiB
GDScript

extends SceneTree
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
const BenchmarkScript := preload("res://simulation/benchmark/SimulationScalingBenchmark.gd")
const DEFAULT_OUTPUT_PATH := "user://simulation_scaling_latest.json"
const DEFAULT_HOST_LABEL := "unspecified"
const BENCHMARK_SEED := 8088
const WARMUP_TICKS := 10
const MEASURED_TICKS := 200
const SAMPLE_COUNT := 3
const CASES := [
{"case_id": "population_006", "population": 6, "history_events": 0},
{"case_id": "population_060", "population": 60, "history_events": 0},
{"case_id": "population_600", "population": 600, "history_events": 0},
{"case_id": "history_000600", "population": 60, "history_events": 600},
{"case_id": "history_006000", "population": 60, "history_events": 6000},
]
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var benchmark := BenchmarkScript.new()
var case_results: Array[Dictionary] = []
for case_config in CASES:
var samples: Array[Dictionary] = []
for _sample_index in SAMPLE_COUNT:
var setup_started_usec := Time.get_ticks_usec()
var manager := SimulationManagerScript.new()
manager.simulation_seed = BENCHMARK_SEED
manager.debug_logs = false
manager.set_process(false)
root.add_child(manager)
var prepared := benchmark.prepare_manager(
manager,
int(case_config["population"]),
int(case_config["history_events"]),
BENCHMARK_SEED
)
var fixture_valid := (
prepared and SimulationStateRecord.from_json(manager.serialize_state()) != null
)
var setup_usec := Time.get_ticks_usec() - setup_started_usec
if not fixture_valid:
push_error(
"Scaling benchmark prepared invalid state for %s" % case_config["case_id"]
)
manager.free()
quit(1)
return
var sample := benchmark.measure_manager(
manager,
int(case_config["population"]),
int(case_config["history_events"]),
WARMUP_TICKS,
MEASURED_TICKS
)
if sample.is_empty():
push_error("Scaling benchmark could not measure %s" % case_config["case_id"])
manager.free()
quit(1)
return
sample["setup_usec"] = maxi(setup_usec, 1)
samples.append(sample)
manager.free()
var summarized := _summarize_samples(String(case_config["case_id"]), samples)
if summarized.is_empty():
quit(1)
return
case_results.append(summarized)
_print_case(summarized)
var report := {
"schema_version": SimulationScalingBenchmark.SCHEMA_VERSION,
"captured_utc": Time.get_datetime_string_from_system(true) + "Z",
"engine_version": String(Engine.get_version_info().get("string", "unknown")),
"platform": OS.get_name(),
"processor_count": OS.get_processor_count(),
"host_label": _get_argument_value("--host-label=", DEFAULT_HOST_LABEL),
"benchmark_seed": BENCHMARK_SEED,
"workload_id": String(SimulationScalingBenchmark.WORKLOAD_ID),
"workload":
(
"All NPCs receive full per-tick needs/task updates and ordinary action decisions; "
+ "travel resolves through the deterministic immediate-arrival headless convention."
),
"timed_phases": ["simulation_tick", "headless_arrival_completion"],
"exclusions":
[
"manager_and_fixture_setup",
"state_serialization",
"world_scene",
"rendering",
"navigation",
"npc_visual",
],
"warmup_ticks": WARMUP_TICKS,
"measured_ticks": MEASURED_TICKS,
"sample_count": SAMPLE_COUNT,
"cases": case_results,
}
var output_path := _get_output_path()
var output := FileAccess.open(output_path, FileAccess.WRITE)
if output == null:
push_error("Could not write scaling benchmark report to %s" % output_path)
quit(1)
return
output.store_string(JSON.stringify(report, "\t") + "\n")
output.close()
print("[BENCH] Report: %s" % ProjectSettings.globalize_path(output_path))
quit(0)
func _summarize_samples(case_id: String, samples: Array[Dictionary]) -> Dictionary:
if samples.size() != SAMPLE_COUNT or samples[0].is_empty():
push_error("Scaling benchmark case %s returned incomplete samples" % case_id)
return {}
var checksum := String(samples[0]["final_checksum"])
var deterministic_keys := [
"start_state_bytes",
"end_state_bytes",
"state_growth_bytes",
"start_event_count",
"end_event_count",
"events_recorded",
"start_known_reference_count",
"end_known_reference_count",
"arrivals_processed",
"warmup_arrivals",
"npc_updates",
"start_tick",
"end_tick",
]
for sample in samples:
if String(sample["final_checksum"]) != checksum:
push_error("Scaling benchmark case %s diverged across checksums" % case_id)
return {}
for key in deterministic_keys:
if sample[key] != samples[0][key]:
push_error("Scaling benchmark case %s diverged at %s" % [case_id, key])
return {}
var elapsed_samples: Array[int] = []
var setup_samples: Array[int] = []
var simulation_samples: Array[int] = []
var arrival_samples: Array[int] = []
for sample in samples:
elapsed_samples.append(int(sample["elapsed_usec"]))
setup_samples.append(int(sample["setup_usec"]))
simulation_samples.append(int(sample["simulation_usec"]))
arrival_samples.append(int(sample["arrival_usec"]))
elapsed_samples.sort()
setup_samples.sort()
simulation_samples.sort()
arrival_samples.sort()
var elapsed_median := elapsed_samples[elapsed_samples.size() / 2]
var simulation_median := simulation_samples[simulation_samples.size() / 2]
var arrival_median := arrival_samples[arrival_samples.size() / 2]
var result := samples[0].duplicate(true)
for transient_key in [
"setup_usec",
"elapsed_usec",
"simulation_usec",
"arrival_usec",
"usec_per_tick",
"ticks_per_second",
"realtime_factor",
]:
result.erase(transient_key)
result["case_id"] = case_id
result["sample_count"] = SAMPLE_COUNT
result["setup_usec_samples"] = setup_samples
result["setup_usec_median"] = setup_samples[setup_samples.size() / 2]
result["elapsed_usec_samples"] = elapsed_samples
result["elapsed_usec_min"] = elapsed_samples[0]
result["elapsed_usec_median"] = elapsed_median
result["elapsed_usec_max"] = elapsed_samples[-1]
result["simulation_usec_samples"] = simulation_samples
result["simulation_usec_median"] = simulation_median
result["arrival_usec_samples"] = arrival_samples
result["arrival_usec_median"] = arrival_median
result["arrival_share_percent"] = float(arrival_median) / float(elapsed_median) * 100.0
result["usec_per_tick_median"] = float(elapsed_median) / float(MEASURED_TICKS)
result["ticks_per_second_median"] = (float(MEASURED_TICKS) * 1000000.0 / float(elapsed_median))
result["realtime_factor_median"] = (
float(result["ticks_per_second_median"]) * float(result["tick_interval"])
)
return result
func _print_case(result: Dictionary) -> void:
print(
(
(
"[BENCH] %s | NPC %d | seeded events %d | %.1f us/tick | "
+ "arrival %.1f%% | %.1fx realtime | %.2f MiB"
)
% [
result["case_id"],
result["population"],
result["history_seed_events"],
result["usec_per_tick_median"],
result["arrival_share_percent"],
result["realtime_factor_median"],
float(result["end_state_bytes"]) / (1024.0 * 1024.0),
]
)
)
func _get_output_path() -> String:
return _get_argument_value("--output=", DEFAULT_OUTPUT_PATH)
func _get_argument_value(prefix: String, default_value: String) -> String:
for argument in OS.get_cmdline_user_args():
if argument.begins_with(prefix):
var value := argument.trim_prefix(prefix)
if not value.is_empty():
return value
return default_value