class_name PlayableMetrics extends RefCounted ## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。 ## ## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。 ## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里, ## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数, ## 测试自身不会因为长跑而无限增长内存。 ## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。 ## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated, ## max 与 >50ms / >100ms 计数始终精确。 ## - 分段标签只取 map / phase / actor_count / gpu_skin;actor_count 请用 actor_bucket() 分桶。 ## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。 ## - GPU 内存拿不到时写 null + "unavailable",从不写 0。 const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"] const LONG_MS := 50.0 const SEVERE_MS := 100.0 ## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。 const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"] const MAX_PENDING_MARKERS := 16 const MAX_MARKER_KIND_LENGTH := 32 var capacity := 4096 var window_us := 60_000_000 var max_windows := 512 var max_long_frames := 512 var max_markers := 1024 var max_segments := 64 var print_frames := false var clock_us: Callable = func() -> int: return Time.get_ticks_usec() var _ring := PackedFloat32Array() var _ring_head := 0 var _ring_len := 0 var _window_count := 0 var _window_over_long := 0 var _window_over_severe := 0 var _window_max := 0.0 var _window_start_us := -1 var _origin_us := -1 var _last_frame_us := -1 var _tags := {} var _tag_key := "" var _frames := 0 var _windows: Array = [] var _windows_dropped := 0 var _segments := {} var _unsegmented_frames := 0 var _severe_by_phase := {} var _long_unattributed_by_phase := {} var _long_frames: Array = [] var _long_frames_dropped := 0 var _markers: Array = [] var _markers_dropped := 0 var _pending_markers: Array[String] = [] var _previous_markers: Array[String] = [] func _init() -> void: _ring.resize(capacity) func configure(options: Dictionary) -> void: capacity = maxi(1, int(options.get("capacity", capacity))) window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0)) max_windows = maxi(1, int(options.get("max_windows", max_windows))) max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames))) max_markers = maxi(1, int(options.get("max_markers", max_markers))) max_segments = maxi(1, int(options.get("max_segments", max_segments))) print_frames = bool(options.get("print_frames", false)) _ring.resize(capacity) _ring_head = 0 _ring_len = 0 ## Switches the segment. A different tag set closes the current window first. func set_tags(tags: Dictionary) -> void: var normalized := {} for key in TAG_KEYS: if tags.has(key): normalized[key] = String(str(tags[key])).left(80) var key := JSON.stringify(normalized) if key == _tag_key: return if _window_count > 0: close_window() _tags = normalized _tag_key = key func tags() -> Dictionary: return _tags.duplicate() ## Call once per rendered frame. The first call only arms the clock. func frame(now_us := -1) -> void: var now := now_us if now_us >= 0 else int(clock_us.call()) if _last_frame_us >= 0: add_interval_us(now - _last_frame_us, now) else: _arm(now) _last_frame_us = now ## Forget the previous frame time (e.g. before a deliberately unmeasured pause). func reset_frame_clock() -> void: _last_frame_us = -1 func add_interval_us(interval_us: int, now_us: int) -> void: if _origin_us < 0: _arm(now_us - interval_us) if _window_start_us < 0: _window_start_us = now_us - interval_us var ms := float(interval_us) / 1000.0 _ring[_ring_head] = ms _ring_head = (_ring_head + 1) % capacity _ring_len = mini(_ring_len + 1, capacity) _window_count += 1 _frames += 1 _window_max = maxf(_window_max, ms) var segment := _segment() if not segment.is_empty(): segment.frames = int(segment.frames) + 1 segment.max_ms = maxf(float(segment.max_ms), ms) var attribution: Array[String] = [] for kind in _previous_markers + _pending_markers: if not (kind in attribution): attribution.append(kind) _previous_markers = _pending_markers _pending_markers = [] if ms > LONG_MS: _window_over_long += 1 if not segment.is_empty(): segment.over_50ms = int(segment.over_50ms) + 1 _push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(), "markers": attribution}, max_long_frames, "_long_frames_dropped") if attribution.is_empty(): var long_phase := String(_tags.get("phase", "")) if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments: _long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1 if ms > SEVERE_MS: _window_over_severe += 1 if not segment.is_empty(): segment.over_100ms = int(segment.over_100ms) + 1 var phase := String(_tags.get("phase", "")) if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments: _severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1 if print_frames: print("FRAME ms=%.3f tags=%s" % [ms, _tag_key]) if now_us - _window_start_us >= window_us: close_window(now_us) ## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...). func mark(kind: String, detail := {}) -> void: var name := kind.left(MAX_MARKER_KIND_LENGTH) var clean := {} for key in detail: if String(key) in MARKER_DETAIL_KEYS: var value: Variant = detail[key] clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80) var now := int(clock_us.call()) _push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean}, max_markers, "_markers_dropped") if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers): _pending_markers.append(name) func close_window(now_us := -1) -> void: if _window_count == 0: return var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us) var sorted := PackedFloat32Array() sorted.resize(_ring_len) for i in _ring_len: sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity] sorted.sort() var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end), "count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len, "p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99), "max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe} _push_bounded(_windows, window, max_windows, "_windows_dropped") var segment: Dictionary = _segments.get(_tag_key, {}) if not segment.is_empty(): segment.windows = int(segment.windows) + 1 segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms)) _ring_head = 0 _ring_len = 0 _window_count = 0 _window_over_long = 0 _window_over_severe = 0 _window_max = 0.0 _window_start_us = end func summary() -> Dictionary: close_window() var segments: Array = [] for key in _segments: segments.append(_segments[key].duplicate(true)) return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0, "thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames, "segments": segments, "unsegmented_frames": _unsegmented_frames, "windows": _windows.duplicate(true), "windows_dropped": _windows_dropped, "long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped, "markers": _markers.duplicate(true), "markers_dropped": _markers_dropped, "severe_by_phase": _severe_by_phase.duplicate(), "long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()} func stored_sample_count() -> int: return _ring_len ## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition. func severe_by_phase() -> Dictionary: return _severe_by_phase.duplicate() ## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames). func long_unattributed_by_phase() -> Dictionary: return _long_unattributed_by_phase.duplicate() ## Retained long frames that have no marker in their frame or the one before. They ## need manual attribution (§9.3); the automated gate never explains them away. func unattributed_long_frames() -> Array: return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty()) ## Nearest-rank percentile of an ascending array. static func percentile(sorted: PackedFloat32Array, q: float) -> float: if sorted.is_empty(): return 0.0 var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1) return sorted[index] static func actor_bucket(count: int) -> String: if count <= 0: return "0" for limit in [8, 16, 32, 64]: if count <= limit: return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit] return "65+" ## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive ## repetitions contained a >100ms frame. history: interaction -> Array[bool]. static func repeated_severe(history: Dictionary) -> Array[String]: var out: Array[String] = [] for key in history: var runs: Array = history[key] for i in range(0, runs.size() - 2): var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2])) if hits >= 2: out.append(String(key)) break return out ## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner ## samples RSS externally). GPU memory has no reliable source here. static func memory_snapshot() -> Dictionary: var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)) return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024, "memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024, "gpu_memory_kib": null, "gpu_memory_status": "unavailable", "godot_video_mem_kib": video / 1024 if video > 0 else null, "godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"} ## Cost of one sample on this machine; compare with the frame budget in reports. static func measure_overhead(iterations := 20000) -> Dictionary: var probe := new() probe.configure({"capacity": 4096, "window_seconds": 3600}) probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false}) var started := Time.get_ticks_usec() for i in iterations: probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000) var elapsed := Time.get_ticks_usec() - started return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)} func _arm(now_us: int) -> void: if _origin_us < 0: _origin_us = now_us if _window_start_us < 0: _window_start_us = now_us func _t_ms(us: int) -> float: return float(us - maxi(_origin_us, 0)) / 1000.0 func _segment() -> Dictionary: if _segments.has(_tag_key): return _segments[_tag_key] if _segments.size() >= max_segments: _unsegmented_frames += 1 return {} var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0, "over_50ms": 0, "over_100ms": 0} _segments[_tag_key] = row return row func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void: if list.size() >= limit: list.pop_front() set(dropped_field, int(get(dropped_field)) + 1) list.append(item)