summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Headless/Program.fs26
-rw-r--r--src/LivingVillage.Kernel.Tests/RelationTests.fs4
-rw-r--r--src/LivingVillage.Kernel.Tests/RumorTests.fs2
-rw-r--r--src/LivingVillage.Kernel.Tests/TradeTests.fs2
-rw-r--r--src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj1
-rw-r--r--src/LivingVillage.Kernel/RumorBench.fs109
-rw-r--r--src/LivingVillage.Kernel/Sim.fs73
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs3
8 files changed, 205 insertions, 15 deletions
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.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs
index b98252d..1a6d178 100644
--- a/src/LivingVillage.Kernel.Tests/RelationTests.fs
+++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs
@@ -47,6 +47,8 @@ module RelationHarness =
Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |]
Events = []
Rumors = []
+ RumorCount = 0
+ RumorOldestDay = System.Int64.MaxValue
Annals = [] }
let chatted (tick: int64) (partner: int) : MemoryEvent =
@@ -208,6 +210,8 @@ type RelationTests () =
Npcs = [| self; nearest; alternate |]
Events = []
Rumors = []
+ RumorCount = 0
+ RumorOldestDay = System.Int64.MaxValue
Annals = [] }
let next = Sim.step RelationHarness.zeroInput world
match List.tryHead next.Npcs.[0].Mind.Memory with
diff --git a/src/LivingVillage.Kernel.Tests/RumorTests.fs b/src/LivingVillage.Kernel.Tests/RumorTests.fs
index 2cbb189..e8cf07a 100644
--- a/src/LivingVillage.Kernel.Tests/RumorTests.fs
+++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs
@@ -52,6 +52,8 @@ module private RumorHarness =
Npcs = Array.init count npc
Events = []
Rumors = []
+ RumorCount = 0
+ RumorOldestDay = System.Int64.MaxValue
Annals = [] }
let readyForChat (world: World) : World =
diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs
index 77e5658..fa40ac3 100644
--- a/src/LivingVillage.Kernel.Tests/TradeTests.fs
+++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs
@@ -56,6 +56,8 @@ module private TradeHarness =
npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |]
Events = []
Rumors = []
+ RumorCount = 0
+ RumorOldestDay = System.Int64.MaxValue
Annals = [] }
let request quantity =
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