docs(render): Campaign V slice V8 - the perf gate, measured in three configurations

The verdict splits, and not where anyone expected. Measured on one machine, one
build, one day, physical console, uncapped, 4x MSAA on both arms, validation off:

  Stationary LIGHT scene (6,675 entities, 780-870 FPS)
    CPU  p50   GL 1.127 ms   -> VK 1.294 ms    MISS  +14.8%
    CPU  p99   GL 1.407 ms   -> VK 1.531 ms    MISS   +8.8%
    GPU  p50   GL 0.651 ms   -> VK 0.160 ms    PASS  -75.4%
    Alloc/frm  GL 77,664 B   -> VK 11,440 B    PASS  -85.3%
    Working    GL 943.7 MiB  -> VK 877.1 MiB   PASS   -7.0%

  Stationary DENSE scene (21,024 entities, identical on both arms)
    CPU  p50   GL 5.934 ms   -> VK 5.775 ms    PASS   -2.7%
    CPU  p99   GL 8.867 ms   -> VK 7.354 ms    PASS  -17.1%
    GPU  p50   GL 1.673 ms   -> VK 0.909 ms    PASS  -45.7%
    Alloc/frm  GL 82,016 B   -> VK 15,752 B    PASS  -80.8%
    Process CPU GL 1.246 cores -> VK 1.016     PASS  -18.5%   (Windows, not ours)

  Canonical nine-stop route, identical world at all nine stops
    Frames     GL 30,378     -> VK 38,683      PASS  +27.3%
    CPU  p50   GL 11.718 ms  -> VK 9.166 ms    PASS  -21.8%
    GPU  p99   GL 2.325 ms   -> VK 1.193 ms    PASS  -48.7%

Vulkan loses two rows in exactly one configuration: a stationary field at a frame
rate no player will ever see. The reason is measured rather than argued. A
temporary probe on BOTH arms, now stripped, attributes 0.148 ms/frame to required
Vulkan WSI and synchronisation calls - vkQueuePresentKHR 0.070, vkQueueSubmit2
0.027, the timeline wait 0.026, vkAcquireNextImageKHR 0.025 - against roughly
0.014 ms for GL's whole SwapBuffers. That cost is FIXED per frame, so it is 12%
of a 1.13 ms frame, 2.5% of a 5.9 ms one and under 1% of a dense-town frame,
while the GPU and allocation savings scale with the work. The sign of the CPU
comparison flips as soon as the frame contains a town.

The campaign's named cost centre is closed rather than carried a fourth time.
Bindings 4, 6, 7 and 8 costing a descriptor write per draw - forward-carried
since V6i-3 as the thing to fix if CPU were short - measures 0.031 ms for ALL
~216 draws of the frame, about 140 ns each and 2.4% of it. No Vulkan code was
changed to chase the miss: every lever the V8 row named was already taken
(coherent rings, one submit per frame), irrelevant to p50 (pipeline pre-warm),
measured and small (descriptors), or would have traded real memory for nothing
(a fourth swapchain image, when acquire is call cost and not waiting).

The methodological finding is worth reading before the numbers. The R6 soak is
NOT the vehicle the founding numbers came from - the G5 production profile states
its own conditions and they exclude the probe, the artifact owner and the
screenshot oracle - and it is biased AGAINST Vulkan, because
VulkanGraphicsContext arms retainBackbufferCapture exactly when
ACDREAM_AUTOMATION_ARTIFACT_DIR is set, making every Vulkan frame copy the whole
swapchain image while GL reads on demand. On one binary in one hour the soak
reports CPU p50 7.3 ms and 2,531 KiB/frame where the ordinary profile reports
1.13 ms and 77 KiB. The route table above is therefore conservative in Vulkan's
favour: it wins on the vehicle that charges it extra.

Gates: Release build green; App tests 4,152 / 3 skipped, the pre-slice baseline,
no #250-family failure; strict GL offline pixel gate against 13c8733d at 1.95e-05
(11 px of 563,200), inside the 9-31 band, so GL did not move; one connected
Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero
errors and zero warnings; and BOTH R6 soaks green - Vulkan 506.6 s and GL 506.8 s,
zero failures, graceful exits - which discharges the soak half of V7's
outstanding list. RenderDoc is not installed on this machine, so that capture
carries to V10 with a cause rather than as an omission.

The recommendation: proceed to V10 and amend the acceptance table rather than
waive it, naming the scene and pacing the floor is judged at. Two natural
candidates are already in the evidence and Vulkan passes both outright. The
opposite reading - that the light-scene rows disqualify the cutover - is
available and has been given the same measurement space. That call is the user's
and this slice does not make it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 21:35:16 +02:00
parent 32f9bcfa60
commit 22a157225c

View file

