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:
parent
6301e4dbea
commit
212f5a12e5
7 changed files with 1051 additions and 359 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue