perf(render) Campaign FW3.4a: one walk pass; prepare-once/draw-ranges; arena records

The FW3.4 dense-Arwic pair triggered the +/-20% stop rule (+33.5% CPU
p50, 14x frame allocation). This slice removes the three measured
costs without changing GPU command order (the referee suites assert
identical recorded call sequences):

- WalkFrameDriver: Collect (ONE walk per frame - no GPU work; leaf
  calls and flush points become a recorded event list; the driver
  absorbed the renderer collection pass and exposes the visited sets)
  + Replay (prepare the whole stream once, then replay events,
  interleaving DrawOrderedRange with leaf calls in the exact recorded
  order). RunFrame = Collect+Replay for existing callers.
- WbDrawDispatcher: SubmitOrderedStream split into PrepareOrderedStream
  (all sections + commands + merge runs uploaded once per frame) and
  DrawOrderedRange (bind-once latch; per-run pipeline + DrawIdOffset +
  DrawIndirectRangeRhi). Load-bearing correctness catch from the
  implementation round: merge runs take FORCED BREAKS at the recorded
  event marks - whole-stream merging must not fuse two segments that
  retail separates with a leaf GPU call (shell, punch); the straddle
  assert stays as a dead-code safety net.
- WalkProductionWorldData: WalkFrameStaticRecords carries an
  ArraySegment into a per-frame grow-only arena; the per-cell
  fresh-array copies (the 1.9 MB/frame alloc p50) are gone - zero
  steady-state allocation after warmup.

Suites (lead-verified): full Release build 0 warnings; hermetic
6,758/0; Walk lane 209/1; InstalledDat Walk conformance 40/1
untouched. Next: the dense-Arwic re-measure against the same-session
baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 17:01:26 +02:00
parent 6301e4dbea
commit 212f5a12e5
7 changed files with 1051 additions and 359 deletions

View file

@ -1,4 +1,5 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Scene;
namespace AcDream.App.Rendering.Walk;
@ -11,7 +12,7 @@ namespace AcDream.App.Rendering.Walk;
///
/// <list type="bullet">
/// <item>Cell statics — <see cref="RenderSceneQuery.CopyCellStaticsTo"/> on
/// demand, one pooled array per distinct cell per frame (a cell can be
/// demand, one arena segment per distinct cell per frame (a cell can be
/// visited once by the root flood OR once per admitting look-in portal; the
/// per-frame cache keeps the copy single).</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
@ -31,6 +32,25 @@ namespace AcDream.App.Rendering.Walk;
/// The tuple landblock id handed to the classifier is the frame's player
/// landblock — the packed path's own convention for every
/// <c>RenderFrameEntityDrawRequest</c>.
///
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>,
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per
/// frame. At a town-density frame (dozens of cells) that was the single
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a
/// grow-only buffer, reset to length 0 once per frame in
/// <see cref="BeginFrame"/>, that every materialization call
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a
/// new array — after the arena reaches its steady-state size (a few frames
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s
/// existing grow-on-demand pattern), zero further heap allocation occurs
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
/// STRICTLY per-frame scratch — nothing holds one across a frame boundary
/// (the driver/populator consume it immediately, matching
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the
/// same backing array's memory next frame is safe.</para>
/// </summary>
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
@ -48,6 +68,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256];
// Campaign FW3.4a: the per-frame, grow-only materialization arena — see
// this type's own doc comment.
private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096];
private int _arenaLength;
internal WalkProductionWorldData(WalkBuildingRegistry buildings)
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
@ -75,6 +100,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_cellCache.Clear();
_outdoorMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
@ -148,7 +174,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
int count = _scene.CopyCellStaticsTo(cellId, _cellScratch);
WalkFrameStaticRecords records = count == 0
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
: new WalkFrameStaticRecords(_cellScratch[..count], _tupleLandblockId);
: new WalkFrameStaticRecords(
AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId);
_cellCache[cellId] = records;
return records;
}
@ -160,7 +187,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
WalkFrameStaticRecords records =
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords([.. bucket], _tupleLandblockId)
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorMaterialized[cellId] = records;
return records;
@ -176,12 +204,42 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
WalkFrameStaticRecords records =
_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)
&& shells.Count > 0
? new WalkFrameStaticRecords([.. shells], _tupleLandblockId)
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(shells)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_shellMaterialized[anchor] = records;
return records;
}
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
/// its current length, growing the arena first if needed (doubling, or
/// exactly enough for an unusually large sweep — the same growth shape
/// <see cref="_sweepScratch"/>/<see cref="_cellScratch"/> already use),
/// and returns the segment the copy landed in. A prior frame's growth can
/// leave an earlier-returned segment pointing at a retired backing array
/// — harmless, since that array's content stays valid and nothing reads
/// a segment across a frame boundary (see this type's own doc
/// comment).</summary>
private ArraySegment<RenderProjectionRecord> AppendToArena(
ReadOnlySpan<RenderProjectionRecord> source)
{
if (source.Length == 0)
return ArraySegment<RenderProjectionRecord>.Empty;
int required = _arenaLength + source.Length;
if (required > _arena.Length)
{
var grown = new RenderProjectionRecord[Math.Max(required, _arena.Length * 2)];
Array.Copy(_arena, grown, _arenaLength);
_arena = grown;
}
source.CopyTo(_arena.AsSpan(_arenaLength, source.Length));
var segment = new ArraySegment<RenderProjectionRecord>(_arena, _arenaLength, source.Length);
_arenaLength += source.Length;
return segment;
}
/// <summary>The building's authored shell anchor: its first non-exit
/// portal's destination cell — the SAME rule <c>LandblockLoader</c> used
/// when it stamped <c>BuildingShellAnchorCellId</c> on the shell entity.</summary>