@ -617,8 +617,8 @@ tenth pair with no consumer at all; see the V6e report.
| **V6k** ✅ | **Sky, viewports, and §5.4**, reported in §5.5.16. Two commits: **1** `SkyRenderer`'s RHI arm — V4f's content, two blend pipelines, `SkyParams` as a per-draw ring slice, the first Vulkan consumer of set 1 binding 4 — plus the retirement of the last interim `GlBindlessHandleTable` (`22aa2edc`); **2** `PrivateEntityViewportRenderer` onto `IGpuRenderTarget`, the deletion of §7.1's external-texture seam, and **§5.4's obligation discharged** (`eb7e6b4e`). **Particles did not land**: the pinned contract cannot express instanced vertex input, which is what both particle pipelines are built on. | GL pixel gate 4.43e-05 then 4.08e-05 (25 and 23 px, band 931), App tests 4,109/3, complete Release suite 9,172/5, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, a seven-day-group GL sky comparison, a connected paperdoll capture, and a GL-versus-Vulkan inspection in §5.5.16 |
| **V6l** ✅ | **Particles, the portal mask and the viewports**, reported in §5.5.17. Three commits, one per contract amendment: **1** instanced vertex input (`GpuVertexLayout` per-binding stride and input rate, `BindVertexBuffer(binding, …)`, `GpuVertexFormat.UInt1`) plus `ParticleRenderer`'s RHI arm, the standalone particle texture cache on both arms, and the stride-equals-the-uploaded-record gate for every RHI vertex layout (`b1ad1d48`); **2** the stencil dimension (`StencilTest` + `GpuStencilState` + `SetStencil`), `portal_depth` as a committed shader pair, `PortalDepthMaskRenderer`'s RHI arm, and the ambient stencil/colour-mask restore (`eced67d0`); **3** a layered sampled view per Vulkan render target, sample-count pipeline variants for `WbDrawDispatcher`, the composite texture cache on both arms, and the backend-derived viewport V orientation (`2e8b8b91`). | GL pixel gate per commit (3.20e-05, 2.31e-05, 3.55e-05; band 931 px), App tests 4,121/4,129/4,129 and complete Release suite 9,184/9,192/9,192, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, and connected Vulkan paperdoll and particle captures inspected against GL |
| **V6m** ✅ | **Portal space, and V7's instrument**, reported in §5.5.18. Two commits: **1** `PortalTunnelPresentation`'s RHI arm — the last raw-GL world-adjacent renderer — as a backbuffer pass published on `IWorldPassScope`, clearing to retail's opaque portal-space black rather than loading it, plus the deletion of `NullLocalPlayerTeleportPresentation` (`59c6b2ae`); **2** `tools/run-backend-differential-gate.ps1` and `connected-backend-differential.route.txt`, with MSAA forced off on both launches, the repeat gate's desktop-witness guards, and **an interior EnvCell stop** — §5.1's durable fix for the campaign's oldest coverage gap (`a99f517e`). One smoke pair was run and is reported in full. | GL pixel gate 4.97e-05 (28 px) vs `280f3b3f` against a 3.55e-05 (20 px) same-commit control, band 931; App tests 4,132/3 and complete Release suite 9,195/5; GL connected `-Runs 3` at 3/3 on both columns; one validation-layer Vulkan run at 0 errors / 0 warnings; connected portal-tunnel, creature-appraisal and interior-EnvCell captures on BOTH backends, inspected in §5.5.18 |
| **V7****partially discharged — §5.5.19** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1` (**built at V6m**), strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** Starting distance, measured at V6m: 18.52% of the frame at the first stop. **What landed:** the world atlases' missing anisotropy (`ad5f8b68`, retail-anchored at `0x005a4230`), and two instrument pins the gate was silently missing — the cloud sheet's phase and, much larger, the Dereth clock, which had never actually been pinned by anything and was moving 22% of the frame between two captures 45 s apart *in the same run*. Offline GL-versus-Vulkan, both clocks pinned, is now **8.82e-04 below the tree band — inside the threshold**; the treeline is `AD-46`, proven not to be the depth class. **What did NOT land:** a passing connected stop (each carries a named phase exception), per-stop masks in the gate script, an aperture stop for the portal depth mask, the R6 soak on Vulkan, and the RenderDoc capture. | every differential checkpoint passes; both connected routes green on VK |
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
| **V7****partially discharged — §5.5.19** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1` (**built at V6m**), strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** Starting distance, measured at V6m: 18.52% of the frame at the first stop. **What landed:** the world atlases' missing anisotropy (`ad5f8b68`, retail-anchored at `0x005a4230`), and two instrument pins the gate was silently missing — the cloud sheet's phase and, much larger, the Dereth clock, which had never actually been pinned by anything and was moving 22% of the frame between two captures 45 s apart *in the same run*. Offline GL-versus-Vulkan, both clocks pinned, is now **8.82e-04 below the tree band — inside the threshold**; the treeline is `AD-46`, proven not to be the depth class. **What did NOT land:** a passing connected stop (each carries a named phase exception), per-stop masks in the gate script, an aperture stop for the portal depth mask, ~~the R6 soak on Vulkan~~ (**run and passed at V8 — §5.5.21**), and the RenderDoc capture (**V8 established the cause: RenderDoc is not installed on this machine**). | every differential checkpoint passes; both connected routes green on VK |
| **V8** **measured; two floors missed; the cutover call is the user's — §5.5.21** | Perf gate on the RX 9070 XT, uncapped, both backends, same scene, same day. **What landed:** `VulkanFrameGpuMeasurement` (`00e1b321`), without which the Vulkan arm emitted no `[frame-prof]` line at all — `NullRenderFrameGpuMeasurement` was the only caller of `FrameProfiler.FrameBoundary`, so no performance vehicle could be pointed at it; the finding that **the R6 soak is NOT the vehicle §2's founding numbers came from and is biased against Vulkan** by the per-frame swapchain copy its own artifact directory arms; a same-day GL-versus-Vulkan profile on the G5 ordinary-production vehicle; a phase-level CPU attribution on both arms; and **the R6 soak run natively on Vulkan** (PASS, 0 failures, graceful exit), which V7 left outstanding. **Result, in three configurations:** on a stationary LIGHT scene at 780-870 FPS Vulkan is 75.4% cheaper on GPU p50 and 85.3% lighter on allocation but **14.8% more expensive on CPU p50 and 8.8% on p99** — two floors missed; on a stationary DENSE scene (21,024 entities, identical on both arms) **Vulkan wins every row**, including CPU p50/p99 and 18.5% less total process CPU by Windows' accounting; and on the nine-stop route against an identical world **Vulkan wins every row** and renders **27.3% more frames**. The Vulkan-specific cost is **fixed per frame** — 0.148 ms of required WSI/sync calls (present 0.070, submit 0.027, timeline wait 0.026, acquire 0.025) against GL's ~0.014 ms of present — so it dominates an almost-empty frame and disappears into a full one. **The campaign's named cost centre is closed as measured-and-not-worth-it:** bindings 4/6/7/8 cost **0.031 ms for all ~216 draws of the frame**, 2.4% of it. No Vulkan code was changed to chase the miss. **Not taken:** the RenderDoc capture — RenderDoc is not installed on this machine; it carries to V10. | §2 acceptance table; parity is the floor |
| **V9****implemented, first CI run pending — §5.5.20** | Linux + CI: a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3+ software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). **What landed:** the eleven-step job; `ACDREAM_VULKAN_PROBE_FRAMES`, without which the harness cannot terminate unattended; `tools/compile-shaders.ps1` made path-portable; and the report's jq contract pinned by App tests so a rename fails locally rather than in CI. lavapipe clears every gate requirement by source inspection, including the `samplerAnisotropy` V7 made load-bearing. **Deferred:** the physical Linux GPU row, post-cutover, as for Slice L; and Wayland, which no runner offers. **Not attempted, with cause:** a GL-versus-Vulkan pixel comparison — the GL job asserts exit 4 and so has no frame, and the probe renders synthetic scenes rather than the DAT world CI cannot have. | CI green including the new job |
| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure |
@ -2732,8 +2732,11 @@ individually, because "phase" is not an excuse unless it is specific:
3. **Whether AD-46 is visible to a human.** It is 15% of the pixels in one band at
an absolute tolerance of 2; nobody has yet looked at a Vulkan treeline beside a
GL one and said whether they can tell. That is a user-stop question.
4. **The R6 soak and a RenderDoc capture natively on Vulkan**, both named in the
V7 row and neither run.
4. ~~**The R6 soak and a RenderDoc capture natively on Vulkan**, both named in the
V7 row and neither run.~~ **Half discharged at V8 (§5.5.21): the R6 soak ran
natively on Vulkan and passed with zero failures and a graceful exit. The
RenderDoc capture has a cause rather than an omission — RenderDoc is not
installed on this machine — and carries to V10.**
**Gates.** Per commit: Release build green; App tests **4,133 / 3** then
**4,134 / 3** against the 4,132/3 baseline (three new — the sky-phase parse, the
@ -2855,6 +2858,406 @@ every extracted `run` block: 9/9 clean, no `actionlint` available locally and
none downloaded. **The job itself has not run**: its first execution is the CI
run this commit triggers, and the row stays ◐ until that is green.
#### 5.5.21 V8 (2026-07-28): the gate, and the instrument it needed first
**The headline is a split verdict, and the split is not where anyone expected.**
Measured in three configurations on the same machine, the same build and the same
day:
- **Stationary, light scene, 780-870 FPS** — Vulkan is 4.1x cheaper on GPU,
allocates 6.8x less and holds 7% less working set, and **costs 14.8% more CPU
per frame**. Two of seven floors missed.
- **Stationary, dense scene, 165-170 FPS, identical 21,024-entity world**
**Vulkan wins every row**, CPU p50 included, and uses **18.5% less total process
CPU** by Windows' own accounting.
- **Canonical nine-stop route, identical world at all nine stops** — **Vulkan
wins every row**, renders **27.3% more frames** in the same 506 seconds and
halves GPU p99.
The three do not contradict each other. The Vulkan-specific cost is **fixed per
frame**, so it dominates an almost-empty frame and disappears into a full one.
The recommendation is at the end; the measurement comes first, because V7's
lesson was that a number is worth what its instrument is worth.
---
**0. The Vulkan arm had no instrument at all** (`00e1b321`). V6h wired the
Vulkan frame spine to `NullRenderFrameGpuMeasurement`, and that class's
`BeginFrame` is the **only** caller of `FrameProfiler.FrameBoundary`. So a Vulkan
run produced no `[frame-prof]` line, no CPU frame distribution, no
allocation-per-frame column, no frame-history CSV and no GPU sample. The R6 soak
waits on `[frame-prof]` boundaries to time its samples, so the campaign's own
performance vehicle could not be pointed at the backend V8 exists to judge.
`VulkanFrameGpuMeasurement` closes it, and the bracket is deliberately the same
one GL uses — resource preparation, world scene and private presentation, not the
swapchain present — because two differently-bracketed numbers in one comparison
table are worse than none. Vulkan timestamps resolve two or three frames late, so
the sample carries the profiler frame index that ISSUED it, the pairing
`GpuFrameTimer` already performs internally on GL. `VulkanGpuTimerPool` gained
`TryTakeResolved`, which consumes what it reports: `TryResolve` reports the last
known value forever, which is right for a readout and wrong for a percentile.
---
**1. The R6 soak is NOT the vehicle the §2 table was measured with, and using it
would have produced a false verdict against Vulkan.** This is the slice's most
important methodological finding and it was nearly missed.
The founding numbers in §2 come from
`docs/research/2026-07-25-slice-g5-production-profile.md`, whose vehicle is
stated there in as many words: "a separate Release client ran uncapped with **no
UI probe or automation route; no automation artifact owner or
screenshot/current-path oracle; no developer tools; no WB diagnostic
comparison**; only the permanent frame profiler and the frame-history recorder."
The R6 soak has every one of those. Measured rather than assumed — one uncapped
GL soak run reported **CPU p50 7.3 ms, 2,531 KiB/frame allocated and a 1,709 MiB
working set** at a stationary checkpoint, against the ordinary profile's 1.13 ms,
77 KiB and 941 MiB on the same binary the same hour.
And the bias is not symmetric. `VulkanGraphicsContext` arms
`retainBackbufferCapture` **exactly when `ACDREAM_AUTOMATION_ARTIFACT_DIR` is
set**, which makes every Vulkan frame copy the whole swapchain image into a
readback buffer (`RecordBackbufferCapture`). GL has no such per-frame cost: it
reads on demand. A soak-versus-soak table would therefore have charged Vulkan for
an instrument the gate itself switched on. That is V7's finding restated, and it
is why the numbers below come from the G5 vehicle.
The G5 vehicle was reproduced with one addition — `ACDREAM_WORLD_TIME=0.5` and
`ACDREAM_DAY_GROUP=0`, V7's two instrument pins, so both arms light the same
scene the same way. **No probe script, no artifact directory, no developer tools
and no movement**: the client logs in where the character stands, settles for 100
seconds, and the final 60 are the window. Every launch variable is disclosed in
each run's `report.json`, and what is absent from that list is as much of the
method as what is present.
---
**2. Every run taken, and the conditions.** Physical console session (session 1,
2560x1440 at 240 Hz, RX 9070 XT, driver 2.0.395 / 32.0.31021.5001), Release,
windowed 1280x720, quality High, **4x MSAA on both arms** (confirmed in both
logs), uncapped (`ACDREAM_UNCAPPED_RENDER=1`; Vulkan selects
`PresentModeImmediateKhr`), live against local ACE, stationary at the Caul
plateau where the previous route left the character, final 60 seconds of a
100-second settle unless noted. Nothing is excluded from this list.
| Run | Backend | CPU p50 | p95 | p99 | GPU p50 | p99 | alloc p50 | WS MiB | Priv MiB | FPS |
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| gl-1 | GL | 1.127 | 1.256 | 1.390 | 0.651 | 0.707 | 77,664 | 941.4 | 1250.1 | 871.7 |
| gl-2 | GL | 1.130 | 1.269 | 1.441 | 0.658 | 0.717 | 77,704 | 944.0 | 1322.7 | 867.7 |
| gl-phase* | GL | 1.125 | 1.260 | 1.407 | 0.646 | 0.708 | 77,664 | 943.7 | 1340.4 | 872.1 |
| vk-1 | Vulkan | 1.319 | 1.427 | 1.543 | 0.159 | 0.189 | 11,440 | 880.0 | 1237.7 | 760.5 |
| vk-2* | Vulkan | 1.250 | 1.405 | 1.519 | 0.162 | 0.180 | 11,440 | 874.1 | 1233.6 | 778.0 |
| vk-3* | Vulkan | 1.314 | 1.409 | 1.512 | 0.159 | 0.179 | 11,400 | 870.0 | 1230.4 | 767.8 |
| vk-phase* | Vulkan | 1.273 | 1.432 | 1.546 | 0.160 | 0.185 | 11,440 | 880.3 | 1241.0 | 762.5 |
| vk-validation | Vulkan | 2.609 | 3.195 | 3.536 | 0.158 | 0.190 | 11,440 | 897.3 | 1268.3 | 371.8 |
| gl-dense† | GL | 5.934 | 6.972 | 8.867 | 1.673 | 1.858 | 82,016 | 1161.0 | 1476.0 | 165.2 |
| vk-dense† | Vulkan | 5.775 | 6.563 | 7.354 | 0.909 | 0.963 | 15,752 | 1106.6 | 1278.5 | 169.5 |
`*` carried the temporary attribution probe described in item 4. It is not an
outlier filter: the probed Vulkan runs are FASTER than the unprobed one, so the
probe is not inflating anything and the 1.25-1.32 ms spread is run-to-run
variance. GL's spread over three runs is 1.125-1.130 ms, which is 0.4%.
`†` is the **dense** stationary pair of item 3b — same spot and same camera as
the rows above it, but taken after the two soaks left the character in a Caul
ACE had grown to **21,024 entities**, so it is a different scene and belongs to
its own comparison rather than to the medians below. Both arms met the identical
population.
`vk-validation` is the required validation-layer run, reported in the same table
rather than in a footnote because it is a real run. Its cost is the layer's:
**zero validation errors and zero validation warnings** over a complete connected
session, with the loader's own `Insert instance layer
"VK_LAYER_KHRONOS_validation"` line as proof the layer was inserted rather than
assumed. Measurement runs have it off.
---
**3. The acceptance table, judged against today's GL rather than the founding
numbers.** Today's GL is faster than the founding profile on CPU and GPU and
heavier on memory, because the scene is Caul rather than Aerlinthe — so per
§2's own method requirement both are reported and the verdict is taken against
today's.
| Dimension | Founding GL (G5, Aerlinthe) | GL today (median of 3) | Vulkan today (median of 4) | Floor | Verdict |
|---|---:|---:|---:|---|---|
| CPU frame p50 | 1.869 ms | **1.127 ms** | **1.294 ms** | <= GL today | **MISS**, +14.8% |
| CPU frame p99 | 2.484 ms | **1.407 ms** | **1.531 ms** | <= GL today | **MISS**, +8.8% |
| GPU frame p50 | 1.096 ms | **0.651 ms** | **0.160 ms** | <= GL today | **PASS**, -75.4% |
| GPU frame p99 | 1.136 ms | **0.708 ms** | **0.182 ms** | <= GL today | **PASS**, -74.3% |
| Working set | 652.1 MiB | **943.7 MiB** | **877.1 MiB** | <= GL today | **PASS**, -7.0% |
| Private set | 928.3 MiB | **1322.7 MiB** | **1235.7 MiB** | <= GL today | **PASS**, -6.6% |
| Frame-thread alloc p50 | 22,880 B | **77,664 B** | **11,440 B** | <= GL today | **PASS**, -85.3% |
Two rows of that table deserve a note rather than a silent pass. **The founding
"~0 B/frame" row in §2 was never true of any whole-frame measurement**: the G5
evidence it cites records 22,880 B/frame, and §2 appears to have borrowed the
figure from the `#250` zero-allocation UNIT tests, which assert zero bytes across
named hot paths and not across a frame. Judged as written, both backends fail a
0 B whole-frame criterion; judged as a comparison, Vulkan allocates 15% of what
GL does. And **memory is higher than the founding profile on both arms** for the
same scene reason as the CPU numbers, which is why the comparison is GL-today
versus Vulkan-today and not either against G5.
---
**3b. The same stationary measurement in a DENSE scene, and it reverses the CPU
rows.** The two soaks left the character standing in a Caul that ACE had grown to
**21,024 entities**, so the ordinary-production profile was taken again on both
arms, same spot, same camera, same settings, populations identical to the entity
— everything as above except that the frame now contains a town instead of a
field.
| Dimension | GL (21,024 ent) | Vulkan (21,024 ent) | |
|---|---:|---:|---|
| CPU frame p50 | 5.934 ms | **5.775 ms** | **-2.7%** |
| CPU frame p95 | 6.972 ms | **6.563 ms** | **-5.9%** |
| CPU frame p99 | 8.867 ms | **7.354 ms** | **-17.1%** |
| GPU frame p50 | 1.673 ms | **0.909 ms** | **-45.7%** |
| GPU frame p99 | 1.858 ms | **0.963 ms** | **-48.2%** |
| Frame-thread alloc p50 | 82,016 B | **15,752 B** | **-80.8%** |
| **Process CPU, whole window** | **1.246 cores** | **1.016 cores** | **-18.5%** |
| Working set | 1,161.0 MiB | **1,106.6 MiB** | **-4.7%** |
| Private set | 1,476.0 MiB | **1,278.5 MiB** | **-13.4%** |
| FPS | 165.2 | **169.5** | **+2.6%** |
**Vulkan wins every row.** This is the cleanest comparison in the slice — one
stationary camera, one identical world, no route, no automation, no probe — and
it is the direct test of the explanation in item 4: the Vulkan-specific per-frame
cost is fixed, so it is 12% of an 1.13 ms frame and 2.5% of a 5.9 ms one, while
the GPU and allocation savings scale with the work.
The process-CPU row is worth its own sentence because it comes from Windows
rather than from acdream's own instrument: **1.016 cores against 1.246**, an
18.5% reduction in total process CPU measured by `TotalProcessorTime` across the
same 60-second window. Nothing in that number passes through `FrameProfiler`.
---
**4. Where the CPU difference is, measured rather than guessed.** A temporary
env-gated probe (`ACDREAM_VK_CPU_PROBE=1`) bracketed the frame spine's phases on
**both** arms and, on Vulkan, the four API calls that can block. It has been
stripped; it was one file plus five call sites in `RenderFrameOrchestrator`, four
in `VulkanGpuDevice` and three in `VulkanGpuPassEncoder`, and re-adding it is
twenty minutes if V10 wants it again.
| Phase (mean ms/frame) | GL | Vulkan | delta |
|---|---:|---:|---:|
| open the GPU frame | 0.001 | 0.056 | **+0.055** |
| resource preparation | 0.003 | 0.002 | -0.001 |
| world scene | 0.737 | 0.828 | **+0.091** |
| private presentation (retained UI, viewports) | 0.229 | 0.148 | **-0.081** |
| close the GPU frame | 0.000 | 0.098 | **+0.098** |
| sum of phases | 0.970 | 1.132 | +0.162 |
| whole-frame CPU p50 | 1.125 | 1.273 | +0.148 |
| remainder outside the phases | 0.155 | 0.141 | -0.014 |
The remainder matters and is easy to misread: **GL's `SwapBuffers` runs in the
Silk window loop after the render callback**, so it sits inside `cpu_ms` but
outside these brackets, while Vulkan's present sits inside "close the GPU frame".
The two remainders are within 0.014 ms of each other, which puts GL's whole
present at roughly that figure.
The Vulkan-only breakdown says the rest:
| Vulkan call, mean ms/frame | |
|---|---:|
| `vkQueuePresentKHR` | **0.070** |
| `vkQueueSubmit2` | 0.027 |
| timeline wait (`VulkanFrameFlightController.BeginFrame`) | 0.026 |
| `vkAcquireNextImageKHR` | 0.025 |
| descriptor resolve + bind + draw, **all 216 draws of the frame** | 0.031 |
| end-of-frame recording (barriers, upload record) | 0.001 |
**So the campaign's named cost centre is not the problem.** §5.5.14 item 5 and
§5.5.18 item 4 both carried "bindings 4, 6, 7 and 8 cost a descriptor write per
draw" forward to V8 as the thing to fix if CPU were short. Measured, that whole
population — the arena resolve, the `vkUpdateDescriptorSets` round and the
`vkCmdBindDescriptorSets` for every one of the frame's ~216 draws — is **0.031
ms, about 140 ns per draw and 2.4% of the frame**. Fixing it perfectly would
recover a fifth of the gap, and §5.5.14 already records that fixing it means
changing the shaders' indexing rather than the layout. **That item can be closed
as measured-and-not-worth-it rather than carried again.**
What the gap actually is: **0.148 ms of per-frame WSI and synchronisation that
Vulkan requires and GL does not expose.** Present, submit, acquire and the
timeline wait total 0.148 ms against GL's ~0.014 ms of present, and none of the
four is a call acdream chooses to make. The world phase's remaining +0.091 ms is
the only part inside our code, and 0.031 ms of it is the descriptor population
above.
---
**5. What was tried, and what was deliberately not.** Of the levers the V8 row
names:
- **Coherent-versus-flush rings** — already taken. Every ring and staging
allocation is `HOST_VISIBLE | HOST_COHERENT` (`VulkanMemoryModel`), so there is
no per-allocation `vkFlushMappedMemoryRanges` to remove.
- **Submit consolidation** — already taken. One `vkQueueSubmit2` per frame,
measured at 0.027 ms.
- **Pipeline pre-warm coverage** — irrelevant to p50 by construction; every
pipeline is built at startup and the pipeline cache is reused from disk
(confirmed in the run logs). It could only move max, and max is 2.65-2.99 ms
on Vulkan against 2.48-2.77 ms on GL, which is the same population.
- **Descriptor writes per draw** — measured at 0.031 ms and left alone, above.
- **A fourth swapchain image / a third frame in flight** — considered and NOT
attempted. Acquire and the timeline wait cost 0.025 and 0.026 ms while the GPU
finishes its frame in **an eighth** of the CPU's time (0.160 ms against 1.294),
so nothing is actually waiting and those figures are the cost of making the
calls. A fourth image would buy nothing and cost real memory. Shipping it would
have been exactly the unproven optimization the slice brief forbids.
**No change was made to the Vulkan backend in this slice beyond the instrument.**
That is the honest outcome of the attribution: there was nothing to fix that was
worth its risk, and inventing one to make a table go green would have been the
worse failure.
---
**6. The route gates. The R6 soak has now run natively on Vulkan, which V7 left
outstanding** (`logs/connected-r6-soak-20260728-210728.report.json`): **PASS, zero
failures, exit code 0, graceful WM_CLOSE exit**, 506.6 s, all nine canonical
checkpoints in order with every reveal materialized/completed/observed, every
`streamingWork` backlog at zero and every named checkpoint artifact present.
Its warnings are the documented benign set — the 25 world-edge landblock misses,
19 DAT-driven VFX diagnostics, and ACE population drift at the Caul plateau
oracle. **A complete connected session on Vulkan: nine teleports, three
movement/jump/combat exercises, nine screenshots, and a clean shutdown.**
**The same route was then run on GL the same hour**
(`logs/connected-r6-soak-20260728-211617.report.json`): **PASS, zero failures,
exit code 0, graceful exit**, 506.8 s against Vulkan's 506.6 s. Its warnings are
the same benign set. **And the two runs met an identical world**: 21,031 /
6,675 / 9,404 / ~6,660 / 6,675 / 10,383 / 21,031 / 6,675 / 21,031 entities at
the nine stops, the same number at every one. That makes them a matched pair
rather than two runs that happen to share a script.
**Which produces the finding that most complicates this slice's verdict, and it
complicates it in Vulkan's favour.** Over the whole route, from the frame
histories:
| Route-wide, 506 s, matched populations | GL | Vulkan | |
|---|---:|---:|---|
| Frames rendered | 30,378 | **38,683** | **+27.3%** |
| CPU p50 | 11.718 ms | **9.166 ms** | **-21.8%** |
| CPU p99 | 51.089 ms | **42.826 ms** | **-16.2%** |
| GPU p50 | 0.702 ms | **0.167 ms** | **-76.2%** |
| GPU p99 | 2.325 ms | **1.193 ms** | **-48.7%** |
| Allocation p50 | 4,318,088 B | **4,244,984 B** | -1.7% |
| Peak working set | 2,318.5 MiB | **2,295.4 MiB** | -1.0% |
**On the canonical nine-stop route Vulkan is 21.8% FASTER on CPU p50, not 14.8%
slower.** The two results are not in conflict; they are the same fixed cost
divided by two very different frames. The stationary profile runs a light scene
at 780-870 FPS, where 0.148 ms of per-frame WSI is 12% of the budget. The route
moves, teleports, streams and stands in towns of ten and twenty thousand
entities at 11-45 FPS, where the same 0.148 ms is under 1% and Vulkan's cheaper
submission, cheaper GPU and lighter allocation carry the frame instead.
**And this comparison is conservative in Vulkan's favour**, because it is taken
on the vehicle that charges Vulkan a full-resolution swapchain copy every frame
and charges GL nothing. Vulkan wins it anyway.
Also worth reading twice: the route's GPU p99 is **2.325 ms on GL against 1.193
on Vulkan**. Whatever headroom the campaign wanted for future content, that is
where it is.
One observation worth keeping, because it bears on any future use of the soak
for numbers and because the matched pair above was luck as much as method: the
local ACE world is not a fixed fixture. An earlier aborted GL run met 6,675
entities at Caul; the two runs compared above both met **21,031** there, forty
minutes later. A pair taken an hour apart would not have been comparable at all.
Check the populations before believing any two soak runs, and note that the
stationary profile above is immune to this because it never leaves one spot.
---
**7. The RenderDoc capture the V7 list carried is still not taken**, and now with
a stated cause rather than an omission: **RenderDoc is not installed on this
machine** — not in either Program Files tree, not in `LOCALAPPDATA`, not on
`PATH`, and not in the uninstall registry, alongside neither Nsight nor PIX. It
carries to V10, which is the right place for it anyway: a capture of the backend
that is about to become the client is worth more than a capture of one that is
still dark.
---
**8. Gates.** Release build green. App tests **4,152 passed / 3 skipped**,
exactly the pre-slice baseline, with no member of the `#250` family failing.
Strict GL offline pixel gate against `13c8733d`: **1.95e-05 (11 differing pixels
of 563,200)**, inside the documented 9-31 px band — **GL did not move**, which is
what the two shared files the instrument touches (`FrameProfiler`,
`FrameRootComposition`) owed. One connected Vulkan run with
`VK_LAYER_KHRONOS_validation` proven inserted by the loader: **zero errors, zero
warnings**. No divergence-register row is owed in either direction: the slice
adds diagnostic apparatus and changes no rendered pixel.
---
**9. The verdict, and the recommendation.**
**Floors met: 5 of 7 measured dimensions. CPU frame p50 and CPU frame p99 are
not met**, by 14.8% and 8.8%.
**The campaign's absolute targets** (§2's middle column) tell a different and
sharper story. Vulkan **beats** the CPU p50 target of 1.60 ms at 1.294 ms and the
GPU p50 target of 1.00 ms at 0.160 ms, and **misses** the working-set target of
600 MiB at 877 MiB, the private-set target of 860 MiB at 1,236 MiB and the 0 B
allocation target at 11,440 B. But GL misses all three of those too, by more —
943 MiB, 1,323 MiB and 77,664 B — at this scene. **The absolute memory targets
were written from a measurement at Aerlinthe and are out of reach for either
backend at Caul**, which is a fact about the target, not about Vulkan.
So the relative floor is the only meaningful test, and it was applied to three
independent configurations:
| Configuration | Rows missed by Vulkan |
|---|---|
| Stationary, light scene (6,675 entities, 780-870 FPS) | **CPU p50, CPU p99** |
| Stationary, dense scene (21,024 entities, 165-170 FPS) | **none — Vulkan wins every row** |
| Canonical nine-stop route, matched world | **none — Vulkan wins every row** |
**The only configuration in which Vulkan loses anything is a stationary field at
a frame rate no player will ever see.** Add a town to the frame, or move through
the world, and the sign flips on every dimension including total process CPU,
which Windows measures rather than acdream.
**V8 therefore cannot declare a clean pass of the gate as literally written, and
would be misrepresenting the evidence if it declared a failure.** What the one
missing configuration is made of:
- It is **0.148 ms/frame of required Vulkan WSI and synchronisation calls**, not
a defect, not a missing optimization, and not in acdream's code.
- It is a **fixed** cost, and that is the whole explanation. It is 12% of a
1.13 ms frame, 2.5% of a 5.9 ms one, and under 1% of the route's dense-town
frames — measured, not modelled, in items 3b and 6. The GPU and allocation
savings scale with the work instead, which is why the sign flips.
- Against it stand: **every row on the dense stationary pair**, including 18.5%
less total process CPU as measured by Windows; **every row on the route**,
including 27.3% more frames and a halved GPU p99; a **75-46% GPU reduction**
and an **85-81% allocation reduction** in every configuration tested; the
compatibility case that started the campaign; and a zero-error validation run.
**The recommendation is to proceed to V10, and to amend §2's acceptance table
rather than waive it.** A floor of "no worse than GL on any dimension" is met in
both configurations that resemble playing the game and missed only in one that
does not. The honest amendment names the scene and the pacing the floor is judged
at — and a natural choice is already sitting in the evidence: the dense
stationary pair and the nine-stop route, both of which Vulkan passes outright.
The opposite reading, that the light-scene rows are disqualifying, is available
and this slice has given it the same measurement space as the rest. **The call is
the user's, and this slice does not make it.**
Three smaller corrections V8 recommends to §2 while it is open: the "~0 B/frame"
allocation row should say what it means (the `#250` unit-test criterion, or a
comparative frame-thread figure — it is not a whole-frame absolute); the table
should name its measurement vehicle, because the obvious candidate is the wrong
one and cost this slice an hour to establish; and the founding numbers should be
labelled with their scene, because Caul and Aerlinthe differ by more than the
entire GL-versus-Vulkan CPU gap.
### 5.4 The null-target `BeginPass` divergence (V4c) — ✅ DISCHARGED at V6k
> **Closed 2026-07-28 by V6k commit 2 (`eb7e6b4e`); see §5.5.16.** The answer is