diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:56:04 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:56:04 +0800 |
| commit | 1b0724d62baa3cf5598d374268e7eb09b5dde4b4 (patch) | |
| tree | 02237d752d4fd86bff8da2da7a721fe32e41d8bf /src/LivingVillage.Kernel | |
| parent | 53ac3ac3ae373e40edc01d58a697ffc10a079129 (diff) | |
| parent | 593b3a69294f2c93df1e14d4be7d2f4cd9f9ac14 (diff) | |
| download | living-village-1b0724d62baa3cf5598d374268e7eb09b5dde4b4.tar.gz | |
merge: P27 Rumor 长局成本修复(去装箱扫描 + O(1) 裁剪 + rumor-bench)— Hermes 复验通过
Diffstat (limited to 'src/LivingVillage.Kernel')
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/RumorBench.fs | 109 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 73 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 3 |
4 files changed, 172 insertions, 14 deletions
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 diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index cedadfa..5c4e107 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -192,6 +192,12 @@ module Sim = Npcs: Npc[] Events: InteractionEvent list Rumors: RumorEvent list + /// 派生镜像:工作集条数(= Rumors.Length)。不参与存档序列化,读档由列表重建; + /// 用于把每追加一条都要全表扫描的裁剪判定降为 O(1)。 + RumorCount: int + /// 派生镜像:工作集内最小 DayIndex(空表为 Int64.MaxValue)。语义等价于 + /// `rumorsNeedTrim` 的「存在早于保留窗口的条目」判定。 + RumorOldestDay: int64 Annals: AnnalEntry list } type DialogueFailure = @@ -481,6 +487,8 @@ module Sim = Npcs = [| npc |] Events = [] Rumors = [] + RumorCount = 0 + RumorOldestDay = System.Int64.MaxValue Annals = [] } let initialWorldN (seed: uint64) (count: int) : World = @@ -680,19 +688,49 @@ module Sim = else rumors - /// 追加一条谣言并顺手裁剪工作集;容量内且新鲜时等价于 `rumor :: rumors`。 - let private consRumor (nowTick: int64) (rumor: RumorEvent) (rumors: RumorEvent list) : RumorEvent list = - trimRumors nowTick (rumor :: rumors) + /// 工作集派生统计:(条数, 最小 DayIndex;空表为 Int64.MaxValue)。仅裁剪/读档等非热路径调用。 + let rumorWorkingSetStats (rumors: RumorEvent list) : int * int64 = + rumors.Length, + (rumors |> List.fold (fun acc rumor -> min acc rumor.DayIndex) System.Int64.MaxValue) + + let private oldestRumorDay (rumors: RumorEvent list) : int64 = snd (rumorWorkingSetStats rumors) + + /// 追加一条谣言并维护派生元数据。容量内且新鲜时等价于 `rumor :: rumors`,且**不再全表扫描** + /// 判断是否需要裁剪(旧 `consRumor` 每次追加都会 `rumorsNeedTrim` 走一遍工作集); + /// 仅当条数超容量或存在早于保留窗口的条目时才走 `trimRumors`,保留集合与旧实现逐条相同。 + let private appendRumor (nowTick: int64) (rumor: RumorEvent) (world: World) : World = + let cutoffDay = max 0L (rumorDayIndex nowTick - rumorRetentionDays) + let nextCount = world.RumorCount + 1 + let nextOldest = min world.RumorOldestDay rumor.DayIndex + if nextCount > rumorCapacity || nextOldest < cutoffDay then + let trimmed = trimRumors nowTick (rumor :: world.Rumors) + { world with + Rumors = trimmed + RumorCount = trimmed.Length + RumorOldestDay = oldestRumorDay trimmed } + else + { world with + Rumors = rumor :: world.Rumors + RumorCount = nextCount + RumorOldestDay = nextOldest } + + /// `NpcId`/`RumorId` 是 `[<Struct>]` 单例判别联合:直接用 `=` 比较会走泛型结构相等并 + /// 在每次比较时为两侧装箱(实测 ~24 B/次)。在按 tick 全表扫描的热路径里逐条比较, + /// 这是 Rumor 分配的主要来源。这里改为先解构出底层 int/int64 再比较(语义完全等价, + /// 仅去掉装箱),扫描不再产生逐条分配。 + let inline private npcIdValue (NpcId id) : int = id + let inline private rumorIdValue (RumorId id) : int64 = id let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option = // 工作集最新在前且 Tick 非递增:第一条命中的即“Tick 最大、Id 最大”,与原实现 // (filter 后 sortByDescending 取头)等价;窗口外无分配提前返回。 + let receiverId = npcIdValue receiver let rec loop (rest: RumorEvent list) : RumorEvent option = match rest with | [] -> None | rumor :: tail -> if world.Tick - rumor.Tick > rumorFreshnessTicks then None - elif rumor.Receiver = receiver + elif npcIdValue rumor.Receiver = receiverId && rumor.Tick <= world.Tick && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then Some rumor else loop tail @@ -783,11 +821,13 @@ module Sim = let avatarMemory = recordMemory world.Tick (Dialogue(target, intent, response)) world.Avatar.Mind.Memory let event = { Tick = world.Tick; Kind = DialogueEvent outcome } let updated = - { world with - Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } } - Npcs = newNpcs - Events = world.Events @ [ event ] - Rumors = consRumor world.Tick rumor world.Rumors } + appendRumor + world.Tick + rumor + { world with + Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } } + Npcs = newNpcs + Events = world.Events @ [ event ] } let withRumorAnnal = appendAnnal { Tick = world.Tick @@ -826,16 +866,21 @@ module Sim = RecentMemory = List.truncate 8 npc.Mind.Memory }) let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool = - let parentId = parent |> Option.map (fun rumor -> rumor.Id) + let parentId = parent |> Option.map (fun rumor -> rumorIdValue rumor.Id) + let narratorId = npcIdValue request.Narrator + let receiverId = npcIdValue request.Receiver // 窗口外条目强度必 < rumorMinimumStrength,与原全表 exists 等价;命中或越窗即无分配返回。 + // `Parent` 用底层 int64 比较而非 `RumorId option` 结构相等,避免逐条装箱。 let rec loop (rest: RumorEvent list) : bool = match rest with | [] -> false | rumor :: tail -> if world.Tick - rumor.Tick > rumorFreshnessTicks then false - elif rumor.Narrator = request.Narrator - && rumor.Receiver = request.Receiver - && rumor.Parent = parentId + elif npcIdValue rumor.Narrator = narratorId + && npcIdValue rumor.Receiver = receiverId + && (match rumor.Parent with + | Some parentRumor -> parentId = Some(rumorIdValue parentRumor) + | None -> parentId.IsNone) && rumor.Tick <= world.Tick && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then true else loop tail @@ -936,7 +981,7 @@ module Sim = { npc with Mind = { npc.Mind with Memory = recordMemory world.Tick (Rumor rumor.Id) npc.Mind.Memory } } { chatted with Npcs = newNpcs } - let updated = { withReceiverRumor with Rumors = consRumor world.Tick rumor world.Rumors } + let updated = appendRumor world.Tick rumor withReceiverRumor ChatSucceeded( Some rumor, appendAnnal diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index f1b0be3..b3bdfa9 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -645,6 +645,7 @@ module WorldSave = let events = List.init eventCount (fun index -> readInteraction reader (sprintf "event[%d]" index)) let rumorCount = readCount reader "rumors" let rumors = List.init rumorCount (fun index -> readRumor reader (sprintf "rumor[%d]" index)) + let rumorStats = rumorWorkingSetStats rumors let annalCount = readCount reader "annals" let annals = List.init annalCount (fun index -> readAnnal reader (sprintf "annal[%d]" index)) let todayTask = @@ -667,6 +668,8 @@ module WorldSave = Npcs = npcs Events = events Rumors = rumors + RumorCount = fst rumorStats + RumorOldestDay = snd rumorStats Annals = annals }, occupation) with |
