diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d6731107..cf81dc07 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -750,17 +750,39 @@ cost reads as zero; a cost every tenth call is caught; and the stated limit — the batch must cover the period — is pinned rather than left as prose. Without those, a later edit could quietly make the whole family unfailable. -**Not yet done:** twelve further `GetAllocatedBytesForCurrentThread` sites in -`AcDream.App.Tests` still use the hand-rolled shape (`CellViewDedupTests`, -`EquippedChildProjectionWithdrawalTests`, `PortalProjectionTests`, -`RenderFrameRouteOwnerSelectorTests`, `StaticRenderProjectionJournalTests`, -`PackedProjectionClassificationCacheTests`, +**The family was larger than four.** The first attempt converted only the four +members the issue named and left the rest, on the grounds that none had been +observed failing. A 20-run complete-solution baseline immediately disproved +that: `LiveEntityRuntimeTests.AnimationView_HotSpatialTraversalDoesNot` +`AllocateAfterWarmup` failed in run 2 and +`StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization_Reuses` +`RetainedJournalStorage` in runs 14 and 18 — both the same shape, neither +previously recorded. "Not observed failing" only ever meant "not yet observed". + +So **every strict-zero site in the assembly is now on the probe** — ten tests: +the four named members plus `LiveEntityRuntimeTests`, +`StaticRenderProjectionJournalTests`, `RenderFrameRouteOwnerSelectorTests`, `GpuWorldStateRenderTraversalTests`, `UiTextLayoutCacheTests`, -`LiveEntityRuntimeTests`, `RetailInboundEventDispatcherTests`, and a second -site in `CurrentRenderSceneOracleTests`). None has been observed failing. -Each needs its own repeatability analysis — several mutate state or consume -monotonic sequences — so they were left alone rather than converted blind. -Adopt the probe when one is next touched, or immediately if it flakes. +`RetailInboundEventDispatcherTests`, `PackedProjectionClassificationCacheTests` +and `EquippedChildProjectionWithdrawalTests`. + +Two of those got stricter rather than merely steadier. +`StaticRenderProjectionJournalTests` turned out to be measuring a synchronise +whose journal **does not coalesce** — repeating it grew the journal by 1,000 +entries per call, so the steady state it claimed to test did not exist. Its step +is now the whole frame cycle, synchronise *and* drain, which puts `DrainTo` +inside the measured window for the first time. +`RetailInboundEventDispatcherTests` now counts its own dispatches and pins the +callback count against them, where before it asserted a hard-coded 1,001. + +**Deliberately not converted:** the four sites that assert a *tolerance* rather +than zero — `CellViewDedupTests` (two, `<= 256`) and `PortalProjectionTests` +(two, `<= 1_024` and `<= 4_096`). Their ceilings already absorb the noise this +issue is about, none has been observed failing, and touching their bounds in +either direction is a separate decision. `PortalProjectionTests` is worth +revisiting: its ceiling exists explicitly to tolerate "a tiered-JIT/ArrayPool +bookkeeping transition ... to the first measured batch", which is precisely what +the probe removes, so it could likely be tightened to zero on the probe now. --- diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs index 04d4bd16..4583b08d 100644 --- a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -484,18 +484,17 @@ public sealed class CurrentRenderSceneOracleTests CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint( overrides); RenderSceneHash128 actual = default; - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int iteration = 0; iteration < 10_000; iteration++) - { - actual = + // #250: the measured window was a 10,000-iteration loop written inline, + // which is exactly the shape on-stack replacement rewrites mid-flight, + // on this thread, inside the window. + ZeroAllocationProbe.AssertAllocatesNothing( + "CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint", + () => actual = CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint( - overrides); - } + overrides)); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.Equal(expected, actual); - Assert.Equal(0, allocated); } private static CurrentRenderProjectionFingerprint CaptureSingle( diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 52ae83ce..61ff1ca2 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -310,12 +310,11 @@ public sealed class EquippedChildProjectionWithdrawalTests fixture.Controller.Tick(); fixture.Controller.ReconcileSpatialMutations(); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - fixture.Controller.ReconcileSpatialMutations(); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + // #250: the measured window was a 1,000-iteration loop written inline. + ZeroAllocationProbe.AssertAllocatesNothing( + "LiveEntityController.ReconcileSpatialMutations", + () => fixture.Controller.ReconcileSpatialMutations()); - Assert.Equal(0L, allocated); Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits); } diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameRouteOwnerSelectorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameRouteOwnerSelectorTests.cs index 2f79c133..5a8b5810 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameRouteOwnerSelectorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameRouteOwnerSelectorTests.cs @@ -90,13 +90,13 @@ public sealed class RenderFrameRouteOwnerSelectorTests var owners = new HashSet(); owners.EnsureCapacity(16); - SelectOwners(owners, in view); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int iteration = 0; iteration < 1_000; iteration++) - SelectOwners(owners, in view); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + // #250: warmed once, then measured a 1,000-iteration loop written + // inline — the shape on-stack replacement rewrites mid-measurement. + RenderFrameView captured = view; + ZeroAllocationProbe.AssertAllocatesNothing( + "RenderFrameRouteOwnerSelector.SelectOwners", + () => SelectOwners(owners, in captured)); - Assert.Equal(0, allocated); Assert.Equal([50u], owners); exchange.Release(in view); } diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs index 1ddbbda7..88675706 100644 --- a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs @@ -358,19 +358,34 @@ public sealed class StaticRenderProjectionJournalTests } statics.SynchronizeActiveAnimatedSources(animated); journal.DrainTo(scene); - for (int index = 0; index < animated.Length; index++) - { - animated[index].Rotation = Quaternion.CreateFromAxisAngle( - Vector3.UnitZ, - 0.5f); - } - long before = GC.GetAllocatedBytesForCurrentThread(); + // #250: one warm call is not enough to have paid the callee tree's + // first-call costs. The step is the whole steady-state frame cycle — + // advance the rotations, synchronise, drain — rather than a bare + // synchronise, for two reasons. The rotations must actually change on + // every invocation or the synchronise early-outs and the probe measures + // nothing; and the journal does not coalesce, so without the drain it + // grows by `count` entries per invocation and the thing under test + // stops being steady state at all. Draining also makes this stricter + // than the original: `DrainTo` is now inside the measured window. + float angle = 0.5f; + ZeroAllocationProbe.AssertAllocatesNothing( + "StaticRenderProjectionJournal animated synchronise and drain", + () => + { + angle += 0.01f; + for (int index = 0; index < animated.Length; index++) + { + animated[index].Rotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + angle); + } - statics.SynchronizeActiveAnimatedSources(animated); + statics.SynchronizeActiveAnimatedSources(animated); + journal.DrainTo(scene); + }); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(count, journal.Count); - Assert.True(allocated == 0, $"Allocated {allocated:N0} bytes."); + // Retained storage: the cycle leaves nothing behind to grow. + Assert.Equal(0, journal.Count); } private static GpuLandblockSpatialPublication Publication( diff --git a/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs index b0176135..95e829bb 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs @@ -171,16 +171,10 @@ public sealed class PackedProjectionClassificationCacheTests cache.CompleteRebuild(entry, reusableAcrossFrames: true); cache.EndFrame(); - for (int i = 0; i < 100; i++) - Hit(cache, generation, id, identity); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - Hit(cache, generation, id, identity); - long allocated = - GC.GetAllocatedBytesForCurrentThread() - before; - - Assert.Equal(0, allocated); + // #250: the measured window was a 1,000-iteration loop written inline. + ZeroAllocationProbe.AssertAllocatesNothing( + "PackedProjectionClassificationCache warm cache hit", + () => Hit(cache, generation, id, identity)); } private static void Hit( diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs index a1bf84e1..f6e516aa 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs @@ -84,15 +84,15 @@ public sealed class GpuWorldStateRenderTraversalTests var bounds = state.LandblockBounds; _ = entries.Count; _ = bounds.Count; - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int iteration = 0; iteration < 1_000; iteration++) - { - _ = state.LandblockEntries.Count; - _ = state.LandblockBounds.Count; - } - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + // #250: the measured window was a 1,000-iteration loop written inline. + ZeroAllocationProbe.AssertAllocatesNothing( + "GpuWorldState landblock collection access", + () => + { + _ = state.LandblockEntries.Count; + _ = state.LandblockBounds.Count; + }); - Assert.Equal(0, allocated); Assert.Same(entries, state.LandblockEntries); Assert.Same(bounds, state.LandblockBounds); diff --git a/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs index 4a097bf0..a6f9ce9b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs @@ -69,11 +69,9 @@ public sealed class UiTextLayoutCacheTests Func> provider = cache.Provider; _ = provider(); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - _ = provider(); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - - Assert.Equal(0L, allocated); + // #250: the measured window was a 1,000-iteration loop written inline. + ZeroAllocationProbe.AssertAllocatesNothing( + "UiTextLayoutCache stable provider poll", + () => _ = provider()); } } diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 1f43e606..e462ad0b 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -556,18 +556,20 @@ public sealed class LiveEntityRuntimeTests view.CopySpatialIdsTo(ids); foreach (KeyValuePair _ in view) { } + // #250: the old shape warmed once and then measured a 1,000-iteration + // loop written inline, which is precisely the shape on-stack + // replacement rewrites mid-flight, on this thread, inside the window. int visits = 0; - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - { - view.CopySpatialIdsTo(ids); - foreach (KeyValuePair _ in view) - visits++; - } - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + ZeroAllocationProbe.AssertAllocatesNothing( + "LiveEntityAnimationRuntimeView spatial traversal", + () => + { + view.CopySpatialIdsTo(ids); + foreach (KeyValuePair _ in view) + visits++; + }); - Assert.Equal(0, allocated); - Assert.Equal(1_000, visits); + Assert.True(visits > 0); Assert.Equal(entity.Id, Assert.Single(ids)); } diff --git a/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs b/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs index 4377edae..299c1291 100644 --- a/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs +++ b/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs @@ -99,14 +99,19 @@ public sealed class RetailInboundEventDispatcherTests static (target, amount) => target.Value += amount; dispatcher.Run(counter, 1, increment); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - { - dispatcher.Run(counter, 1, increment); - } - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + // #250: the measured window was a 1,000-iteration loop written inline. + int dispatches = 0; + ZeroAllocationProbe.AssertAllocatesNothing( + "RetailInboundEventDispatcher.Run state fast path", + () => + { + dispatches++; + dispatcher.Run(counter, 1, increment); + }); - Assert.Equal(0, allocated); - Assert.Equal(1_001, counter.Value); + // The fast path really ran the callback on every dispatch — once for + // the priming call above, then once per probe invocation. + Assert.True(dispatches > 0); + Assert.Equal(dispatches + 1, counter.Value); } }