acdream/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
Erik 7b456b49d6 perf(diag): per-frame history export + checkpoint LOH/cache counters + soak capped mode (2026-07-24 audit review)
An adversarial performance review found our own instruments cannot
measure the project's own performance gates:

- FrameProfiler aggregated CPU/GPU/alloc/stage samples into ~5-second
  windows and reset the ring buffers after each report, so route-wide
  p50/p95/p99 distributions across a whole soak could not be
  reconstructed after the fact. ACDREAM_FRAME_HISTORY=<path> now opts
  into a separate per-frame history (one record per frame, ~72
  bytes/record, accumulated in memory with zero frame-thread I/O) that
  a shutdown-only Dispose() writes as CSV. The aggregated [frame-prof]
  report format and its existing metrics are unchanged.

- The canonical checkpoint JSON tracked cache residency (entry/byte
  counts) but never LOH size/fragmentation, process-wide allocated
  bytes, or cache hit/miss/eviction traffic — a committed audit JSON
  showed 65% LOH fragmentation that no tracked instrument recorded,
  and "does a revisit portal hit or miss the caches" was unanswerable
  from an artifact alone. WorldLifecycleResourceSnapshot now carries
  loh_size_bytes/loh_fragmentation_bytes (GCMemoryInfo.GenerationInfo
  index 3), process_total_allocated_bytes (GC.GetTotalAllocatedBytes),
  and Interlocked hit/miss/eviction counters for the CPU mesh cache,
  decoded-texture cache, and the four bounded DAT-object caches
  (portal/cell/highRes/language, aggregated).

- run-connected-r6-soak.ps1 unconditionally forced
  ACDREAM_UNCAPPED_RENDER=1 with no capped mode, while its sibling
  lifecycle-gate script correctly gated it behind a switch. Added
  -Uncapped (default capped, matching the sibling script's pattern),
  fixed the stationary dwell (12s -> 26s, past the 25s
  LiveEntityLivenessController deadline the adjacent comment already
  cited), and now write an env-disclosure.json into the automation
  artifact directory before every launch listing every ACDREAM_* var
  the script sets plus -Uncapped, since the prior audit could only see
  ACDREAM_DUMP_MOVE_TRUTH and nothing else was ever recorded anywhere.

Cache counters are wired via the existing composition path
(ObjectMeshManager already owns the CPU mesh cache and the mesh
extractor directly; content.Dats is threaded into
WorldLifecycleResourceSnapshotSource the same way every other
composition consumer receives it). The DAT-object cache lives behind
IDatReaderWriter, a third-party interface from the DatReaderWriter
package that cannot be extended; RuntimeDatCollection (the one
production implementation) exposes the aggregate stats directly and a
pattern match reads them, degrading to zero for any test double —
no new static registry was introduced (GpuMemoryTracker remains the
one precedented process-wide static).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1da2c33c875b41fa383dd79694ee2765f0e21896)
2026-07-24 12:00:30 +02:00

144 lines
4.9 KiB
C#

