diff options
| -rw-r--r-- | docs/维护说明.md | 25 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 26 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/RumorBench.fs | 109 |
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 |
