perf(pipeline): MP-Alloc Task 3 - pool InteriorEntityPartition.Result

InteriorEntityPartition.Partition allocated a fresh Result (a Dictionary
plus 2 Lists) and a new List<WorldEntity> per newly-seen visible cell,
every frame — the sole call site is RetailPViewRenderer.DrawInside,
once per frame, single-threaded.

Fix: add a Partition(Result, visibleCells, landblockEntries) overload
that clears an existing Result in place (Result.ClearForReuse) and
refills it, reusing each cell's List<WorldEntity> across frames when
the cell key survives (the visible-cell set is normally stable frame to
frame). RetailPViewRenderer now owns one _partitionResult instance
(matching its existing _shellBatch/_buildingGroups/_lookInPrepareScratch
reuse pattern) instead of allocating one per DrawInside call. The
original allocating Partition(visibleCells, landblockEntries) overload
is kept unchanged for tests and one-shot callers (it now delegates to
the reuse overload against a fresh Result).

Bit-identical output required pruning: without removing cell buckets
that end a frame with zero entries, a cell that leaves visibility would
leave a stale empty List sitting in ByCell, changing ByCell.Count and
.Keys enumeration versus the old always-fresh-Dictionary behavior (two
GameWindow diagnostics - sigSceneParticles and FormatPartitionCounts -
read those directly, not just via TryGetValue). Result.PruneEmptyCellBuckets
removes any zero-count bucket after each Partition call, keeping
ByCell's key set exactly what a fresh dictionary would have held.

Verified safe: the sole production consumer (RetailPViewRenderer.
DrawInside) and every downstream reader (WbDrawDispatcher walks,
GameWindow diagnostics) consume partition.ByCell/OutdoorStatic/Dynamics
synchronously within the same frame that built them - no cached
reference survives into the next frame's Partition() call (the one
GameWindow field that stores the reference, _interiorPartition, is
write-only, never read).

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 23:47:23 +02:00
parent a4b42a7417
commit 76b8b4e9fc
2 changed files with 81 additions and 2 deletions

View file

@ -42,6 +42,14 @@ public sealed class RetailPViewRenderer
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
// place and reuses each cell's list across frames when the cell stays
// visible.
private readonly InteriorEntityPartition.Result _partitionResult = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -137,7 +145,8 @@ public sealed class RetailPViewRenderer
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,