diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 09:42:17 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 09:42:17 +0800 |
| commit | f01aa1a9a8d19db5ab908ddbb30b0467be8aa092 (patch) | |
| tree | 2385fd0b75b3b35f5a29ac9b5c02d9b9e5aea13b | |
| parent | 54732d906a68d89ee1446ddb82ae639a016925be (diff) | |
| download | living-village-f01aa1a9a8d19db5ab908ddbb30b0467be8aa092.tar.gz | |
p70: 启动帧率与渲染基准(真实可复跑数据)
- scripts/bench-p70.sh:两档基准——Kernel --performance-baseline(6000 tick ×3)
+ Desktop xvfb 60s 真实帧循环;scripts/analyze-p70.py 汇总裁决
- Game.fs:LV_FRAME_LOG=1 逐帧日志(墙钟帧间隔 + GC 线程分配增量 + tick/panel),
仅新增日志,不改渲染/更新逻辑
- P70PerformanceBaselineTests:Pin --performance-baseline final_digest=953775FA…(防漂移)
- 实测:Kernel tps mean=466679 alloc=3032184 gen0/1/2=0/0/0 determinism=PASS;
Desktop 60s frames=8788 fps=139.3 mean=7.179ms max=33.919ms 掉帧>33.3ms=2
每帧分配 mean=231KB p95=297KB;dotnet-counters 缺席已如实标注
- Desktop 338/338、Kernel 120/120;Kernel/Sim/渲染口径/字形表未动
| -rw-r--r-- | docs/evidence/p70-analysis.txt | 23 | ||||
| -rw-r--r-- | docs/evidence/p70-env.txt | 5 | ||||
| -rw-r--r-- | docs/evidence/p70-frames-sample.txt | 84 | ||||
| -rw-r--r-- | docs/evidence/p70-kernel-baseline.txt | 6 | ||||
| -rw-r--r-- | docs/evidence/p70-verification.txt | 49 | ||||
| -rwxr-xr-x | scripts/analyze-p70.py | 160 | ||||
| -rwxr-xr-x | scripts/bench-p70.sh | 82 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 22 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/P70PerformanceBaselineTests.fs | 18 |
10 files changed, 450 insertions, 0 deletions
diff --git a/docs/evidence/p70-analysis.txt b/docs/evidence/p70-analysis.txt new file mode 100644 index 0000000..16541c4 --- /dev/null +++ b/docs/evidence/p70-analysis.txt @@ -0,0 +1,23 @@ +P70 启动帧率与渲染绘制概要基准 — 真实可复跑数据 +Kernel:Headless --performance-baseline(已有入口) +Desktop:xvfb 1280x720 真实帧循环(LV_FRAME_LOG=1,墙钟帧间隔 + 线程分配) + +## Kernel 吞吐(Sim.step,6000 measure tick) +samples=3 digests=3 determinism=PASS +final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C +ticks_per_second: mean=466679.2 min=278557.2 max=646088.9 n=3 +allocated_bytes: mean=3032184 min=3032104 max=3032344 n=3 +gc collections (last sample): gen0=0 gen1=0 gen2=0 + +## Desktop 渲染帧(真实帧循环) +frames_logged=8788 +interval_ms: mean=7.179 min=1.664 max=33.919 p50=7.909 p95=9.847 p99=10.823 std=2.262 +measured_fps=139.3 (frames=8788, wall_ms=63087.3) +dropped_frames: >25.0ms=4 >33.3ms=2 (0.05% / 0.02%) +per_frame_alloc_kb: mean=231.22 min=49.40 max=1835.20 p95=297.00 +dotnet-counters=missing +(帧率随机器/xvfb 软件渲染波动;本报告只列本次实测值,不外推。) + +bounds: Kernel/Sim 未改(digest 钉值);Desktop 仅新增 LV_FRAME_LOG 逐帧日志,不改渲染口径/字形表。 + +verdict: PASS diff --git a/docs/evidence/p70-env.txt b/docs/evidence/p70-env.txt new file mode 100644 index 0000000..2178e6b --- /dev/null +++ b/docs/evidence/p70-env.txt @@ -0,0 +1,5 @@ +dotnet-counters=missing +xvfb-run=available +bench_seconds=60 +desktop_dll=/home/somhairle/projects/living-village/src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll +kernel_dll=/home/somhairle/projects/living-village/src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll diff --git a/docs/evidence/p70-frames-sample.txt b/docs/evidence/p70-frames-sample.txt new file mode 100644 index 0000000..c0e6830 --- /dev/null +++ b/docs/evidence/p70-frames-sample.txt @@ -0,0 +1,84 @@ +# P70 Desktop 帧日志抽样(完整 8956 行见 /tmp/opencode/lv-p70/desktop-log.txt,可复跑生成) +# head 40 +world-mode=legacy-64x48 bounds=64x48 +mode=sample seed=42 +controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit +splash=done frame=150 +p70-frame index=1 ms=3.845 alloc_kb=79.9 tick=0 panel=world +p70-frame index=2 ms=2.537 alloc_kb=89.5 tick=0 panel=world +p70-frame index=3 ms=3.371 alloc_kb=80.6 tick=0 panel=world +p70-frame index=4 ms=2.145 alloc_kb=80.6 tick=0 panel=world +p70-frame index=5 ms=1.754 alloc_kb=80.6 tick=0 panel=world +p70-frame index=6 ms=1.664 alloc_kb=80.6 tick=0 panel=world +p70-frame index=7 ms=1.699 alloc_kb=80.6 tick=0 panel=world +p70-frame index=8 ms=1.744 alloc_kb=80.6 tick=0 panel=world +p70-frame index=9 ms=1.723 alloc_kb=80.6 tick=0 panel=world +p70-frame index=10 ms=1.723 alloc_kb=80.6 tick=0 panel=world +p70-frame index=11 ms=1.724 alloc_kb=80.6 tick=0 panel=world +p70-frame index=12 ms=1.957 alloc_kb=80.6 tick=0 panel=world +p70-frame index=13 ms=1.978 alloc_kb=80.6 tick=0 panel=world +p70-frame index=14 ms=1.978 alloc_kb=80.6 tick=0 panel=world +menu=occupation-select +p70-frame index=15 ms=6.823 alloc_kb=84.4 tick=0 panel=world +new-game=ok seed=42 npcs=30 occupation=none tick=0 +p70-frame index=16 ms=33.919 alloc_kb=1835.2 tick=0 panel=world +p70-frame index=17 ms=13.625 alloc_kb=284.2 tick=1 panel=world +p70-frame index=18 ms=9.052 alloc_kb=284.2 tick=2 panel=world +p70-frame index=19 ms=10.122 alloc_kb=282.9 tick=3 panel=world +p70-frame index=20 ms=8.960 alloc_kb=282.9 tick=4 panel=world +p70-frame index=21 ms=9.781 alloc_kb=282.9 tick=5 panel=world +p70-frame index=22 ms=12.926 alloc_kb=284.4 tick=6 panel=world +p70-frame index=23 ms=8.904 alloc_kb=284.4 tick=7 panel=world +p70-frame index=24 ms=7.907 alloc_kb=284.4 tick=8 panel=world +p70-frame index=25 ms=20.297 alloc_kb=285.6 tick=9 panel=world +p70-frame index=26 ms=9.487 alloc_kb=285.6 tick=10 panel=world +p70-frame index=27 ms=9.017 alloc_kb=285.6 tick=11 panel=world +p70-frame index=28 ms=9.262 alloc_kb=285.4 tick=12 panel=world +p70-frame index=29 ms=8.948 alloc_kb=285.4 tick=13 panel=world +p70-frame index=30 ms=8.775 alloc_kb=285.4 tick=14 panel=world +p70-frame index=31 ms=9.081 alloc_kb=284.0 tick=15 panel=world +p70-frame index=32 ms=8.883 alloc_kb=284.0 tick=16 panel=world +p70-frame index=33 ms=9.031 alloc_kb=284.0 tick=17 panel=world +p70-frame index=34 ms=8.835 alloc_kb=283.8 tick=18 panel=world +# ... +# tail 40 +p70-frame index=8751 ms=8.671 alloc_kb=283.6 tick=3732 panel=world +p70-frame index=8752 ms=8.935 alloc_kb=283.6 tick=3733 panel=world +p70-frame index=8753 ms=9.440 alloc_kb=283.6 tick=3734 panel=world +p70-frame index=8754 ms=8.547 alloc_kb=283.6 tick=3735 panel=world +p70-frame index=8755 ms=8.476 alloc_kb=285.0 tick=3736 panel=world +p70-frame index=8756 ms=8.713 alloc_kb=285.0 tick=3737 panel=world +p70-frame index=8757 ms=8.743 alloc_kb=285.0 tick=3738 panel=world +p70-frame index=8758 ms=8.500 alloc_kb=285.0 tick=3739 panel=world +p70-frame index=8759 ms=8.441 alloc_kb=285.0 tick=3740 panel=world +p70-frame index=8760 ms=8.535 alloc_kb=285.0 tick=3741 panel=world +p70-frame index=8761 ms=8.481 alloc_kb=285.0 tick=3742 panel=world +p70-frame index=8762 ms=8.749 alloc_kb=285.0 tick=3743 panel=world +p70-frame index=8763 ms=8.746 alloc_kb=285.0 tick=3744 panel=world +p70-frame index=8764 ms=8.610 alloc_kb=285.0 tick=3745 panel=world +p70-frame index=8765 ms=8.536 alloc_kb=285.0 tick=3746 panel=world +p70-frame index=8766 ms=8.193 alloc_kb=285.0 tick=3747 panel=world +p70-frame index=8767 ms=8.657 alloc_kb=285.0 tick=3748 panel=world +p70-frame index=8768 ms=8.948 alloc_kb=285.0 tick=3749 panel=world +p70-frame index=8769 ms=8.737 alloc_kb=285.0 tick=3750 panel=world +p70-frame index=8770 ms=9.309 alloc_kb=283.6 tick=3751 panel=world +p70-frame index=8771 ms=8.558 alloc_kb=283.6 tick=3752 panel=world +p70-frame index=8772 ms=8.383 alloc_kb=283.6 tick=3753 panel=world +p70-frame index=8773 ms=8.970 alloc_kb=283.6 tick=3754 panel=world +p70-frame index=8774 ms=8.810 alloc_kb=283.6 tick=3755 panel=world +p70-frame index=8775 ms=8.349 alloc_kb=283.6 tick=3756 panel=world +p70-frame index=8776 ms=8.374 alloc_kb=283.6 tick=3757 panel=world +p70-frame index=8777 ms=8.291 alloc_kb=283.6 tick=3758 panel=world +p70-frame index=8778 ms=8.504 alloc_kb=283.6 tick=3759 panel=world +p70-frame index=8779 ms=8.121 alloc_kb=283.6 tick=3760 panel=world +p70-frame index=8780 ms=8.564 alloc_kb=283.6 tick=3761 panel=world +p70-frame index=8781 ms=8.339 alloc_kb=283.6 tick=3762 panel=world +p70-frame index=8782 ms=8.307 alloc_kb=283.6 tick=3763 panel=world +p70-frame index=8783 ms=8.792 alloc_kb=283.6 tick=3764 panel=world +p70-frame index=8784 ms=8.509 alloc_kb=283.6 tick=3765 panel=world +p70-frame index=8785 ms=9.034 alloc_kb=285.0 tick=3766 panel=world +p70-frame index=8786 ms=8.682 alloc_kb=285.0 tick=3767 panel=world +p70-frame index=8787 ms=8.671 alloc_kb=285.0 tick=3768 panel=world +p70-frame index=8788 ms=8.516 alloc_kb=285.0 tick=3769 panel=world +sample stage=SampleComplete tick=3769 pos=(1019,821) +sample result=ok diff --git a/docs/evidence/p70-kernel-baseline.txt b/docs/evidence/p70-kernel-baseline.txt new file mode 100644 index 0000000..c43281a --- /dev/null +++ b/docs/evidence/p70-kernel-baseline.txt @@ -0,0 +1,6 @@ +performance_config seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 repetitions=3 input_pattern=8-direction-cyclic +performance_runtime framework=.NET 8.0.31 version=8.0.31 os=Pop!_OS 24.04 LTS architecture=X64 stopwatch_frequency=1000000000 +performance_sample repetition=1 seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 elapsed_stopwatch_ticks=21539565 stopwatch_frequency=1000000000 elapsed_seconds=0.021539565 ticks_per_second=278557.157 allocated_bytes=3032344 gen0=0 gen1=0 gen2=0 final_tick=6120 final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C runtime=.NET 8.0.31 runtime_version=8.0.31 +performance_sample repetition=2 seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 elapsed_stopwatch_ticks=12621178 stopwatch_frequency=1000000000 elapsed_seconds=0.012621178 ticks_per_second=475391.441 allocated_bytes=3032104 gen0=0 gen1=0 gen2=0 final_tick=6120 final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C runtime=.NET 8.0.31 runtime_version=8.0.31 +performance_sample repetition=3 seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 elapsed_stopwatch_ticks=9286647 stopwatch_frequency=1000000000 elapsed_seconds=0.009286647 ticks_per_second=646088.949 allocated_bytes=3032104 gen0=0 gen1=0 gen2=0 final_tick=6120 final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C runtime=.NET 8.0.31 runtime_version=8.0.31 +performance_determinism=PASS diff --git a/docs/evidence/p70-verification.txt b/docs/evidence/p70-verification.txt new file mode 100644 index 0000000..6794edc --- /dev/null +++ b/docs/evidence/p70-verification.txt @@ -0,0 +1,49 @@ +P70(启动帧率与渲染绘制概要基准)验证记录 +基线:commit 1d56991(P69)。本单未 push。 + +目标与可见性 +- 为「性能吞吐/分配/GC 基准与真实数据」补一组真实可复跑的 Desktop 侧基准;本单可在桌面/dev UI 观看。 +- 不宣称未测性能:只列本次实跑数字,未测项如实标注。 + +改动 +- src/LivingVillage.Desktop/Game.fs:新增 LV_FRAME_LOG=1 逐帧日志(仅日志,不改渲染/更新逻辑)—— + 每帧打印墙钟帧间隔 `ms`、线程分配增量 `alloc_kb`(GC.GetAllocatedBytesForCurrentThread)、 + tick 与当前 panel。配合 LV_PERF_SOAK_SECONDS + LV_AUTOPLAY_SAMPLE + LV_UNLOCK_FPS 跑 N 秒真实帧循环。 +- src/LivingVillage.Kernel.Tests/P70PerformanceBaselineTests.fs(新):Pin Headless --performance-baseline + 最终 digest = 953775FA…(防 Kernel/Sim 漂移)+ fsproj 挂载。 +- scripts/bench-p70.sh(新):Kernel 吞吐 + Desktop 60s xvfb 帧循环两档,落盘后调 analyzer。 +- scripts/analyze-p70.py(新):汇总数字与裁决,写 docs/evidence/p70-analysis.txt。 + +命令 / 环境 / 输入 +- 构建:dotnet build LivingVillage.sln -c Release +- 基准:P70_SECONDS=60 bash scripts/bench-p70.sh /tmp/opencode/lv-p70 + - Kernel:dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --performance-baseline + (seed=42 npc=4 warmup=120 measure=6000 ×3) + - Desktop:xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 LV_PERF_SOAK_SECONDS=60 + LV_FRAME_LOG=1 LV_UNLOCK_FPS=1 LV_LEGACY_MAP=1 LV_RECORD_DIR=... dotnet .../LivingVillage.Desktop.dll +- 环境:linux,Xvfb 软件渲染(llvmpipe),1280x720;dotnet-counters=missing(逐帧分配改由进程内 + GC API 实测,非 counters)。 + +实测数字(本次,不外推) +- Kernel 吞吐(6000 tick ×3):ticks_per_second mean=466679 / min=278557 / max=646089; + allocated_bytes mean=3032184;gc gen0/1/2=0/0/0;determinism=PASS;final_digest=953775FA…7F6C(钉值一致)。 +- Desktop 帧循环(60s,解锁 vsync/固定步长):frames=8788,wall=63087ms,measured_fps=139.3; + frame interval ms mean=7.179 / min=1.664 / max=33.919 / p50=7.909 / p95=9.847 / p99=10.823 / std=2.262; + 掉帧 >25ms=4(0.05%)、>33.3ms=2(0.02%);每帧分配 KB mean=231.22 / p95=297.00 / max=1835.20。 +- 说明:帧率依赖机器与 xvfb 软件渲染,本报告只列实测,不设硬性 fps 门槛。 +- 未测项:dotnet-counters 缺席 → live counters 无;已用 GC API 替代给出每帧分配。 + +复现 + dotnet build LivingVillage.sln -c Release + P70_SECONDS=60 bash scripts/bench-p70.sh /tmp/opencode/lv-p70 + cat docs/evidence/p70-analysis.txt # verdict 应为 PASS + +验收 +- dotnet build -c Release → 0 错误,1 警告(P40ArtTests.fs FS0760,既有)。 +- Desktop 338/338(--no-build);Kernel 120/120(--no-build,含新增 Pin 1 例)。 +- p70-analysis.txt verdict=PASS,含真实数字(见上)。 + +边界 +- 未改 Kernel/Sim 数值路径、渲染口径、字形表、地图生成;未 push。 +- 提交物:脚本 + 证据(analysis/env/kernel-baseline/帧日志抽样)+ 1 条 pin 测试 + 仅日志式的 Game 帧记录。 +- 完整 8956 行帧日志在 /tmp/opencode/lv-p70/desktop-log.txt(复跑生成),仓库内留 head/tail 抽样。 diff --git a/scripts/analyze-p70.py b/scripts/analyze-p70.py new file mode 100755 index 0000000..acafe32 --- /dev/null +++ b/scripts/analyze-p70.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""P70(启动帧率与渲染绘制概要基准)分析:汇总 Kernel 吞吐 + Desktop 真实帧循环。 + +输入: + <record-dir>/kernel-baseline.txt Headless --performance-baseline 输出 + <record-dir>/desktop-log.txt LV_FRAME_LOG=1 逐帧 p70-frame 行 +判据: + 1. Kernel:final_digest == 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C, + determinism=1,并报告 ticks_per_second / allocated_bytes / gen0..2。 + 2. Desktop:p70-frame 行 >= 1;由墙钟帧间隔算 mean/min/max/p50/p95/p99、std、fps, + 统计 >25ms(约 1.5×16.7)与 >33.3ms 的掉帧数;逐帧线程分配 mean/p95 KB。 + 不宣称未测指标;帧率依赖机器/xvfb,仅报实测值。 + 3. 只做真实数字汇总,不设硬性 fps 门槛(除 >0、数值合法)。 + +Usage: python3 scripts/analyze-p70.py <record-dir> <out-txt> +Exit 0 pass / 1 fail / 2 usage. +""" + +import re +import statistics +import sys +from pathlib import Path + +FRAME_RE = re.compile( + r"^p70-frame index=(\d+) ms=([\d.]+) alloc_kb=([\d.]+) tick=(\d+) panel=(\S+)$", + re.MULTILINE, +) +KERNEL_SAMPLE_RE = re.compile(r"^performance_sample repetition=(\d+) .*$", re.MULTILINE) +DIGEST_RE = re.compile(r"final_digest=([0-9A-F]+)") +TPS_RE = re.compile(r"ticks_per_second=([\d.]+)") +ALLOC_RE = re.compile(r"allocated_bytes=(\d+)") +GEN_RE = re.compile(r"gen0=(\d+) gen1=(\d+) gen2=(\d+)") +DET_RE = re.compile(r"performance_determinism=(\w+)") +COUNTERS_RE = re.compile(r"^dotnet-counters=(\S+)", re.MULTILINE) + +PINNED_DIGEST = "953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C" +FPS_60_MS = 1000.0 / 60.0 +DROPPED_1X = 1.5 * FPS_60_MS +DROPPED_2X = 2.0 * FPS_60_MS + + +def percentile(sorted_values, q): + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + pos = q * (len(sorted_values) - 1) + lo = int(pos) + hi = min(lo + 1, len(sorted_values) - 1) + frac = pos - lo + return sorted_values[lo] * (1 - frac) + sorted_values[hi] * frac + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + return 2 + rec = Path(sys.argv[1]) + out = Path(sys.argv[2]) + failures = [] + lines = [ + "P70 启动帧率与渲染绘制概要基准 — 真实可复跑数据", + "Kernel:Headless --performance-baseline(已有入口)", + "Desktop:xvfb 1280x720 真实帧循环(LV_FRAME_LOG=1,墙钟帧间隔 + 线程分配)", + "", + ] + + # ---- Kernel ---- + kernel_path = rec / "kernel-baseline.txt" + if not kernel_path.exists(): + failures.append("missing kernel-baseline.txt") + else: + kernel = kernel_path.read_text(encoding="utf-8", errors="replace") + samples = KERNEL_SAMPLE_RE.findall(kernel) + digests = DIGEST_RE.findall(kernel) + det = DET_RE.search(kernel) + tps = [float(v) for v in TPS_RE.findall(kernel)] + alloc = [int(v) for v in ALLOC_RE.findall(kernel)] + gens = GEN_RE.findall(kernel) + lines.append("## Kernel 吞吐(Sim.step,6000 measure tick)") + lines.append(f"samples={len(samples)} digests={len(digests)} determinism={det.group(1) if det else '<missing>'}") + if digests: + lines.append(f"final_digest={digests[-1]}") + if digests[-1] != PINNED_DIGEST: + failures.append(f"final_digest {digests[-1]} != pinned {PINNED_DIGEST}") + else: + failures.append("kernel log missing final_digest") + if tps: + lines.append( + f"ticks_per_second: mean={statistics.mean(tps):.1f} min={min(tps):.1f} max={max(tps):.1f} n={len(tps)}" + ) + if alloc: + lines.append( + f"allocated_bytes: mean={statistics.mean(alloc):.0f} min={min(alloc)} max={max(alloc)} n={len(alloc)}" + ) + if gens: + g = gens[-1] + lines.append(f"gc collections (last sample): gen0={g[0]} gen1={g[1]} gen2={g[2]}") + if det is None or det.group(1) != "PASS": + failures.append("performance_determinism != PASS") + lines.append("") + + # ---- Desktop ---- + env_path = rec / "env.txt" + counters = COUNTERS_RE.search(env_path.read_text(encoding="utf-8", errors="replace")) if env_path.exists() else None + desktop_path = rec / "desktop-log.txt" + if not desktop_path.exists(): + failures.append("missing desktop-log.txt") + else: + log = desktop_path.read_text(encoding="utf-8", errors="replace") + frames = FRAME_RE.findall(log) + lines.append("## Desktop 渲染帧(真实帧循环)") + lines.append(f"frames_logged={len(frames)}") + if not frames: + failures.append("no p70-frame lines") + else: + ms = [float(f[1]) for f in frames] + alloc_kb = [float(f[2]) for f in frames] + total_ms = sum(ms) + fps = (len(ms) / total_ms * 1000.0) if total_ms > 0 else 0.0 + ordered = sorted(ms) + mean_ms = statistics.mean(ms) + std_ms = statistics.pstdev(ms) if len(ms) > 1 else 0.0 + dropped_1x = sum(1 for v in ms if v > DROPPED_1X) + dropped_2x = sum(1 for v in ms if v > DROPPED_2X) + lines.append( + f"interval_ms: mean={mean_ms:.3f} min={min(ms):.3f} max={max(ms):.3f} " + f"p50={percentile(ordered, 0.50):.3f} p95={percentile(ordered, 0.95):.3f} " + f"p99={percentile(ordered, 0.99):.3f} std={std_ms:.3f}" + ) + lines.append(f"measured_fps={fps:.1f} (frames={len(ms)}, wall_ms={total_ms:.1f})") + lines.append( + f"dropped_frames: >{DROPPED_1X:.1f}ms={dropped_1x} >{DROPPED_2X:.1f}ms={dropped_2x} " + f"({dropped_1x / len(ms) * 100.0:.2f}% / {dropped_2x / len(ms) * 100.0:.2f}%)" + ) + lines.append( + f"per_frame_alloc_kb: mean={statistics.mean(alloc_kb):.2f} " + f"min={min(alloc_kb):.2f} max={max(alloc_kb):.2f} p95={percentile(sorted(alloc_kb), 0.95):.2f}" + ) + if not (0.0 < mean_ms < 60000.0): + failures.append(f"mean frame ms {mean_ms} out of plausible range") + if fps <= 0.0: + failures.append("measured fps <= 0") + lines.append(f"dotnet-counters={counters.group(1) if counters else '<unknown>'}") + lines.append("(帧率随机器/xvfb 软件渲染波动;本报告只列本次实测值,不外推。)") + lines.append("") + + lines.append("bounds: Kernel/Sim 未改(digest 钉值);Desktop 仅新增 LV_FRAME_LOG 逐帧日志,不改渲染口径/字形表。") + lines.append("") + verdict = "PASS" if not failures else "FAIL" + lines.append(f"verdict: {verdict}") + for f in failures: + lines.append(f"FAIL: {f}") + out.write_text("\n".join(lines) + "\n", encoding="utf-8") + print("\n".join(lines)) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bench-p70.sh b/scripts/bench-p70.sh new file mode 100755 index 0000000..f035bb2 --- /dev/null +++ b/scripts/bench-p70.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# P70:启动帧率与渲染绘制概要基准(真实可复跑,不宣称未测性能)。 +# +# 两档: +# * Kernel 吞吐:Headless `--performance-baseline`(6000 measure tick ×3,已有入口),落 kernel-baseline.txt。 +# * Desktop 渲染帧:xvfb 真实帧循环 N 秒(默认 60),逐帧打印墙钟帧间隔与线程分配增量 +# (LV_FRAME_LOG=1,仅新增日志、不改渲染逻辑),落 desktop-log.txt。 +# 之后由 scripts/analyze-p70.py 汇总裁决到 docs/evidence/p70-analysis.txt。 +# +# 用法:bash scripts/bench-p70.sh [out-dir] (out-dir 默认 /tmp/opencode/lv-p70) +# 环境:P70_SECONDS(默认 60)基准秒数;P70_FORCE=1 允许 fps=0 时仍返回 0。 +set -u + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${1:-/tmp/opencode/lv-p70}" +BENCH_SECONDS="${P70_SECONDS:-60}" +XVFB_ARGS="-screen 0 1280x720x24" + +KERNEL_DLL="$REPO/src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll" +DESKTOP_DLL="$REPO/src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll" +KERNEL_LOG="$OUT/kernel-baseline.txt" +DESKTOP_LOG="$OUT/desktop-log.txt" + +mkdir -p "$OUT" +echo "[p70] repo=$REPO out=$OUT bench_seconds=$BENCH_SECONDS" +echo "[p70] started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "[p70] kernel_dll=$KERNEL_DLL" +echo "[p70] desktop_dll=$DESKTOP_DLL" + +for dll in "$KERNEL_DLL" "$DESKTOP_DLL"; do + if [ ! -f "$dll" ]; then + echo "[p70] FATAL missing $dll(先 dotnet build LivingVillage.sln -c Release)" >&2 + exit 90 + fi +done + +if command -v dotnet-counters >/dev/null 2>&1; then + COUNTERS=available +else + COUNTERS=missing +fi +if command -v xvfb-run >/dev/null 2>&1; then + XVFB=available +else + XVFB=missing +fi +{ + echo "dotnet-counters=$COUNTERS" + echo "xvfb-run=$XVFB" + echo "bench_seconds=$BENCH_SECONDS" + echo "desktop_dll=$DESKTOP_DLL" + echo "kernel_dll=$KERNEL_DLL" +} > "$OUT/env.txt" +echo "[p70] dotnet-counters=$COUNTERS" +if [ "$XVFB" = "missing" ]; then + echo "[p70] FATAL xvfb-run 不可用(Desktop 需虚拟显示)" >&2 + exit 91 +fi +echo "[p70] xvfb-run=$XVFB(不阻塞:逐帧分配用 GC.GetAllocatedBytesForCurrentThread 实测)" + +echo "[p70] kernel baseline: dotnet $KERNEL_DLL --performance-baseline" +dotnet "$KERNEL_DLL" --performance-baseline > "$KERNEL_LOG" 2>&1 +KCODE=$? +echo "[p70] kernel exit=$KCODE" + +echo "[p70] desktop frame loop: ${BENCH_SECONDS}s @ 1280x720 under xvfb" +timeout $((BENCH_SECONDS + 180)) xvfb-run -a -s "$XVFB_ARGS" env \ + LV_AUTOPLAY_SAMPLE=1 \ + LV_PERF_SOAK_SECONDS="$BENCH_SECONDS" \ + LV_FRAME_LOG=1 \ + LV_UNLOCK_FPS=1 \ + LV_LEGACY_MAP=1 \ + LV_RECORD_DIR="$OUT" \ + dotnet "$DESKTOP_DLL" > "$DESKTOP_LOG" 2>&1 +DCODE=$? +echo "[p70] desktop exit=$DCODE frames=$(grep -c '^p70-frame ' "$DESKTOP_LOG" || true)" +echo "[p70] ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +python3 "$REPO/scripts/analyze-p70.py" "$OUT" "$REPO/docs/evidence/p70-analysis.txt" +ACODE=$? +echo "[p70] analyze exit=$ACODE -> $REPO/docs/evidence/p70-analysis.txt" +exit "$ACODE" diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 63f69cd..ff1dfe0 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -248,6 +248,14 @@ type LivingVillageGame() as this = | _ -> 0.0 let perfSoakWatch = System.Diagnostics.Stopwatch() let mutable perfSoakStarted = false + // P70 基准:LV_FRAME_LOG=1 时逐帧打印真实帧间隔(墙钟 ms,含 Update+Draw)与线程分配增量, + // 供 scripts/bench-p70.sh + analyze-p70.py 计算平均/最大帧间隔、掉帧与每帧分配。 + // 仅新增日志,不改变渲染/更新逻辑;配合 LV_PERF_SOAK_SECONDS + LV_AUTOPLAY_SAMPLE 循环 N 秒。 + let frameLogEnabled = Environment.GetEnvironmentVariable("LV_FRAME_LOG") = "1" + let frameLogWatch = System.Diagnostics.Stopwatch() + let mutable frameLogIndex = 0L + let mutable frameLogAllocStart = 0L + let mutable frameLogLastTick = 0L let mutable prevKb = Unchecked.defaultof<KeyboardState> let mutable relationView = false let mutable relationSnapshot: float32[,] option = None @@ -2240,6 +2248,20 @@ type LivingVillageGame() as this = (Occupation.saveToken kind) stage world.Tick world.Annals.Length override this.Update(gameTime: GameTime) = + if frameLogEnabled && not splashActive then + let allocatedNow = System.GC.GetAllocatedBytesForCurrentThread() + if frameLogIndex = 0L then + frameLogWatch.Start() + frameLogAllocStart <- allocatedNow + else + let frameMs = frameLogWatch.Elapsed.TotalMilliseconds + let allocKb = float (allocatedNow - frameLogAllocStart) / 1024.0 + printfn + "p70-frame index=%d ms=%.3f alloc_kb=%.1f tick=%d panel=%s" + frameLogIndex frameMs allocKb world.Tick (M5Interaction.panelName m5View) + frameLogWatch.Restart() + frameLogAllocStart <- allocatedNow + frameLogIndex <- frameLogIndex + 1L let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) let pressedAny (keys: Keys list) = keys |> List.exists pressed diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index 1a36ea7..ade9f44 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -17,6 +17,7 @@ <Compile Include="OccupationBiasTests.fs" /> <Compile Include="OccupationStoryTests.fs" /> <Compile Include="PerformanceTests.fs" /> + <Compile Include="P70PerformanceBaselineTests.fs" /> </ItemGroup> <ItemGroup> diff --git a/src/LivingVillage.Kernel.Tests/P70PerformanceBaselineTests.fs b/src/LivingVillage.Kernel.Tests/P70PerformanceBaselineTests.fs new file mode 100644 index 0000000..ea98f34 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/P70PerformanceBaselineTests.fs @@ -0,0 +1,18 @@ +namespace LivingVillage.Kernel.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Headless + +/// P70:把 Headless `--performance-baseline`(defaultConfiguration)的最终 digest 钉住, +/// 作为 Kernel/Sim 数值路径漂移的护栏。与 scripts/bench-p70.sh 的 Kernel 档同一输入。 +[<TestClass>] +type P70PerformanceBaselineTests () = + + [<TestMethod>] + member _.PerformanceBaselineDigestIsPinned () = + let world = PerformanceProbe.runSteps PerformanceProbe.defaultConfiguration + let digest = PerformanceProbe.worldDigest world + printfn "P70 performance baseline digest=%s" digest + Assert.AreEqual<string>( + "953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C", + digest) |
