OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_gpu_submit_budget.gd

91 lines · gd ref:
extends Node
## GATE: no multi-chunk dc-terrain GPU submit holds the GPU longer than the
## submit budget (hyper-games #88: on ashatron's GTX 1060 a streaming submit of
## up to 8 chunks held the GPU 50-350 ms, and the game's frame could not present
## until it finished -- the draw-phase hitches).
##
##   DC_GPU_SUBMIT_BUDGET_MS=2 "$G" --path . --rendering-driver vulkan --resolution 1280x720 res://scenes/test/gate_gpu_submit_budget.tscn
##
## The budget is forced LOW (2 ms) so a fast GPU has to obey it too: an 8-chunk
## stream submit is ~4.7 ms of GPU on tabertron's 2070, so without sizing this is
## red. Reads DCTerrain.get_frame_stats each frame: gpu_submit_max_multi_gpu_ms is
## the GPU-execution time (timestamps) of the slowest submit that carried more
## than one chunk.
##
## THE LIMIT: budget + one heavy chunk (p99 of single-chunk submits). Chunks at
## one LOD cost 0.4-3 ms each on the 2070 and a chunk's surface is unknown until
## it is meshed, so sizing from the per-chunk estimate can overshoot by one heavy
## chunk (measured: 3.13 and 3.86 ms against a 2 ms budget, single-chunk p99
## 2.97). Chosen 2026-10-01 over a tighter 1.5x-budget limit that runs failed by
## that one chunk. p99, not max: the root chunk (voxel 4096) is a one-off ~22 ms.
## ⛔ NOT HEADLESS (the mountain's field is GPU-only).

const MAP := "res://scenes/maps/map_mountain.tscn"
const MAP_ID := "map_mountain"
const WARMUP_S := 5.0     # the per-chunk estimate starts from nothing
const RUN_S := 25.0


func _ready() -> void:
	_run.call_deferred()


func _run() -> void:
	if DisplayServer.get_name() == "headless":
		print("RESULT: SKIP (needs a display)")
		get_tree().quit(2)
		return
	var budget := float(OS.get_environment("DC_GPU_SUBMIT_BUDGET_MS"))
	if budget <= 0.0:
		print("RESULT: FAIL (run with DC_GPU_SUBMIT_BUDGET_MS=2)")
		get_tree().quit(1)
		return
	GameState.enter_map(MAP_ID)
	var cam := Camera3D.new()
	cam.far = 20000.0
	add_child(cam)
	cam.current = true
	var map := (load(MAP) as PackedScene).instantiate()
	add_child(map)
	var spawn := Vector3(0, 250, 0)
	var sp := get_tree().get_nodes_in_group("spawn_points")
	if not sp.is_empty():
		spawn = (sp[0] as Node3D).global_position
	cam.global_position = spawn + Vector3(0, 2, 0)
	cam.look_at(spawn + Vector3(20, 0, 0))
	var found := get_tree().root.find_children("*", "DCTerrain", true, false)
	if found.is_empty():
		print("RESULT: FAIL (no DCTerrain)")
		get_tree().quit(1)
		return
	var terrain: Node = found[0]
	var t0 := Time.get_ticks_msec()
	var submits := 0
	var worst_multi := 0.0
	var worst_any := 0.0
	var worst_line := ""
	var singles: Array[float] = []
	while (Time.get_ticks_msec() - t0) / 1000.0 < RUN_S:
		await get_tree().process_frame
		var d: Dictionary = terrain.call("get_frame_stats")
		if (Time.get_ticks_msec() - t0) / 1000.0 < WARMUP_S:
			continue
		submits += int(d.get("gpu_submits", 0))
		var m := float(d.get("gpu_submit_max_multi_gpu_ms", 0.0))
		if m > worst_multi:
			worst_multi = m
			worst_line = str(d.get("gpu_submit_worst", ""))
		worst_any = maxf(worst_any, float(d.get("gpu_submit_max_gpu_ms", 0.0)))
		var s1 := float(d.get("gpu_submit_max_single_gpu_ms", 0.0))
		if s1 > 0.0:
			singles.append(s1)
	singles.sort()
	var single_p99 := singles[int(singles.size() * 0.99)] if not singles.is_empty() else 0.0
	var limit := budget + single_p99
	var ok := submits > 50 and worst_multi > 0.0 and worst_multi <= limit
	print("[GATE-GPU-BUDGET] budget %.1f ms, single-chunk p99 %.2f ms (%d frames) -> limit %.2f: %d submits after warm-up, slowest multi-chunk submit %.2f ms GPU, slowest any %.2f ms" % [
			budget, single_p99, singles.size(), limit, submits, worst_multi, worst_any])
	print("[GATE-GPU-BUDGET] a slow frame's worst job: %s" % worst_line)
	print("RESULT: %s" % ("PASS" if ok else "FAIL"))
	get_tree().quit(0 if ok else 1)