diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:36:39 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:36:39 +0800 |
| commit | 164f4749460a304c1aaad4900ef3facc29859eb0 (patch) | |
| tree | 05794b5f853566d68ebefc822b3dce3b5f780ca3 /src/LivingVillage.Kernel/RumorBench.fs | |
| parent | b377e750a6a3f35c5d64a29f14ac3fbd87d4a752 (diff) | |
| download | living-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 小节记录上述现状与量级。不改任何模拟语义。
Diffstat (limited to 'src/LivingVillage.Kernel/RumorBench.fs')
| -rw-r--r-- | src/LivingVillage.Kernel/RumorBench.fs | 109 |
1 files changed, 109 insertions, 0 deletions
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 |
