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)
144 lines
4.9 KiB
C#
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 };
|
|
}
|