OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / tools / bench_mountain_nodes.gd

215 lines · gd ref:
extends SceneTree
## Per-node GPU cost of the mountain's density body (blender/mountain_voronoi3.tscn),
## as map_mountain configures it (DCTerrainGeneratorProcNodesBounded, world_scale 1,
## heightfield off).
##
## ABLATION: a variant DISCONNECTS a node's outputs, so what it fed falls back to its
## stored default and proc-nodes emits only the density's cone — that node and
## everything feeding only it drop out of the shader. Its time saved vs the full graph
## is its EXCLUSIVE cost. "trivial" cuts the density itself: the fixed cost (dispatch,
## sync, readback) every variant pays.
##
## TIMING: DCTerrain.debug_gpu_density over the mountain's whole box (cells³ points);
## the GPU's own time for it (debug_last_gpu_fill_ms: timestamps around the compute
## list). ⛔ NOT wall clock: a first run timed the call and got removals that were
## SLOWER than the full graph and "all textures" below "trivial" — the wait behind the
## renderer's frame, not the shader. Median of REPS after WARM, once the terrain's own
## streaming has settled.
## ⛔ PAIRED: the full graph is re-measured right before and after EVERY variant, and a
## variant is compared to the mean of that pair only. A second run (timestamps, unpaired)
## had "full" drift 2.81 -> 3.26 ms mid-run — a GPU clock change — which made most
## removals look slower than the reference. The report checks the ruler (each pair
## within 5%, no removal slower than its pair, nothing below trivial) before any number.
## Each variant reloads the scene uncached, so variants cannot leak into each other.
##
## ⚠ WINDOWED (needs a RenderingDevice):
##   CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 DISPLAY=:0 "$HYPER_TPS_GODOT" --path . \
##       --script res://tools/bench_mountain_nodes.gd

const SCENE := "res://blender/mountain_voronoi3.tscn"
const HOLDER := ^"Mountain/ProcGeoNodes"
const CELLS := 128
const WARM := 10
const REPS := 15
const SETTLE_FRAMES := 30
const SETTLE_MAX_MS := 30000
const KINDS := ["NoiseTexture", "VoronoiTexture", "GradientTexture"]

var _terrain: Node
var _variants: Array = []      # [name, Array[node idx] to cut, or -1 = cut the density]
var _vi := -1
var _phase := ""
var _quiet := 0
var _t_settle := 0
var _box_min := Vector3.ZERO
var _voxel := 0.0
var _times: Array[float] = []
var _results: Array = []
var _evals := 0


func _initialize() -> void:
	var g := _fresh()
	var gen := _gen(g)
	if not gen.call("has_compiled_shader"):
		print("FAIL the full graph did not compile: ", gen.call("get_last_compile_error"))
		quit(1)
		return
	_box_min = gen.call("get_bounds_min")
	var size: Vector3 = Vector3(gen.call("get_bounds_max")) - _box_min
	_voxel = maxf(size.x, maxf(size.y, size.z)) / CELLS
	_evals = (CELLS + 1) * (CELLS + 1) * (CELLS + 1)
	var emitted := _emitted(gen)
	var by_kind := {}
	for i in g.get_node_count():
		var n: ProcNode = g.get_node(i)
		if n != null and emitted.has(i) and KINDS.has(String(n.get_node_type())):
			_variants.append(["%s #%d" % [n.get_node_type(), i], [i]])
			by_kind[n.get_node_type()] = by_kind.get(n.get_node_type(), []) + [i]
	for k in by_kind:
		_variants.append(["ALL %s (%d)" % [k, by_kind[k].size()], by_kind[k]])
	var all_tex := []
	for k in by_kind:
		all_tex += by_kind[k]
	_variants.append(["ALL textures (%d)" % all_tex.size(), all_tex])
	_variants.push_front(["trivial", -1])
	# full, v1, full, v2, full, ... full: every variant bracketed by the reference.
	var paired := [["full", []]]
	for v in _variants:
		paired.append(v)
		paired.append(["full", []])
	_variants = paired
	print("BENCH box %s + %s, %d^3 points (%d evals/call), voxel %.1f m, %d variants, %d emitted nodes"
			% [_box_min, size, CELLS + 1, _evals, _voxel, _variants.size(), emitted.size()])
	_terrain = ClassDB.instantiate("DCTerrain")
	_terrain.set("use_gpu", true)
	_terrain.set("voxel_size", 64.0)
	root.add_child(_terrain)
	_next()


