acdream/tests/AcDream.Content.Tests/DecodedTextureCacheTests.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

171 lines
5.9 KiB
C#

using AcDream.Content;
using Xunit;
namespace AcDream.Content.Tests;
public sealed class DecodedTextureCacheTests {
private static DecodedTextureKey Key(
uint id,
bool clip = false,
bool additive = false) => new(id, clip, additive);
[Fact]
public void RetainOrUse_EvictsLeastRecentlyUsedEntryToMeetByteBudget() {
var cache = new DecodedTextureCache(maximumBytes: 8, maximumEntries: 8);
var first = new byte[4];
var second = new byte[4];
var third = new byte[4];
Assert.Same(first, cache.RetainOrUse(Key(1), first, out var firstCached));
Assert.Same(second, cache.RetainOrUse(Key(2), second, out var secondCached));
Assert.True(cache.TryGet(Key(1), out _)); // ID 2 is now least recently used.
Assert.Same(third, cache.RetainOrUse(Key(3), third, out var thirdCached));
Assert.True(firstCached);
Assert.True(secondCached);
Assert.True(thirdCached);
Assert.True(cache.TryGet(Key(1), out _));
Assert.False(cache.TryGet(Key(2), out _));
Assert.True(cache.TryGet(Key(3), out _));
Assert.Equal(8, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_DoesNotAdmitOversizedEntry() {
var cache = new DecodedTextureCache(maximumBytes: 3, maximumEntries: 8);
var pixels = new byte[4];
Assert.Same(pixels, cache.RetainOrUse(Key(1), pixels, out var isCached));
Assert.False(isCached);
Assert.False(cache.TryGet(Key(1), out _));
Assert.Equal(0, cache.Count);
Assert.Equal(0, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_ExistingCanonicalEntryWinsConcurrentDecodeRace() {
var cache = new DecodedTextureCache(maximumBytes: 16, maximumEntries: 8);
var canonical = new byte[4];
var duplicate = new byte[4];
cache.RetainOrUse(Key(1), canonical, out _);
var result = cache.RetainOrUse(Key(1), duplicate, out var isCached);
Assert.True(isCached);
Assert.Same(canonical, result);
Assert.Equal(1, cache.Count);
Assert.Equal(4, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_EnforcesEntryBudget() {
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 2);
cache.RetainOrUse(Key(1), new byte[1], out _);
cache.RetainOrUse(Key(2), new byte[1], out _);
cache.RetainOrUse(Key(3), new byte[1], out _);
Assert.False(cache.TryGet(Key(1), out _));
Assert.True(cache.TryGet(Key(2), out _));
Assert.True(cache.TryGet(Key(3), out _));
Assert.Equal(2, cache.Count);
}
[Fact]
public void DecodeAffectingSurfaceFlags_DoNotAliasSameRenderSurface()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
var opaque = new byte[] { 1 };
var clip = new byte[] { 2 };
var additive = new byte[] { 3 };
cache.RetainOrUse(Key(1), opaque, out _);
cache.RetainOrUse(Key(1, clip: true), clip, out _);
cache.RetainOrUse(Key(1, additive: true), additive, out _);
Assert.Same(opaque, AssertCacheHit(cache, Key(1)));
Assert.Same(clip, AssertCacheHit(cache, Key(1, clip: true)));
Assert.Same(additive, AssertCacheHit(cache, Key(1, additive: true)));
}
[Fact]
public async Task GetOrCreate_ConcurrentMissRunsFactoryOnce()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
int calls = 0;
using var release = new ManualResetEventSlim();
Task<byte[]>[] readers = Enumerable.Range(0, 8)
.Select(readerIndex => Task.Run(() => cache.GetOrCreate(
Key(1),
() =>
{
Interlocked.Increment(ref calls);
release.Wait();
return new byte[] { 1, 2, 3, 4 };
},
out _)))
.ToArray();
Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref calls) == 1, 5_000));
release.Set();
byte[][] results = await Task.WhenAll(readers);
Assert.Equal(1, calls);
Assert.All(results, result => Assert.Same(results[0], result));
}
[Fact]
public void GetOrCreate_FailedFactoryCanRetry()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
int calls = 0;
Assert.Throws<InvalidOperationException>(() => cache.GetOrCreate(
Key(1),
() =>
{
calls++;
throw new InvalidOperationException("decode failed");
},
out _));
byte[] recovered = cache.GetOrCreate(
Key(1),
() =>
{
calls++;
return new byte[] { 1 };
},
out var retained);
Assert.Equal(2, calls);
Assert.True(retained);
Assert.Equal(new byte[] { 1 }, recovered);
}
[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 = new DecodedTextureCache(maximumBytes: 4, maximumEntries: 1);
Assert.False(cache.TryGet(Key(1), out _)); // miss
cache.RetainOrUse(Key(1), new byte[4], out _);
Assert.True(cache.TryGet(Key(1), out _)); // hit
cache.RetainOrUse(Key(2), new byte[4], out _); // maximumEntries=1 evicts key 1
CacheStats stats = cache.Stats;
Assert.Equal(1, stats.Hits);
Assert.Equal(1, stats.Misses);
Assert.Equal(1, stats.Evictions);
}
private static byte[] AssertCacheHit(DecodedTextureCache cache, DecodedTextureKey key)
{
Assert.True(cache.TryGet(key, out byte[] pixels));
return pixels;
}
}