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:
Erik 2026-08-24 09:14:00 +02:00
parent 5b9d0260bb
commit 0330fcd0d1
3 changed files with 183 additions and 34 deletions

View file

@ -207,7 +207,10 @@ public sealed class MeshPipelineDeviceSeamTests
vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f);
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.Indices.Length);
@ -229,6 +232,75 @@ public sealed class MeshPipelineDeviceSeamTests
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>
/// The production Vulkan implementation of the seam, checked against the
/// same surface. Its two capability flags answer true because what they