fix #429: allocation-free shadow topology rebuild + churn-frame pipelining
The directional-shadow topology rebuilt on every streaming-churn frame and was the measured body of the run-hitch stalls (701 of 708 baseline stalls alloc-correlated): - The draw sort comparer's enum-vs-enum CompareTo bound to Enum.CompareTo(object) and boxed BOTH operands on every comparison — a constant ~38.9 MB of garbage per topology rebuild (~4M boxes), handing the GC a forced gen0 collection mid-frame. The full ~100k-draw sort is replaced outright: draws hash-group by exact DrawKey in one O(n) pass over retained chained-index arrays, and only the few-thousand DISTINCT group keys sort (order-preserving packed material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys, first-appearance tie-break) — bit-identical emission order to the old stable sort, near-zero allocation, and no per-draw comparisons at all. - The caster frame sorts 4-byte indices keyed on SortKey.Value instead of shuffling multi-hundred-byte records through a boxing comparer. - Owner-approved pipelining: on a frame whose shadow inputs just changed (the same frame already paying frame-view/landscape rebuilds), the caster-frame and prepared-draws topology rebuilds defer to the next quieter frame, capped at two consecutive deferrals — inside the GPU fence depth, so retained draws never reference a released arena range. First build, generation change, caster BuildSequence change, and journal overflow force the immediate path; deferred refreshes skip identity-mismatched journal rows. Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests). docs/ISSUES.md carries the full evidence trail; the residual content-proportional rebuild milliseconds are filed as the incremental-topology successor, and the pre-existing town-view scaling latch is filed as #432. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4873c10673
commit
ad69558908
6 changed files with 652 additions and 69 deletions
|
|
@ -598,4 +598,87 @@ public sealed class DirectionalShadowPreparedDrawTests
|
|||
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
|
||||
Assert.True(expectedBits.SequenceEqual(actualBits));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #429 allocation gate (I1 style). A warmed topology rebuild owns every
|
||||
/// retained buffer it needs, so the whole
|
||||
/// TryBegin → Add×N → Complete transaction must allocate near zero.
|
||||
/// The regression this pins: the Complete sort's comparer used
|
||||
/// <c>enum.CompareTo(enum)</c>, which binds to
|
||||
/// <c>Enum.CompareTo(object)</c> and boxes BOTH operands on every
|
||||
/// comparison — measured at 38.9 MB of garbage per rebuild in a
|
||||
/// production window (~4M boxes across the N·log N sort), rebuilt on
|
||||
/// every streaming-churn frame while the player moves. That was the
|
||||
/// #429 run-hitch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AWarmedTopologyRebuildAllocatesNearZero()
|
||||
{
|
||||
const int drawCount = 4096;
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
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;
|
||||
|
||||
Assert.Equal(drawCount, product.Stats.PreparedInstances);
|
||||
// One small constant covers the sort's comparison-delegate wrapper.
|
||||
// The boxing regression allocates ~2 MB at this draw count and fails
|
||||
// this gate by three orders of magnitude.
|
||||
Assert.True(
|
||||
allocated < 2048,
|
||||
$"A warmed directional-shadow topology rebuild allocated {allocated} bytes.");
|
||||
}
|
||||
|
||||
private static void BuildVariedTopology(
|
||||
DirectionalShadowPreparedDraws product,
|
||||
RenderSceneGeneration generation,
|
||||
ulong buildSequence,
|
||||
int drawCount)
|
||||
{
|
||||
Assert.True(product.TryBegin(
|
||||
generation,
|
||||
buildSequence,
|
||||
estimatedInstances: drawCount));
|
||||
Matrix4x4 transform = Matrix4x4.Identity;
|
||||
for (int i = 0; i < drawCount; i++)
|
||||
{
|
||||
// Vary every sort-key dimension so Complete's sort exercises the
|
||||
// full comparison chain (material, cull mode, then the integers).
|
||||
bool cutout = (i & 1) != 0;
|
||||
product.Add(
|
||||
firstIndex: (uint)((i * 37) % 1024),
|
||||
baseVertex: (i * 13) % 512,
|
||||
indexCount: 3 + (i % 5) * 3,
|
||||
cutout ? new GpuTextureSlot((uint)(i % 7)) : GpuTextureSlot.Unassigned,
|
||||
textureLayer: (uint)(i % 11),
|
||||
(i % 3) switch
|
||||
{
|
||||
0 => CullMode.None,
|
||||
1 => CullMode.Clockwise,
|
||||
_ => CullMode.CounterClockwise,
|
||||
},
|
||||
cutout
|
||||
? DirectionalShadowCasterMaterial.AlphaCutout
|
||||
: DirectionalShadowCasterMaterial.Opaque,
|
||||
in transform);
|
||||
}
|
||||
product.Complete(
|
||||
generation,
|
||||
buildSequence,
|
||||
new DirectionalShadowPreparationStats(
|
||||
SourceCasters: drawCount,
|
||||
SourceMeshRefs: drawCount,
|
||||
SourceParts: drawCount,
|
||||
SourceBatches: drawCount,
|
||||
PreparedInstances: 0,
|
||||
PreparedOpaqueCommands: 0,
|
||||
PreparedAlphaCutoutCommands: 0,
|
||||
RejectedTransparentBatches: 0,
|
||||
RejectedFadedParts: 0,
|
||||
MissingMeshes: 0,
|
||||
UnresolvedAlphaCutoutTextures: 0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue