P28-01-COLLECTOR: 現HEADの性能基準:windowed x1/x3・Operations ON collector
This commit is contained in:
+150
-22
@@ -14,6 +14,77 @@ PHASES = {"R0": ("R", 1, 0, 10), "P1": ("R", 1, 30, 120),
|
||||
FORBIDDEN = ("_toggle_pause", "clear(", "enable_production_", "set_paused(false)",
|
||||
"advance_", "spawn_guest", "SimulationRuntime.new", "initialize(")
|
||||
|
||||
# Only declared collector fields are normalized. Product dictionaries (RNG,
|
||||
# startup, raw reports) retain their original JSON representation.
|
||||
INTEGER_FIELDS = {
|
||||
"cores", "memory_bytes", "pid", "seed", "frame", "game_start", "game_end", "game_minute",
|
||||
"minutes_advanced", "start_minute", "end_minute", "opening_minute", "actual_minutes",
|
||||
"warmup_target", "measurement_target", "speed", "speed_after", "backlog",
|
||||
"toggle_pause_calls", "performance_clear_calls", "zero_minute_frames_excluded",
|
||||
"observation_serial", "carried_from_serial", "counts_from_minute",
|
||||
"actors_in_layer", "actors_in_viewport", "facilities", "cells", "guests",
|
||||
"staff", "walkable_cells", "count", "lifetime_count", "observed_minutes", "target_minutes",
|
||||
}
|
||||
OPAQUE_FIELDS = {"rng_streams", "startup", "kernel_at_warmup_end", "kernel_at_phase_end",
|
||||
"operations_at_end", "restore_result", "save_policy_after"}
|
||||
|
||||
|
||||
def normalize_report(value, path=()):
|
||||
"""Single ingestion boundary, also used when checking raw stdout binding."""
|
||||
if isinstance(value, dict):
|
||||
return {k: (v if k in OPAQUE_FIELDS else normalize_report(v, path + (k,)))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [normalize_report(v, path + ("[]",)) for v in value]
|
||||
key = path[-1] if path else ""
|
||||
declared = key in INTEGER_FIELDS or key.endswith("_us")
|
||||
declared = declared or (key == "frames" and not isinstance(value, list))
|
||||
declared = declared or (len(path) >= 2 and path[-2] in ("samples_us", "kernel_stall_samples_us", "sample_counts"))
|
||||
declared = declared or ("budget_verdict" in path and key == "value")
|
||||
declared = declared or ("count_ranges" in path and key in ("min", "max"))
|
||||
declared = declared or (len(path) >= 2 and path[-2] in
|
||||
("frame_wall_us", "stall_over_50000_us", "observer_us", "render_submit_wall_us")
|
||||
and key in ("p95", "max"))
|
||||
if declared and value is not None:
|
||||
need(type(value) in (int, float) and math.isfinite(value)
|
||||
and abs(value) <= 2**53 and (type(value) is int or value.is_integer()),
|
||||
"invalid declared integer: " + ".".join(path))
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def budget_verdict(before, after, speed):
|
||||
result = {}
|
||||
for metric, aggregation, limit in ((f"simulation_x{speed}", "average" if speed == 1 else "p95",
|
||||
4000 if speed == 1 else 12000),
|
||||
("main_thread_stall", "maximum", 50000)):
|
||||
begin = (before or {}).get(metric, {})
|
||||
end = (after or {}).get(metric, {})
|
||||
a, b = begin.get("lifetime_count"), end.get("lifetime_count")
|
||||
raw = end.get("samples_us")
|
||||
reason = None
|
||||
if type(a) is not int or type(b) is not int or b < a or not isinstance(raw, list):
|
||||
reason = "counts_unavailable"
|
||||
elif b == a:
|
||||
reason = "no_interval_samples"
|
||||
elif b - a > len(raw):
|
||||
reason = "interval_exceeds_window"
|
||||
samples = [] if reason else raw[-(b - a):]
|
||||
need(all(type(x) is int and x >= 0 for x in samples), "invalid kernel sample")
|
||||
summary = None
|
||||
if samples:
|
||||
ordered = sorted(samples)
|
||||
summary = {"count": len(samples), "average_us": sum(samples) // len(samples),
|
||||
"p95_us": ordered[(95 * len(samples) + 99) // 100 - 1],
|
||||
"maximum_us": max(samples)}
|
||||
value = summary[aggregation + "_us"] if summary else None
|
||||
result[metric] = {"source": "clock.performance_budget._samples", "aggregation": aggregation,
|
||||
"limit_us": limit, "value": value,
|
||||
"passed": value <= limit if value is not None else None,
|
||||
"window": "interval_exact_from_samples" if not reason else None,
|
||||
"reason": reason, "statistics": summary}
|
||||
return result
|
||||
|
||||
|
||||
def need(ok, reason):
|
||||
if not ok:
|
||||
@@ -52,6 +123,8 @@ def equal(actual, expected, label):
|
||||
need(isinstance(actual, dict) and actual.keys() == expected.keys(), label + " keys")
|
||||
for key in expected:
|
||||
equal(actual[key], expected[key], label + "." + key)
|
||||
elif type(expected) is int:
|
||||
need(number(actual) and actual == expected, label)
|
||||
elif number(expected):
|
||||
need(number(actual) and math.isclose(actual, expected, rel_tol=1e-9, abs_tol=1e-7), label)
|
||||
else:
|
||||
@@ -77,7 +150,12 @@ def interval(report, name):
|
||||
rows = [f for f in report["frames"] if f["segment"] == name]
|
||||
value = report[name]
|
||||
keys(value, ("frames", "actual_minutes", "start_minute", "end_minute", "start_wall", "end_wall",
|
||||
"zero_minute_frames_excluded", "count_ranges", "budget", "stall_budget"))
|
||||
"zero_minute_frames_excluded", "count_ranges", "frame_observation"))
|
||||
need("budget" not in value and "stall_budget" not in value and "passed" not in value,
|
||||
"probe budget forbidden")
|
||||
observation = value["frame_observation"]
|
||||
need(set(observation) == {"frame_wall_us", "sim_us_per_minute", "stall_over_50000_us"},
|
||||
"frame observation must not contain verdict")
|
||||
need(value["frames"] == len(rows), "frame count")
|
||||
need(value["actual_minutes"] == sum(f["minutes_advanced"] for f in rows), "false interval duration")
|
||||
for field, expected in (("start_minute", rows[0]["game_start"] if rows else None),
|
||||
@@ -88,9 +166,12 @@ def interval(report, name):
|
||||
need(value.get("empty_reason") == ("no_frames" if not rows else None), "empty interval reason")
|
||||
wall = [f["frame_wall_us"] for f in rows]
|
||||
minutes = [f["frame_wall_us"] / f["minutes_advanced"] for f in rows if f["minutes_advanced"]]
|
||||
equal(value["frame_wall_us"], stats(wall), "frame stats")
|
||||
equal(value["sim_us_per_minute"], stats(minutes), "minute stats")
|
||||
equal(value["stall_over_50000_us"], stats([x for x in wall if x > 50000]), "stalls")
|
||||
equal(observation["frame_wall_us"], stats(wall), "frame stats")
|
||||
equal(observation["sim_us_per_minute"], stats(minutes), "minute stats")
|
||||
equal(observation["stall_over_50000_us"], stats([x for x in wall if x > 50000]), "stalls")
|
||||
equal(value["observer_us"], stats([f["observer_us"] for f in rows]), "observer stats")
|
||||
equal(value["render_submit_wall_us"], stats([f["render_submit_wall_us"] for f in rows
|
||||
if f["render_submit_wall_us"] is not None]), "render submission stats")
|
||||
equal(value["time_process_seconds"], stats([f["time_process_seconds"] for f in rows]), "process")
|
||||
need(value["zero_minute_frames_excluded"] == sum(f["minutes_advanced"] == 0 for f in rows), "excluded frames")
|
||||
for kind in ("cpu", "gpu"):
|
||||
@@ -103,15 +184,6 @@ def interval(report, name):
|
||||
ranges[kind] = {key: {"min": min(f[kind][key] for f in rows), "max": max(f[kind][key] for f in rows)}
|
||||
for key in rows[0][kind]} if rows else {}
|
||||
equal(value["count_ranges"], ranges, "count ranges")
|
||||
metric = stats(minutes)["mean" if report["speed"] == 1 else "p95"]
|
||||
limit = 4000 if report["speed"] == 1 else 12000
|
||||
equal(value["budget"], {"limit_us": limit, "value": metric,
|
||||
"passed": metric <= limit if metric is not None else None,
|
||||
"reason": "no_samples" if metric is None else None}, "budget")
|
||||
maximum = max(wall) if wall else None
|
||||
equal(value["stall_budget"], {"limit_us": 50000, "value": maximum,
|
||||
"passed": maximum <= 50000 if maximum is not None else None,
|
||||
"reason": "no_samples" if maximum is None else None}, "stall budget")
|
||||
|
||||
|
||||
def check_report(report):
|
||||
@@ -170,11 +242,33 @@ def check_report(report):
|
||||
previous = report["start_minute"]
|
||||
previous_wall = report["start_wall"]
|
||||
previous_drawing = None
|
||||
previous_counts = None
|
||||
for index, frame in enumerate(frames):
|
||||
keys(frame, ("frame", "game_start", "game_end", "minutes_advanced", "frame_wall_us", "wall", "wall_start",
|
||||
"speed", "paused", "segment", "registered", "active", "drawn", "seed", "rng_streams",
|
||||
"metadata", "input_sha256", "preparation_sha256", "render_cpu_raw_ms",
|
||||
"render_gpu_raw_ms", "time_process_seconds"))
|
||||
"render_gpu_raw_ms", "time_process_seconds", "observer_us", "stall_causes",
|
||||
"kernel_stall_samples_us", "is_minute_sample", "counts_from_minute",
|
||||
"render_submit_wall_us", "render_time_source"))
|
||||
need(type(frame["observer_us"]) is int and frame["observer_us"] >= 0, "observer cost")
|
||||
nullable(frame, "render_submit_wall_us")
|
||||
need(frame["render_submit_wall_us"] is None or
|
||||
(type(frame["render_submit_wall_us"]) is int and frame["render_submit_wall_us"] >= 0),
|
||||
"render submit duration")
|
||||
need(frame["render_time_source"] == "RenderingServer.viewport_get_measured_render_time",
|
||||
"render submit is not GPU time")
|
||||
need(frame["is_minute_sample"] is (frame["minutes_advanced"] > 0), "minute sampling")
|
||||
if frame["is_minute_sample"]:
|
||||
need(frame["counts_from_minute"] == frame["game_end"], "minute sample provenance")
|
||||
elif previous_counts:
|
||||
equal({k: frame[k] for k in previous_counts}, previous_counts, "carried minute counts")
|
||||
previous_counts = {k: frame[k] for k in ("registered", "active", "rng_streams", "counts_from_minute")}
|
||||
saves = [s for s in report["save_events"] if s["frame"] == index]
|
||||
kernel_stalls = frame["kernel_stall_samples_us"]
|
||||
need(isinstance(kernel_stalls, list) and all(type(x) is int and x >= 0 for x in kernel_stalls),
|
||||
"kernel frame stalls")
|
||||
cause = bool(saves) and (frame["frame_wall_us"] > 50000 or any(x > 50000 for x in kernel_stalls))
|
||||
equal(frame["stall_causes"], ["save_operation_completed"] if cause else [], "save stall cause")
|
||||
need(frame["frame"] == index and frame["game_start"] == previous, "frame continuity")
|
||||
need(type(frame["minutes_advanced"]) is int and 0 <= frame["minutes_advanced"] <= 8, "kernel frame cap")
|
||||
need(frame["game_end"] - previous == frame["minutes_advanced"], "minute delta")
|
||||
@@ -200,14 +294,20 @@ def check_report(report):
|
||||
need(all(frame["active"][k] <= frame["registered"][k] for k in frame["active"]), "active > registered")
|
||||
drawn = frame["drawn"]
|
||||
keys(drawn, ("actors_in_layer", "actors_in_viewport", "facilities", "cells",
|
||||
"observation_serial", "carried_from_serial", "camera"))
|
||||
need(all(type(drawn[k]) is int and drawn[k] >= 0 for k in
|
||||
("actors_in_layer", "actors_in_viewport", "facilities", "cells", "observation_serial")), "draw counts")
|
||||
need(drawn["actors_in_viewport"] <= drawn["actors_in_layer"], "draw viewport")
|
||||
if previous_drawing and drawn["observation_serial"] == previous_drawing["observation_serial"]:
|
||||
"observation_serial", "carried_from_serial", "camera", "cell_count_from_hash"))
|
||||
need(sha_string(drawn["cell_count_from_hash"]), "cell cache provenance")
|
||||
for k in ("actors_in_layer", "actors_in_viewport", "facilities", "cells", "observation_serial"):
|
||||
nullable(drawn, k)
|
||||
need(drawn[k] is None or (type(drawn[k]) is int and drawn[k] >= 0), "draw counts")
|
||||
if drawn["actors_in_viewport"] is not None and drawn["actors_in_layer"] is not None:
|
||||
need(drawn["actors_in_viewport"] <= drawn["actors_in_layer"], "draw viewport")
|
||||
if previous_drawing and drawn["cell_count_from_hash"] == previous_drawing["cell_count_from_hash"]:
|
||||
equal(drawn["cells"], previous_drawing["cells"], "cached cell count changed")
|
||||
if previous_drawing and drawn["observation_serial"] is not None and \
|
||||
drawn["observation_serial"] == previous_drawing["observation_serial"]:
|
||||
need(drawn["carried_from_serial"] == drawn["observation_serial"], "carried observation called fresh")
|
||||
equal({k: v for k, v in drawn.items() if k != "carried_from_serial"},
|
||||
{k: v for k, v in previous_drawing.items() if k != "carried_from_serial"}, "carried changed")
|
||||
for k in ("actors_in_layer", "actors_in_viewport", "facilities"):
|
||||
equal(drawn[k], previous_drawing[k], "carried changed")
|
||||
else:
|
||||
need(drawn["carried_from_serial"] is None, "fresh drawing marked carried")
|
||||
need(drawn["camera"] == report["camera"], "camera changed")
|
||||
@@ -227,6 +327,20 @@ def check_report(report):
|
||||
need("kernel_at_warmup_end" in report, "warmup kernel report")
|
||||
for name in ("warmup", "measurement"):
|
||||
interval(report, name)
|
||||
need(report["budget_scope"] == "kernel process_real_delta only; excludes observer_us", "budget scope")
|
||||
need(report["render_submit_note"] == "CPU側の描画提出wall、GPU時間ではない", "render submit scope")
|
||||
equal(report["budget_verdict"], budget_verdict(report["budget_at_warmup_end"],
|
||||
report["budget_at_phase_end"], speed), "kernel interval budget")
|
||||
need(number(report["startup_wall_seconds"]) and report["startup_wall_seconds"] >= 0, "startup timing")
|
||||
for save in report["save_events"]:
|
||||
keys(save, ("frame", "game_minute", "ok", "operation", "path", "sample_counts"))
|
||||
need(type(save["frame"]) is int and 0 <= save["frame"] < len(frames), "save frame")
|
||||
for field in ("ok", "operation", "path"):
|
||||
nullable(save, field)
|
||||
need(save["path"] is None or
|
||||
(isinstance(save["path"], str) and not Path(save["path"]).is_absolute()
|
||||
and ".." not in Path(save["path"]).parts and not save["path"].startswith("user://")),
|
||||
"save path must be private relative")
|
||||
if report.get("label") == "200客":
|
||||
need(frames and all(f["registered"]["guests"] == 200 for f in frames), "200 guest claim")
|
||||
|
||||
@@ -236,21 +350,35 @@ def check_receipt(receipt, directory):
|
||||
need(receipt["default_player_before"] == receipt["default_player_after"], "player changed")
|
||||
need(not receipt["cleanup_errors"] and all(r["engine_ended"] is True for r in receipt["cleanup"]), "cleanup")
|
||||
need(receipt["error"] is None and receipt["passed"] is True, "failed collection")
|
||||
probe = receipt["probe_check"]
|
||||
text = (directory / "measure_p28_01.gd").read_text()
|
||||
need(probe == {"sha256": hashlib.sha256(text.encode()).hexdigest(), "passed": True},
|
||||
"execution probe pin/check")
|
||||
check_probe(text)
|
||||
rows = receipt["phases"]
|
||||
need([r["phase"] for r in rows] == ["import", *PHASES], "phase order")
|
||||
for row in rows:
|
||||
need(row["timeout_seconds"] == 1800 and row["passed"] is True, "phase timeout/pass")
|
||||
need(row["probe_check"] == probe, "phase execution copy check")
|
||||
for name, pin in row["raw"].items():
|
||||
data = (directory / row["phase"] / name).read_bytes()
|
||||
need(hashlib.sha256(data).hexdigest() == pin["sha256"] and len(data) == pin["bytes"], "raw SHA")
|
||||
if row["phase"] == "import":
|
||||
continue
|
||||
stdout = (directory / row["phase"] / "stdout.log").read_text()
|
||||
printed = [json.loads(x[len(PREFIX):]) for x in stdout.splitlines() if x.startswith(PREFIX)]
|
||||
printed = [normalize_report(json.loads(x[len(PREFIX):])) for x in stdout.splitlines() if x.startswith(PREFIX)]
|
||||
need(printed == [row["report"]], "raw/report mismatch")
|
||||
need(row["capture"]["actual_captured_pids"] == [row["report"]["pid"]], "PID mismatch")
|
||||
check_report(row["report"])
|
||||
need(row["report"]["stop_reason"] == "complete", "collection includes stopped phase")
|
||||
if row["phase"] in ("P1", "P2", "P3", "P4"):
|
||||
stage0 = next(r for r in rows if r["phase"] == row["report"]["input"] + "0")
|
||||
duration = stage0["extrapolation"]["seconds_per_phase"]
|
||||
startup = stage0["report"]["startup_wall_seconds"]
|
||||
equal(row["projection"], {"seconds_per_phase": duration, "startup_wall_seconds": startup,
|
||||
"projected_total_seconds": duration + startup, "limit_seconds": 1800,
|
||||
"stage0_phase": stage0["phase"], "stop_reason": None}, "phase projection")
|
||||
need(duration + startup <= 1800, "phase exceeds projected limit")
|
||||
if row["phase"] in ("R0", "O0"):
|
||||
estimate = row["extrapolation"]
|
||||
equal(estimate["seconds_per_phase"], row["report"]["elapsed_wall_seconds"] * 150 /
|
||||
|
||||
+32
-2
@@ -13,7 +13,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
from check_p28_01_report import check_report, check_receipt
|
||||
from check_p28_01_report import check_report, check_receipt, check_probe, normalize_report
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[5]
|
||||
FIXTURE_SHA = "eb4f770071eb07cd7e136fe54c3f78768cc8de7a4264e62a9af1f06a8f90f9bd"
|
||||
@@ -98,6 +98,22 @@ def argv_for(engine, stage, wrapper, importing=False):
|
||||
return [str(engine), "--path", str(stage), "--script", str(wrapper)]
|
||||
|
||||
|
||||
def checked_probe(path):
|
||||
text = path.read_text()
|
||||
check_probe(text)
|
||||
return {"sha256": sha(path), "passed": True}
|
||||
|
||||
|
||||
def projection(stage0):
|
||||
evidence = stage0["extrapolation"]
|
||||
seconds = evidence["seconds_per_phase"] + stage0["report"]["startup_wall_seconds"]
|
||||
return {"seconds_per_phase": evidence["seconds_per_phase"],
|
||||
"startup_wall_seconds": stage0["report"]["startup_wall_seconds"],
|
||||
"projected_total_seconds": seconds, "limit_seconds": LIMIT,
|
||||
"stage0_phase": stage0["phase"],
|
||||
"stop_reason": "projected_over_limit" if seconds > LIMIT else None}
|
||||
|
||||
|
||||
def run_engine(outer, command, folder, environment):
|
||||
no_live_engine()
|
||||
native = folder / "engine-native.log"
|
||||
@@ -213,6 +229,7 @@ def main():
|
||||
rows = []
|
||||
error = None
|
||||
profiles = []
|
||||
probe_check = None
|
||||
try:
|
||||
stage.mkdir()
|
||||
for name in before:
|
||||
@@ -222,6 +239,7 @@ def main():
|
||||
require(source_map(standard, stage) == before, "CoW source differs")
|
||||
probe = out / "measure_p28_01.gd"
|
||||
probe.write_bytes((HERE / probe.name).read_bytes())
|
||||
probe_check = checked_probe(probe)
|
||||
guard = out / "profile_guard.gd"
|
||||
guard.write_bytes((args.readonly / "run-r02/profile_guard.gd").read_bytes())
|
||||
wrapper.write_text("extends " + json.dumps(str(probe)) + "\nconst Guard = preload(" +
|
||||
@@ -241,7 +259,16 @@ def main():
|
||||
"source/player changed before phase")
|
||||
require(source_map(standard, stage) == expected, "stage changed before phase")
|
||||
require(all(sha(Path(p)) == pin for p, pin in script_pins.items()), "execution scripts changed")
|
||||
require(checked_probe(probe) == probe_check, "probe check changed")
|
||||
require(sha(args.fixture) == FIXTURE_SHA, "input_shortfall: changed input")
|
||||
forecast = None
|
||||
if label in ("P1", "P2", "P3", "P4"):
|
||||
stage0 = next(r for r in rows if r["phase"] == source + "0")
|
||||
forecast = projection(stage0)
|
||||
if forecast["stop_reason"]:
|
||||
rows.append({"phase": label, "passed": False, "engines_started": 0,
|
||||
"stop_reason": "projected_over_limit", "projection": forecast})
|
||||
raise ValueError("projected_over_limit: " + label)
|
||||
name = "TheTree-PCK-tests-" + uuid.uuid4().hex + "-p28-startup"
|
||||
startup = standard.profile_path(name)
|
||||
require(not startup.exists(), "startup profile not fresh")
|
||||
@@ -262,6 +289,8 @@ def main():
|
||||
finally:
|
||||
(stage / "project.godot").write_bytes(public)
|
||||
row["phase"] = label
|
||||
row["projection"] = forecast
|
||||
row["probe_check"] = probe_check
|
||||
rows.append(row)
|
||||
require(row["passed"], "native phase failed; raw retained: " + label)
|
||||
after = source_map(standard, stage)
|
||||
@@ -283,7 +312,7 @@ def main():
|
||||
raw = (out / label / "stdout.log").read_text()
|
||||
printed = [line.split("=", 1)[1] for line in raw.splitlines() if line.startswith("P28_01_PERF_REPORT=")]
|
||||
require(len(printed) == 1, "exactly one raw report required")
|
||||
report = json.loads(printed[0])
|
||||
report = normalize_report(json.loads(printed[0]))
|
||||
require(report["pid"] == row["capture"]["actual_captured_pids"][0], "PID binding")
|
||||
row.update(report=report, report_sha256=digest(report), guard=observation,
|
||||
guard_sha256=sha(guard_path))
|
||||
@@ -304,6 +333,7 @@ def main():
|
||||
after = source_map(standard, ROOT)
|
||||
player_after = standard.default_player(ROOT)
|
||||
receipt = {"phases": rows, "error": error, "source_before": before, "source_after": after,
|
||||
"probe_check": probe_check,
|
||||
"default_player_before": player, "default_player_after": player_after,
|
||||
"cleanup": cleanup, "cleanup_errors": cleanup_errors,
|
||||
"profiles": {str(p): profile_files(p) for p in profiles},
|
||||
|
||||
+234
-51
@@ -6,7 +6,8 @@ class Observer:
|
||||
extends Node
|
||||
var owner_probe: Variant
|
||||
func _process(_delta: float) -> void:
|
||||
owner_probe.sample()
|
||||
var observer_started_us := Time.get_ticks_usec()
|
||||
owner_probe.sample(observer_started_us)
|
||||
|
||||
var clock: Variant
|
||||
var runtime: Variant
|
||||
@@ -22,7 +23,7 @@ var start_minute := 0
|
||||
var warmup := 0
|
||||
var target := 0
|
||||
var speed := 0
|
||||
var previous_serial := -1
|
||||
var previous_serial: Variant = null
|
||||
var drawing := {}
|
||||
var camera := {}
|
||||
var overloads: Array = []
|
||||
@@ -32,6 +33,100 @@ var measurement_start := 0
|
||||
var minute_ticks: Array = []
|
||||
var previous_tick_us := 0
|
||||
var previous_wall := 0.0
|
||||
var key_forms := {}
|
||||
var cached_registered := {}
|
||||
var cached_active := {}
|
||||
var cached_rng := {}
|
||||
var counts_minute := 0
|
||||
var cell_hash := ""
|
||||
var cached_cells: Variant = null
|
||||
var save_events: Array = []
|
||||
var last_stall_count: Variant = null
|
||||
var render_started_us := -1
|
||||
var render_submit_us: Variant = null
|
||||
var render_serial := 0
|
||||
var last_render_serial := -1
|
||||
|
||||
# Product dictionaries may expose StringName keys. Never supply inferred defaults.
|
||||
func field(d: Variant, name: String) -> Variant:
|
||||
if d is Dictionary:
|
||||
if d.has(StringName(name)):
|
||||
key_forms[name] = {"key_form": "StringName", "reason": null}
|
||||
return d[StringName(name)]
|
||||
if d.has(String(name)):
|
||||
key_forms[name] = {"key_form": "String", "reason": null}
|
||||
return d[String(name)]
|
||||
key_forms[name] = {"key_form": null, "reason": "missing_product_field"}
|
||||
return null
|
||||
|
||||
func budget_snapshot() -> Dictionary:
|
||||
var result := {}
|
||||
var profiler: Variant = clock.performance_budget
|
||||
for metric: String in ["simulation_x1", "simulation_x3", "main_thread_stall"]:
|
||||
var samples: Variant = field(profiler.get("_samples"), metric)
|
||||
var count: Variant = field(profiler.get("_sample_counts"), metric)
|
||||
result[metric] = {"samples_us": samples.duplicate() if samples is Array else null,
|
||||
"lifetime_count": count, "reason": "counts_unavailable" if count == null or samples == null else null}
|
||||
return result
|
||||
|
||||
func kernel_verdict(before: Dictionary, after: Dictionary) -> Dictionary:
|
||||
var result := {}
|
||||
for metric: String in ["simulation_x%d" % speed, "main_thread_stall"]:
|
||||
var first: Dictionary = before.get(metric, {})
|
||||
var last: Dictionary = after.get(metric, {})
|
||||
var a: Variant = first.get("lifetime_count")
|
||||
var b: Variant = last.get("lifetime_count")
|
||||
var raw: Variant = last.get("samples_us")
|
||||
var reason: Variant = null
|
||||
var selected: Array = []
|
||||
if a == null or b == null or b < a or not raw is Array:
|
||||
reason = "counts_unavailable"
|
||||
elif b == a:
|
||||
reason = "no_interval_samples"
|
||||
elif b - a > raw.size():
|
||||
reason = "interval_exceeds_window"
|
||||
else:
|
||||
selected = raw.slice(raw.size() - (b - a))
|
||||
var aggregate := "maximum" if metric == "main_thread_stall" else ("average" if speed == 1 else "p95")
|
||||
var limit := 50000 if metric == "main_thread_stall" else (4000 if speed == 1 else 12000)
|
||||
var statistics: Variant = null
|
||||
var value: Variant = null
|
||||
if not selected.is_empty():
|
||||
var total: int = 0
|
||||
for duration: int in selected:
|
||||
total += duration
|
||||
selected.sort()
|
||||
var count: int = selected.size()
|
||||
statistics = {"count": count, "average_us": total / count,
|
||||
"p95_us": selected[(95 * count + 99) / 100 - 1], "maximum_us": selected.back()}
|
||||
value = statistics[aggregate + "_us"]
|
||||
result[metric] = {"source": "clock.performance_budget._samples", "aggregation": aggregate,
|
||||
"limit_us": limit, "value": value, "passed": value <= limit if value != null else null,
|
||||
"window": "interval_exact_from_samples" if reason == null else null,
|
||||
"reason": reason, "statistics": statistics}
|
||||
return result
|
||||
|
||||
func on_save(result: Dictionary) -> void:
|
||||
var path: Variant = field(result, "save_path")
|
||||
var relative: Variant = null
|
||||
if path is String and path.begins_with("user://") and not ".." in path.split("/"):
|
||||
relative = path.trim_prefix("user://")
|
||||
var counts: Variant = clock.performance_budget.get("_sample_counts")
|
||||
var event := {"frame": frames.size(), "game_minute": clock.absolute_minute,
|
||||
"ok": field(result, "ok"), "ok_reason": key_forms["ok"].reason,
|
||||
"operation": field(result, "operation"), "operation_reason": key_forms["operation"].reason,
|
||||
"path": relative, "path_reason": "missing_or_not_private_user_path" if relative == null else null,
|
||||
"sample_counts": counts.duplicate(true)}
|
||||
save_events.append(event)
|
||||
print("P28_01_SAVE=" + JSON.stringify(event))
|
||||
|
||||
func on_pre_draw() -> void:
|
||||
render_started_us = Time.get_ticks_usec()
|
||||
|
||||
func on_post_draw() -> void:
|
||||
if render_started_us >= 0:
|
||||
render_submit_us = Time.get_ticks_usec() - render_started_us
|
||||
render_serial += 1
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("begin")
|
||||
@@ -80,42 +175,76 @@ func rng_state() -> Dictionary:
|
||||
var result := {}
|
||||
var streams: Dictionary = runtime.get("_rng_streams")
|
||||
for key: Variant in streams:
|
||||
result[String(key)] = streams[key].snapshot()
|
||||
var stream: Variant = field(streams, String(key))
|
||||
result[String(key)] = stream.snapshot() if stream != null else null
|
||||
return result
|
||||
|
||||
func draw_counts() -> Dictionary:
|
||||
var observation: Dictionary = preview.get_life_observation_report()
|
||||
var serial := int(observation.get("observation_serial", -1))
|
||||
if serial == previous_serial and not drawing.is_empty():
|
||||
var serial: Variant = field(observation, "observation_serial")
|
||||
update_cell_count()
|
||||
if serial != null and serial == previous_serial and not drawing.is_empty():
|
||||
var carried := drawing.duplicate(true)
|
||||
carried["carried_from_serial"] = serial
|
||||
carried["cells"] = cached_cells
|
||||
carried["cells_reason"] = "missing_bounds" if cached_cells == null else null
|
||||
carried["cell_count_from_hash"] = cell_hash
|
||||
carried["camera"] = camera_state()
|
||||
return carried
|
||||
previous_serial = serial
|
||||
var area := Rect2(Vector2.ZERO, preview.size)
|
||||
var actors: Array = observation.get("actor_drawings", [])
|
||||
var visible := 0
|
||||
for actor: Dictionary in actors:
|
||||
if area.has_point(actor["at"]):
|
||||
visible += 1
|
||||
var facilities := 0
|
||||
var actors: Variant = field(observation, "actor_drawings")
|
||||
var visible: Variant = null
|
||||
if actors is Array:
|
||||
visible = 0
|
||||
for actor: Dictionary in actors:
|
||||
var at: Variant = field(actor, "at")
|
||||
if at == null:
|
||||
visible = null
|
||||
break
|
||||
if area.has_point(at):
|
||||
visible += 1
|
||||
var facilities: Variant = 0
|
||||
for facility: Dictionary in preview.call("_completed_facilities_for_draw"):
|
||||
if int(facility.get("layer", 0)) != int(preview.layer_mode):
|
||||
var layer: Variant = field(facility, "layer")
|
||||
var occupied: Variant = field(facility, "occupied_cells")
|
||||
if layer == null or occupied == null:
|
||||
facilities = null
|
||||
break
|
||||
if int(layer) != int(preview.layer_mode):
|
||||
continue
|
||||
var cells: Array = preview.call("_cells_from_serialized", facility.get("occupied_cells", []))
|
||||
var cells: Array = preview.call("_cells_from_serialized", occupied)
|
||||
if not cells.is_empty() and preview.call("_cell_bounds_rect", cells).intersects(area):
|
||||
facilities += 1
|
||||
var bounds: Dictionary = preview.call("_world_bounds")
|
||||
var cells := 0
|
||||
if not bounds.is_empty():
|
||||
for y in range(int(bounds["min_y"]), int(bounds["max_y"]) + 1):
|
||||
for x in range(int(bounds["min_x"]), int(bounds["max_x"]) + 1):
|
||||
if preview.get_cell_rect(Vector3i(x, y, preview.layer_mode)).intersects(area):
|
||||
cells += 1
|
||||
drawing = {"actors_in_layer": actors.size(), "actors_in_viewport": visible,
|
||||
"facilities": facilities, "cells": cells, "observation_serial": serial,
|
||||
drawing = {"actors_in_layer": actors.size() if actors is Array else null,
|
||||
"actors_in_layer_reason": "missing_actor_drawings" if not actors is Array else null,
|
||||
"actors_in_viewport": visible, "actors_in_viewport_reason": "missing_actor_geometry" if visible == null else null,
|
||||
"facilities": facilities, "facilities_reason": "missing_facility_geometry" if facilities == null else null,
|
||||
"cells": cached_cells, "observation_serial": serial,
|
||||
"observation_serial_reason": "missing_observation_serial" if serial == null else null,
|
||||
"cells_reason": "missing_bounds" if cached_cells == null else null,
|
||||
"cell_count_from_hash": cell_hash,
|
||||
"carried_from_serial": null, "camera": camera_state()}
|
||||
return drawing.duplicate(true)
|
||||
|
||||
func update_cell_count() -> void:
|
||||
var area := Rect2(Vector2.ZERO, preview.size)
|
||||
var bounds: Dictionary = preview.call("_world_bounds")
|
||||
var geometry_hash := hashed({"bounds": bounds, "camera": camera_state()})
|
||||
if geometry_hash != cell_hash:
|
||||
cell_hash = geometry_hash
|
||||
cached_cells = null
|
||||
var min_y: Variant = field(bounds, "min_y")
|
||||
var max_y: Variant = field(bounds, "max_y")
|
||||
var min_x: Variant = field(bounds, "min_x")
|
||||
var max_x: Variant = field(bounds, "max_x")
|
||||
if min_y != null and max_y != null and min_x != null and max_x != null:
|
||||
cached_cells = 0
|
||||
for y in range(int(min_y), int(max_y) + 1):
|
||||
for x in range(int(min_x), int(max_x) + 1):
|
||||
if preview.get_cell_rect(Vector3i(x, y, preview.layer_mode)).intersects(area):
|
||||
cached_cells += 1
|
||||
|
||||
func begin() -> void:
|
||||
clock = root.get_node("GameClock")
|
||||
runtime = root.get_node("SimulationRuntime")
|
||||
@@ -133,6 +262,11 @@ func begin() -> void:
|
||||
"renderer": ProjectSettings.get_setting("rendering/renderer/rendering_method"),
|
||||
"warmup_target": warmup, "measurement_target": target,
|
||||
"window_note": "Product reports are raw rolling capacity-512 reports; warm-up may remain; no subtraction.",
|
||||
"budget_scope": "kernel process_real_delta only; excludes observer_us",
|
||||
"render_submit_note": "CPU側の描画提出wall、GPU時間ではない",
|
||||
"observer_note": "observer_us includes reads, serialization and frame emission; excludes its own timing line and final report; never subtracted",
|
||||
"sample_definition": "is_minute_sample marks a changed game minute; other frames carry counts_from_minute",
|
||||
"budget_at_warmup_end": {}, "save_events": save_events, "key_form": key_forms,
|
||||
"count_definitions": {"registered": "service collections; walkable cells",
|
||||
"active": "guests exclude SPAWNING/LEAVING/DESPAWNING; staff current attended unended shift; facilities OPERATING",
|
||||
"drawn": "world_preview submitted model serial and viewport geometry"},
|
||||
@@ -170,14 +304,14 @@ func begin() -> void:
|
||||
"save_policy": {"return": null, "reason": "return_discarded_by_main(main.gd:222)"},
|
||||
"save_policy_after": runtime.get_save_policy(),
|
||||
"start_report": main.get("_operations_start_report").duplicate(true),
|
||||
"operations_enabled_after": runtime.get_operations_report().enabled,
|
||||
"operations_enabled_after": field(runtime.get_operations_report(), "enabled"),
|
||||
"opening_minute": opening_minute}
|
||||
if report.startup.start_report.is_empty():
|
||||
report.startup["start_report_reason"] = "main_retained_empty_dictionary"
|
||||
var start: Dictionary = report.startup.start_report
|
||||
var expected: bool = bool(start.get("ok", false)) and report.startup.operations_enabled_after and opening_minute == 360
|
||||
var expected: bool = field(start, "ok") == true and report.startup.operations_enabled_after == true and opening_minute == 360
|
||||
if input == "R":
|
||||
expected = not report.startup.operations_enabled_after and String(start.get("code", "")) == "INVALID_START_TIME"
|
||||
expected = report.startup.operations_enabled_after == false and field(start, "code") == "INVALID_START_TIME"
|
||||
if not expected:
|
||||
finish("input_shortfall")
|
||||
return
|
||||
@@ -194,10 +328,16 @@ func begin() -> void:
|
||||
clock.simulation_overloaded.connect(on_overload)
|
||||
clock.tick_completed.connect(on_tick)
|
||||
runtime.checkpoint_presentation_requested.connect(on_checkpoint)
|
||||
runtime.save_operation_completed.connect(on_save)
|
||||
RenderingServer.frame_pre_draw.connect(on_pre_draw)
|
||||
RenderingServer.frame_post_draw.connect(on_post_draw)
|
||||
var observer := Observer.new()
|
||||
observer.owner_probe = self
|
||||
observer.process_priority = 100000
|
||||
root.add_child(observer)
|
||||
cached_registered = registered()
|
||||
cached_active = active_counts()
|
||||
cached_rng = rng_state()
|
||||
main.call("_set_speed", speed)
|
||||
report["speed_after"] = clock.speed
|
||||
report["paused_after_speed"] = clock.paused
|
||||
@@ -210,6 +350,14 @@ func begin() -> void:
|
||||
start_us = Time.get_ticks_usec()
|
||||
previous_us = start_us
|
||||
previous_tick_us = start_us
|
||||
counts_minute = start_minute
|
||||
var initial_budget := budget_snapshot()
|
||||
last_stall_count = initial_budget.main_thread_stall.lifetime_count
|
||||
if warmup == 0:
|
||||
warmup_ended = true
|
||||
report["kernel_at_warmup_end"] = clock.get_performance_reports()
|
||||
report["budget_at_warmup_end"] = initial_budget
|
||||
report["startup_wall_seconds"] = float(start_us) / 1000000.0
|
||||
report["start_minute"] = start_minute
|
||||
report["start_wall"] = Time.get_unix_time_from_system()
|
||||
previous_wall = report["start_wall"]
|
||||
@@ -233,53 +381,86 @@ func on_tick(minute: int) -> void:
|
||||
previous_tick_us = tick_us
|
||||
print("P28_01_MINUTE=" + JSON.stringify(row))
|
||||
|
||||
func sample() -> void:
|
||||
func sample(observer_started_us: int) -> void:
|
||||
if not ready_to_sample:
|
||||
return
|
||||
var current_us := Time.get_ticks_usec()
|
||||
var minute: int = clock.absolute_minute
|
||||
var elapsed := current_us - previous_us
|
||||
var advanced := minute - previous_minute
|
||||
if advanced > 0:
|
||||
cached_registered = registered()
|
||||
cached_active = active_counts()
|
||||
cached_rng = rng_state()
|
||||
counts_minute = minute
|
||||
var stall_raw: Variant = field(clock.performance_budget.get("_samples"), "main_thread_stall")
|
||||
var stall_count: Variant = field(clock.performance_budget.get("_sample_counts"), "main_thread_stall")
|
||||
var kernel_stalls: Array = []
|
||||
if stall_count != null and last_stall_count != null and stall_raw is Array:
|
||||
var delta: int = stall_count - last_stall_count
|
||||
if delta > 0 and delta <= stall_raw.size():
|
||||
kernel_stalls = stall_raw.slice(stall_raw.size() - delta)
|
||||
last_stall_count = stall_count
|
||||
var causes: Array = []
|
||||
for event: Dictionary in save_events:
|
||||
if event.frame == frames.size() and (elapsed > 50000 or (not kernel_stalls.is_empty() and kernel_stalls.max() > 50000)):
|
||||
causes = ["save_operation_completed"]
|
||||
break
|
||||
var row := {"frame": frames.size(), "game_start": previous_minute, "game_end": minute,
|
||||
"minutes_advanced": advanced, "frame_wall_us": elapsed, "wall_start": previous_wall,
|
||||
"wall": Time.get_unix_time_from_system(),
|
||||
"speed": clock.speed, "paused": clock.paused,
|
||||
"segment": "warmup" if previous_minute - start_minute < warmup else "measurement",
|
||||
"registered": registered(), "active": active_counts(), "drawn": draw_counts(),
|
||||
"seed": runtime.world_seed, "rng_streams": rng_state(),
|
||||
"registered": cached_registered, "active": cached_active, "drawn": draw_counts(),
|
||||
"seed": runtime.world_seed, "rng_streams": cached_rng,
|
||||
"is_minute_sample": advanced > 0, "counts_from_minute": counts_minute,
|
||||
"stall_causes": causes, "kernel_stall_samples_us": kernel_stalls,
|
||||
"render_submit_wall_us": render_submit_us if render_serial != last_render_serial else null,
|
||||
"render_submit_wall_us_reason": "no_new_draw_submission" if render_serial == last_render_serial or render_submit_us == null else null,
|
||||
"render_time_source": "RenderingServer.viewport_get_measured_render_time",
|
||||
"metadata": report.metadata, "input_sha256": report.input_sha256,
|
||||
"input_sha256_reason": report.input_sha256_reason,
|
||||
"preparation_sha256": report.preparation_sha256,
|
||||
"render_cpu_raw_ms": RenderingServer.viewport_get_measured_render_time_cpu(root.get_viewport_rid()),
|
||||
"render_gpu_raw_ms": RenderingServer.viewport_get_measured_render_time_gpu(root.get_viewport_rid()),
|
||||
"time_process_seconds": Performance.get_monitor(Performance.TIME_PROCESS)}
|
||||
frames.append(row)
|
||||
print("P28_01_FRAME=" + JSON.stringify(row))
|
||||
last_render_serial = render_serial
|
||||
previous_us = current_us
|
||||
previous_wall = row.wall
|
||||
previous_minute = minute
|
||||
if not warmup_ended and minute - start_minute >= warmup:
|
||||
warmup_ended = true
|
||||
report["kernel_at_warmup_end"] = clock.get_performance_reports()
|
||||
report["budget_at_warmup_end"] = budget_snapshot()
|
||||
if warmup > 0:
|
||||
measurement_start = minute
|
||||
var operations: Dictionary = runtime.get_operations_report()
|
||||
var reason := ""
|
||||
if not overloads.is_empty():
|
||||
finish("simulation_overloaded")
|
||||
elif bool(operations.get("runtime_failure_latched", false)):
|
||||
finish("operations_runtime_failure")
|
||||
reason = "simulation_overloaded"
|
||||
elif field(operations, "runtime_failure_latched") == true:
|
||||
reason = "operations_runtime_failure"
|
||||
elif runtime.operations_coordinator != null and runtime.operations_coordinator.is_terminal():
|
||||
finish("operations_terminal")
|
||||
elif bool(operations.get("tick_blocked", false)):
|
||||
finish("operations_pause")
|
||||
reason = "operations_terminal"
|
||||
elif field(operations, "tick_blocked") == true:
|
||||
reason = "operations_pause"
|
||||
elif clock.paused:
|
||||
finish("clock_paused")
|
||||
reason = "clock_paused"
|
||||
elif camera_state() != camera:
|
||||
finish("camera_changed")
|
||||
elif float(current_us - start_us) / 1000000.0 >= 1800.0:
|
||||
finish("timeout")
|
||||
reason = "camera_changed"
|
||||
elif float(current_us) / 1000000.0 >= 1740.0:
|
||||
reason = "timeout"
|
||||
elif measurement_start >= 0 and minute - measurement_start >= target:
|
||||
finish("complete")
|
||||
reason = "complete"
|
||||
# This includes count/drawing reads and serialization; emission and final
|
||||
# summary are disclosed separately and are never subtracted from frame wall.
|
||||
var raw_line := JSON.stringify(row)
|
||||
print("P28_01_FRAME=" + raw_line)
|
||||
row["observer_us"] = Time.get_ticks_usec() - observer_started_us
|
||||
frames.append(row)
|
||||
print("P28_01_OBSERVER=" + JSON.stringify({"frame": row.frame, "observer_us": row.observer_us}))
|
||||
if not reason.is_empty():
|
||||
finish(reason)
|
||||
|
||||
func stats(values: Array, reason := "no_samples") -> Dictionary:
|
||||
if values.is_empty():
|
||||
@@ -300,6 +481,8 @@ func summary(segment: String) -> Dictionary:
|
||||
var process: Array = []
|
||||
var selected: Array = []
|
||||
var stalls: Array = []
|
||||
var observer: Array = []
|
||||
var submit: Array = []
|
||||
var excluded := 0
|
||||
var minutes := 0
|
||||
var cpu_missing := false
|
||||
@@ -310,6 +493,9 @@ func summary(segment: String) -> Dictionary:
|
||||
continue
|
||||
selected.append(frame)
|
||||
wall.append(frame.frame_wall_us)
|
||||
observer.append(frame.observer_us)
|
||||
if frame.render_submit_wall_us != null:
|
||||
submit.append(frame.render_submit_wall_us)
|
||||
process.append(frame.time_process_seconds)
|
||||
minutes += frame.minutes_advanced
|
||||
if frame.frame_wall_us > 50000:
|
||||
@@ -331,24 +517,19 @@ func summary(segment: String) -> Dictionary:
|
||||
ranges[kind][key].max = maxi(ranges[kind][key].max, value)
|
||||
var unavailable := "zero_from_api_on_gl_compatibility_unsupported_or_unmeasured"
|
||||
var minute_stats := stats(per_minute)
|
||||
var budget := 4000 if speed == 1 else 12000
|
||||
var metric: Variant = minute_stats.mean if speed == 1 else minute_stats.p95
|
||||
return {"frames": selected.size(), "actual_minutes": minutes,
|
||||
"start_minute": selected.front().game_start if not selected.is_empty() else null,
|
||||
"end_minute": selected.back().game_end if not selected.is_empty() else null,
|
||||
"start_wall": selected.front().wall_start if not selected.is_empty() else null,
|
||||
"end_wall": selected.back().wall if not selected.is_empty() else null,
|
||||
"empty_reason": "no_frames" if selected.is_empty() else null,
|
||||
"frame_wall_us": stats(wall), "sim_us_per_minute": minute_stats,
|
||||
"zero_minute_frames_excluded": excluded, "stall_over_50000_us": stats(stalls),
|
||||
"frame_observation": {"frame_wall_us": stats(wall), "sim_us_per_minute": minute_stats,
|
||||
"stall_over_50000_us": stats(stalls)},
|
||||
"observer_us": stats(observer), "render_submit_wall_us": stats(submit),
|
||||
"zero_minute_frames_excluded": excluded,
|
||||
"render_cpu_ms": stats([], unavailable) if cpu_missing else stats(cpu),
|
||||
"render_gpu_ms": stats([], unavailable) if gpu_missing else stats(gpu),
|
||||
"time_process_seconds": stats(process), "count_ranges": ranges,
|
||||
"budget": {"limit_us": budget, "value": metric, "passed": metric <= budget if metric != null else null,
|
||||
"reason": "no_samples" if metric == null else null},
|
||||
"stall_budget": {"limit_us": 50000, "value": wall.max() if not wall.is_empty() else null,
|
||||
"passed": wall.max() <= 50000 if not wall.is_empty() else null,
|
||||
"reason": "no_samples" if wall.is_empty() else null}}
|
||||
"time_process_seconds": stats(process), "count_ranges": ranges}
|
||||
|
||||
func finish(reason: String) -> void:
|
||||
ready_to_sample = false
|
||||
@@ -357,6 +538,8 @@ func finish(reason: String) -> void:
|
||||
report["elapsed_wall_seconds"] = float(Time.get_ticks_usec() - start_us) / 1000000.0 if start_us > 0 else 0.0
|
||||
report["end_wall"] = Time.get_unix_time_from_system()
|
||||
report["kernel_at_phase_end"] = clock.get_performance_reports()
|
||||
report["budget_at_phase_end"] = budget_snapshot()
|
||||
report["budget_verdict"] = kernel_verdict(report.budget_at_warmup_end, report.budget_at_phase_end)
|
||||
report["operations_at_end"] = runtime.get_operations_report()
|
||||
report["warmup"] = summary("warmup")
|
||||
report["measurement"] = summary("measurement")
|
||||
|
||||
+153
-3
@@ -80,6 +80,34 @@ def synthetic_report():
|
||||
"count_ranges": {"registered": {}, "active": {}},
|
||||
"budget": {"limit_us": 4000, "value": None, "passed": None, "reason": "no_samples"},
|
||||
"stall_budget": {"limit_us": 50000, "value": None, "passed": None, "reason": "no_samples"}}
|
||||
for name in ("warmup", "measurement"):
|
||||
section = report[name]
|
||||
section.pop("budget")
|
||||
section.pop("stall_budget")
|
||||
section["frame_observation"] = {k: section.pop(k) for k in
|
||||
("frame_wall_us", "sim_us_per_minute", "stall_over_50000_us")}
|
||||
section["observer_us"] = constant(section["frames"], 1500)
|
||||
section["render_submit_wall_us"] = constant(section["frames"], 1100)
|
||||
for frame in frames:
|
||||
frame.update(observer_us=1500, render_submit_wall_us=1100, is_minute_sample=True,
|
||||
counts_from_minute=frame["game_end"], stall_causes=[],
|
||||
kernel_stall_samples_us=[3000],
|
||||
render_time_source="RenderingServer.viewport_get_measured_render_time")
|
||||
frame["drawn"]["cell_count_from_hash"] = "c" * 64
|
||||
report.update(save_events=[], startup_wall_seconds=1.5,
|
||||
budget_scope="kernel process_real_delta only; excludes observer_us",
|
||||
render_submit_note="CPU側の描画提出wall、GPU時間ではない",
|
||||
budget_at_warmup_end={k: {"lifetime_count": 30, "samples_us": [3000] * 30}
|
||||
for k in ("simulation_x1", "main_thread_stall")},
|
||||
budget_at_phase_end={k: {"lifetime_count": 40, "samples_us": [3000] * 40}
|
||||
for k in ("simulation_x1", "main_thread_stall")})
|
||||
report["budget_verdict"] = {
|
||||
k: {"source": "clock.performance_budget._samples", "aggregation": aggregate,
|
||||
"limit_us": limit, "value": 3000, "passed": True,
|
||||
"window": "interval_exact_from_samples", "reason": None,
|
||||
"statistics": {"count": 10, "average_us": 3000, "p95_us": 3000, "maximum_us": 3000}}
|
||||
for k, aggregate, limit in (("simulation_x1", "average", 4000),
|
||||
("main_thread_stall", "maximum", 50000))}
|
||||
return report
|
||||
|
||||
|
||||
@@ -117,8 +145,8 @@ class ReportTests(unittest.TestCase):
|
||||
"carried as fresh": lambda r: r["frames"][1]["drawn"].update(observation_serial=1),
|
||||
"short measurement called 120": lambda r: r["measurement"].update(actual_minutes=120),
|
||||
"false 200 guests": lambda r: r.update(label="200客"),
|
||||
"budget fabricated pass": lambda r: r["measurement"]["budget"].update(passed=True),
|
||||
"statistics fabricated": lambda r: r["measurement"]["frame_wall_us"].update(p95=3),
|
||||
"budget fabricated pass": lambda r: r["budget_verdict"]["simulation_x1"].update(passed=False),
|
||||
"statistics fabricated": lambda r: r["measurement"]["frame_observation"]["frame_wall_us"].update(p95=3),
|
||||
"continue after pause": lambda r: r["frames"][0].update(paused=True),
|
||||
"wrong O start minute": lambda r: r["startup"].update(opening_minute=602),
|
||||
}
|
||||
@@ -190,9 +218,14 @@ class ReportTests(unittest.TestCase):
|
||||
"default_player_before": {}, "default_player_after": {}, "cleanup_errors": [],
|
||||
"cleanup": [{"engine_ended": True}], "error": None, "passed": True,
|
||||
"phases": [{"phase": "import", "passed": True, "timeout_seconds": 1800, "raw": {}}, row]}
|
||||
probe_text = (HERE / "measure_p28_01.gd").read_text()
|
||||
receipt["probe_check"] = {"sha256": hashlib.sha256(probe_text.encode()).hexdigest(), "passed": True}
|
||||
for phase in receipt["phases"]:
|
||||
phase["probe_check"] = receipt["probe_check"].copy()
|
||||
with patch.object(checker, "PHASES", {"O0": ("O", 1, 0, 10)}), \
|
||||
patch.object(Path, "read_bytes", return_value=raw), \
|
||||
patch.object(Path, "read_text", return_value=raw.decode()):
|
||||
patch.object(Path, "read_text", autospec=True,
|
||||
side_effect=lambda p: probe_text if p.name == "measure_p28_01.gd" else raw.decode()):
|
||||
checker.check_receipt(receipt, Path("synthetic"))
|
||||
for label, mutate in {
|
||||
"source": lambda x: x.update(source_after={"x": "b"}),
|
||||
@@ -200,12 +233,129 @@ class ReportTests(unittest.TestCase):
|
||||
"raw mismatch": lambda x: x["phases"][1]["report"].update(pid=444),
|
||||
"PID mismatch": lambda x: x["phases"][1]["capture"].update(actual_captured_pids=[444]),
|
||||
"raw SHA": lambda x: x["phases"][1]["raw"]["stdout.log"].update(sha256="f" * 64),
|
||||
"probe SHA": lambda x: x["probe_check"].update(sha256="f" * 64),
|
||||
"probe not checked": lambda x: x["probe_check"].update(passed=False),
|
||||
"different execution copy": lambda x: x["phases"][1]["probe_check"].update(sha256="e" * 64),
|
||||
}.items():
|
||||
broken = copy.deepcopy(receipt)
|
||||
mutate(broken)
|
||||
with self.subTest(label=label), self.assertRaises(ValueError):
|
||||
checker.check_receipt(broken, Path("synthetic"))
|
||||
|
||||
def test_h1_h2_exact_kernel_aggregation(self):
|
||||
before = {"simulation_x1": {"lifetime_count": 100},
|
||||
"simulation_x3": {"lifetime_count": 100},
|
||||
"main_thread_stall": {"lifetime_count": 100}}
|
||||
samples = [900000] * 10 + [3001, 3002, 3002]
|
||||
after = {k: {"lifetime_count": 103, "samples_us": samples} for k in before}
|
||||
verdict = checker.budget_verdict(before, after, 1)
|
||||
self.assertEqual(verdict["simulation_x1"]["value"], 3001)
|
||||
self.assertEqual(verdict["main_thread_stall"]["value"], 3002)
|
||||
self.assertEqual(verdict["simulation_x1"]["window"], "interval_exact_from_samples")
|
||||
self.assertEqual(checker.budget_verdict(before, after, 3)["simulation_x3"]["value"], 3002)
|
||||
for mutate in (
|
||||
lambda r: r["measurement"]["frame_observation"].update(passed=True),
|
||||
lambda r: r["measurement"].update(budget={"passed": True}),
|
||||
lambda r: r["budget_verdict"]["simulation_x1"].update(value=200000),
|
||||
lambda r: r["budget_verdict"]["simulation_x1"].update(window="lifetime"),
|
||||
lambda r: r["budget_verdict"]["simulation_x1"].update(value=3000.01),
|
||||
):
|
||||
self.reject(mutate)
|
||||
|
||||
def test_h1_h2_unavailable_intervals(self):
|
||||
for reason, count in (("interval_exceeds_window", 600),
|
||||
("no_interval_samples", 30), ("counts_unavailable", None)):
|
||||
r = synthetic_report()
|
||||
r["budget_at_phase_end"]["simulation_x1"]["lifetime_count"] = count
|
||||
r["budget_verdict"] = checker.budget_verdict(r["budget_at_warmup_end"],
|
||||
r["budget_at_phase_end"], 1)
|
||||
self.assertEqual(r["budget_verdict"]["simulation_x1"]["reason"], reason)
|
||||
self.assertIsNone(r["budget_verdict"]["simulation_x1"]["value"])
|
||||
checker.check_report(r)
|
||||
r["budget_verdict"]["simulation_x1"].update(value=3000, passed=True)
|
||||
with self.assertRaises(ValueError):
|
||||
checker.check_report(r)
|
||||
|
||||
def test_h3_save_stall_correlation(self):
|
||||
r = synthetic_report()
|
||||
r["save_events"] = [{"frame": 2, "game_minute": 363, "ok": True,
|
||||
"operation": "autosave", "path": "saves/autosave.variant",
|
||||
"sample_counts": {"main_thread_stall": 32}}]
|
||||
r["frames"][2]["stall_causes"] = ["save_operation_completed"]
|
||||
checker.check_report(r)
|
||||
r["frames"][2]["stall_causes"] = []
|
||||
with self.assertRaises(ValueError):
|
||||
checker.check_report(r)
|
||||
self.reject(lambda r: r["frames"][0].update(stall_causes=["save_operation_completed"]))
|
||||
r["frames"][2]["stall_causes"] = ["save_operation_completed"]
|
||||
r["save_events"][0]["path"] = "../public-save"
|
||||
with self.assertRaises(ValueError):
|
||||
checker.check_report(r)
|
||||
|
||||
def test_h4_observer_and_cache_provenance(self):
|
||||
checker.check_report(synthetic_report())
|
||||
for mutate in (
|
||||
lambda r: r["frames"][0].update(observer_us=-1),
|
||||
lambda r: r["measurement"]["observer_us"].update(mean=0),
|
||||
lambda r: r["frames"][0]["drawn"].pop("cell_count_from_hash"),
|
||||
lambda r: r["frames"][0].update(is_minute_sample=False),
|
||||
lambda r: r["frames"][0].update(counts_from_minute=360),
|
||||
):
|
||||
self.reject(mutate)
|
||||
|
||||
def test_h5_godot_json_integer_normalization(self):
|
||||
r = synthetic_report()
|
||||
r["metadata"]["machine"]["cores"] = 10.0
|
||||
r["metadata"]["machine"]["memory_bytes"] = 24000000000.0
|
||||
r["pid"] = 123.0
|
||||
r["frames"][0]["registered"]["guests"] = 3.0
|
||||
r["budget_at_phase_end"]["simulation_x1"]["samples_us"] = [3000.0] * 40
|
||||
normalized = collector.normalize_report(json.loads(json.dumps(r)))
|
||||
checker.check_report(normalized)
|
||||
self.assertIs(type(normalized["pid"]), int)
|
||||
for value in (1.5, float(2**53 + 2), float("inf"), True):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
collector.normalize_report({"pid": value})
|
||||
unknown = collector.normalize_report({"undeclared": 1.0, "rng_streams": {"state": 1.0}})
|
||||
self.assertIs(type(unknown["undeclared"]), float)
|
||||
self.assertIs(type(unknown["rng_streams"]["state"]), float)
|
||||
text = (HERE / "measure_p28_01.gd").read_text()
|
||||
self.assertIn("d.has(StringName(name))", text)
|
||||
self.assertIn("d.has(String(name))", text)
|
||||
self.assertIn('"missing_product_field"', text)
|
||||
for bad in ('actor["at"]', 'facility.get("layer"', 'observation.get("observation_serial"'):
|
||||
self.assertNotIn(bad, text)
|
||||
|
||||
def test_h6_projection_and_probe_deadline(self):
|
||||
stage0 = {"phase": "O0", "report": {"startup_wall_seconds": 20},
|
||||
"extrapolation": {"seconds_per_phase": 1780}}
|
||||
self.assertIsNone(collector.projection(stage0)["stop_reason"])
|
||||
stage0["report"]["startup_wall_seconds"] = 21
|
||||
self.assertEqual(collector.projection(stage0)["stop_reason"], "projected_over_limit")
|
||||
text = (HERE / "measure_p28_01.gd").read_text()
|
||||
self.assertIn("float(current_us) / 1000000.0 >= 1740.0", text)
|
||||
self.assertNotIn("current_us - start_us) / 1000000.0 >= 1800", text)
|
||||
|
||||
def test_h7_execution_copy_admission(self):
|
||||
text = (HERE / "measure_p28_01.gd").read_text()
|
||||
with patch.object(Path, "read_text", return_value=text), \
|
||||
patch.object(collector, "sha", return_value="a" * 64):
|
||||
self.assertEqual(collector.checked_probe(Path("execution-copy")),
|
||||
{"sha256": "a" * 64, "passed": True})
|
||||
with patch.object(Path, "read_text", return_value=text + "\nspawn_guest()"), \
|
||||
patch.object(collector, "run_engine") as launch:
|
||||
with self.assertRaises(ValueError):
|
||||
collector.checked_probe(Path("execution-copy"))
|
||||
launch.assert_not_called()
|
||||
|
||||
def test_render_submission_not_gpu(self):
|
||||
r = synthetic_report()
|
||||
checker.check_report(r)
|
||||
self.assertIsNone(r["measurement"]["render_gpu_ms"]["mean"])
|
||||
self.assertEqual(r["measurement"]["render_submit_wall_us"]["mean"], 1100)
|
||||
self.reject(lambda r: r["measurement"].update(render_gpu_ms=r["measurement"]["render_submit_wall_us"]))
|
||||
self.reject(lambda r: r["frames"][0].update(render_time_source="render_submit_wall_us"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
Reference in New Issue
Block a user