diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 95d5feb6..fd6cde4d 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -588,7 +588,15 @@ internal sealed class FrameRootCompositionPhase
host.GpuFrameLifetime,
gl is not null
? new FrameProfilerGpuMeasurement(d.FrameProfiler, gl)
- : AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance,
+ // Campaign V slice V8: the Vulkan arm measures the same bracket
+ // through its own timestamp scope. It stayed on the null adapter
+ // from V6h until V8, which meant no [frame-prof] line existed on
+ // Vulkan at all — see VulkanFrameGpuMeasurement.
+ : d.Graphics.Vulkan is { } vulkanGraphics
+ ? new AcDream.App.Rendering.Gpu.Vk.VulkanFrameGpuMeasurement(
+ d.FrameProfiler,
+ vulkanGraphics.Device)
+ : AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance,
framePreparation,
worldSceneRenderer,
privatePresentation,
diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs
index 69d02add..ab083aff 100644
--- a/src/AcDream.App/Diagnostics/FrameProfiler.cs
+++ b/src/AcDream.App/Diagnostics/FrameProfiler.cs
@@ -99,6 +99,7 @@ public sealed class FrameProfiler : IDisposable
private int _currentFrameIndex = -1;
private GpuFrameTimer? _gpuTimer;
+ private bool _externalGpuActive;
private long _lastBoundaryTimestamp;
private long _lastAllocBytes;
private long _lastReportTicks;
@@ -112,6 +113,14 @@ public sealed class FrameProfiler : IDisposable
/// Most recent immutable report line, for explicit automation checkpoints.
public string? LastReport { get; private set; }
+ ///
+ /// Index of the frame currently being measured, or -1 before the first
+ /// enabled boundary. Campaign V slice V8: a backend whose GPU timer is not
+ /// GL's query ring pairs its delayed samples with this index, exactly as
+ /// does internally on the GL arm.
+ ///
+ public int CurrentFrameIndex => _currentFrameIndex;
+
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
@@ -128,7 +137,20 @@ public sealed class FrameProfiler : IDisposable
/// Call once at the accepted render-transaction boundary, before
/// .
///
- public void FrameBoundary(GL gl)
+ public void FrameBoundary(GL gl) => FrameBoundary(gl, ownsGpuTimer: true);
+
+ ///
+ /// Campaign V slice V8: the same CPU/allocation boundary with no GL context.
+ /// The Vulkan arm has no TimeElapsed query ring to own, so it drives
+ /// this overload and feeds GPU time in through
+ /// . Everything else — the CPU delta, the
+ /// per-thread allocation delta, the stage buffers, the history row and the
+ /// five-second report — is the identical code path GL uses, because a
+ /// perf gate that compared two different instruments would compare nothing.
+ ///
+ public void FrameBoundary() => FrameBoundary(null, ownsGpuTimer: false);
+
+ private void FrameBoundary(GL? gl, bool ownsGpuTimer)
{
bool enabled = RenderingDiagnostics.FrameProfEnabled;
if (!enabled)
@@ -171,7 +193,8 @@ public sealed class FrameProfiler : IDisposable
_lastReportTicks = DateTime.UtcNow.Ticks;
Array.Clear(_stageAccumTicks);
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
- if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
+ if (ownsGpuTimer && gl is not null && _gpuTimer is null && !_wbDiagActive)
+ _gpuTimer = new GpuFrameTimer(gl);
_currentFrameIndex = 0;
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
@@ -219,7 +242,8 @@ public sealed class FrameProfiler : IDisposable
int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base;
LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs,
- gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs);
+ gpuActive: _gpuTimer is not null || _externalGpuActive,
+ _allocBytes, gc0, gc1, gc2, _stageUs);
Console.WriteLine(LastReport);
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
@@ -261,6 +285,28 @@ public sealed class FrameProfiler : IDisposable
_gpuTimer?.EndFrame();
}
+ ///
+ /// Campaign V slice V8: publish one delayed GPU sample measured by a backend
+ /// that owns its own timer (Vulkan timestamp queries). Identical bookkeeping
+ /// to 's promotion loop — the sample joins the
+ /// five-second window and back-fills the history row of the frame that
+ /// issued it, so gpu_us in the CSV is never attributed to a frame
+ /// that merely happened to poll it.
+ ///
+ public void RecordGpuSample(int frameIndex, long elapsedUs)
+ {
+ if (!_wasEnabled)
+ return;
+
+ _externalGpuActive = true;
+ _gpuUs.Push(elapsedUs);
+ if (_history is not null && (uint)frameIndex < (uint)_history.Count)
+ {
+ FrameHistoryRecord row = _history[frameIndex];
+ _history[frameIndex] = row with { GpuUs = elapsedUs };
+ }
+ }
+
///
/// Attribute the enclosed CPU time to .
/// Usage: using var _ = profiler.BeginStage(FrameStage.Update);.
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameGpuMeasurement.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameGpuMeasurement.cs
new file mode 100644
index 00000000..634f797f
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameGpuMeasurement.cs
@@ -0,0 +1,56 @@
+using AcDream.App.Diagnostics;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V8: the Vulkan arm's render-transaction measurement.
+///
+/// V6h left the Vulkan arm on NullRenderFrameGpuMeasurement,
+/// which meant was never called at
+/// all — so a Vulkan run produced no [frame-prof] line, no frame-history
+/// CSV and no allocation-per-frame column. That is not a missing nicety: the R6
+/// soak waits on [frame-prof] 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.
+///
+/// The bracket is deliberately the SAME one GL uses. On GL,
+/// begins a TimeElapsed query
+/// at and ends it at , 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 gpu_ms means the same thing in both
+/// columns of the V8 table. Comparing two differently-bracketed numbers would
+/// have been worse than reporting none.
+///
+/// Timestamps resolve two or three frames late, which is why the sample
+/// carries the profiler frame index that ISSUED it
+/// () rather than being
+/// credited to the frame that happened to read it — the same pairing
+/// GpuFrameTimer performs internally on the GL arm.
+///
+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();
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
index a2f140cc..71af91f4 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
@@ -32,6 +32,23 @@ internal sealed unsafe partial class VulkanGpuDevice
private VulkanGpuPassEncoder? _openPass;
private bool _openPassIsBackbuffer;
+ ///
+ /// Campaign V slice V8: name of the whole-frame GPU timer scope. One scope
+ /// spanning the frame's entire command buffer is the Vulkan equivalent of
+ /// GL's TimeElapsed query around the render transaction, and is what
+ /// makes the two backends' gpu_ms columns the same measurement.
+ ///
+ private const string FrameTimerScopeName = "frame";
+
+ ///
+ /// Profiler frame index the scope in each flight slot was opened under. The
+ /// timestamps resolve two or three frames later, so the sample has to carry
+ /// the index of the frame that ISSUED it rather than the one that read it.
+ ///
+ private int[] _frameTimerTags = [];
+ private readonly Queue<(int Tag, long ElapsedUs)> _frameGpuSamples = new();
+ private IDisposable? _openFrameTimerScope;
+
private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory)
{
_shaderSpirvDirectory = shaderSpirvDirectory ?? string.Empty;
@@ -47,6 +64,8 @@ internal sealed unsafe partial class VulkanGpuDevice
_device,
_flights.SlotCount,
Capabilities.SupportsTimestampQueries);
+ _frameTimerTags = new int[_flights.SlotCount];
+ Array.Fill(_frameTimerTags, -1);
_textureTable = new VulkanTextureTable(
_vk,
_device,
@@ -112,7 +131,64 @@ internal sealed unsafe partial class VulkanGpuDevice
}
}
- private void BeginFrameResources(int slotIndex) => _timerPool?.BeginSlot(slotIndex);
+ private void BeginFrameResources(int slotIndex)
+ {
+ if (_timerPool is null)
+ return;
+
+ // BeginSlot reads back whatever this slot recorded the last time it was
+ // used. That measurement belongs to the frame whose tag the slot still
+ // holds — read it BEFORE BeginFrameTimerScope overwrites the tag.
+ int issuingTag = _frameTimerTags[slotIndex];
+ _timerPool.BeginSlot(slotIndex);
+ if (issuingTag >= 0
+ && _timerPool.TryTakeResolved(FrameTimerScopeName, out double milliseconds))
+ {
+ _frameGpuSamples.Enqueue((issuingTag, (long)(milliseconds * 1000d)));
+ }
+ }
+
+ ///
+ /// Campaign V slice V8: opens the whole-frame timer scope on the open
+ /// frame's command buffer, tagged with the profiler frame index that will
+ /// own the delayed result. Called by the frame spine's measurement adapter
+ /// immediately after the frame opens and before any renderer records work,
+ /// which is exactly where GL's BeginQuery sits.
+ ///
+ internal void BeginFrameTimerScope(int frameIndex)
+ {
+ if (_openFrame is null || _timerPool is null || _openFrameTimerScope is not null)
+ return;
+
+ int slot = _openFrame.SlotIndex;
+ _frameTimerTags[slot] = frameIndex;
+ _openFrameTimerScope = _timerPool.BeginScope(_commandBuffers[slot], FrameTimerScopeName);
+ }
+
+ /// Closes the whole-frame timer scope. Idempotent.
+ internal void EndFrameTimerScope()
+ {
+ _openFrameTimerScope?.Dispose();
+ _openFrameTimerScope = null;
+ }
+
+ ///
+ /// Drains one completed whole-frame GPU measurement, with the profiler frame
+ /// index that issued it. Never blocks: a frame whose timestamps have not
+ /// landed yet simply is not in the queue.
+ ///
+ internal bool TryTakeFrameGpuSample(out int frameIndex, out long elapsedUs)
+ {
+ if (_frameGpuSamples.Count == 0)
+ {
+ frameIndex = -1;
+ elapsedUs = 0;
+ return false;
+ }
+
+ (frameIndex, elapsedUs) = _frameGpuSamples.Dequeue();
+ return true;
+ }
/// Slice V6i: the descriptor-set arena for one flight slot.
private VulkanFrameBindings FrameBindingsAt(int slotIndex) => _frameBindings[slotIndex];
@@ -121,6 +197,11 @@ internal sealed unsafe partial class VulkanGpuDevice
{
_ = slotIndex;
_ = commands;
+ // Normally already closed by the measurement adapter, which brackets the
+ // same phases GL's TimeElapsed query does. This is the safety net: a
+ // scope left open would write its closing timestamp into a pool the next
+ // frame has already reset.
+ EndFrameTimerScope();
if (_openPass is not null)
{
throw new InvalidOperationException(
@@ -131,6 +212,9 @@ internal sealed unsafe partial class VulkanGpuDevice
private void DisposeResources()
{
+ _openFrameTimerScope = null;
+ _frameGpuSamples.Clear();
+ _frameTimerTags = [];
foreach (VulkanFrameBindings bindings in _frameBindings)
bindings.Dispose();
_frameBindings = [];
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
index abe24bd4..6f08a3ec 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
@@ -170,6 +170,20 @@ internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable
public bool TryResolve(string scopeName, out double milliseconds) =>
_resolved.TryGetValue(scopeName, out milliseconds);
+ ///
+ /// Campaign V slice V8: reads a resolved value and CONSUMES it, so a caller
+ /// building a distribution counts each measurement exactly once.
+ /// deliberately reports the last known value
+ /// forever — right for a diagnostic readout, wrong for a percentile.
+ ///
+ internal bool TryTakeResolved(string scopeName, out double milliseconds)
+ {
+ if (!_resolved.TryGetValue(scopeName, out milliseconds))
+ return false;
+ _resolved.Remove(scopeName);
+ return true;
+ }
+
public void Dispose()
{
if (_disposed)