summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 04:23:26 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 04:23:26 +0800
commitf681867692f575b078f46afaf07060faed457461 (patch)
tree4ab11638357891257703e55f5aa790868e902e3c
parent2d9e4d845019b6c8793633258a45b91e0358e0e9 (diff)
downloadliving-village-f681867692f575b078f46afaf07060faed457461.tar.gz
p59: Sim 多世界并存后台压测(内存/GC 增长曲线证据单,只读)
目标:量化「多世界并存 × 天数」的内存/GC 增长,收口 P49 范围外项。 口径:新增 Headless `--profile-multiworld <worlds> <days> <npcs> <sampleEveryDays> <seedBase>`, N 个 Sim 世界同时存活,每轮 Parallel.For 各推进 sampleEveryDays 天并采样一行 (worlds/events/rumors/annals/memory_entries/gc_live_mb/working_set_rss_mb/elapsed_s, 行尾 flush);未改 Kernel/Sim 任何语义(Sim.initialWorldN / Sim.step 原样调用)。 标定:1 世界 × 2 天探针 = 34.2s(与 overnight-status 的 34.6s 一致),据此定 N=10 × 20 天。 运行:独立 systemd --user transient unit living-village-multiworld.service (scripts/profile-multiworld.sh,StandardOutput=append: + exec 2>&1,state.json 记 pid/unit/log/commit/参数/起止/exit)。主运行 wall=1024.4s(17.1min < 40min),exit=0。 结论(artifacts/perf-multiworld/20260928T195839Z/README.md): - 随世界数近似线性,未超线性:N=1 vs N=10(同日 D=20)annals/memory_entries 恰 10×、 rumors 9.67×、gc_live 8.81×(每世界 1.23MB,共享运行时开销摊薄)。 - RSS 有界无泄漏:N=10 39→峰值123(day8)→稳定104~112MB;每 2 天一轮墙钟恒 101~105s。 原因是历史集合有界(rumorRetentionDays=3 / rumorCapacity=16384 / maxAnnalEntries=128 / Mind.Memory 上限),约 day4 后进入稳态。 - 两轮世界0 digest 一致(2364CB74…)→ 并存+并行不改变单世界语义。 - 数字未越界 → 明确「不修」;只读本 slice 不修 Kernel/Sim。 回归:Desktop 304/304 + Kernel 119/119;--performance-baseline ×3 digest 953775FAEB2F… 不变,performance_determinism=PASS。未 push。
-rw-r--r--artifacts/perf-multiworld/20260928T195839Z/README.md113
-rw-r--r--artifacts/perf-multiworld/20260928T195839Z/acceptance.log15
-rw-r--r--artifacts/perf-multiworld/20260928T195839Z/control-n1.log13
-rw-r--r--artifacts/perf-multiworld/20260928T195839Z/state.json16
-rw-r--r--deploy/living-village-multiworld.service40
-rwxr-xr-xscripts/profile-multiworld.sh80
-rw-r--r--src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs7
-rw-r--r--src/LivingVillage.Headless/BatchOutput.fs17
-rw-r--r--src/LivingVillage.Headless/Program.fs64
9 files changed, 364 insertions, 1 deletions
diff --git a/artifacts/perf-multiworld/20260928T195839Z/README.md b/artifacts/perf-multiworld/20260928T195839Z/README.md
new file mode 100644
index 0000000..f5dc676
--- /dev/null
+++ b/artifacts/perf-multiworld/20260928T195839Z/README.md
@@ -0,0 +1,113 @@
+# P59 多世界并存压测结论(Sim 多世界 × 天数的内存/GC 增长曲线)
+
+- 日期:2026-09-28
+- 代码 commit:`2c559a7`(本 slice 起点;压测期间未改 Kernel/Sim)
+- 运行方式:独立 `systemd --user` transient unit `living-village-multiworld.service`
+ (wrapper `scripts/profile-multiworld.sh`,`StandardOutput=append:` + `exec 2>&1` 汇一个文件)
+- 证据:本目录 `acceptance.log`(主运行 N=10)、`control-n1.log`(控制组 N=1)、`state.json`
+- 只读结论,**未修改任何 Kernel/Sim 语义**;`--performance-baseline` ×3 digest 仍为
+ `953775FAEB2F…`,Kernel 119/119。
+
+## 口径
+
+`dotnet LivingVillage.Headless.dll --profile-multiworld <worlds> <days> <npcs> <sampleEveryDays> <seedBase>`
+
+- N 个 `Sim.initialWorldN(seedBase+i, 30)` 世界**同时存活**;
+- 按采样轮推进:每轮用 `Parallel.For` 让全部世界各前进 `sampleEveryDays` 天,随后采样一次;
+- 采样字段:`worlds`(并存世界数)、`events`、`rumors`、`annals`、`memory_entries`
+ (全部世界的聚合)、`gc_live_mb`(`GC.GetTotalMemory(true)`)、`working_set_rss_mb`
+ (`Process.WorkingSet64`)、`elapsed_s`;每行 flush;
+- ground truth 为 **gc_live 与 RSS**;条目计数只作解释用。
+
+### 标定探针(先跑)
+
+`--profile-multiworld 1 2`(1 世界 × 2 天)= **34.2s**,与 `docs/overnight-status.md`
+中 Hermes 单 worker 亲测 34.6s 一致。据此定 **N=10 × 20 天**(10 世界并行一轮约 100s)。
+
+## 主运行 N=10 × 20 天(12 天口径内的采样,`acceptance.log`)
+
+| day | rumors | annals | memory_entries | gc_live_mb | rss_mb | elapsed_s |
+|----:|------:|------:|--------------:|----------:|------:|---------:|
+| 0 | 0 | 0 | 0 | 0.12 | 39.05 | 0.0 |
+| 2 | 38536 | 1280 | 19200 | 7.28 | 74.61 | 101.0 |
+| 4 | 76353 | 1280 | 19200 | 12.19 | 91.55 | 206.1 |
+| 6 | 75488 | 1280 | 19200 | 12.09 | 102.58 | 309.6 |
+| 8 | 75737 | 1280 | 19200 | 12.13 | 123.10 | 412.4 |
+| 10 | 75667 | 1280 | 19200 | 12.11 | 106.71 | 514.9 |
+| 12 | 75908 | 1280 | 19200 | 12.15 | 112.00 | 616.9 |
+| 14 | 76017 | 1280 | 19200 | 12.16 | 110.58 | 719.2 |
+| 16 | 75334 | 1280 | 19200 | 12.06 | 111.41 | 820.4 |
+| 18 | 75907 | 1280 | 19200 | 12.15 | 105.52 | 922.5 |
+| 20 | 76687 | 1280 | 19200 | 12.25 | 103.96 | 1024.3 |
+
+wall = **1024.4s(≈17.1 min,< 40 min 预算)**,exit=0。
+
+## 控制组 N=1 × 20 天(`control-n1.log`)
+
+| day | rumors | annals | memory_entries | gc_live_mb | rss_mb | elapsed_s |
+|----:|------:|------:|--------------:|----------:|------:|---------:|
+| 2 | 3921 | 128 | 1920 | 0.85 | 57.19 | 34.0 |
+| 4 | 7843 | 128 | 1920 | 1.37 | 61.71 | 68.1 |
+| 20 | 7932 | 128 | 1920 | 1.39 | 67.94 | 339.0 |
+
+wall = 339.1s。世界 0 的 `world_digests=0:2364CB74F5FCA345…` 与 N=10 主运行的
+世界 0 **逐字节一致** → 并存 + 并行推进不改变单世界语义。
+
+## 结论
+
+### 1) 增长是否随世界数超线性?——否,近似线性(内存实为略次线性)
+
+以 N=1 与 N=10 同日(D=20)对比:
+
+| 量 | N=1 | N=10 | 倍率 |
+|---|---:|---:|---:|
+| rumors | 7932 | 76687 | 9.67× |
+| annals | 128 | 1280 | 10.00× |
+| memory_entries | 1920 | 19200 | 10.00× |
+| gc_live_mb | 1.39 | 12.25 | 8.81×(每世界 1.23MB) |
+| wall_s | 339 | 1024 | 3.02×(10× 工作量,16 核并行) |
+
+条目计数严格 ~N 倍;GC live set **略低于** N 倍(运行时/JIT/GC 堆共享的固定开销被摊薄)。
+**未观测到任何超线性增长**。
+
+### 2) RSS 是否有界?——有界,无单调泄漏
+
+- N=10:39MB → 峰值 123MB(day 8)→ 回落并稳定在 **104–112MB**,day 10 后不再上升。
+- N=1:39MB → day 4 后稳定在 **64–68MB**。
+- 时间维度上,10 世界每 2 天一轮墙钟恒为 **101–105s**(波动 <4%),说明 2–20 天窗口内
+ 逐日成本**不随天数上升**——与早期 M3 phase2 在 100 天视野看到的逐日成本上升不同。
+
+原因(读码确认,非猜测,未改码):三类历史集合均有界——
+`Sim.rumorRetentionDays = 3`(按天淘汰,`rumorCapacity = 16384`/世界)、
+`maxAnnalEntries = 128`、`Mind.Memory` 每 NPC 上限。因此 day ~4 后每条曲线进入稳态。
+
+`events` 全程为 0:自主模拟不产生 `InteractionEvent`(仅玩家交互写入),符合预期,非泄漏信号。
+
+### 3) 是否有建议的最小修复?——**不修**
+
+所有指标未越界:live set 有界、RSS 有界且回落、随 N 线性、无 NaN(判据门在 M3R 另测)。
+本 slice 只读,不改任何 Kernel/Sim。
+
+观察项(留档,不在本 slice 处理):
+- M3 phase2 在 100 天视野的逐日成本上升未在 20 天窗口复现;若需定位需另设 >20 天采样。
+- 10 世界并行时单世界墙钟约为串行的 3.0×(GC/CPU 争用),16 核下仍远低于预算。
+
+## 复现
+
+```bash
+# 控制组(前台)
+dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll \
+ --profile-multiworld 1 20 30 2 42
+
+# 主运行(独立 systemd --user transient unit,见 deploy/living-village-multiworld.service)
+TS=$(date -u +%Y%m%dT%H%M%SZ)
+DIR=$PWD/artifacts/perf-multiworld/$TS
+systemd-run --user --unit=living-village-multiworld \
+ --property=Type=simple --property=WorkingDirectory=$PWD \
+ --setenv=PMW_REPO=$PWD --setenv=PMW_DIR=$DIR --setenv=PMW_COMMIT=2c559a7 \
+ --setenv=PMW_WORLDS=10 --setenv=PMW_DAYS=20 --setenv=PMW_NPCS=30 \
+ --setenv=PMW_SAMPLE_EVERY=2 --setenv=PMW_SEED_BASE=42 --setenv=PMW_LOG=$DIR/acceptance.log \
+ --property=StandardOutput=append:$DIR/acceptance.log \
+ --property=StandardError=append:$DIR/acceptance.log --property=Restart=no \
+ /usr/bin/bash $PWD/scripts/profile-multiworld.sh
+```
diff --git a/artifacts/perf-multiworld/20260928T195839Z/acceptance.log b/artifacts/perf-multiworld/20260928T195839Z/acceptance.log
new file mode 100644
index 0000000..8f6ac82
--- /dev/null
+++ b/artifacts/perf-multiworld/20260928T195839Z/acceptance.log
@@ -0,0 +1,15 @@
+[pmw] start unit=living-village-multiworld.service pid=165537 commit=2c559a7 worlds=10 days=20 npcs=30 sample_every=2 seed_base=42 started_at=2026-09-28T19:58:39Z
+multiworld_start worlds=10 days=20 npcs=30 sample_every_days=2 seed_base=42 ticks_per_day=5184000
+multiworld_sample worlds=10 day=0/20 events=0 rumors=0 annals=0 memory_entries=0 gc_live_mb=0.12 working_set_rss_mb=39.05 elapsed_s=0.0
+multiworld_sample worlds=10 day=2/20 events=0 rumors=38536 annals=1280 memory_entries=19200 gc_live_mb=7.28 working_set_rss_mb=74.61 elapsed_s=101.0
+multiworld_sample worlds=10 day=4/20 events=0 rumors=76353 annals=1280 memory_entries=19200 gc_live_mb=12.19 working_set_rss_mb=91.55 elapsed_s=206.1
+multiworld_sample worlds=10 day=6/20 events=0 rumors=75488 annals=1280 memory_entries=19200 gc_live_mb=12.09 working_set_rss_mb=102.58 elapsed_s=309.6
+multiworld_sample worlds=10 day=8/20 events=0 rumors=75737 annals=1280 memory_entries=19200 gc_live_mb=12.13 working_set_rss_mb=123.10 elapsed_s=412.4
+multiworld_sample worlds=10 day=10/20 events=0 rumors=75667 annals=1280 memory_entries=19200 gc_live_mb=12.11 working_set_rss_mb=106.71 elapsed_s=514.9
+multiworld_sample worlds=10 day=12/20 events=0 rumors=75908 annals=1280 memory_entries=19200 gc_live_mb=12.15 working_set_rss_mb=112.00 elapsed_s=616.9
+multiworld_sample worlds=10 day=14/20 events=0 rumors=76017 annals=1280 memory_entries=19200 gc_live_mb=12.16 working_set_rss_mb=110.58 elapsed_s=719.2
+multiworld_sample worlds=10 day=16/20 events=0 rumors=75334 annals=1280 memory_entries=19200 gc_live_mb=12.06 working_set_rss_mb=111.41 elapsed_s=820.4
+multiworld_sample worlds=10 day=18/20 events=0 rumors=75907 annals=1280 memory_entries=19200 gc_live_mb=12.15 working_set_rss_mb=105.52 elapsed_s=922.5
+multiworld_sample worlds=10 day=20/20 events=0 rumors=76687 annals=1280 memory_entries=19200 gc_live_mb=12.25 working_set_rss_mb=103.96 elapsed_s=1024.3
+multiworld_done worlds=10 day=20 elapsed_s=1024.4 world_digests=0:2364CB74F5FCA34503269F413F7184F455006C9975EB01B11219D25A3A33FDD2,1:BDD2F62D96C0F4A72ECDDA5DA17106B7910C5FEAE0FC197610DA0C467066467C,2:5F59925D880B8059E52B939BE72773BFD7C5971AC2D1DD9541C556577A4D23B2,3:AB78433838A44F13524D08ACC6639B2C4DA07A829AFD0C01AEA5AAEF7D09C72A,4:778B3F47193D016F314280A1752A34390699B1825C422D34C24C8F34FF8F965E,5:C3C7B02C3C7E8A368462F3C7E547C756477F38A4341D86E2259F1F7417D21587,6:1343B54480349D8807FC9AC0A886F4B30446F76346507CA2FB5A0C3A0521F737,7:6AA8096FA263FA2306B2C14298FD3C824FC8AB032176FE2A6CA85BDB9759F5EE,8:10537DB6C5CFC902A6FB05D2A0D94CF5087C4F31CC29D5A7320AD533C4679911,9:AB5EFD42759CA622AF94ADF875E082C6DADEB2C22DA43C35C01DF9C431C6BFBB
+[pmw] end pid=165537 exit=0 ended_at=2026-09-28T20:15:43Z
diff --git a/artifacts/perf-multiworld/20260928T195839Z/control-n1.log b/artifacts/perf-multiworld/20260928T195839Z/control-n1.log
new file mode 100644
index 0000000..88fe297
--- /dev/null
+++ b/artifacts/perf-multiworld/20260928T195839Z/control-n1.log
@@ -0,0 +1,13 @@
+multiworld_start worlds=1 days=20 npcs=30 sample_every_days=2 seed_base=42 ticks_per_day=5184000
+multiworld_sample worlds=1 day=0/20 events=0 rumors=0 annals=0 memory_entries=0 gc_live_mb=0.09 working_set_rss_mb=38.83 elapsed_s=0.0
+multiworld_sample worlds=1 day=2/20 events=0 rumors=3921 annals=128 memory_entries=1920 gc_live_mb=0.85 working_set_rss_mb=57.19 elapsed_s=34.0
+multiworld_sample worlds=1 day=4/20 events=0 rumors=7843 annals=128 memory_entries=1920 gc_live_mb=1.37 working_set_rss_mb=61.71 elapsed_s=68.1
+multiworld_sample worlds=1 day=6/20 events=0 rumors=7849 annals=128 memory_entries=1920 gc_live_mb=1.38 working_set_rss_mb=63.63 elapsed_s=101.9
+multiworld_sample worlds=1 day=8/20 events=0 rumors=7925 annals=128 memory_entries=1920 gc_live_mb=1.39 working_set_rss_mb=64.29 elapsed_s=135.8
+multiworld_sample worlds=1 day=10/20 events=0 rumors=7723 annals=128 memory_entries=1920 gc_live_mb=1.37 working_set_rss_mb=64.57 elapsed_s=169.6
+multiworld_sample worlds=1 day=12/20 events=0 rumors=7727 annals=128 memory_entries=1920 gc_live_mb=1.37 working_set_rss_mb=64.92 elapsed_s=203.6
+multiworld_sample worlds=1 day=14/20 events=0 rumors=8038 annals=128 memory_entries=1920 gc_live_mb=1.41 working_set_rss_mb=67.88 elapsed_s=237.2
+multiworld_sample worlds=1 day=16/20 events=0 rumors=7953 annals=128 memory_entries=1920 gc_live_mb=1.40 working_set_rss_mb=67.88 elapsed_s=271.2
+multiworld_sample worlds=1 day=18/20 events=0 rumors=7901 annals=128 memory_entries=1920 gc_live_mb=1.39 working_set_rss_mb=67.89 elapsed_s=305.2
+multiworld_sample worlds=1 day=20/20 events=0 rumors=7932 annals=128 memory_entries=1920 gc_live_mb=1.39 working_set_rss_mb=67.94 elapsed_s=339.0
+multiworld_done worlds=1 day=20 elapsed_s=339.1 world_digests=0:2364CB74F5FCA34503269F413F7184F455006C9975EB01B11219D25A3A33FDD2
diff --git a/artifacts/perf-multiworld/20260928T195839Z/state.json b/artifacts/perf-multiworld/20260928T195839Z/state.json
new file mode 100644
index 0000000..3ead223
--- /dev/null
+++ b/artifacts/perf-multiworld/20260928T195839Z/state.json
@@ -0,0 +1,16 @@
+{
+ "commit": "2c559a7",
+ "ended_at": "2026-09-28T20:15:43Z",
+ "exit": 0,
+ "log": "/home/somhairle/projects/living-village/artifacts/perf-multiworld/20260928T195839Z/acceptance.log",
+ "params": {
+ "days": 20,
+ "npcs": 30,
+ "sample_every_days": 2,
+ "seed_base": 42,
+ "worlds": 10
+ },
+ "pid": 165537,
+ "started_at": "2026-09-28T19:58:39Z",
+ "unit": "living-village-multiworld.service"
+}
diff --git a/deploy/living-village-multiworld.service b/deploy/living-village-multiworld.service
new file mode 100644
index 0000000..a16544d
--- /dev/null
+++ b/deploy/living-village-multiworld.service
@@ -0,0 +1,40 @@
+# P59 多世界并存压测 systemd --user 单元模板(N 世界 × D 天,逐采样行流式)。
+#
+# 独立于 writer cgroup 运行(transient unit),输出 append 到 acceptance.log、exec 2>&1。
+# 安装示例:
+# TS=$(date -u +%Y%m%dT%H%M%SZ)
+# systemd-run --user --unit=living-village-multiworld \
+# --property=Type=simple \
+# --property=WorkingDirectory=@REPO@ \
+# --setenv=PMW_REPO=@REPO@ \
+# --setenv=PMW_DIR=@REPO@/artifacts/perf-multiworld/$TS \
+# --setenv=PMW_COMMIT=@COMMIT@ \
+# --setenv=PMW_WORLDS=10 --setenv=PMW_DAYS=20 --setenv=PMW_NPCS=30 \
+# --setenv=PMW_SAMPLE_EVERY=2 --setenv=PMW_SEED_BASE=42 \
+# --setenv=PMW_LOG=@REPO@/artifacts/perf-multiworld/$TS/acceptance.log \
+# --property=StandardOutput=append:@REPO@/artifacts/perf-multiworld/$TS/acceptance.log \
+# --property=StandardError=append:@REPO@/artifacts/perf-multiworld/$TS/acceptance.log \
+# --property=Restart=no \
+# /usr/bin/bash @REPO@/scripts/profile-multiworld.sh
+[Unit]
+Description=Living Village multi-world coexistence memory/GC profile
+After=default.target
+
+[Service]
+Type=simple
+WorkingDirectory=@REPO@
+Environment=PMW_REPO=@REPO@
+Environment=PMW_DIR=@REPO@/artifacts/perf-multiworld/@TS@
+Environment=PMW_COMMIT=@COMMIT@
+Environment=PMW_WORLDS=10
+Environment=PMW_DAYS=20
+Environment=PMW_NPCS=30
+Environment=PMW_SAMPLE_EVERY=2
+Environment=PMW_SEED_BASE=42
+Environment=PMW_LOG=@REPO@/artifacts/perf-multiworld/@TS@/acceptance.log
+ExecStart=/usr/bin/bash @REPO@/scripts/profile-multiworld.sh
+StandardOutput=append:@REPO@/artifacts/perf-multiworld/@TS@/acceptance.log
+StandardError=append:@REPO@/artifacts/perf-multiworld/@TS@/acceptance.log
+Restart=no
+TimeoutStartSec=infinity
+TimeoutStopSec=30s
diff --git a/scripts/profile-multiworld.sh b/scripts/profile-multiworld.sh
new file mode 100755
index 0000000..3ab099d
--- /dev/null
+++ b/scripts/profile-multiworld.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+# P59 多世界并存后台压测 MasterProcess(systemd --user transient unit 的 ExecStart)。
+#
+# 职责:
+# * 在 <PMW_DIR> 落 state.json(pid/unit/log/commit/参数/起止/exit);
+# * 以 2>&1 运行 Headless `--profile-multiworld N D npcs sampleEvery seedBase`,
+# 逐采样行流式打印并 flush;
+# * 退出时写回 ended_at/exit,并回传真实退出码。
+#
+# 环境变量(由 unit/transient 属性注入):
+# PMW_REPO / PMW_DIR / PMW_COMMIT / PMW_LOG
+# PMW_WORLDS(默认 10)/ PMW_DAYS(默认 20)/ PMW_NPCS(默认 30)
+# PMW_SAMPLE_EVERY(默认 2)/ PMW_SEED_BASE(默认 42)
+set -u
+
+REPO="${PMW_REPO:?PMW_REPO required}"
+DIR="${PMW_DIR:?PMW_DIR required}"
+COMMIT="${PMW_COMMIT:?PMW_COMMIT required}"
+WORLDS="${PMW_WORLDS:-10}"
+DAYS="${PMW_DAYS:-20}"
+NPCS="${PMW_NPCS:-30}"
+SAMPLE_EVERY="${PMW_SAMPLE_EVERY:-2}"
+SEED_BASE="${PMW_SEED_BASE:-42}"
+LOG="${PMW_LOG:-$REPO/artifacts/perf-multiworld/latest/acceptance.log}"
+UNIT="living-village-multiworld.service"
+DLL="$REPO/src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll"
+
+mkdir -p "$DIR"
+if [ ! -f "$DLL" ]; then
+ echo "[pmw] FATAL missing build: $DLL" >&2
+ exit 90
+fi
+
+STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+echo "$$" > "$DIR/pid"
+echo "[pmw] start unit=$UNIT pid=$$ commit=$COMMIT worlds=$WORLDS days=$DAYS npcs=$NPCS sample_every=$SAMPLE_EVERY seed_base=$SEED_BASE started_at=$STARTED"
+
+python3 - "$DIR/state.json" "$UNIT" "$COMMIT" "$WORLDS" "$DAYS" "$NPCS" "$SAMPLE_EVERY" "$SEED_BASE" "$LOG" "$STARTED" "$$" <<'PY'
+import json, sys
+out, unit, commit, worlds, days, npcs, sample, seed, log, started, pid = sys.argv[1:12]
+state = {
+ "pid": int(pid),
+ "unit": unit,
+ "log": log,
+ "commit": commit,
+ "params": {
+ "worlds": int(worlds),
+ "days": int(days),
+ "npcs": int(npcs),
+ "sample_every_days": int(sample),
+ "seed_base": int(seed),
+ },
+ "started_at": started,
+ "ended_at": None,
+ "exit": None,
+}
+with open(out, "w", encoding="utf-8") as fh:
+ json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True)
+ fh.write("\n")
+PY
+
+( exec dotnet "$DLL" --profile-multiworld "$WORLDS" "$DAYS" "$NPCS" "$SAMPLE_EVERY" "$SEED_BASE" 2>&1 )
+CODE=$?
+
+ENDED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+echo "[pmw] end pid=$$ exit=$CODE ended_at=$ENDED"
+
+python3 - "$DIR/state.json" "$CODE" "$ENDED" <<'PY'
+import json, sys
+path, code, ended = sys.argv[1:4]
+with open(path, encoding="utf-8") as fh:
+ state = json.load(fh)
+state["ended_at"] = ended
+state["exit"] = int(code)
+with open(path, "w", encoding="utf-8") as fh:
+ json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True)
+ fh.write("\n")
+PY
+
+exit "$CODE"
diff --git a/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs b/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
index b1822c7..963d571 100644
--- a/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
+++ b/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
@@ -32,6 +32,13 @@ type M3BatchOutputTests () =
Assert.AreEqual<string>("[world 0] progress day=15/15 elapsed=260.0s", BatchOutput.progressLine 0 15L 15L 260.0)
[<TestMethod>]
+ member _.MultiworldLineReportsCountsGcRssAndElapsed () =
+ let line = BatchOutput.multiworldLine 8 4L 20L 1600L 32000L 64L 5120L (1048576L * 96L) (1048576L * 240L) 31.5
+ Assert.AreEqual<string>(
+ "multiworld_sample worlds=8 day=4/20 events=1600 rumors=32000 annals=64 memory_entries=5120 gc_live_mb=96.00 working_set_rss_mb=240.00 elapsed_s=31.5",
+ line)
+
+ [<TestMethod>]
member _.SummaryLineKeepsOldFieldsAndAppendsWallClock () =
let line = BatchOutput.summaryLine 4 3 1 4 1.100 9.900 0.010 0.400 4 610.5
// 监督器正则只读前三个字段,前缀必须逐字保持。
diff --git a/src/LivingVillage.Headless/BatchOutput.fs b/src/LivingVillage.Headless/BatchOutput.fs
index 971da97..aa79391 100644
--- a/src/LivingVillage.Headless/BatchOutput.fs
+++ b/src/LivingVillage.Headless/BatchOutput.fs
@@ -42,6 +42,23 @@ module BatchOutput =
"batch_summary worlds=%d passed=%d failed=%d old_passed=%d/%d ratio_min=%.3f ratio_max=%.3f gini_min=%.3f gini_max=%.3f elapsed_s=%.1f workers=%d wall_s=%.1f"
worlds passed failed oldPassed worlds ratioMin ratioMax giniMin giniMax elapsedSeconds workers elapsedSeconds
+ /// 多世界并存压测采样行:worlds/day 与各历史集合计数 + GC live set + OS RSS + 墙钟。
+ let multiworldLine
+ (worlds: int)
+ (day: int64)
+ (totalDays: int64)
+ (events: int64)
+ (rumors: int64)
+ (annals: int64)
+ (memoryEntries: int64)
+ (gcLiveBytes: int64)
+ (rssBytes: int64)
+ (elapsedSeconds: float) : string =
+ let mb (bytes: int64) = float bytes / (1024.0 * 1024.0)
+ sprintf
+ "multiworld_sample worlds=%d day=%d/%d events=%d rumors=%d annals=%d memory_entries=%d gc_live_mb=%.2f working_set_rss_mb=%.2f elapsed_s=%.1f"
+ worlds day totalDays events rumors annals memoryEntries (mb gcLiveBytes) (mb rssBytes) elapsedSeconds
+
/// --cost-probe 单段输出:ticks/s、墙钟、分配与 GC 全部来自真实测量。
let costLine
(days: int64)
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index 4bd6424..c9dd442 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -612,6 +612,66 @@ let runProfileLongRun (argv: string[]) : int =
Console.Out.Flush()
if world.Tick = days * ticksPerDay then 0 else 1
+/// `--profile-multiworld [worlds] [days] [npcs] [sampleEveryDays] [seedBase]`:
+/// N 个 Sim 世界并存、按采样轮并行推进 D 天(只读 profile,不改 Kernel/Sim 语义)。
+/// 每 `sampleEveryDays` 天打印一行流式采样(worlds/count、events、rumors、annals、
+/// memory_entries、gc_live_mb、working_set_rss_mb、elapsed_s)并 flush;行的聚合口径为
+/// 全部并存世界之和,ground truth 是 GC live set 与 OS WorkingSet。
+let runProfileMultiworld (argv: string[]) : int =
+ let argInt64 index (dflt: int64) =
+ if argv.Length > index then
+ match Int64.TryParse argv.[index] with
+ | true, v when v > 0L -> v
+ | _ -> dflt
+ else dflt
+ let worlds = int (argInt64 1 10L)
+ let days = argInt64 2 20L
+ let npcs = int (argInt64 3 (int64 Sim.npcCount))
+ let sampleEvery = argInt64 4 2L
+ let seedBase = if argv.Length > 5 then (match UInt64.TryParse argv.[5] with | true, v -> v | _ -> 42UL) else 42UL
+ let proc = System.Diagnostics.Process.GetCurrentProcess()
+ printfn "multiworld_start worlds=%d days=%d npcs=%d sample_every_days=%d seed_base=%d ticks_per_day=%d"
+ worlds days npcs sampleEvery seedBase ticksPerDay
+ Console.Out.Flush()
+ let ws = Array.init worlds (fun i -> Sim.initialWorldN (seedBase + uint64 i) npcs)
+ let drivers = Array.init worlds (fun i -> { Rng = Rng.ofSeed (seedBase + uint64 i); Until = segmentTicks; Input = zeroInput })
+ let sw = System.Diagnostics.Stopwatch.StartNew()
+ let advanceOne (i: int) (targetDay: int64) =
+ let mutable world = ws.[i]
+ let mutable driver = drivers.[i]
+ let dayEnd = targetDay * ticksPerDay
+ while world.Tick < dayEnd do
+ let d, input =
+ if world.Tick < driver.Until then driver, driver.Input
+ else
+ let d = nextSegment driver.Rng driver.Until
+ d, d.Input
+ driver <- d
+ world <- Sim.step { Input = input } world
+ ws.[i] <- world
+ drivers.[i] <- driver
+ let sample (day: int64) =
+ let gcLive = System.GC.GetTotalMemory true
+ proc.Refresh()
+ let rss = proc.WorkingSet64
+ let events = ws |> Array.sumBy (fun w -> int64 w.Events.Length)
+ let rumors = ws |> Array.sumBy (fun w -> int64 w.Rumors.Length)
+ let annals = ws |> Array.sumBy (fun w -> int64 w.Annals.Length)
+ let memEntries = ws |> Array.sumBy (fun w -> int64 (w.Npcs |> Array.sumBy (fun n -> n.Mind.Memory.Length)))
+ printfn "%s" (LivingVillage.Headless.BatchOutput.multiworldLine worlds day days events rumors annals memEntries gcLive rss sw.Elapsed.TotalSeconds)
+ Console.Out.Flush()
+ sample 0L
+ let mutable dayNo = 0L
+ while dayNo < days do
+ let target = min days (dayNo + sampleEvery)
+ System.Threading.Tasks.Parallel.For(0, worlds, fun i -> advanceOne i target) |> ignore
+ dayNo <- target
+ sample dayNo
+ let digests = ws |> Array.mapi (fun i w -> sprintf "%d:%s" i (LivingVillage.Headless.PerformanceProbe.worldDigest w)) |> String.concat ","
+ printfn "multiworld_done worlds=%d day=%d elapsed_s=%.1f world_digests=%s" worlds dayNo sw.Elapsed.TotalSeconds digests
+ Console.Out.Flush()
+ if ws |> Array.forall (fun w -> w.Tick = days * ticksPerDay) then 0 else 1
+
let runExisting (argv: string[]) =
let rec parse
(i: int)
@@ -675,7 +735,7 @@ let runExisting (argv: string[]) =
if stats.NonFinite > 0L || stats.OutOfBounds > 0L || not replayOk then 1 else 0
| Error msg ->
eprintfn $"headless: {msg}"
- eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --m6a-smoke | --batch K D | --performance-baseline | --cost-probe [D1,D2,...] | --rumor-bench [days npcs chats_per_day]"
+ eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --m6a-smoke | --batch K D | --performance-baseline | --cost-probe [D1,D2,...] | --rumor-bench [days npcs chats_per_day] | --profile-long-run [days sampleEveryDays npcs seed] | --profile-multiworld [worlds days npcs sampleEveryDays seedBase]"
2
let private parseCostLadder (argv: string[]) : int64 list =
@@ -706,5 +766,7 @@ let main argv =
runRumorBench argv
elif argv.Length >= 1 && argv.[0] = "--profile-long-run" then
runProfileLongRun argv
+ elif argv.Length >= 1 && argv.[0] = "--profile-multiworld" then
+ runProfileMultiworld argv
else
runExisting argv