RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
performance_monitor.gd
Go to the documentation of this file.
1extends RefCounted
2
3
10
11const DataCacheManager = preload("res://scripts/systems/dataCacheManager/data_cache_manager.gd")
12
13var enabled:bool = true
14var max_frame_samples:int = 240
15var timers:Dictionary = {}
16var active_timers:Dictionary = {}
17var counters:Dictionary = {}
18var gauges:Dictionary = {}
19var marks:Array = []
20var frame_samples:Array = []
21var data_cache_manager = DataCacheManager.new()
22
23
24func configure(options:Dictionary = {}) -> void:
25 enabled = bool(options.get("enabled", enabled))
26 max_frame_samples = max(1, int(options.get("max_frame_samples", max_frame_samples)))
27 _trim_frame_samples()
28
29
30func reset() -> void:
31 timers.clear()
32 active_timers.clear()
33 counters.clear()
34 gauges.clear()
35 marks.clear()
36 frame_samples.clear()
37
38
39func start_timer(id:String) -> void:
40 if not enabled or id.is_empty():
41 return
42 active_timers[id] = Time.get_ticks_usec()
43
44
45func stop_timer(id:String, metadata:Dictionary = {}) -> Dictionary:
46 if not enabled or id.is_empty():
47 return {}
48 if not active_timers.has(id):
49 return {}
50 var start_usec:int = int(active_timers.get(id, Time.get_ticks_usec()))
51 active_timers.erase(id)
52 var duration_usec:int = max(0, Time.get_ticks_usec() - start_usec)
53 return record_duration(id, duration_usec, metadata)
54
55
56func record_duration(id:String, duration_usec:int, metadata:Dictionary = {}) -> Dictionary:
57 if not enabled or id.is_empty():
58 return {}
59 var normalized_duration:int = max(0, duration_usec)
60 var aggregate:Dictionary = timers.get(id, {
61 "count": 0,
62 "total_usec": 0,
63 "min_usec": normalized_duration,
64 "max_usec": normalized_duration,
65 "last_usec": 0,
66 "last_metadata": {}
67 })
68 aggregate["count"] = int(aggregate.get("count", 0)) + 1
69 aggregate["total_usec"] = int(aggregate.get("total_usec", 0)) + normalized_duration
70 aggregate["min_usec"] = min(int(aggregate.get("min_usec", normalized_duration)), normalized_duration)
71 aggregate["max_usec"] = max(int(aggregate.get("max_usec", normalized_duration)), normalized_duration)
72 aggregate["last_usec"] = normalized_duration
73 aggregate["last_metadata"] = metadata.duplicate(true)
74 timers[id] = aggregate
75 return {
76 "id": id,
77 "duration_usec": normalized_duration,
78 "metadata": metadata.duplicate(true)
79 }
80
81
82func increment(id:String, amount:int = 1) -> int:
83 if not enabled or id.is_empty():
84 return int(counters.get(id, 0))
85 var next_value:int = int(counters.get(id, 0)) + amount
86 counters[id] = next_value
87 return next_value
88
89
90func set_gauge(id:String, value:Variant) -> void:
91 if not enabled or id.is_empty():
92 return
93 gauges[id] = value
94
95
96func mark(id:String, metadata:Dictionary = {}) -> Dictionary:
97 if not enabled or id.is_empty():
98 return {}
99 var entry:Dictionary = {
100 "id": id,
101 "at_usec": Time.get_ticks_usec(),
102 "metadata": metadata.duplicate(true)
103 }
104 marks.append(entry)
105 return entry
106
107
108func sample_frame(delta:float) -> Dictionary:
109 if not enabled:
110 return {}
111 var normalized_delta:float = max(0.0, delta)
112 var fps:float = 0.0
113 if normalized_delta > 0.0:
114 fps = 1.0 / normalized_delta
115 var sample:Dictionary = {
116 "delta": normalized_delta,
117 "fps": fps,
118 "at_usec": Time.get_ticks_usec()
119 }
120 frame_samples.append(sample)
121 _trim_frame_samples()
122 return sample
123
124
125func capture_node_counts(root_node:Node, id:String = "nodes") -> Dictionary:
126 if not enabled or root_node == null:
127 return {}
128 var counts:Dictionary = {
129 "total": 0,
130 "control": 0,
131 "canvas_item": 0,
132 "visible": 0,
133 "hidden": 0
134 }
135 _count_node(root_node, counts)
136 for key in counts.keys():
137 set_gauge("%s.%s" % [id, str(key)], counts.get(key))
138 return counts
139
140
141func capture_provider_stats(id:String, provider:Object) -> Dictionary:
142 if not enabled or id.is_empty() or provider == null:
143 return {}
144 var stats:Dictionary = _provider_stats(provider)
145 for key in stats.keys():
146 var value:Variant = stats.get(key)
147 if value is int or value is float:
148 set_gauge("%s.%s" % [id, str(key)], value)
149 return stats
150
151
152func measure_callable(id:String, action:Callable, metadata:Dictionary = {}) -> Dictionary:
153 if not enabled or id.is_empty() or not action.is_valid():
154 return {}
155 var start_usec:int = Time.get_ticks_usec()
156 var result:Variant = action.call()
157 var sample:Dictionary = record_duration(id, max(0, Time.get_ticks_usec() - start_usec), metadata)
158 sample["result"] = result
159 return sample
160
161
162func capture_provider_delta(id:String, provider:Object, action:Callable, metadata:Dictionary = {}) -> Dictionary:
163 if not enabled or id.is_empty() or provider == null:
164 return {}
165 var before:Dictionary = _provider_stats(provider)
166 var timed:Dictionary = measure_callable(id, action, metadata)
167 var after:Dictionary = _provider_stats(provider)
168 var delta:Dictionary = _numeric_delta(before, after)
169 for key in delta.keys():
170 set_gauge("%s.delta.%s" % [id, str(key)], delta.get(key))
171 for key in after.keys():
172 var value:Variant = after.get(key)
173 if value is int or value is float:
174 set_gauge("%s.after.%s" % [id, str(key)], value)
175 return {
176 "id": id,
177 "before": before,
178 "after": after,
179 "delta": delta,
180 "timing": timed
181 }
182
183
184func benchmark_scene_instantiation(id:String, packed_scene:PackedScene, parent:Node, iterations:int = 1, metadata:Dictionary = {}) -> Dictionary:
185 if not enabled or id.is_empty() or packed_scene == null or parent == null:
186 return {}
187 var safe_iterations:int = max(1, iterations)
188 var samples:Array = []
189 var total_usec:int = 0
190 var max_usec:int = 0
191 var min_usec:int = 0
192 var max_nodes:int = 0
193 var before_parent_counts:Dictionary = capture_node_counts(parent, "%s.parent_before" % id)
194 for index in range(safe_iterations):
195 var start_usec:int = Time.get_ticks_usec()
196 var instance:Node = packed_scene.instantiate()
197 var instantiate_usec:int = max(0, Time.get_ticks_usec() - start_usec)
198 total_usec += instantiate_usec
199 max_usec = max(max_usec, instantiate_usec)
200 min_usec = instantiate_usec if index == 0 else min(min_usec, instantiate_usec)
201 if instance != null:
202 parent.add_child(instance)
203 var node_counts:Dictionary = capture_node_counts(instance, "%s.instance_%d" % [id, index])
204 max_nodes = max(max_nodes, int(node_counts.get("total", 0)))
205 parent.remove_child(instance)
206 instance.free()
207 samples.append({
208 "iteration": index,
209 "instantiate_usec": instantiate_usec
210 })
211 var average_usec:int = int(round(float(total_usec) / float(safe_iterations)))
212 record_duration("%s.instantiate" % id, average_usec, metadata)
213 var after_parent_counts:Dictionary = capture_node_counts(parent, "%s.parent_after" % id)
214 var result:Dictionary = {
215 "id": id,
216 "iterations": safe_iterations,
217 "total_usec": total_usec,
218 "avg_usec": average_usec,
219 "min_usec": min_usec,
220 "max_usec": max_usec,
221 "max_instance_nodes": max_nodes,
222 "parent_before": before_parent_counts,
223 "parent_after": after_parent_counts,
224 "samples": samples
225 }
226 for key in ["iterations", "total_usec", "avg_usec", "min_usec", "max_usec", "max_instance_nodes"]:
227 set_gauge("%s.%s" % [id, key], result.get(key))
228 mark("%s.scene_benchmark" % id, {
229 "iterations": safe_iterations,
230 "avg_usec": average_usec,
231 "max_instance_nodes": max_nodes
232 })
233 return result
234
235
236func benchmark_scene_profiles(profiles:Array, parent:Node, options:Dictionary = {}) -> Dictionary:
237 var results:Dictionary = {}
238 var errors:Array = []
239 for profile_value in profiles:
240 if not profile_value is Dictionary:
241 continue
242 var profile:Dictionary = profile_value
243 var id:String = str(profile.get("id", ""))
244 var scene_path:String = str(profile.get("scene", ""))
245 if id.is_empty() or scene_path.is_empty():
246 errors.append({"id": id, "scene": scene_path, "error": "missing_id_or_scene"})
247 continue
248 var loaded_scene = load(scene_path)
249 if loaded_scene == null or not loaded_scene is PackedScene:
250 errors.append({"id": id, "scene": scene_path, "error": "scene_not_loaded"})
251 continue
252 var metadata:Dictionary = profile.duplicate(true)
253 if options.has("metadata") and options["metadata"] is Dictionary:
254 for key in options["metadata"].keys():
255 metadata[key] = options["metadata"][key]
256 var iterations:int = max(1, int(profile.get("iterations", options.get("iterations", 1))))
257 results[id] = benchmark_scene_instantiation(id, loaded_scene, parent, iterations, metadata)
258 return {
259 "results": results,
260 "errors": errors,
261 "count": results.size()
262 }
263
264
265func load_baseline_profiles(file_path:String) -> Array:
266 var data:Dictionary = data_cache_manager.load_json(file_path)
267 if data is Dictionary and data.get("profiles", []) is Array:
268 return data.get("profiles", [])
269 return []
270
271
272
273func get_cache_stats() -> Dictionary:
274 return data_cache_manager.get_cache_stats()
275
276
277func format_summary_text() -> String:
278 return "\n".join(format_summary_lines())
279
280
281func write_summary_file(file_path:String) -> bool:
282 if file_path.is_empty():
283 return false
284 var file = FileAccess.open(file_path, FileAccess.WRITE)
285 if file == null:
286 return false
287 file.store_string(JSON.stringify(get_summary(), "\t"))
288 return true
289
290
291func get_summary() -> Dictionary:
292 return {
293 "timers": _timer_summary(),
294 "counters": counters.duplicate(true),
295 "gauges": gauges.duplicate(true),
296 "marks": marks.duplicate(true),
297 "frames": _frame_summary()
298 }
299
300
301func get_state() -> Dictionary:
302 return {
303 "enabled": enabled,
304 "max_frame_samples": max_frame_samples,
305 "timers": timers.duplicate(true),
306 "counters": counters.duplicate(true),
307 "gauges": gauges.duplicate(true),
308 "marks": marks.duplicate(true),
309 "frame_samples": frame_samples.duplicate(true)
310 }
311
312
313func apply_state(state:Dictionary) -> void:
314 enabled = bool(state.get("enabled", enabled))
315 max_frame_samples = max(1, int(state.get("max_frame_samples", max_frame_samples)))
316 timers = _dictionary_copy(state.get("timers", {}))
317 active_timers.clear()
318 counters = _dictionary_copy(state.get("counters", {}))
319 gauges = _dictionary_copy(state.get("gauges", {}))
320 marks = _array_copy(state.get("marks", []))
321 frame_samples = _array_copy(state.get("frame_samples", []))
322 _trim_frame_samples()
323
324
325func format_summary_lines() -> Array:
326 var lines:Array = []
327 var summary:Dictionary = get_summary()
328 var timer_summary:Dictionary = summary.get("timers", {})
329 for timer_id in timer_summary.keys():
330 var timer_data:Dictionary = timer_summary.get(timer_id, {})
331 lines.append("%s: %s calls, avg %sus" % [
332 str(timer_id),
333 str(timer_data.get("count", 0)),
334 str(timer_data.get("avg_usec", 0))
335 ])
336 for counter_id in counters.keys():
337 lines.append("%s: %s" % [str(counter_id), str(counters.get(counter_id))])
338 var frame_summary:Dictionary = summary.get("frames", {})
339 if int(frame_summary.get("sample_count", 0)) > 0:
340 lines.append("frames: avg %sfps, min %sfps" % [
341 str(frame_summary.get("avg_fps", 0.0)),
342 str(frame_summary.get("min_fps", 0.0))
343 ])
344 return lines
345
346
347func _timer_summary() -> Dictionary:
348 var summary:Dictionary = {}
349 for id in timers.keys():
350 var aggregate:Dictionary = timers.get(id, {})
351 var count:int = max(0, int(aggregate.get("count", 0)))
352 var avg_usec:int = 0
353 if count > 0:
354 avg_usec = int(round(float(aggregate.get("total_usec", 0)) / float(count)))
355 var row:Dictionary = aggregate.duplicate(true)
356 row["avg_usec"] = avg_usec
357 summary[id] = row
358 return summary
359
360
361func _frame_summary() -> Dictionary:
362 if frame_samples.is_empty():
363 return {
364 "sample_count": 0,
365 "avg_delta": 0.0,
366 "avg_fps": 0.0,
367 "min_fps": 0.0,
368 "max_delta": 0.0
369 }
370 var total_delta:float = 0.0
371 var total_fps:float = 0.0
372 var min_fps:float = INF
373 var max_delta:float = 0.0
374 for sample in frame_samples:
375 var row:Dictionary = sample
376 var delta:float = float(row.get("delta", 0.0))
377 var fps:float = float(row.get("fps", 0.0))
378 total_delta += delta
379 total_fps += fps
380 min_fps = min(min_fps, fps)
381 max_delta = max(max_delta, delta)
382 var sample_count:int = frame_samples.size()
383 return {
384 "sample_count": sample_count,
385 "avg_delta": total_delta / float(sample_count),
386 "avg_fps": total_fps / float(sample_count),
387 "min_fps": min_fps,
388 "max_delta": max_delta
389 }
390
391
392func _count_node(node:Node, counts:Dictionary) -> void:
393 counts["total"] = int(counts.get("total", 0)) + 1
394 if node is Control:
395 counts["control"] = int(counts.get("control", 0)) + 1
396 if node is CanvasItem:
397 counts["canvas_item"] = int(counts.get("canvas_item", 0)) + 1
398 if node.visible:
399 counts["visible"] = int(counts.get("visible", 0)) + 1
400 else:
401 counts["hidden"] = int(counts.get("hidden", 0)) + 1
402 for child in node.get_children():
403 _count_node(child, counts)
404
405
406func _provider_stats(provider:Object) -> Dictionary:
407 if provider == null:
408 return {}
409 var stats:Dictionary = {}
410 if provider.has_method("get_cache_stats"):
411 stats = provider.call("get_cache_stats")
412 elif provider.has_method("get_stats"):
413 stats = provider.call("get_stats")
414 return stats
415
416
417func _numeric_delta(before:Dictionary, after:Dictionary) -> Dictionary:
418 var delta:Dictionary = {}
419 for key in after.keys():
420 var after_value:Variant = after.get(key)
421 if not (after_value is int or after_value is float):
422 continue
423 var before_value:Variant = before.get(key, 0)
424 if not (before_value is int or before_value is float):
425 before_value = 0
426 delta[key] = after_value - before_value
427 return delta
428
429
430func _trim_frame_samples() -> void:
431 while frame_samples.size() > max_frame_samples:
432 frame_samples.pop_front()
433
434
435func _dictionary_copy(value:Variant) -> Dictionary:
436 if value is Dictionary:
437 return value.duplicate(true)
438 return {}
439
440
441func _array_copy(value:Variant) -> Array:
442 if value is Array:
443 return value.duplicate(true)
444 return []