perf(render) #429: allocation-exact streamed-mesh completion
UploadGfxObjMeshData built every completed mesh's index data three-plus times over in LINQ transients (per-batch Indices.ToArray copies plus an unsized SelectMany growth) on the render thread, up to the per-frame upload budget. The conversion now fills one exact-size retained CPUIndices array (the same one the B.4b pick path keeps) and hands the shared arena (offset, count) segments of it; CPUPositions fills by a direct pre-sized loop; the Sum/Any/FirstOrDefault transients are gone. GlobalMeshBuffer.UploadMesh takes the segment form — the staged bytes per batch are unchanged. Gate: a warmed completion must allocate near its retained-copy size (MeshPipelineDeviceSeamTests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5b9d0260bb
commit
0330fcd0d1
3 changed files with 183 additions and 34 deletions
|
|
@ -286,9 +286,15 @@ public sealed class GlobalMeshBuffer : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #429: index batches arrive as (offset, count) segments of one shared
|
||||||
|
/// index array — the caller's retained CPU pick copy — instead of one
|
||||||
|
/// managed array per batch. The bytes staged per batch are unchanged.
|
||||||
|
/// </summary>
|
||||||
internal GlobalMeshAllocation UploadMesh(
|
internal GlobalMeshAllocation UploadMesh(
|
||||||
VertexPositionNormalTexture[] vertices,
|
VertexPositionNormalTexture[] vertices,
|
||||||
IReadOnlyList<ushort[]> indexBatches)
|
ushort[] indices,
|
||||||
|
ReadOnlySpan<(int Offset, int Count)> indexBatches)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
_retirementLedger.RetryPendingPublications();
|
_retirementLedger.RetryPendingPublications();
|
||||||
|
|
@ -296,16 +302,21 @@ public sealed class GlobalMeshBuffer : IDisposable
|
||||||
if (_migration is not null)
|
if (_migration is not null)
|
||||||
throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress.");
|
throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress.");
|
||||||
ArgumentNullException.ThrowIfNull(vertices);
|
ArgumentNullException.ThrowIfNull(vertices);
|
||||||
ArgumentNullException.ThrowIfNull(indexBatches);
|
ArgumentNullException.ThrowIfNull(indices);
|
||||||
if (vertices.Length == 0)
|
if (vertices.Length == 0)
|
||||||
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
|
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
|
||||||
|
|
||||||
int totalIndices = 0;
|
int totalIndices = 0;
|
||||||
for (int i = 0; i < indexBatches.Count; i++)
|
for (int i = 0; i < indexBatches.Length; i++)
|
||||||
{
|
{
|
||||||
ushort[] batch = indexBatches[i]
|
(int offset, int count) = indexBatches[i];
|
||||||
?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
|
if (offset < 0 || count <= 0 || (long)offset + count > indices.Length)
|
||||||
totalIndices = checked(totalIndices + batch.Length);
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Index batch {i} ({offset}, {count}) is outside the shared index array ({indices.Length}).",
|
||||||
|
nameof(indexBatches));
|
||||||
|
}
|
||||||
|
totalIndices = checked(totalIndices + count);
|
||||||
}
|
}
|
||||||
if (totalIndices == 0)
|
if (totalIndices == 0)
|
||||||
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
|
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
|
||||||
|
|
@ -322,7 +333,7 @@ public sealed class GlobalMeshBuffer : IDisposable
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstIndices = new int[indexBatches.Count];
|
var firstIndices = new int[indexBatches.Length];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// IGpuBuffer.Upload stages through a neutral binding point of the
|
// IGpuBuffer.Upload stages through a neutral binding point of the
|
||||||
|
|
@ -337,18 +348,15 @@ public sealed class GlobalMeshBuffer : IDisposable
|
||||||
|
|
||||||
IGpuBuffer indexStore = RequireStore(_indexBuffer);
|
IGpuBuffer indexStore = RequireStore(_indexBuffer);
|
||||||
int indexOffset = indexRange.Offset;
|
int indexOffset = indexRange.Offset;
|
||||||
for (int i = 0; i < indexBatches.Count; i++)
|
for (int i = 0; i < indexBatches.Length; i++)
|
||||||
{
|
{
|
||||||
ushort[] batch = indexBatches[i];
|
(int offset, int count) = indexBatches[i];
|
||||||
firstIndices[i] = indexOffset;
|
firstIndices[i] = indexOffset;
|
||||||
if (batch.Length > 0)
|
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
|
||||||
{
|
indexStore.Upload(
|
||||||
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
|
indexOffsetBytes,
|
||||||
indexStore.Upload(
|
MemoryMarshal.AsBytes(new ReadOnlySpan<ushort>(indices, offset, count)));
|
||||||
indexOffsetBytes,
|
indexOffset = checked(indexOffset + count);
|
||||||
MemoryMarshal.AsBytes(new ReadOnlySpan<ushort>(batch)));
|
|
||||||
indexOffset = checked(indexOffset + batch.Length);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|
|
||||||
|
|
@ -2045,13 +2045,47 @@ namespace AcDream.App.Rendering.Wb
|
||||||
{
|
{
|
||||||
if (meshData.Vertices.Length == 0) return null;
|
if (meshData.Vertices.Length == 0) return null;
|
||||||
|
|
||||||
var modernIndexBatches = meshData.TextureBatches.Values
|
// #429: allocation-exact conversion. The retained pick copies
|
||||||
.SelectMany(batches => batches)
|
// (CPUPositions/CPUIndices) are the only geometry-proportional
|
||||||
.Where(batch => batch.Indices.Count != 0)
|
// allocations this method makes; the per-batch segments handed to
|
||||||
.Select(batch => batch.Indices.ToArray())
|
// the shared arena are (offset, count) views into the same
|
||||||
.ToArray();
|
// retained CPUIndices array. The former LINQ chain materialized
|
||||||
|
// every batch twice (a per-batch ToArray plus an unsized
|
||||||
|
// SelectMany growth) — megabytes of transient garbage per mesh on
|
||||||
|
// the render thread.
|
||||||
|
int totalIndexCount = 0;
|
||||||
|
int nonEmptyBatchCount = 0;
|
||||||
|
foreach (List<TextureBatchData> formatBatches in meshData.TextureBatches.Values)
|
||||||
|
{
|
||||||
|
foreach (TextureBatchData formatBatch in formatBatches)
|
||||||
|
{
|
||||||
|
if (formatBatch.Indices.Count == 0) continue;
|
||||||
|
totalIndexCount = checked(totalIndexCount + formatBatch.Indices.Count);
|
||||||
|
nonEmptyBatchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cpuIndices = new ushort[totalIndexCount];
|
||||||
|
var indexSegments = new (int Offset, int Count)[nonEmptyBatchCount];
|
||||||
|
{
|
||||||
|
int fillOffset = 0;
|
||||||
|
int segmentIndex = 0;
|
||||||
|
foreach (List<TextureBatchData> formatBatches in meshData.TextureBatches.Values)
|
||||||
|
{
|
||||||
|
foreach (TextureBatchData formatBatch in formatBatches)
|
||||||
|
{
|
||||||
|
int count = formatBatch.Indices.Count;
|
||||||
|
if (count == 0) continue;
|
||||||
|
CollectionsMarshal.AsSpan(formatBatch.Indices)
|
||||||
|
.CopyTo(cpuIndices.AsSpan(fillOffset, count));
|
||||||
|
indexSegments[segmentIndex++] = (fillOffset, count);
|
||||||
|
fillOffset = checked(fillOffset + count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GlobalMeshAllocation? globalAllocation = null;
|
GlobalMeshAllocation? globalAllocation = null;
|
||||||
var renderBatches = new List<ObjectRenderBatch>();
|
var renderBatches = new List<ObjectRenderBatch>(nonEmptyBatchCount);
|
||||||
var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>();
|
var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>();
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -2068,8 +2102,13 @@ namespace AcDream.App.Rendering.Wb
|
||||||
// or vertex/index buffer to build here — Vulkan bakes vertex input
|
// or vertex/index buffer to build here — Vulkan bakes vertex input
|
||||||
// into the pipeline, and the shared arena's stores are bound once
|
// into the pipeline, and the shared arena's stores are bound once
|
||||||
// per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh).
|
// per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh).
|
||||||
if (GlobalBuffer is not null && modernIndexBatches.Length != 0)
|
if (GlobalBuffer is not null && nonEmptyBatchCount != 0)
|
||||||
globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches);
|
{
|
||||||
|
globalAllocation = GlobalBuffer.UploadMesh(
|
||||||
|
meshData.Vertices,
|
||||||
|
cpuIndices,
|
||||||
|
indexSegments);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var (format, batches) in meshData.TextureBatches)
|
foreach (var (format, batches) in meshData.TextureBatches)
|
||||||
{
|
{
|
||||||
|
|
@ -2093,8 +2132,25 @@ namespace AcDream.App.Rendering.Wb
|
||||||
// family. Choosing an earlier reclaimed slot first
|
// family. Choosing an earlier reclaimed slot first
|
||||||
// duplicates a layer already resident in a later array
|
// duplicates a layer already resident in a later array
|
||||||
// every time portal churn revisits that texture.
|
// every time portal churn revisits that texture.
|
||||||
atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key))
|
for (int i = 0; i < atlasList.Count; i++)
|
||||||
?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0);
|
{
|
||||||
|
if (atlasList[i].HasTexture(batch.Key))
|
||||||
|
{
|
||||||
|
atlasManager = atlasList[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (atlasManager is null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < atlasList.Count; i++)
|
||||||
|
{
|
||||||
|
if (atlasList[i].AvailableSlots > 0)
|
||||||
|
{
|
||||||
|
atlasManager = atlasList[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (atlasManager == null)
|
if (atlasManager == null)
|
||||||
{
|
{
|
||||||
atlasManager = new TextureAtlasManager(
|
atlasManager = new TextureAtlasManager(
|
||||||
|
|
@ -2180,21 +2236,34 @@ namespace AcDream.App.Rendering.Wb
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every renderBatches entry is one non-empty texture batch, so
|
||||||
|
// their index counts sum to exactly totalIndexCount.
|
||||||
long geometryBytes = checked(
|
long geometryBytes = checked(
|
||||||
(long)meshData.Vertices.Length * VertexPositionNormalTexture.Size
|
(long)meshData.Vertices.Length * VertexPositionNormalTexture.Size
|
||||||
+ renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)));
|
+ (long)totalIndexCount * sizeof(ushort));
|
||||||
|
bool hasCutoutSubset = false;
|
||||||
|
for (int i = 0; i < renderBatches.Count; i++)
|
||||||
|
{
|
||||||
|
if (renderBatches[i].Translucency
|
||||||
|
== AcDream.Core.Meshing.TranslucencyKind.ClipMap)
|
||||||
|
{
|
||||||
|
hasCutoutSubset = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cpuPositions = new Vector3[meshData.Vertices.Length];
|
||||||
|
for (int i = 0; i < cpuPositions.Length; i++)
|
||||||
|
cpuPositions[i] = meshData.Vertices[i].Position;
|
||||||
var renderData = new ObjectRenderData
|
var renderData = new ObjectRenderData
|
||||||
{
|
{
|
||||||
VertexCount = meshData.Vertices.Length,
|
VertexCount = meshData.Vertices.Length,
|
||||||
Batches = renderBatches,
|
Batches = renderBatches,
|
||||||
HasCutoutSubset = renderBatches.Any(
|
HasCutoutSubset = hasCutoutSubset,
|
||||||
static batch => batch.Translucency
|
|
||||||
== AcDream.Core.Meshing.TranslucencyKind.ClipMap),
|
|
||||||
GlobalAllocation = globalAllocation,
|
GlobalAllocation = globalAllocation,
|
||||||
ParticleEmitters = meshData.ParticleEmitters,
|
ParticleEmitters = meshData.ParticleEmitters,
|
||||||
DIDDegrade = meshData.DIDDegrade,
|
DIDDegrade = meshData.DIDDegrade,
|
||||||
CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(),
|
CPUPositions = cpuPositions,
|
||||||
CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(),
|
CPUIndices = cpuIndices,
|
||||||
CPUEdgeLines = meshData.EdgeLines,
|
CPUEdgeLines = meshData.EdgeLines,
|
||||||
MemorySize = geometryBytes,
|
MemorySize = geometryBytes,
|
||||||
NonArenaGpuBytes = CalculateNonArenaGeometryBytes(
|
NonArenaGpuBytes = CalculateNonArenaGeometryBytes(
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,10 @@ public sealed class MeshPipelineDeviceSeamTests
|
||||||
vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f);
|
vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f);
|
||||||
ushort[] indices = [0, 1, 2];
|
ushort[] indices = [0, 1, 2];
|
||||||
|
|
||||||
GlobalMeshAllocation allocation = arena.UploadMesh(vertices, [indices]);
|
GlobalMeshAllocation allocation = arena.UploadMesh(
|
||||||
|
vertices,
|
||||||
|
indices,
|
||||||
|
[(0, indices.Length)]);
|
||||||
|
|
||||||
Assert.Equal(3, allocation.Vertices.Length);
|
Assert.Equal(3, allocation.Vertices.Length);
|
||||||
Assert.Equal(3, allocation.Indices.Length);
|
Assert.Equal(3, allocation.Indices.Length);
|
||||||
|
|
@ -229,6 +232,75 @@ public sealed class MeshPipelineDeviceSeamTests
|
||||||
System.Runtime.InteropServices.MemoryMarshal.Cast<byte, ushort>(indexBytes).ToArray());
|
System.Runtime.InteropServices.MemoryMarshal.Cast<byte, ushort>(indexBytes).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #429 allocation gate (I1 style). Completing a prepared mesh on the
|
||||||
|
/// render thread must allocate near its retained pick-copy size
|
||||||
|
/// (CPUPositions + CPUIndices), not multiples of it. The regression this
|
||||||
|
/// pins: the upload conversion ran LINQ chains — a per-batch
|
||||||
|
/// <c>Indices.ToArray()</c> plus an unsized <c>SelectMany().ToArray()</c>
|
||||||
|
/// — that materialized every index three-plus times in transient garbage
|
||||||
|
/// per completed mesh, on the render thread, up to the per-frame upload
|
||||||
|
/// budget.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AWarmedMeshCompletionAllocatesNearItsRetainedCopySize()
|
||||||
|
{
|
||||||
|
using var device = new RecordingGpuDevice();
|
||||||
|
using ObjectMeshManager manager = Build(device, modernPath: true);
|
||||||
|
|
||||||
|
// Warm: an identically shaped mesh grows the arena, the atlas family,
|
||||||
|
// and every pool the completion path touches.
|
||||||
|
Assert.NotNull(manager.UploadMeshData(
|
||||||
|
CreateLargeMeshData(0x0100AA01u, surfaceSeed: 0x08000000u)));
|
||||||
|
|
||||||
|
ObjectMeshData meshData =
|
||||||
|
CreateLargeMeshData(0x0100AA02u, surfaceSeed: 0x08001000u);
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
ObjectRenderData? uploaded = manager.UploadMeshData(meshData);
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
|
||||||
|
Assert.NotNull(uploaded);
|
||||||
|
long retained =
|
||||||
|
(long)uploaded!.CPUIndices.Length * sizeof(ushort)
|
||||||
|
+ (long)uploaded.CPUPositions.Length * 3 * sizeof(float);
|
||||||
|
// Sanity: the fixture is actually index-heavy enough to discriminate.
|
||||||
|
Assert.True(retained >= 480_000, $"fixture retained only {retained} bytes");
|
||||||
|
// The LINQ regression allocates over 3x the index bytes and fails
|
||||||
|
// this bound by more than a megabyte.
|
||||||
|
long bound = retained + retained / 2 + 128 * 1024;
|
||||||
|
Assert.True(
|
||||||
|
allocated < bound,
|
||||||
|
$"A warmed mesh completion allocated {allocated} bytes "
|
||||||
|
+ $"(retained copies {retained}, bound {bound}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectMeshData CreateLargeMeshData(ulong id, uint surfaceSeed)
|
||||||
|
{
|
||||||
|
const int vertexCount = 1024;
|
||||||
|
const int batchCount = 4;
|
||||||
|
const int indicesPerBatch = 60_000;
|
||||||
|
var data = new ObjectMeshData
|
||||||
|
{
|
||||||
|
ObjectId = id,
|
||||||
|
Vertices = new VertexPositionNormalTexture[vertexCount],
|
||||||
|
};
|
||||||
|
var batches = new System.Collections.Generic.List<TextureBatchData>(batchCount);
|
||||||
|
for (int b = 0; b < batchCount; b++)
|
||||||
|
{
|
||||||
|
var indices = new System.Collections.Generic.List<ushort>(indicesPerBatch);
|
||||||
|
for (int i = 0; i < indicesPerBatch; i++)
|
||||||
|
indices.Add((ushort)((i + b) % vertexCount));
|
||||||
|
batches.Add(new TextureBatchData
|
||||||
|
{
|
||||||
|
Key = new TextureKey { SurfaceId = surfaceSeed + (uint)b },
|
||||||
|
TextureData = new byte[8 * 8 * 4],
|
||||||
|
Indices = indices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The production Vulkan implementation of the seam, checked against the
|
/// The production Vulkan implementation of the seam, checked against the
|
||||||
/// same surface. Its two capability flags answer true because what they
|
/// same surface. Its two capability flags answer true because what they
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue