test(ci): harden scheduling and allocation gates
All checks were successful
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Successful in 6m15s
CI / release (push) Successful in 2m11s

This commit is contained in:
Erik 2026-08-27 19:28:10 +02:00
parent c4608b1127
commit d123c4b67c
11 changed files with 101 additions and 87 deletions

View file

@ -71,10 +71,9 @@ public class CellViewDedupTests
// Warm the method/JIT before measuring. Duplicate portal emissions are the hot path.
Assert.False(view.Add(polygon));
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
Assert.False(view.Add(polygon));
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
long allocated = ZeroAllocationProbe.MeasureWarmed(
() => _ = view.Add(polygon),
batchSize: 1_000);
Assert.True(allocated <= 256, $"duplicate probes allocated {allocated:N0} bytes");
}
@ -91,13 +90,13 @@ public class CellViewDedupTests
view.Reset();
Assert.True(view.Add(polygon));
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
view.Reset();
Assert.True(view.Add(polygon));
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
long allocated = ZeroAllocationProbe.MeasureWarmed(
() =>
{
view.Reset();
_ = view.Add(polygon);
},
batchSize: 1_000);
Assert.True(allocated <= 256, $"steady rebuilds allocated {allocated:N0} bytes");
}

View file

@ -138,20 +138,12 @@ public sealed class DirectionalShadowCascadeFitterTests
DirectionalShadowCascadeFitInput input = CameraInput(
Vector3.Zero,
DirectionalShadowQuality.For(DirectionalShadowPreset.High));
Span<DirectionalShadowCascade> cascades =
stackalloc DirectionalShadowCascade[4];
var cascades = new DirectionalShadowCascade[4];
// Cross the tiered-JIT promotion threshold before taking the thread's
// allocation counter; measuring immediately after one call makes the
// runtime's compilation bookkeeping look like renderer allocation.
for (int i = 0; i < 128; i++)
DirectionalShadowCascadeFitter.Fit(in input, cascades);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++)
DirectionalShadowCascadeFitter.Fit(in input, cascades);
long after = GC.GetAllocatedBytesForCurrentThread();
Assert.Equal(0, after - before);
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowCascadeFitter.Fit",
() => DirectionalShadowCascadeFitter.Fit(in input, cascades),
batchSize: 100);
}
[Fact]

View file

@ -465,12 +465,10 @@ public sealed class DirectionalShadowCasterFrameTests
frame.Build(in query);
int copies = source.IndexCopies;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
frame.Build(in query);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowCasterFrame.Build stable frame",
() => frame.Build(in query),
batchSize: 256);
Assert.Equal(1ul, frame.BuildSequence);
Assert.Equal(copies, source.IndexCopies);
Assert.Equal(0, frame.Stats.Classifications);
@ -508,16 +506,16 @@ public sealed class DirectionalShadowCasterFrameTests
frame.Build(in query);
}
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 64; iteration++)
void RefreshDenseFrame()
{
for (int index = 0; index < 75; index++)
source.PublishTransformChanges(dynamics[index].Id, 1);
frame.Build(in query);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowCasterFrame.Build dense changed frame",
RefreshDenseFrame,
batchSize: 64);
Assert.True(frame.Stats.DensityBulkRefresh);
Assert.Equal(75, frame.Stats.DynamicTransformRefreshes);
Assert.Equal(0, source.ProjectionReads);

View file

@ -54,18 +54,14 @@ public sealed class DirectionalShadowTransformBufferSetTests
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
{
buffers.Publish(
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowTransformBufferSet.Publish",
() => buffers.Publish(
warmed,
topologyBuildSequence: 7,
transforms,
ReadOnlySpan<int>.Empty);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
ReadOnlySpan<int>.Empty),
batchSize: 256);
Assert.Equal(buffersBefore, device.CreatedBuffers.Count);
Assert.Equal(uploadsBefore, slotZero.UploadCount);
Assert.Empty(device.OfKind<GpuRecordedHostStorageVisibility>());

View file

@ -38,15 +38,11 @@ public sealed class VulkanDrawBindingStateTests
var state = new VulkanDrawBindingState();
state.MarkBound(pipelineLayout: 30, packGeneration: 8);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 10_000; iteration++)
{
Assert.False(state.RequiresBind(
ZeroAllocationProbe.AssertAllocatesNothing(
"VulkanDrawBindingState.RequiresBind",
() => _ = state.RequiresBind(
pipelineLayout: 30,
packGeneration: 8));
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
packGeneration: 8),
batchSize: 10_000);
}
}

View file

@ -2,21 +2,38 @@ using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
[Collection(AcDream.App.Tests.ThreadSchedulingCollection.Name)]
public sealed class HostQuiescenceGateTests
{
[Fact]
public async Task ExternalStopWaitsForAdmittedCallbackToReturn()
public void ExternalStopWaitsForAdmittedCallbackToReturn()
{
var gate = new HostQuiescenceGate();
using var callbackEntered = new ManualResetEventSlim(false);
using var releaseCallback = new ManualResetEventSlim(false);
int calls = 0;
Task callback = Task.Run(() => gate.Invoke(() =>
Exception? callbackError = null;
var callbackThread = new Thread(() =>
{
callbackEntered.Set();
Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
calls++;
}));
try
{
gate.Invoke(() =>
{
callbackEntered.Set();
Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
calls++;
});
}
catch (Exception error)
{
callbackError = error;
}
})
{
IsBackground = true,
Name = "HostQuiescenceGate admitted callback contract",
};
callbackThread.Start();
Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5)));
using var stopStarted = new ManualResetEventSlim(false);
@ -44,13 +61,15 @@ public sealed class HostQuiescenceGateTests
TimeSpan.FromSeconds(5));
releaseCallback.Set();
await callback;
bool callbackJoined = callbackThread.Join(TimeSpan.FromSeconds(5));
bool stopJoined = stopThread.Join(TimeSpan.FromSeconds(5));
gate.Invoke(() => calls++);
Assert.True(stopStartedInTime, "the dedicated stop thread did not start");
Assert.True(stopBlockedOnCallback, "stop never waited for the admitted callback");
Assert.True(callbackJoined, "the admitted callback did not finish after release");
Assert.True(stopJoined, "stop did not finish after the callback returned");
Assert.Null(callbackError);
Assert.Null(stopError);
Assert.Equal(1, calls);
Assert.False(gate.IsAccepting);

View file

@ -45,7 +45,6 @@ public sealed class AtmosphericGpuTimerSamplingTests
frameSerial: 1);
int measured = 0;
long before = GC.GetAllocatedBytesForCurrentThread();
for (long frame = 1; frame <= 10_000; frame++)
{
if (AtmosphericGpuTimerSampling.ShouldMeasure(
@ -55,9 +54,12 @@ public sealed class AtmosphericGpuTimerSamplingTests
measured++;
}
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(2_500, measured);
Assert.Equal(0, allocated);
ZeroAllocationProbe.AssertAllocatesNothing(
"AtmosphericGpuTimerSampling.ShouldMeasure",
() => _ = AtmosphericGpuTimerSampling.ShouldMeasure(
RenderQualitySemantic.Low,
frameSerial: 4),
batchSize: 10_000);
}
}

View file

@ -383,12 +383,10 @@ public sealed class RenderPackAutoRuntimeTests
for (int i = 0; i < 128; i++)
fixture.Observe(1, stable: true);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 128; i++)
fixture.Observe(1, stable: true);
long after = GC.GetAllocatedBytesForCurrentThread();
Assert.Equal(0, after - before);
ZeroAllocationProbe.AssertAllocatesNothing(
"RenderPackAutoRuntime stable observation",
() => fixture.Observe(1, stable: true),
batchSize: 128);
}
private sealed class Fixture : IDisposable

View file

@ -371,11 +371,10 @@ public sealed class DirectionalShadowPreparedDrawTests
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
product.RefreshDynamicTransforms(changed);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
product.RefreshDynamicTransforms(changed);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowPreparedDraws.RefreshDynamicTransforms",
() => product.RefreshDynamicTransforms(changed),
batchSize: 256);
RenderProjectionRecord wrongId = current with
{
@ -508,16 +507,12 @@ public sealed class DirectionalShadowPreparedDrawTests
product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
long beforeDense = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
{
product.RefreshDynamicTransforms(
ZeroAllocationProbe.AssertAllocatesNothing(
"DirectionalShadowPreparedDraws.RefreshDynamicTransforms dense",
() => product.RefreshDynamicTransforms(
reversedDenseChanges,
denseRefresh: true);
}
long denseAllocated =
GC.GetAllocatedBytesForCurrentThread() - beforeDense;
Assert.Equal(0, denseAllocated);
denseRefresh: true),
batchSize: 256);
Assert.True(product.TryBegin(generation, 14, estimatedInstances: 0));
product.Complete(generation, 14, in stats);
@ -619,9 +614,14 @@ public sealed class DirectionalShadowPreparedDrawTests
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(9);
BuildVariedTopology(product, generation, buildSequence: 1, drawCount);
long before = GC.GetAllocatedBytesForCurrentThread();
BuildVariedTopology(product, generation, buildSequence: 2, drawCount);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
ulong buildSequence = 1;
long allocated = ZeroAllocationProbe.MeasureWarmed(
() => BuildVariedTopology(
product,
generation,
++buildSequence,
drawCount),
batchSize: 1);
Assert.Equal(drawCount, product.Stats.PreparedInstances);
// One small constant covers the sort's comparison-delegate wrapper.

View file

@ -13,6 +13,7 @@ namespace AcDream.App.Tests.Streaming;
/// before Far-tier ones within a lane, a pool of one reproduces the serial
/// pre-#418 behavior, and disposal joins every worker.
/// </summary>
[Collection(AcDream.App.Tests.ThreadSchedulingCollection.Name)]
public sealed class LandblockStreamerPoolTests
{
private const int SpinTimeoutMs = 10_000;

View file

@ -0,0 +1,13 @@
namespace AcDream.App.Tests;
/// <summary>
/// Dedicated-thread coordination tests must not compete with the thousands of
/// parallel App tests for host scheduling time. They verify ordering and
/// lifecycle contracts, not behavior under an intentionally saturated test
/// runner.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class ThreadSchedulingCollection
{
public const string Name = "Dedicated thread scheduling";
}