using Xunit;
namespace AcDream.App.Tests;
///
/// Measurement apparatus for the "this warmed path allocates nothing" assertions
/// (docs/ISSUES.md #250). The bound it enforces is strict — zero managed bytes,
/// never a tolerance — and the whole point of the class is that the number it
/// hands to that bound is the steady-state allocation rather than a one-time
/// startup cost the runtime happened to charge to the measuring thread.
///
///
/// The family flaked because in every member the measured window was *not* the
/// warmed path. Two distinct shapes produced the same symptom:
///
///
///
/// -
/// The window contained a hot loop. `UiDatFontTests` measured a
/// 10,000-iteration loop and `RenderFrameProductTests` a 1,000-iteration one,
/// both written directly in the test method. A test method is JIT-compiled at
/// tier 0 like anything else, and a long-running loop inside tier-0 code is
/// replaced mid-flight by on-stack replacement. OSR compiles on the thread
/// running the loop — the measuring thread — so its bookkeeping lands inside
/// the window being measured.
///
/// -
/// The warmup exercised a different branch. `ArchRenderSceneTests` warmed
/// with `Apply(registrations)` and then measured `Apply(updates)`, which is the
/// `ApplyUpdate` arm rather than the `ApplyRegister` one — so the measured call
/// was the first-ever call into that half of the switch, and its tier-0 JIT,
/// type loads and static initialisation were all charged to the window.
/// `CurrentRenderSceneOracleTests` warmed once and measured once, which is the
/// same problem with a shorter fuse.
///
///
///
///
/// Both explain the signature the issue recorded — clean in isolation, failing
/// roughly one full-suite run in three. Running alone, the process is quiet and
/// the runtime's tiering work is finished before the assertion arrives. Running
/// alongside eight other test assemblies, tier-0 compilation never stops, the
/// call-counting delay is re-armed continually, and the work slides into the
/// measured window.
///
///
///
/// So the probe invokes the delegate many times before it measures anything,
/// and every measured window then runs the same already-warmed loop over the
/// same already-taken code path. Nothing is left for OSR to replace and nothing
/// is left to JIT.
///
///
///
/// Each window is a batch of invocations rather than a single one, and
/// the probe reports the minimum across several such batches. Both halves
/// of that matter, and the batch is the half that is easy to get wrong:
///
///
///
/// -
/// The minimum is what excludes one-time costs. A promotion enqueue or a
/// lazily-grown internal buffer happens in at most one batch, so some other
/// batch is clean and the floor is zero. This is not a retry and not a
/// tolerance — it is the ordinary way to estimate a steady-state floor.
///
/// -
/// The batch is what keeps the assertion as strong as the loops it
/// replaced. Minimising over single invocations would report zero for a
/// path that allocates every tenth call, because nine windows in ten would be
/// clean — a real regression, invisible. Summing a batch first means any cost
/// recurring at a period the batch covers appears in every batch, so the
/// minimum is above zero and the assertion fails. That restores what the
/// original 1,000- and 10,000-iteration loops were measuring, without putting
/// the loop somewhere the runtime will rewrite it mid-measurement.
///
///
///
///
/// A cost recurring less often than once per batch is the one thing this cannot
/// see. is chosen well above any plausible cache
/// or ring period in the paths under test, and a test guarding a longer cycle
/// should pass a batch size that covers it.
///
///
internal static class ZeroAllocationProbe
{
///
/// Invocations per measured window. Large enough that any per-call or
/// short-period allocation lands inside every window, and therefore in the
/// minimum across them.
///
internal const int DefaultBatchSize = 32;
///
/// Batches run before the first measurement. Their invocation count is
/// comfortably past the tier-0 call-counting threshold of 30, so the callee
/// tree is promoted and its one-time costs are paid while nobody is
/// watching — and the batch loop itself is warm by the time it is measured.
///
internal const int DefaultWarmupBatches = 4;
///
/// Measured windows to take the minimum over. Small: the warmup does the
/// real work, and this only has to outlast a one-time cost that landed
/// unluckily inside the first batch.
///
internal const int DefaultSamples = 4;
///
/// Returns the smallest number of managed bytes that a batch of warmed
/// invocations of charged to the current thread.
/// Zero means the path allocates nothing in steady state.
///
///
/// One complete unit of the work under test. Must be safe to invoke
/// repeatedly and must take the same code path every time — if it needs
/// fresh input per invocation (a monotonic sequence number, say), it is
/// responsible for producing that itself, and for doing so on every
/// invocation including the warm ones, so the cost is never novel inside a
/// measured window.
///
/// Invocations summed into one window.
/// Batches run before measuring.
/// Measured windows; the minimum is returned.
internal static long MeasureWarmed(
Action step,
int batchSize = DefaultBatchSize,
int warmupBatches = DefaultWarmupBatches,
int samples = DefaultSamples)
{
ArgumentNullException.ThrowIfNull(step);
ArgumentOutOfRangeException.ThrowIfLessThan(batchSize, 1);
ArgumentOutOfRangeException.ThrowIfNegative(warmupBatches);
ArgumentOutOfRangeException.ThrowIfLessThan(samples, 1);
for (int batch = 0; batch < warmupBatches; batch++)
RunBatch(step, batchSize);
long smallest = long.MaxValue;
for (int sample = 0; sample < samples; sample++)
{
// The window holds one call to an already-jitted RunBatch, running
// an already-warmed loop over already-warmed code. Both reads
// bracket that call and nothing else.
long before = GC.GetAllocatedBytesForCurrentThread();
RunBatch(step, batchSize);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
if (allocated < smallest)
smallest = allocated;
// Nothing can go below the floor, so stop once it is reached.
if (smallest == 0)
break;
}
return smallest;
}
private static void RunBatch(Action step, int batchSize)
{
for (int invocation = 0; invocation < batchSize; invocation++)
step();
}
///
/// Asserts that a warmed invocation of allocates no
/// managed bytes at all. The bound is exact and deliberately has no slack.
///
///
/// What the path is, for the failure message — e.g.
/// "ArchRenderScene.Apply(updates)".
///
internal static void AssertAllocatesNothing(
string what,
Action step,
int batchSize = DefaultBatchSize,
int warmupBatches = DefaultWarmupBatches,
int samples = DefaultSamples)
{
long allocated = MeasureWarmed(step, batchSize, warmupBatches, samples);
Assert.True(
allocated == 0,
$"{what} allocated {allocated:N0} managed bytes per warmed batch of "
+ $"{batchSize} invocations, expected 0. Measured as the minimum of "
+ $"{samples} such batches after {warmupBatches} warmup batches, so "
+ "this is a steady-state cost rather than a one-time startup cost "
+ "(see docs/ISSUES.md #250).");
}
}