summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Headless/LivingVillage.Headless.fsproj1
-rw-r--r--src/LivingVillage.Headless/PerformanceProbe.fs214
-rw-r--r--src/LivingVillage.Headless/Program.fs12
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj2
-rw-r--r--src/LivingVillage.Kernel.Tests/PerformanceTests.fs51
5 files changed, 277 insertions, 3 deletions
diff --git a/src/LivingVillage.Headless/LivingVillage.Headless.fsproj b/src/LivingVillage.Headless/LivingVillage.Headless.fsproj
index 612b584..d9cd0ab 100644
--- a/src/LivingVillage.Headless/LivingVillage.Headless.fsproj
+++ b/src/LivingVillage.Headless/LivingVillage.Headless.fsproj
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
+ <Compile Include="PerformanceProbe.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Headless/PerformanceProbe.fs b/src/LivingVillage.Headless/PerformanceProbe.fs
new file mode 100644
index 0000000..4641fb4
--- /dev/null
+++ b/src/LivingVillage.Headless/PerformanceProbe.fs
@@ -0,0 +1,214 @@
+namespace LivingVillage.Headless
+
+open System
+open System.Diagnostics
+open System.Runtime.InteropServices
+open System.Security.Cryptography
+open System.Text
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+/// 性能基线的固定输入配置。
+///
+/// 所有 tick 数均为模拟 tick;输入序列由本模块按固定八步模式生成,不依赖墙上时钟、
+/// 全局随机数或线程局部状态。`WarmupTicks` 不计入样本,`MeasureTicks` 只计量
+/// `Sim.step` 调用循环,`Repetitions` 由命令入口控制重复次数。
+type PerformanceConfiguration =
+ { Seed: uint64
+ NpcCount: int
+ WarmupTicks: int
+ MeasureTicks: int
+ Repetitions: int }
+
+/// 一次性能样本及其完整测量口径。
+///
+/// `ElapsedStopwatchTicks` 使用 `Stopwatch.Frequency` 解释;分配量是当前测量线程的
+/// `GC.GetAllocatedBytesForCurrentThread` 增量;GC 次数是测量前后进程计数器的差值。
+/// `FinalDigest` 只用于证明重复运行的最终快照一致,不是安全校验和。
+type PerformanceSample =
+ { Seed: uint64
+ NpcCount: int
+ WarmupTicks: int
+ MeasureTicks: int
+ ElapsedStopwatchTicks: int64
+ StopwatchFrequency: int64
+ AllocatedBytes: int64
+ Gen0Collections: int64
+ Gen1Collections: int64
+ Gen2Collections: int64
+ FinalTick: int64
+ FinalDigest: string
+ RuntimeDescription: string
+ RuntimeVersion: string }
+
+module PerformanceProbe =
+
+ /// 固定的基线配置;本次 Task 0 的原始证据必须使用此配置或明确记录覆盖值。
+ let defaultConfiguration : PerformanceConfiguration =
+ { Seed = 42UL
+ NpcCount = 4
+ WarmupTicks = 120
+ MeasureTicks = 6000
+ Repetitions = 3 }
+
+ /// 验证探针配置。失败返回中文/英文混合的稳定字段名,便于命令行和测试定位。
+ let validateConfiguration (configuration: PerformanceConfiguration) : Result<unit, string> =
+ if configuration.NpcCount <= 0 then
+ Error "NpcCount must be greater than zero"
+ elif configuration.NpcCount > 1000 then
+ Error "NpcCount must be at most 1000"
+ elif configuration.WarmupTicks < 0 then
+ Error "WarmupTicks must not be negative"
+ elif configuration.MeasureTicks <= 0 then
+ Error "MeasureTicks must be greater than zero"
+ elif configuration.Repetitions <= 0 then
+ Error "Repetitions must be greater than zero"
+ else
+ Ok ()
+
+ let private requireValid (configuration: PerformanceConfiguration) : unit =
+ match validateConfiguration configuration with
+ | Ok () -> ()
+ | Error message -> invalidArg (nameof configuration) message
+
+ let private inputAt (index: int) : Input =
+ let diagonal = 0.70710678f
+ match index % 8 with
+ | 0 -> { MoveX = 1.0f; MoveY = 0.0f }
+ | 1 -> { MoveX = diagonal; MoveY = diagonal }
+ | 2 -> { MoveX = 0.0f; MoveY = 1.0f }
+ | 3 -> { MoveX = -diagonal; MoveY = diagonal }
+ | 4 -> { MoveX = -1.0f; MoveY = 0.0f }
+ | 5 -> { MoveX = -diagonal; MoveY = -diagonal }
+ | 6 -> { MoveX = 0.0f; MoveY = -1.0f }
+ | _ -> { MoveX = diagonal; MoveY = -diagonal }
+
+ let private inputSequence (configuration: PerformanceConfiguration) : Input[] =
+ let count = max 1 (configuration.WarmupTicks + configuration.MeasureTicks)
+ Array.init count inputAt
+
+ /// 运行固定输入序列并返回最终 World 快照。
+ ///
+ /// 输入:合法的 `PerformanceConfiguration`;单位为模拟 tick,NPC 数为实体数量。
+ /// 输出:包含预热和测量 tick 的最终快照。函数只使用局部 `world` 和输入数组,
+ /// 不修改调用方快照,也不写入正式 headless 批验状态。`World.Npcs` 只读;每次
+ /// `Sim.step` 返回的新数组视为逻辑快照,step 内部的 `Array.copy`、嵌套 struct
+ /// 复制和 `ResizeArray` 暂存只在该 tick 生命周期内有效。非法配置抛出
+ /// `ArgumentException`;调用方可先用 `validateConfiguration` 获取 `Result`。
+ let runSteps (configuration: PerformanceConfiguration) : World =
+ requireValid configuration
+ let inputs = inputSequence configuration
+ let totalTicks = configuration.WarmupTicks + configuration.MeasureTicks
+ let mutable world = Sim.initialWorldN configuration.Seed configuration.NpcCount
+ for tick in 0 .. totalTicks - 1 do
+ world <- Sim.step { Input = inputs.[tick % inputs.Length] } world
+ world
+
+ let private runStepsFrom
+ (inputs: Input[])
+ (startIndex: int)
+ (steps: int)
+ (world: World)
+ : World =
+ let mutable next = world
+ if steps > 0 then
+ for offset in 0 .. steps - 1 do
+ next <- Sim.step { Input = inputs.[(startIndex + offset) % inputs.Length] } next
+ next
+
+ /// 为最终快照生成稳定摘要;摘要计算发生在计时区间之外。
+ ///
+ /// `WorldSave.save` 提供完整且有序的快照序列化,SHA-256 只压缩输出长度,
+ /// 不参与模拟和性能计时;若存档格式改变,摘要也应随之改变并触发回归。
+ let worldDigest (world: World) : string =
+ let encoded = WorldSave.save world
+ use sha = SHA256.Create()
+ encoded
+ |> Encoding.UTF8.GetBytes
+ |> sha.ComputeHash
+ |> Convert.ToHexString
+
+ /// 对一个固定配置执行预热和单次测量。
+ ///
+ /// 计时区间只包含从预热后快照开始的 `MeasureTicks` 次 `Sim.step`;输入数组、
+ /// 初始世界、预热和 GC 归零动作均在计时区间外。由于 GC 计数器是进程级计数器,
+ /// 该探针应以单进程、单测量线程运行;输出必须连同运行时和配置保存。
+ let measure (configuration: PerformanceConfiguration) : PerformanceSample =
+ requireValid configuration
+ let inputs = inputSequence configuration
+ let initial = Sim.initialWorldN configuration.Seed configuration.NpcCount
+ let warmed = runStepsFrom inputs 0 configuration.WarmupTicks initial
+
+ GC.Collect(2, GCCollectionMode.Forced, true, true)
+ GC.WaitForPendingFinalizers()
+ GC.Collect(2, GCCollectionMode.Forced, true, true)
+
+ let allocatedBefore = GC.GetAllocatedBytesForCurrentThread()
+ let gen0Before = GC.CollectionCount(0)
+ let gen1Before = GC.CollectionCount(1)
+ let gen2Before = GC.CollectionCount(2)
+ let stopwatch = Stopwatch.StartNew()
+ let finalWorld = runStepsFrom inputs configuration.WarmupTicks configuration.MeasureTicks warmed
+ stopwatch.Stop()
+ let allocatedAfter = GC.GetAllocatedBytesForCurrentThread()
+
+ { Seed = configuration.Seed
+ NpcCount = configuration.NpcCount
+ WarmupTicks = configuration.WarmupTicks
+ MeasureTicks = configuration.MeasureTicks
+ ElapsedStopwatchTicks = stopwatch.ElapsedTicks
+ StopwatchFrequency = Stopwatch.Frequency
+ AllocatedBytes = allocatedAfter - allocatedBefore
+ Gen0Collections = int64 (GC.CollectionCount(0) - gen0Before)
+ Gen1Collections = int64 (GC.CollectionCount(1) - gen1Before)
+ Gen2Collections = int64 (GC.CollectionCount(2) - gen2Before)
+ FinalTick = finalWorld.Tick
+ FinalDigest = worldDigest finalWorld
+ RuntimeDescription = RuntimeInformation.FrameworkDescription
+ RuntimeVersion = Environment.Version.ToString() }
+
+ /// 将单次样本格式化为可直接保存的完整 key/value 行;不隐藏实际测量数字。
+ let formatSample (sample: PerformanceSample) : string =
+ let elapsedSeconds =
+ if sample.StopwatchFrequency > 0L then
+ float sample.ElapsedStopwatchTicks / float sample.StopwatchFrequency
+ else
+ 0.0
+ let throughput =
+ if elapsedSeconds > 0.0 then float sample.MeasureTicks / elapsedSeconds else 0.0
+ sprintf
+ "seed=%d npc_count=%d warmup_ticks=%d measure_ticks=%d elapsed_stopwatch_ticks=%d stopwatch_frequency=%d elapsed_seconds=%.9f ticks_per_second=%.3f allocated_bytes=%d gen0=%d gen1=%d gen2=%d final_tick=%d final_digest=%s runtime=%s runtime_version=%s"
+ sample.Seed sample.NpcCount sample.WarmupTicks sample.MeasureTicks
+ sample.ElapsedStopwatchTicks sample.StopwatchFrequency elapsedSeconds throughput
+ sample.AllocatedBytes sample.Gen0Collections sample.Gen1Collections sample.Gen2Collections
+ sample.FinalTick sample.FinalDigest sample.RuntimeDescription sample.RuntimeVersion
+
+ /// 执行默认三次重复测量并打印配置、样本和确定性结论。
+ ///
+ /// 返回 0 表示所有重复样本的最终摘要一致;返回 1 表示摘要不一致;非法配置
+ /// 不会静默继续。该入口只由显式 `--performance-baseline` 调用,不改变 `--batch`。
+ let runDefault () : int =
+ let configuration = defaultConfiguration
+ match validateConfiguration configuration with
+ | Error message ->
+ eprintfn "performance_baseline=INVALID configuration=%s" message
+ 2
+ | Ok () ->
+ printfn
+ "performance_config seed=%d npc_count=%d warmup_ticks=%d measure_ticks=%d repetitions=%d input_pattern=8-direction-cyclic"
+ configuration.Seed configuration.NpcCount configuration.WarmupTicks configuration.MeasureTicks
+ configuration.Repetitions
+ printfn "performance_runtime framework=%s version=%s os=%s architecture=%s stopwatch_frequency=%d"
+ RuntimeInformation.FrameworkDescription (Environment.Version.ToString())
+ RuntimeInformation.OSDescription (RuntimeInformation.ProcessArchitecture.ToString()) Stopwatch.Frequency
+ let mutable firstDigest = None
+ let mutable deterministic = true
+ for repetition in 1 .. configuration.Repetitions do
+ let sample = measure configuration
+ printfn "performance_sample repetition=%d %s" repetition (formatSample sample)
+ match firstDigest with
+ | None -> firstDigest <- Some sample.FinalDigest
+ | Some digest when digest <> sample.FinalDigest -> deterministic <- false
+ | Some _ -> ()
+ printfn "performance_determinism=%s" (if deterministic then "PASS" else "FAIL")
+ if deterministic then 0 else 1
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index e34ecc0..f5f9c5b 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -367,8 +367,7 @@ let runBatch (worlds: int) (days: int64) : int =
printfn "M3_ACCEPTANCE=%s" (if allPass then "PASS" else "FAIL")
if allPass then 0 else 1
-[<EntryPoint>]
-let main argv =
+let runExisting (argv: string[]) =
let rec parse
(i: int)
(days: int64 option)
@@ -431,5 +430,12 @@ let main argv =
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 | --batch K D"
+ eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --batch K D | --performance-baseline"
2
+
+[<EntryPoint>]
+let main argv =
+ if argv.Length = 1 && argv.[0] = "--performance-baseline" then
+ LivingVillage.Headless.PerformanceProbe.runDefault ()
+ else
+ runExisting argv
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
index b30fc8f..f618dc9 100644
--- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
+++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
@@ -12,6 +12,7 @@
<Compile Include="TradeTests.fs" />
<Compile Include="M5Tests.fs" />
<Compile Include="M6aTests.fs" />
+ <Compile Include="PerformanceTests.fs" />
</ItemGroup>
<ItemGroup>
@@ -22,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\LivingVillage.Kernel\LivingVillage.Kernel.fsproj" />
+ <ProjectReference Include="..\LivingVillage.Headless\LivingVillage.Headless.fsproj" />
</ItemGroup>
</Project>
diff --git a/src/LivingVillage.Kernel.Tests/PerformanceTests.fs b/src/LivingVillage.Kernel.Tests/PerformanceTests.fs
new file mode 100644
index 0000000..c7e65e4
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/PerformanceTests.fs
@@ -0,0 +1,51 @@
+namespace LivingVillage.Kernel.Tests
+
+open System
+open System.Diagnostics
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Headless
+
+[<TestClass>]
+type PerformanceTests () =
+
+ let testConfiguration =
+ { PerformanceProbe.defaultConfiguration with
+ WarmupTicks = 32
+ MeasureTicks = 256
+ Repetitions = 1 }
+
+ [<TestMethod>]
+ member _.FixedSeedAndInputSequenceProduceTheSameWorldDigest () =
+ let first = PerformanceProbe.runSteps testConfiguration
+ let second = PerformanceProbe.runSteps testConfiguration
+
+ Assert.AreEqual<int64>(
+ int64 (testConfiguration.WarmupTicks + testConfiguration.MeasureTicks),
+ first.Tick)
+ Assert.AreEqual<int>(testConfiguration.NpcCount, first.Npcs.Length)
+ Assert.AreEqual<string>(PerformanceProbe.worldDigest first, PerformanceProbe.worldDigest second)
+
+ [<TestMethod>]
+ member _.MeasurementReportsConfiguredTicksAndRuntimeCounters () =
+ let sample = PerformanceProbe.measure testConfiguration
+
+ Assert.AreEqual<int>(testConfiguration.MeasureTicks, sample.MeasureTicks)
+ Assert.AreEqual<int64>(int64 (testConfiguration.WarmupTicks + testConfiguration.MeasureTicks), sample.FinalTick)
+ Assert.AreEqual<int64>(Stopwatch.Frequency, sample.StopwatchFrequency)
+ Assert.IsTrue(sample.ElapsedStopwatchTicks > 0L)
+ Assert.IsTrue(sample.AllocatedBytes >= 0L)
+ Assert.IsTrue(sample.Gen0Collections >= 0L)
+ Assert.IsTrue(sample.Gen1Collections >= 0L)
+ Assert.IsTrue(sample.Gen2Collections >= 0L)
+ let formatted = PerformanceProbe.formatSample sample
+ Assert.IsTrue(formatted.Contains("ticks_per_second="))
+ Assert.IsTrue(formatted.Contains("allocated_bytes="))
+ Assert.IsTrue(formatted.Contains("gen0="))
+
+ [<TestMethod>]
+ member _.InvalidConfigurationReportsAUsefulFailure () =
+ let invalid = { testConfiguration with NpcCount = 0 }
+
+ match PerformanceProbe.validateConfiguration invalid with
+ | Ok () -> Assert.Fail("zero NPC configuration must be rejected")
+ | Error message -> Assert.IsTrue(message.Contains("NpcCount"))