func _fresh() -> ProcNodeGraph:
	var ps := ResourceLoader.load(SCENE, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as PackedScene
	var inst := ps.instantiate()
	var g := inst.get_node(HOLDER).get("graph") as ProcNodeGraph
	inst.free()
	return g


func _gen(g: ProcNodeGraph) -> Object:
	var gen: Object = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
	gen.call("set_world_scale", 1.0)
	gen.call("set_heightfield", false)
	gen.call("set_graph", g)
	return gen


func _emitted(gen: Object) -> Dictionary:
	var out := {}
	for m in RegEx.create_from_string("// Node (\\d+): ").search_all(String(gen.call("get_compiled_glsl"))):
		out[int(m.get_string(1))] = true
	return out


## Disconnect every output of the given nodes; -1 = the Group Output's density input.
func _cut(g: ProcNodeGraph, what: Variant) -> void:
	var c := g.get_connections_flat()
	if typeof(what) == TYPE_INT:
		for i in g.get_node_count():
			if g.get_node(i) is GroupOutputNode:
				for j in range(0, c.size(), 4):
					if c[j + 2] == i and c[j + 3] == 1:   # socket 1 = Density
						g.disconnect_sockets(c[j], c[j + 1], i, 1)
		return
	for idx in what:
		for j in range(0, c.size(), 4):
			if c[j] == idx:
				g.disconnect_sockets(c[j], c[j + 1], c[j + 2], c[j + 3])


func _next() -> void:
	_vi += 1
	if _vi >= _variants.size():
		_report()
		return
	var g := _fresh()
	_cut(g, _variants[_vi][1])
	var gen := _gen(g)
	if not gen.call("has_compiled_shader"):
		_results.append([_variants[_vi][0], -1.0, -1.0, 0, "did not compile: " + String(gen.call("get_last_compile_error")).left(80)])
		_next()
		return
	_variants[_vi].append(_emitted(gen).size())
	_terrain.set("generator", gen)
	_phase = "settle"
	_quiet = 0
	_t_settle = Time.get_ticks_msec()
	_times.clear()


func _process(_dt: float) -> bool:
	match _phase:
		"settle":
			_quiet = _quiet + 1 if int(_terrain.call("get_pending_count")) == 0 else 0
			if _quiet >= SETTLE_FRAMES or Time.get_ticks_msec() - _t_settle > SETTLE_MAX_MS:
				_phase = "time"
		"time":
			var d: PackedFloat32Array = _terrain.call("debug_gpu_density", _box_min, _voxel, CELLS)
			var ms := float(_terrain.call("debug_last_gpu_fill_ms"))
			if d.size() != _evals or ms < 0.0:
				_results.append([_variants[_vi][0], -1.0, -1.0, 0, "GPU declined (%d values)" % d.size()])
				_next()
				return false
			_times.append(ms)
			if _times.size() >= WARM + REPS:
				var s := _times.slice(WARM)
				s.sort()
				_results.append([_variants[_vi][0], s[s.size() / 2], s[s.size() / 10], _variants[_vi][2], ""])
				_next()
		"done":
			return true
	return false


func _report() -> void:
	_phase = "done"
	# _results alternates: full, v, full, v, ..., full. Each v against its bracketing pair.
	var rows := []
	var worst_pair := 0.0
	var slower := 0
	var below := 0
	var trivial_ms := -1.0
	var ref_trivial := -1.0
	for i in range(1, _results.size() - 1, 2):
		var a: float = _results[i - 1][1]
		var b: float = _results[i + 1][1]
		var v: Array = _results[i]
		var ref := (a + b) * 0.5
		worst_pair = maxf(worst_pair, absf(a - b) / ref)
		rows.append([v, ref])
		if v[0] == "trivial":
			trivial_ms = v[1]
			ref_trivial = ref
	for row in rows:
		var v: Array = row[0]
		if v[1] < 0.0 or v[0] == "trivial":
			continue
		if v[1] > row[1] * 1.03: slower += 1
		if v[1] < trivial_ms * 0.97: below += 1
	print("\nRULER worst pair spread %.1f%%, removals slower than their pair: %d, below trivial: %d -> %s"
			% [100.0 * worst_pair, slower, below,
			   "VALID" if worst_pair < 0.05 and slower == 0 and below == 0 else "INVALID — do not use"])
	print("\n%-26s %9s %9s %8s %10s %8s" % ["variant", "median ms", "pair ms", "nodes", "saves ms", "% body"])
	for row in rows:
		var v: Array = row[0]
		if v[1] < 0.0:
			print("%-26s  %s" % [v[0], v[4]])
			continue
		var body: float = row[1] - trivial_ms        # this pair's body
		var saves: float = row[1] - v[1]
		print("%-26s %9.3f %9.3f %8d %10.3f %7.1f%%" % [v[0], v[1], row[1], v[3], saves,
				100.0 * saves / body if body > 0.0 else 0.0])
	var body0 := ref_trivial - trivial_ms
	print("\nBODY = full - trivial = %.3f ms per %d evals = %.2f ns/eval" % [body0, _evals, body0 * 1.0e6 / _evals])
	quit(0)