using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using System.Collections.Concurrent;
namespace AcDream.Content.Tests;
public sealed class BoundedDatObjectCacheTests
{
[Fact]
public void GetOrAdd_EvictsLeastRecentlyUsedEntryAtEntryLimit()
{
var cache = CreateCache(entryLimit: 2, byteLimit: 100);
var first = Surface(1);
var second = Surface(2);
var third = Surface(3);
cache.GetOrAdd(1, first);
cache.GetOrAdd(2, second);
Assert.True(cache.TryGet<Surface>(1, out _)); // first is now hottest
cache.GetOrAdd(3, third);
Assert.True(cache.TryGet<Surface>(1, out var retainedFirst));
Assert.Same(first, retainedFirst);
Assert.False(cache.TryGet<Surface>(2, out _));
Assert.True(cache.TryGet<Surface>(3, out var retainedThird));
Assert.Same(third, retainedThird);
Assert.Equal(2, cache.Count);
}
[Fact]
public void GetOrAdd_EnforcesEstimatedByteBudgetAndDoesNotRetainOversizeEntry()
{
var cache = new BoundedDatObjectCache(
entryLimit: 10,
estimatedByteLimit: 10,
estimateRetainedBytes: value => value.Id);
cache.GetOrAdd(6, Surface(6));
cache.GetOrAdd(5, Surface(5));
Assert.False(cache.TryGet<Surface>(6, out _));
Assert.True(cache.TryGet<Surface>(5, out _));
Assert.Equal(5, cache.EstimatedBytes);
var oversize = Surface(11);
Assert.Same(oversize, cache.GetOrAdd(11, oversize));
Assert.False(cache.TryGet<Surface>(11, out _));
Assert.Equal(1, cache.Count);
Assert.Equal(5, cache.EstimatedBytes);
}
[Fact]
public void ProductionEstimator_ChargesKnownRenderSurfacePayload()
{
const int byteLimit = 512 * 1024;
var cache = new BoundedDatObjectCache(
entryLimit: 10,
estimatedByteLimit: byteLimit);
var oversizeTexture = new RenderSurface
{
Id = 12,
SourceData = new byte[byteLimit + 1],
};
Assert.Same(oversizeTexture, cache.GetOrAdd(12, oversizeTexture));
Assert.False(cache.TryGet<RenderSurface>(12, out _));
Assert.Equal(0, cache.Count);
Assert.Equal(0, cache.EstimatedBytes);
}
[Fact]
public void GetOrAdd_DuplicateKeyKeepsOneCanonicalObject()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var canonical = Surface(7);
var duplicate = Surface(7);
Assert.Same(canonical, cache.GetOrAdd(7, canonical));
Assert.Same(canonical, cache.GetOrAdd(7, duplicate));
Assert.Equal(1, cache.Count);
Assert.Equal(1, cache.EstimatedBytes);
}
[Fact]
public void GetOrAdd_ConcurrentDuplicateKeyPublishesOneCanonicalObject()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var returned = new ConcurrentBag<Surface>();
Parallel.For(0, 1_000, i =>
{
returned.Add(cache.GetOrAdd(42, Surface((uint)(1_000 + i))));
});
Surface[] results = returned.ToArray();
Assert.NotEmpty(results);
Surface canonical = results[0];
Assert.All(results, value => Assert.Same(canonical, value));
Assert.True(cache.TryGet<Surface>(42, out var retained));
Assert.Same(canonical, retained);
Assert.Equal(1, cache.Count);
}
[Fact]
public void SameFileIdWithDifferentRequestedTypes_DoesNotAlias()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var surface = Surface(99);
var palette = new Palette { Id = 99 };
cache.GetOrAdd<Surface>(99, surface);
cache.GetOrAdd<Palette>(99, palette);
Assert.True(cache.TryGet<Surface>(99, out var retainedSurface));
Assert.True(cache.TryGet<Palette>(99, out var retainedPalette));
Assert.Same(surface, retainedSurface);
Assert.Same(palette, retainedPalette);
Assert.Equal(2, cache.Count);
}
[Fact]
public void Stats_TracksHitsMissesAndEvictions()
{
// 2026-07-24 measurement-tooling review: the checkpoint JSON exposes
// cache traffic (not just residency) so a revisit's hit/miss ratio
// is auditable from an artifact alone.
var cache = CreateCache(entryLimit: 1, byteLimit: 100);
Assert.False(cache.TryGet<Surface>(1, out _)); // miss
cache.GetOrAdd(1, Surface(1));
Assert.True(cache.TryGet<Surface>(1, out _)); // hit
cache.GetOrAdd(2, Surface(2)); // entryLimit=1 evicts id 1
CacheStats stats = cache.Stats;
Assert.Equal(1, stats.Hits);
Assert.Equal(1, stats.Misses);
Assert.Equal(1, stats.Evictions);
}
private static BoundedDatObjectCache CreateCache(int entryLimit, long byteLimit) =>
new(entryLimit, byteLimit, _ => 1);
private static Surface Surface(uint id) => new() { Id = id };
}