The zero-allocation family failed about one full-suite run in three, on unchanged trees, and had been dismissed as inherent noise in `GC.GetAllocatedBytesForCurrentThread` three separate times. It is not noise. Reading the four members side by side, they share one root: **the measured window was never the warmed path.** UiDatFontTests 1 warm call, then a 10,000-iteration loop inline RenderFrameProductTests 8 warm calls, then a 1,000-iteration loop inline OracleTests 1 warm call, 1 measured call ArchRenderSceneTests warms Apply(registrations), measures Apply(updates) Two mechanisms come out of that table. A test method is JIT-compiled at tier 0 like anything else, and a long-running loop in tier-0 code gets replaced mid-flight by on-stack replacement — which compiles on the thread running the loop, so its bookkeeping is charged to the window being measured. That is the first two. And `ArchRenderSceneTests` warmed one arm of a switch and measured the other, so the measured call was the first ever into `ApplyUpdate` and paid that arm's JIT, type loads and static initialisation inside the window; `RenderFrameProductTests` warmed 8 times, below the tier-0 call-counting threshold of 30, so promotion was still pending when measurement began. That also explains the signature nobody could account for. Alone, the process is quiet and the runtime has finished before the assertion arrives. Alongside eight other test assemblies, tier-0 compilation never stops, the call-counting delay is re-armed continually, and the work slides into the window. Clean in isolation, failing under load, on a tree that changed nothing. `ZeroAllocationProbe` invokes the step many times before measuring anything, then measures windows that run the same already-warmed loop over the same already-taken path. Each window is a batch of 32 invocations and it reports the minimum across 4 of them. Both halves are load-bearing: the minimum is what excludes a one-time cost, and the batch is what keeps the assertion as strong as the loops it replaces — minimising over *single* invocations would report zero for a path that allocates every tenth call, which is a real regression made invisible. I had written it that way first and the apparatus test caught it. **The bound is untouched: exactly zero, no tolerance, no retry, no assertion relaxed.** `ZeroAllocationProbeTests` proves the apparatus can still fail — a step allocating every call reads above zero and does throw, a first-invocation cost reads as zero, a cost every tenth call is caught, and the one stated limit (the batch must cover the period) is pinned as a test rather than left as prose. Without those, a later edit could quietly make the whole family unfailable. Twelve further sites in this assembly still use the hand-rolled shape. None has been observed failing, and each needs its own repeatability analysis — several mutate state or consume monotonic sequences — so they are listed in the issue for adoption when next touched rather than converted blind at scale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.6 KiB
C#
130 lines
4.6 KiB
C#
using Xunit;
|
|
|
|
namespace AcDream.App.Tests;
|
|
|
|
/// <summary>
|
|
/// Guards the apparatus itself (docs/ISSUES.md #250). A probe that smooths away
|
|
/// measurement noise is only worth having if it still reports real allocation,
|
|
/// so these pin both directions: a genuinely allocating step must be reported
|
|
/// above zero, and the assertion helper built on it must actually throw.
|
|
/// Without this, a future edit could quietly turn the whole zero-allocation
|
|
/// family into tests that cannot fail.
|
|
/// </summary>
|
|
public class ZeroAllocationProbeTests
|
|
{
|
|
[Fact]
|
|
public void GenuinelyAllocatingStep_IsReportedAboveZero()
|
|
{
|
|
// Allocates on every invocation, so every window sees it and the
|
|
// minimum cannot be zero no matter how long the warmup runs.
|
|
object? sink = null;
|
|
long allocated = ZeroAllocationProbe.MeasureWarmed(
|
|
() => sink = new byte[1024]);
|
|
|
|
Assert.NotNull(sink);
|
|
Assert.True(
|
|
allocated >= 1024,
|
|
$"Expected at least the 1 KiB the step allocates, measured {allocated:N0}.");
|
|
}
|
|
|
|
[Fact]
|
|
public void GenuinelyAllocatingStep_FailsTheAssertion()
|
|
{
|
|
object? sink = null;
|
|
|
|
var failure = Assert.Throws<Xunit.Sdk.TrueException>(
|
|
() => ZeroAllocationProbe.AssertAllocatesNothing(
|
|
"deliberately allocating step",
|
|
() => sink = new byte[1024]));
|
|
|
|
Assert.Contains("deliberately allocating step", failure.Message);
|
|
Assert.Contains("expected 0", failure.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonAllocatingStep_IsReportedAsZero()
|
|
{
|
|
int counter = 0;
|
|
long allocated = ZeroAllocationProbe.MeasureWarmed(() => counter++);
|
|
|
|
Assert.True(
|
|
counter
|
|
> ZeroAllocationProbe.DefaultBatchSize
|
|
* ZeroAllocationProbe.DefaultWarmupBatches);
|
|
Assert.Equal(0, allocated);
|
|
}
|
|
|
|
[Fact]
|
|
public void OneTimeCost_IsExcluded()
|
|
{
|
|
// Half of the distinction the probe exists to draw: a step that
|
|
// allocates only on its first invocation is a startup cost, is gone by
|
|
// the time the first window opens, and must read as zero.
|
|
int invocations = 0;
|
|
object? sink = null;
|
|
|
|
long allocated = ZeroAllocationProbe.MeasureWarmed(() =>
|
|
{
|
|
if (invocations++ == 0)
|
|
sink = new byte[4096];
|
|
});
|
|
|
|
Assert.NotNull(sink);
|
|
Assert.Equal(0, allocated);
|
|
}
|
|
|
|
[Fact]
|
|
public void PeriodicCost_ShorterThanTheBatch_IsNotExcluded()
|
|
{
|
|
// The other half, and the reason a window is a batch rather than a
|
|
// single invocation. A cost recurring every tenth call is steady-state.
|
|
// Minimising over single invocations would miss it — nine windows in
|
|
// ten are clean — so the probe sums a batch of 32 first, which cannot
|
|
// avoid containing at least three of them.
|
|
int invocations = 0;
|
|
object? sink = null;
|
|
|
|
long allocated = ZeroAllocationProbe.MeasureWarmed(() =>
|
|
{
|
|
if (invocations++ % 10 == 0)
|
|
sink = new byte[4096];
|
|
});
|
|
|
|
Assert.NotNull(sink);
|
|
Assert.True(
|
|
allocated > 0,
|
|
"A cost recurring every tenth invocation is steady-state and must "
|
|
+ $"not read as zero; measured {allocated:N0}.");
|
|
}
|
|
|
|
[Fact]
|
|
public void PeriodicCost_IsCaughtWheneverTheBatchCoversThePeriod()
|
|
{
|
|
// Pins the stated limit rather than leaving it as prose: the batch has
|
|
// to cover the period. A cost every 8th call is caught by a batch of
|
|
// 16; the identical cost is invisible to a batch of 4, which is why
|
|
// DefaultBatchSize sits well above the periods in the paths under test.
|
|
Assert.True(MeasurePeriodicCost(period: 8, batchSize: 16) > 0);
|
|
Assert.Equal(0, MeasurePeriodicCost(period: 8, batchSize: 4));
|
|
|
|
static long MeasurePeriodicCost(int period, int batchSize)
|
|
{
|
|
int invocations = 0;
|
|
object? sink = null;
|
|
|
|
long allocated = ZeroAllocationProbe.MeasureWarmed(
|
|
() =>
|
|
{
|
|
// Offset so the first invocation of a batch is never the
|
|
// allocating one; otherwise the small-batch case would
|
|
// catch it by alignment rather than by coverage.
|
|
if (invocations++ % period == period - 1)
|
|
sink = new byte[4096];
|
|
},
|
|
batchSize: batchSize);
|
|
|
|
Assert.NotNull(sink);
|
|
return allocated;
|
|
}
|
|
}
|
|
}
|