- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
293 lines
12 KiB
GDScript
293 lines
12 KiB
GDScript
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)
|