perf(render): Campaign V slice V8 commit 1 - the Vulkan arm gets an instrument

V8 is the performance gate, and it opened on a plain fact: the Vulkan arm
emitted no [frame-prof] line at all. V6h wired it to
NullRenderFrameGpuMeasurement, whose BeginFrame does nothing - and that method
is the ONLY caller of FrameProfiler.FrameBoundary. So a Vulkan run produced no
CPU frame distribution, no allocation-per-frame column, no frame-history CSV
and no GPU sample. The campaign's own performance vehicle,
tools/run-connected-r6-soak.ps1, waits on [frame-prof] boundaries to time its
samples, so it could not be pointed at the backend V8 exists to judge. You
cannot measure what you have not instrumented, so the instrument lands first.

The bracket is deliberately identical on both arms. On GL,
FrameProfilerGpuMeasurement begins a TimeElapsed query at BeginFrame and ends
it at EndFrame, spanning resource preparation, the world scene and private
presentation, and NOT the swapchain present. VulkanFrameGpuMeasurement opens
and closes a Vulkan timestamp scope at exactly those two points. Two
differently-bracketed numbers in one comparison table would have been worse
than reporting none.

Vulkan timestamps resolve two or three frames late, so the sample carries the
profiler frame index that ISSUED it rather than being credited to the frame
that happened to read it - the pairing GpuFrameTimer already performs
internally on GL. VulkanGpuDevice opens the whole-frame scope tagged with that
index, reads the previous use of the slot back in BeginFrameResources BEFORE
the tag is overwritten, and hands completed (tag, microseconds) pairs to the
adapter through a bounded queue that never blocks.

VulkanGpuTimerPool gains TryTakeResolved, which consumes the value it reports.
TryResolve deliberately reports the last known measurement forever, which is
right for a diagnostic readout and wrong for a percentile: counting one
measurement into the distribution twice is how an instrument flatters itself.

FrameProfiler gains a GL-free FrameBoundary() overload and RecordGpuSample.
Every other line of its bookkeeping - the CPU delta, the per-thread allocation
delta, the stage buffers, the history row, the five-second report - is the same
code the GL arm runs. The GL path is byte-for-byte unchanged in behaviour:
FrameBoundary(GL) still owns and creates the query ring.

Gates: Release build green. App tests 4,152 passed / 3 skipped, exactly the
pre-slice baseline. Strict GL offline pixel gate against 13c8733d:
1.95e-05 (11 px of 563,200), inside the documented 9-31 px band, so GL did not
move. An offline Vulkan run now reports gpu_ms in [frame-prof] and fills gpu_us
in the frame-history CSV, where before this commit it reported neither.

No divergence-register row is owed: this is diagnostic apparatus and no
rendered pixel depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 20:35:59 +02:00
parent 13c8733d90
commit 00e1b32177
5 changed files with 213 additions and 5 deletions

View file

@ -0,0 +1,56 @@
using AcDream.App.Diagnostics;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V8: the Vulkan arm's render-transaction measurement.
///
/// <para>V6h left the Vulkan arm on <c>NullRenderFrameGpuMeasurement</c>,
/// which meant <see cref="FrameProfiler.FrameBoundary()"/> was never called at
/// all — so a Vulkan run produced no <c>[frame-prof]</c> line, no frame-history
/// CSV and no allocation-per-frame column. That is not a missing nicety: the R6
/// soak waits on <c>[frame-prof]</c> boundaries to time its samples, so the
/// campaign's performance vehicle could not be pointed at the backend it was
/// meant to judge. V8 cannot measure what it cannot instrument, so the
/// instrument lands first.</para>
///
/// <para>The bracket is deliberately the SAME one GL uses. On GL,
/// <see cref="FrameProfilerGpuMeasurement"/> begins a <c>TimeElapsed</c> query
/// at <see cref="BeginFrame"/> and ends it at <see cref="EndFrame"/>, spanning
/// resource preparation, the world scene and private presentation but not the
/// swapchain present. This adapter opens and closes a Vulkan timestamp scope at
/// exactly those two points, so <c>gpu_ms</c> means the same thing in both
/// columns of the V8 table. Comparing two differently-bracketed numbers would
/// have been worse than reporting none.</para>
///
/// <para>Timestamps resolve two or three frames late, which is why the sample
/// carries the profiler frame index that ISSUED it
/// (<see cref="VulkanGpuDevice.TryTakeFrameGpuSample"/>) rather than being
/// credited to the frame that happened to read it — the same pairing
/// <c>GpuFrameTimer</c> performs internally on the GL arm.</para>
/// </summary>
internal sealed class VulkanFrameGpuMeasurement : IRenderFrameGpuMeasurement
{
private readonly FrameProfiler _profiler;
private readonly VulkanGpuDevice _device;
internal VulkanFrameGpuMeasurement(FrameProfiler profiler, VulkanGpuDevice device)
{
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_device = device ?? throw new ArgumentNullException(nameof(device));
}
public void BeginFrame()
{
_profiler.FrameBoundary();
// Drain first: these are measurements of frames that have already
// retired, and the queue is bounded by the flight count.
while (_device.TryTakeFrameGpuSample(out int frameIndex, out long elapsedUs))
_profiler.RecordGpuSample(frameIndex, elapsedUs);
_device.BeginFrameTimerScope(_profiler.CurrentFrameIndex);
}
public void EndFrame() => _device.EndFrameTimerScope();
}