summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 10:36:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 10:36:39 +0800
commit164f4749460a304c1aaad4900ef3facc29859eb0 (patch)
tree05794b5f853566d68ebefc822b3dce3b5f780ca3
parentb377e750a6a3f35c5d64a29f14ac3fbd87d4a752 (diff)
downloadliving-village-164f4749460a304c1aaad4900ef3facc29859eb0.tar.gz
perf(kernel): P27 第一步 — Rumor 长跑基准与修复前量化
新增 Kernel 层 Rumor 长跑基准(RumorBench.fs + headless --rumor-bench [days npcs chats_per_day]),把「聊天→生成/追溯谣言」路径从 Sim.step 的 NPC 快照分配解耦,隔离测量谣言工作集随世界年龄的分配/耗时;同 Config 的 retained_id_checksum 证明确定性。 复核 M3-1 前提:World.Rumors 已被 M6a(d94ec2d/cc850bf)按 3 天保留窗口收敛, 单日成本平稳(1/5/10 天 ≈18.7/18.1/19.8s、≈19GB/天),超线性项消失。 剩余 Rumor 成本 = 每次聊天的两条 O(N) 扫描 latestRumorFor/rumorIsDuplicate, 且 [<Struct>] NpcId/RumorId 用 = 比较会逐条装箱(Sim.chat 单独测得 N=16000 时 393,736 B/次、随 N 线性)。 修复前基准(30 NPC,--rumor-bench D 30 2000): 3d chat_calls=6000 retained=6000 alloc=930,716,456 ms=1626.7 30d chat_calls=60000 retained=8000 alloc=13,419,045,864 ms=12182.5 60d chat_calls=120000 retained=8000 alloc=27,297,668,608 ms=23769.7 (60d 工作集稳定 8000,不随年龄增长。) docs/维护说明.md 新增 P27 小节记录上述现状与量级。不改任何模拟语义。
-rw-r--r--docs/维护说明.md25
-rw-r--r--src/LivingVillage.Headless/Program.fs26
-rw-r--r--src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj1
-rw-r--r--src/LivingVillage.Kernel/RumorBench.fs109
4 files changed, 160 insertions, 1 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index cc3a698..7be174a 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -459,6 +459,31 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
各出一格,相邻民居极近时挑檐可能视觉叠压,可在布点时加 1 格间隔;③ 巡游 40s 档的 `visible>=8`
占比仅 13%(P25 150s 档为 74%),属取证时长差异,非可见性回归。
+## P27:Rumor 长局性能修复(工作集扫描去装箱 + 裁剪判定)
+
+- **现状复核(更正 M3-1 前提)**:M3-1 记录的「`World.Rumors` 不裁剪、逐次聊天全表扫描」已由 M6a
+ (`d94ec2d` 工作集容量上界+按天淘汰、`cc850bf` 窗口化早停扫描)修复:`RumorEvent.DayIndex` 派生字段
+ + `trimRumors` 把工作集按「最新在前 + 3 天保留窗口」收敛。实测单日成本已平稳(本机 `--cost-probe`:
+ 1 天 18.7s/18.9GB、5 天 90.5s/95.7GB、10 天 198.2s/191.9GB,≈19GB/天常数),M3 记录的 20 天
+ ≈45 s/天的超线性项已消失。故 P27 不采用「砍 depth/strength 传播」这类会改变模拟语义的方案。
+- **剩余 Rumor 成本定位**:工作集有界后每次聊天仍有两条 O(N) 扫描 `latestRumorFor` / `rumorIsDuplicate`;
+ 且此前用 `=` 比较 `[<Struct>]` 的 `NpcId`/`RumorId`,会走泛型结构相等,对每一条被扫描到的谣言
+ 装箱(实测 ≈24 B/条、单次聊天随 N 线性增长——`Sim.chat` 单独测得 N=16000 时 393,736 B/次);
+ 这是 Rumor 逐条分配的主因,而非列表 cons 本身。
+- **Kernel 长跑基准**:新增 `src/LivingVillage.Kernel/RumorBench.fs` 与 headless
+ `--rumor-bench [days npcs chats_per_day]`,把「聊天→生成/追溯谣言」路径从 `Sim.step` 的 NPC 快照
+ 分配解耦,按模拟日推进 tick、固定节奏注入聊天,测工作集分配与耗时;同 `Config` 的
+ `retained_id_checksum` 可证明确定性。
+ 修复前(30 NPC,`--rumor-bench D 30 2000`):
+
+ | days | chat_calls | rumors_retained | allocated_bytes | elapsed_ms |
+ |---|---|---|---|---|
+ | 3 | 6000 | 6000 | 930,716,456 | 1626.7 |
+ | 30 | 60000 | 8000 | 13,419,045,864 | 12182.5 |
+ | 60 | 120000 | 8000 | 27,297,668,608 | 23769.7 |
+
+ 60 天工作集稳定在 8000(M6a 的 3 天保留窗口 + 2000/天),不再随年龄增长。
+
## 已验证命令
```bash
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index 89f4f26..7f5dcf5 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -518,6 +518,28 @@ let runCostProbe (ladder: int64 list) : int =
Console.Out.Flush()
0
+/// `--rumor-bench [days] [npcs] [chats_per_day]`:Kernel 层 Rumor 长跑基准
+/// (默认 30 NPC × 60 模拟日 × 每日 2000 次聊天)。只读、不改模拟语义。
+let runRumorBench (argv: string[]) : int =
+ let tryInt64 (index: int) =
+ if argv.Length > index then
+ match Int64.TryParse argv.[index] with
+ | true, value -> Some value
+ | _ -> None
+ else None
+ let days = tryInt64 1 |> Option.defaultValue RumorBench.defaultConfig.Days
+ let npcs = tryInt64 2 |> Option.defaultValue (int64 RumorBench.defaultConfig.NpcCount) |> int
+ let chatsPerDay = tryInt64 3 |> Option.defaultValue (int64 RumorBench.defaultConfig.ChatsPerDay) |> int
+ let config =
+ { RumorBench.defaultConfig with
+ Days = days
+ NpcCount = npcs
+ ChatsPerDay = chatsPerDay }
+ let result = RumorBench.run config
+ printfn "%s" (RumorBench.format result)
+ Console.Out.Flush()
+ 0
+
let runExisting (argv: string[]) =
let rec parse
(i: int)
@@ -581,7 +603,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,...]"
+ 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]"
2
let private parseCostLadder (argv: string[]) : int64 list =
@@ -608,5 +630,7 @@ let main argv =
LivingVillage.Headless.PerformanceProbe.runDefault ()
elif argv.Length >= 1 && argv.[0] = "--cost-probe" then
runCostProbe (parseCostLadder argv)
+ elif argv.Length >= 1 && argv.[0] = "--rumor-bench" then
+ runRumorBench argv
else
runExisting argv
diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj
index c6b3bf9..72e5def 100644
--- a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj
+++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj
@@ -7,6 +7,7 @@
<ItemGroup>
<Compile Include="Rng.fs" />
<Compile Include="Sim.fs" />
+ <Compile Include="RumorBench.fs" />
<Compile Include="Occupation.fs" />
<Compile Include="SimulationControl.fs" />
<Compile Include="WorldSave.fs" />
diff --git a/src/LivingVillage.Kernel/RumorBench.fs b/src/LivingVillage.Kernel/RumorBench.fs
new file mode 100644
index 0000000..145f92c
--- /dev/null
+++ b/src/LivingVillage.Kernel/RumorBench.fs
@@ -0,0 +1,109 @@
+namespace LivingVillage.Kernel
+
+open System
+open System.Diagnostics
+open LivingVillage.Kernel.Sim
+
+/// Kernel 层 Rumor 长跑基准:把「聊天 → 生成/追溯传闻」这条路径单独拎出来,按
+/// 模拟日推进 tick 并注入固定节奏的聊天,长期测量 Rumor 工作集随世界年龄的
+/// 分配与耗时量级(与 `Sim.step` 的 NPC 快照分配解耦,便于隔离谣言子系统)。
+///
+/// 该基准只读、不改变任何模拟语义;所有输入由 `Config` 决定,无墙钟/`System.Random`,
+/// 同一 `Config` 两次运行产生相同的工作集校验和。
+module RumorBench =
+
+ type Config =
+ { Seed: uint64
+ NpcCount: int
+ Days: int64
+ /// 每个模拟日注入的聊天次数(决定谣言产生速率)。
+ ChatsPerDay: int }
+
+ type Result =
+ { Seed: uint64
+ NpcCount: int
+ Days: int64
+ ChatsPerDay: int
+ ChatCalls: int64
+ RumorsCreated: int64
+ RumorsRetained: int
+ /// 保留工作集内各条目 Id 之和,用于证明同一 Config 的确定性。
+ RetainedIdChecksum: int64
+ AllocatedBytes: int64
+ ElapsedMs: float }
+
+ /// 任务默认口径:30 NPC × 60 模拟日 × 每日 2000 次聊天。
+ let defaultConfig : Config =
+ { Seed = 42UL
+ NpcCount = 30
+ Days = 60L
+ ChatsPerDay = 2000 }
+
+ /// 测试/快速核验口径(同样是 30 NPC,但只跑 3 天)。
+ let quickConfig : Config =
+ { defaultConfig with Days = 3L }
+
+ let private resetParticipants (ids: NpcId list) (world: World) : World =
+ let npcs = Array.copy world.Npcs
+ for id in ids do
+ let (NpcId index) = id
+ if index >= 0 && index < npcs.Length then
+ npcs.[index] <-
+ { npcs.[index] with
+ Mind = { npcs.[index].Mind with Action = Wander } }
+ { world with Npcs = npcs }
+
+ let private retainedChecksum (rumors: RumorEvent list) : int64 =
+ rumors |> List.sumBy (fun rumor -> let (RumorId id) = rumor.Id in id)
+
+ let run (config: Config) : Result =
+ if config.NpcCount < 2 then invalidArg (nameof config) "NpcCount must be at least 2"
+ if config.Days <= 0L then invalidArg (nameof config) "Days must be positive"
+ if config.ChatsPerDay <= 0 then invalidArg (nameof config) "ChatsPerDay must be positive"
+ if config.ChatsPerDay > 1000000 then invalidArg (nameof config) "ChatsPerDay is too large"
+ let callsPerDay = int64 config.ChatsPerDay
+ let ticksPerCall = max 1L (ticksPerDay / callsPerDay)
+ let totalCalls = config.Days * callsPerDay
+ let mutable world = Sim.initialWorldN config.Seed config.NpcCount
+ let mutable created = 0L
+ let mutable calls = 0L
+ GC.Collect(2, GCCollectionMode.Forced, true, true)
+ GC.WaitForPendingFinalizers()
+ GC.Collect(2, GCCollectionMode.Forced, true, true)
+ let allocatedBefore = GC.GetAllocatedBytesForCurrentThread()
+ let stopwatch = Stopwatch.StartNew()
+ for i in 0L .. totalCalls - 1L do
+ let dayIndex = i / callsPerDay
+ let within = i % callsPerDay
+ let tick = dayIndex * ticksPerDay + within * ticksPerCall
+ let narratorIndex = int (i % int64 config.NpcCount)
+ let receiverIndex =
+ int ((i + 1L + (i / int64 config.NpcCount) % int64 (config.NpcCount - 1)) % int64 config.NpcCount)
+ let narrator = NpcId narratorIndex
+ let receiver = NpcId receiverIndex
+ world <-
+ resetParticipants [ narrator; receiver ] { world with Tick = tick }
+ match Sim.chat { Narrator = narrator; Receiver = receiver } world with
+ | ChatSucceeded (rumor, next) ->
+ world <- next
+ calls <- calls + 1L
+ if rumor.IsSome then created <- created + 1L
+ | ChatRejected _ -> ()
+ stopwatch.Stop()
+ let allocatedAfter = GC.GetAllocatedBytesForCurrentThread()
+ { Seed = config.Seed
+ NpcCount = config.NpcCount
+ Days = config.Days
+ ChatsPerDay = config.ChatsPerDay
+ ChatCalls = calls
+ RumorsCreated = created
+ RumorsRetained = world.Rumors.Length
+ RetainedIdChecksum = retainedChecksum world.Rumors
+ AllocatedBytes = allocatedAfter - allocatedBefore
+ ElapsedMs = stopwatch.Elapsed.TotalMilliseconds }
+
+ let format (result: Result) : string =
+ sprintf
+ "rumor_bench seed=%d npcs=%d days=%d chats_per_day=%d chat_calls=%d rumors_created=%d rumors_retained=%d retained_id_checksum=%d allocated_bytes=%d elapsed_ms=%.3f"
+ result.Seed result.NpcCount result.Days result.ChatsPerDay result.ChatCalls result.RumorsCreated
+ result.RumorsRetained result.RetainedIdChecksum result.AllocatedBytes result.ElapsedMs