perf(vk): Arc cards read the occlusion counts from a GPU copy: Castle Town's market from 34.8 to 45.8 fps on a B580 #761

Merged
HumanBean merged 4 commits from nyannoying/Longshot:perf/vk-occ-copy into main 2026-11-15 10:22:55 +00:00
Contributor

Type: perf · Risk: low · Tested on: Windows 11, Arc B580 · Needs: Linux, any GPU (a build and a short run with RX_VK_OCC_COPY=1)

Why

In busy places like Castle Town's market the game waits on the CPU, not the GPU. On an Arc B580 about a quarter of
the render thread's time went into reading back the occlusion counts: the numbers the game uses to leave out objects
hidden behind others (the GPU's real counts, #122). Intel's Windows driver takes ~3.4 µs for each count it is asked
for, and the replay asked for about 2,000 a frame: 7.4 ms of a 28.8 ms frame. With this PR the GPU writes the counts
into ordinary memory itself, and reading them costs next to nothing: the market runs at 45.8 fps instead of 34.8 on
the B580.

See #122.

What changed

At the end of each recording the GPU copies the results of the occlusion queries that recording ran into a buffer in
host memory (vkCmdCopyQueryPoolResults), and the harvest reads them there as plain memory instead of asking the
driver for each one. A count is taken only once its availability word and its value are both written; whatever a copy
didn't have is read from the query pool once its submission is done, as before. On by default with Intel's Windows
driver on a discrete GPU (Arc), where it was measured; RX_VK_OCC_COPY=0 or 1 turns it off or on anywhere.

  • gpu/vk/vk_replay.cpp: occ_copy_wanted() (the default and the switch); the host buffer occ_host, made in
    occ_queries_on() (host-cached and coherent, else the pool is read); occ_copy_part(), called after the
    recording's end barrier in maybe_split() and finish_recording(), copies the queries occ_take() handed out in
    that recording (occ_part_lo/occ_part_hi, forgotten by every submit_recording()) and makes them the host's with
    a transfer → host barrier; occ_collect() reads the copy, and the pool (vkGetQueryPoolResults) only for what the
    copy didn't have, once the submission is done; occ_reset() clears the host words with the queries; the
    RX_VK_OCC_LOG line counts the copies and the pool reads after a copy.
  • gpu/vk/vk_recorder.h, gpu/vk/vk_work.h: vkCmdCopyQueryPoolResults goes through the recorder like the other
    commands, and counts as work for full_barrier.
  • gpu/vk/vk_vma.cpp, gpu/vk/vk_internal.h: host_coherent().
  • tests/test_vk_occlusion_queries.py: the ring's harvest from the copies (no pool read when the copy has the count;
    the pool read for a query not done at the copy and for a recording that ended without one; a count without its
    availability word, or an availability word without its count, not taken; a never-drawn query stays visible; two
    copies across the ring's end; the host words cleared at the reset), occ_copy_wanted() for each kind of GPU and the
    switch, and the wiring (the copy after the end barrier and before the submit, the range forgotten at every submit).
  • tests/recorder_sync_test.cpp, tests/draw_stream_visibility_test.cpp, tests/fsr_image_retirement_test.cpp: the
    checks that cut submit_recording, finish_recording and the recorder's calls out of the source know the new names;
    the recorder's check records the copy through the recorder like the other packed calls.
  • docs/development.md: the copy and RX_VK_OCC_COPY, with the numbers; docs/tested/: the 6 runs.

Measurements

Verdict: faster on the Arc B580 in the market: 34.8 → 45.8 fps average (+32%), 1% low 22.7 → 25.3 fps, median
frame time 27.59 → 20.41 ms; the counts' reads went from 7.43 to 0.11 ms a frame, for 2.0 ms more GPU time a frame
(10.2 → 12.2 ms). The picture is the same.

Baseline: RX_VK_OCC_COPY=0 on the same build, which reads the query pool exactly as main does (none of the new code
runs). Both columns are one build: 690d8446, this PC's game folder (main at 2b1d38c6 with my open #443, #446, #459 and #500
merged), with this PR's diff applied: main's renderer (MKWVK_API_VERSION 23 since #729) doesn't load in that
folder's host (22), and a new folder for main means preparing the 38,809 shaders again. The shader stamp is the same
at 2b1d38c6, 690d8446 and 9e6f231d, and neither the four PRs nor main since 2b1d38c6 touches the occlusion code.
Measured before the rebase onto a00c485c, which adds only an agents' skill (#742): the numbers hold.

Hardware: Windows 11 Pro Insider 10.0.29704, Intel Arc B580 12 GB (driver 32.0.101.9154), Core i5-13600K, 64 GB
Scene: Castle Town's market as child Link by day, standing at the entrance (the control bridge's teleport Market_Child); 1280x720 window, render 75% with Lanczos, frame generation off, V-Sync Auto, the console's 60 fps cap
(never reached)
Method: 3 interleaved pairs, each from a fresh start (on, off, off, on, on, off): 45 s after the teleport, then 45 s
measured. fps and frame times from RX_VK_FRAME_LOG (every present of the 45 s; the 1% low is the rate of the slowest
1% of frames); the render thread's time from HOS_PROFILE=5; the counts' reads, copies and zeros from
RX_VK_OCC_LOG=1; GPU time from the log's GPU time line (timestamps around each recording). Each cell is the median
of the arm's 3 runs, the runs in brackets.
How to reproduce: python -I scripts/longshot_ctl.py launch <game folder> --env RX_VK_OCC_LOG=1 --env RX_VK_FRAME_LOG=frames.csv [--env RX_VK_OCC_COPY=0], then longshot_ctl.py teleport Market_Child, 45 s, and 45 s
measured.

Before, RX_VK_OCC_COPY=0 (the pool read, as on main): this PR @ c0da900e After, the default (the copies): this PR @ c0da900e Δ
Average fps 34.8 (33.7, 35.5, 34.8) 45.8 (45.8, 45.6, 46.1) +32%
1% low 22.7 fps (21.6, 22.7, 24.1) 25.3 fps (23.9, 25.3, 27.9) +11%
Median frame time 27.59 ms (28.23, 26.95, 27.59) 20.41 ms (20.21, 20.41, 20.59) −7.18 ms
p99 frame time 40.75 ms (43.26, 40.70, 40.75) 37.71 ms (39.37, 37.71, 34.22) −3.04 ms
The render thread, a frame 28.80 ms 21.86 ms −6.94 ms
The counts' reads, a frame 7.43 ms 0.11 ms −7.32 ms
GPU time, a frame 10.2 ms 12.2 ms +2.0 ms
Reports that read 0 53.9% 53.5%
A count written after its report 0.6 recordings 2.2 recordings
Copies, a frame none 14.0 (1,388 queries)

The render thread's submits take 4 ms more a frame with the copies (2.8 → 6.8 ms) while its other jobs drop from 23.0
to 11.9 ms: most likely the wait for the recorder thread, which each pool read began with (vkGetQueryPoolResults
drains it), now comes at the submit instead. Where the 2 ms of GPU time go, from a diagnostic build (690d8446 with this
change and my other work in progress, one 30 s run each, not part of this PR): the copies without the host barrier
12.2 ms, the barrier without the copies 11.2 ms, the copies without the availability words 12.7 ms, both 12.2 ms,
neither 10.2 ms. It is the copies themselves, ~1.4 µs a query on the GPU; the barrier adds nothing next to them.

P0-3, RX_VK_OCC_COPY=0 (median run of the three):
vk: occlusion queries on (RX_VK_OCC_QUERIES): the game's samples-passed reports get the GPU's counts; read from the pool
vk: occlusion queries: 5161 reports written (2780 of them 0, 53.9%), 0 left visible (a draw did not draw), 0 reported again first; written 0.6 recordings after the report; 0 waiting; 80 reads (38 wrote), 412.7 us each; the others: 2033 samples on average, 1145 under 64; runs with colour draws 0 (0 of them 0); queries not begun: 0 draws returned before drawing, 0 with vertex input not all bound, 0 task batches; 0 runs split by another queue's queries; 4984 slots made visible ahead of a stopped queue (20 walks, 44.1 us each); 0 copies, 0.0 queries each, 0 queries read from the pool after their copy (RX_VK_OCC_COPY)
vk: a frame's replay time (ms): submit 25.07, present 0.08, draw 11.59, descriptors 7.78, texture lookups 1.66, uploads 1.56, pipelines 0.35, programs 0.92, commands 1.73, dispatch 1.04, memory sync 1.94, queue submit 0.65, slot wait 0.12
vk: the render thread (600 presents, 28.87 ms a frame): its jobs 26.07 ms a frame (submits 2.78, presents 0.30, others 22.99), idle 2.79
vk: GPU time 10.3 ms a frame (the longest submission 2.7 ms); 0.1 textures made a frame
vi: 5222 frames presented (34.8 fps over the last 5.0 s, interval 1)
RX_VK_FRAME_LOG: 1560 frames over 44.9 s; average 34.8 fps, 1% low 24.1 fps; frame time median 27.59 ms, p99 40.75 ms

P1-2, the default (median run of the three):
vk: occlusion queries on (RX_VK_OCC_QUERIES): the game's samples-passed reports get the GPU's counts; copied into host memory at each recording's end (RX_VK_OCC_COPY)
vk: occlusion queries: 5060 reports written (2737 of them 0, 54.1%), 0 left visible (a draw did not draw), 15 reported again first; written 2.0 recordings after the report; 6 waiting; 86 reads (59 wrote), 5.2 us each; the others: 2085 samples on average, 1103 under 64; runs with colour draws 0 (0 of them 0); queries not begun: 0 draws returned before drawing, 0 with vertex input not all bound, 0 task batches; 0 runs split by another queue's queries; 4526 slots made visible ahead of a stopped queue (20 walks, 39.8 us each); 63 copies, 96.9 queries each, 0 queries read from the pool after their copy (RX_VK_OCC_COPY)
vk: a frame's replay time (ms): submit 17.97, present 0.09, draw 10.85, descriptors 7.08, texture lookups 1.11, uploads 1.04, pipelines 0.34, programs 0.93, commands 1.73, dispatch 1.05, memory sync 1.93, queue submit 0.60, slot wait 0.11
vk: the render thread (600 presents, 21.89 ms a frame): its jobs 19.09 ms a frame (submits 6.83, presents 0.35, others 11.91), idle 2.80
vk: GPU time 12.0 ms a frame (the longest submission 2.5 ms); 0.0 textures made a frame
vi: 5952 frames presented (46.1 fps over the last 5.0 s, interval 1)
RX_VK_FRAME_LOG: 2040 frames over 44.7 s; average 45.6 fps, 1% low 25.3 fps; frame time median 20.41 ms, p99 37.71 ms

Flags and settings

One new switch, RX_VK_OCC_COPY, on by default where it was measured.

Flag What it is Before this PR After this PR Effect Helps / doesn't help / untested Recommendation
RX_VK_OCC_COPY The occlusion counts read from the GPU's copy in host memory (1) or from the query pool (0) (none: the pool) 1 with Intel's Windows driver on a discrete GPU; 0 elsewhere, with the parallel replay (RX_VK_PARALLEL) and without coherent host memory +32% fps in the market on a B580; 2 ms more GPU time a frame Helps: Arc B580 on Windows. Untested: Arc A-series, Intel iGPUs (mostly held back by the GPU, where the copies' GPU time would cost frames), AMD, NVIDIA, Linux On as shipped; elsewhere off until a run shows the copies pay there

Testing

Tested:

  • The game-free checks, python -I -B tests/run_all.py with the toolchain's Vulkan headers (RX_CPM), on this PR and
    on main 9e6f231d on the same PC (before the rebase onto a00c485c, which changes no code): 540 passed, 18 failed, 35 skipped on both, the same 18 failing the same way on
    main (e.g. test_env_on on RX_VK_PRESENT_AFTER_SIGNAL, test_tested_configurations' CLI pipe on Windows line
    ends); the 35 skipped run on Linux or need spirv-val or glslang. tests/test_vk_occlusion_queries.py passes all four
    parts, and fails as it should with any of three deliberate bugs put in: the host words not cleared at the reset, an
    availability word taken without its count, the range not forgotten at a submit.
  • In the game, Windows 11, Arc B580, driver 101.9154: Castle Town's market, 3 runs with the copies and 3 without
    (above), no device lost, the same picture each way: nothing missing, nothing extra. Link flashes red in two of the
    copies' end-of-run screenshots: that is the game's low-health warning (he is on one heart), and nine shots 0.4 s
    apart caught it each way (2 of 9 with the pool read, 1 of 9 with the copies). The log's copied into host memory at each recording's end line with the default, and 0 counts read from the pool after their copy in every run.

Not tested: Linux (the copies are off there by default; the code uses no platform API), Arc A-series cards,
Intel iGPUs, AMD and NVIDIA with RX_VK_OCC_COPY=1. The parallel replay keeps the pool read.

Must be tested:

  • Linux, any GPU (Test Farm: @Beanstalk linux): build this PR, then play Castle Town's market for a minute with
    RX_VK_OCC_COPY=1 RX_VK_OCC_LOG=1, expecting the build to pass, the log's occlusion queries on line to end with
    copied into host memory at each recording's end (RX_VK_OCC_COPY), its occlusion queries: lines to show copies
    with few or no queries read from the pool after their copy, and the picture the same as with RX_VK_OCC_COPY=0.

Risks

What could break: a count read wrong would hide an object that is in view. The GPU writes a count and its
availability word in either order, so a count is taken only when both are there (the test covers both orders), and an
unknown count stays "visible" as before. The copies cost GPU time: 2 ms a frame in the market on the B580 at 75% of
720p, which a GPU-bound Arc (an A380, or 4K) would pay in frames. The counts land ~2.2 recordings after their report
instead of ~0.6, still well inside the frame or two the game waits (the share read 0 is the same: 53.5% with the
copies, 53.9% without).

Turning it off: RX_VK_OCC_COPY=0.

Compatibility: Nothing changes: no option, cache or save format, and the renderer's interface is the same
(MKWVK_API_VERSION unchanged).

Issues

See #122

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named).
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Numbers from the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value.
  • A file in docs/tested/ for each new measured run (format in docs/tested-configurations.md).
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (Built on Windows only; the Test
    Farm's Linux run under Must be tested builds it.)
  • Docs updated.

🤖 Generated with Claude Code

**Type:** perf · **Risk:** low · **Tested on:** Windows 11, Arc B580 · **Needs:** Linux, any GPU (a build and a short run with `RX_VK_OCC_COPY=1`) ## Why In busy places like Castle Town's market the game waits on the CPU, not the GPU. On an Arc B580 about a quarter of the render thread's time went into reading back the occlusion counts: the numbers the game uses to leave out objects hidden behind others (the GPU's real counts, #122). Intel's Windows driver takes ~3.4 µs for each count it is asked for, and the replay asked for about 2,000 a frame: 7.4 ms of a 28.8 ms frame. With this PR the GPU writes the counts into ordinary memory itself, and reading them costs next to nothing: the market runs at 45.8 fps instead of 34.8 on the B580. See #122. ## What changed At the end of each recording the GPU copies the results of the occlusion queries that recording ran into a buffer in host memory (`vkCmdCopyQueryPoolResults`), and the harvest reads them there as plain memory instead of asking the driver for each one. A count is taken only once its availability word and its value are both written; whatever a copy didn't have is read from the query pool once its submission is done, as before. On by default with Intel's Windows driver on a discrete GPU (Arc), where it was measured; `RX_VK_OCC_COPY=0` or `1` turns it off or on anywhere. - `gpu/vk/vk_replay.cpp`: `occ_copy_wanted()` (the default and the switch); the host buffer `occ_host`, made in `occ_queries_on()` (host-cached and coherent, else the pool is read); `occ_copy_part()`, called after the recording's end barrier in `maybe_split()` and `finish_recording()`, copies the queries `occ_take()` handed out in that recording (`occ_part_lo`/`occ_part_hi`, forgotten by every `submit_recording()`) and makes them the host's with a transfer → host barrier; `occ_collect()` reads the copy, and the pool (`vkGetQueryPoolResults`) only for what the copy didn't have, once the submission is done; `occ_reset()` clears the host words with the queries; the `RX_VK_OCC_LOG` line counts the copies and the pool reads after a copy. - `gpu/vk/vk_recorder.h`, `gpu/vk/vk_work.h`: `vkCmdCopyQueryPoolResults` goes through the recorder like the other commands, and counts as work for `full_barrier`. - `gpu/vk/vk_vma.cpp`, `gpu/vk/vk_internal.h`: `host_coherent()`. - `tests/test_vk_occlusion_queries.py`: the ring's harvest from the copies (no pool read when the copy has the count; the pool read for a query not done at the copy and for a recording that ended without one; a count without its availability word, or an availability word without its count, not taken; a never-drawn query stays visible; two copies across the ring's end; the host words cleared at the reset), `occ_copy_wanted()` for each kind of GPU and the switch, and the wiring (the copy after the end barrier and before the submit, the range forgotten at every submit). - `tests/recorder_sync_test.cpp`, `tests/draw_stream_visibility_test.cpp`, `tests/fsr_image_retirement_test.cpp`: the checks that cut `submit_recording`, `finish_recording` and the recorder's calls out of the source know the new names; the recorder's check records the copy through the recorder like the other packed calls. - `docs/development.md`: the copy and `RX_VK_OCC_COPY`, with the numbers; `docs/tested/`: the 6 runs. ## Measurements **Verdict:** faster on the Arc B580 in the market: 34.8 → 45.8 fps average (+32%), 1% low 22.7 → 25.3 fps, median frame time 27.59 → 20.41 ms; the counts' reads went from 7.43 to 0.11 ms a frame, for 2.0 ms more GPU time a frame (10.2 → 12.2 ms). The picture is the same. Baseline: `RX_VK_OCC_COPY=0` on the same build, which reads the query pool exactly as `main` does (none of the new code runs). Both columns are one build: 690d8446, this PC's game folder (`main` at 2b1d38c6 with my open #443, #446, #459 and #500 merged), with this PR's diff applied: `main`'s renderer (`MKWVK_API_VERSION` 23 since #729) doesn't load in that folder's host (22), and a new folder for `main` means preparing the 38,809 shaders again. The shader stamp is the same at 2b1d38c6, 690d8446 and 9e6f231d, and neither the four PRs nor `main` since 2b1d38c6 touches the occlusion code. Measured before the rebase onto a00c485c, which adds only an agents' skill (#742): the numbers hold. Hardware: Windows 11 Pro Insider 10.0.29704, Intel Arc B580 12 GB (driver 32.0.101.9154), Core i5-13600K, 64 GB Scene: Castle Town's market as child Link by day, standing at the entrance (the control bridge's `teleport Market_Child`); 1280x720 window, render 75% with Lanczos, frame generation off, V-Sync Auto, the console's 60 fps cap (never reached) Method: 3 interleaved pairs, each from a fresh start (on, off, off, on, on, off): 45 s after the teleport, then 45 s measured. fps and frame times from `RX_VK_FRAME_LOG` (every present of the 45 s; the 1% low is the rate of the slowest 1% of frames); the render thread's time from `HOS_PROFILE=5`; the counts' reads, copies and zeros from `RX_VK_OCC_LOG=1`; GPU time from the log's `GPU time` line (timestamps around each recording). Each cell is the median of the arm's 3 runs, the runs in brackets. How to reproduce: `python -I scripts/longshot_ctl.py launch <game folder> --env RX_VK_OCC_LOG=1 --env RX_VK_FRAME_LOG=frames.csv [--env RX_VK_OCC_COPY=0]`, then `longshot_ctl.py teleport Market_Child`, 45 s, and 45 s measured. | | Before, `RX_VK_OCC_COPY=0` (the pool read, as on `main`): this PR @ c0da900e | After, the default (the copies): this PR @ c0da900e | Δ | |---|---|---|---| | Average fps | 34.8 (33.7, 35.5, 34.8) | 45.8 (45.8, 45.6, 46.1) | +32% | | 1% low | 22.7 fps (21.6, 22.7, 24.1) | 25.3 fps (23.9, 25.3, 27.9) | +11% | | Median frame time | 27.59 ms (28.23, 26.95, 27.59) | 20.41 ms (20.21, 20.41, 20.59) | −7.18 ms | | p99 frame time | 40.75 ms (43.26, 40.70, 40.75) | 37.71 ms (39.37, 37.71, 34.22) | −3.04 ms | | The render thread, a frame | 28.80 ms | 21.86 ms | −6.94 ms | | The counts' reads, a frame | 7.43 ms | 0.11 ms | −7.32 ms | | GPU time, a frame | 10.2 ms | 12.2 ms | +2.0 ms | | Reports that read 0 | 53.9% | 53.5% | | | A count written after its report | 0.6 recordings | 2.2 recordings | | | Copies, a frame | none | 14.0 (1,388 queries) | | The render thread's submits take 4 ms more a frame with the copies (2.8 → 6.8 ms) while its other jobs drop from 23.0 to 11.9 ms: most likely the wait for the recorder thread, which each pool read began with (`vkGetQueryPoolResults` drains it), now comes at the submit instead. Where the 2 ms of GPU time go, from a diagnostic build (690d8446 with this change and my other work in progress, one 30 s run each, not part of this PR): the copies without the host barrier 12.2 ms, the barrier without the copies 11.2 ms, the copies without the availability words 12.7 ms, both 12.2 ms, neither 10.2 ms. It is the copies themselves, ~1.4 µs a query on the GPU; the barrier adds nothing next to them. ``` P0-3, RX_VK_OCC_COPY=0 (median run of the three): vk: occlusion queries on (RX_VK_OCC_QUERIES): the game's samples-passed reports get the GPU's counts; read from the pool vk: occlusion queries: 5161 reports written (2780 of them 0, 53.9%), 0 left visible (a draw did not draw), 0 reported again first; written 0.6 recordings after the report; 0 waiting; 80 reads (38 wrote), 412.7 us each; the others: 2033 samples on average, 1145 under 64; runs with colour draws 0 (0 of them 0); queries not begun: 0 draws returned before drawing, 0 with vertex input not all bound, 0 task batches; 0 runs split by another queue's queries; 4984 slots made visible ahead of a stopped queue (20 walks, 44.1 us each); 0 copies, 0.0 queries each, 0 queries read from the pool after their copy (RX_VK_OCC_COPY) vk: a frame's replay time (ms): submit 25.07, present 0.08, draw 11.59, descriptors 7.78, texture lookups 1.66, uploads 1.56, pipelines 0.35, programs 0.92, commands 1.73, dispatch 1.04, memory sync 1.94, queue submit 0.65, slot wait 0.12 vk: the render thread (600 presents, 28.87 ms a frame): its jobs 26.07 ms a frame (submits 2.78, presents 0.30, others 22.99), idle 2.79 vk: GPU time 10.3 ms a frame (the longest submission 2.7 ms); 0.1 textures made a frame vi: 5222 frames presented (34.8 fps over the last 5.0 s, interval 1) RX_VK_FRAME_LOG: 1560 frames over 44.9 s; average 34.8 fps, 1% low 24.1 fps; frame time median 27.59 ms, p99 40.75 ms P1-2, the default (median run of the three): vk: occlusion queries on (RX_VK_OCC_QUERIES): the game's samples-passed reports get the GPU's counts; copied into host memory at each recording's end (RX_VK_OCC_COPY) vk: occlusion queries: 5060 reports written (2737 of them 0, 54.1%), 0 left visible (a draw did not draw), 15 reported again first; written 2.0 recordings after the report; 6 waiting; 86 reads (59 wrote), 5.2 us each; the others: 2085 samples on average, 1103 under 64; runs with colour draws 0 (0 of them 0); queries not begun: 0 draws returned before drawing, 0 with vertex input not all bound, 0 task batches; 0 runs split by another queue's queries; 4526 slots made visible ahead of a stopped queue (20 walks, 39.8 us each); 63 copies, 96.9 queries each, 0 queries read from the pool after their copy (RX_VK_OCC_COPY) vk: a frame's replay time (ms): submit 17.97, present 0.09, draw 10.85, descriptors 7.08, texture lookups 1.11, uploads 1.04, pipelines 0.34, programs 0.93, commands 1.73, dispatch 1.05, memory sync 1.93, queue submit 0.60, slot wait 0.11 vk: the render thread (600 presents, 21.89 ms a frame): its jobs 19.09 ms a frame (submits 6.83, presents 0.35, others 11.91), idle 2.80 vk: GPU time 12.0 ms a frame (the longest submission 2.5 ms); 0.0 textures made a frame vi: 5952 frames presented (46.1 fps over the last 5.0 s, interval 1) RX_VK_FRAME_LOG: 2040 frames over 44.7 s; average 45.6 fps, 1% low 25.3 fps; frame time median 20.41 ms, p99 37.71 ms ``` ### Flags and settings One new switch, `RX_VK_OCC_COPY`, on by default where it was measured. | Flag | What it is | Before this PR | After this PR | Effect | Helps / doesn't help / untested | Recommendation | |---|---|---|---|---|---|---| | `RX_VK_OCC_COPY` | The occlusion counts read from the GPU's copy in host memory (1) or from the query pool (0) | (none: the pool) | 1 with Intel's Windows driver on a discrete GPU; 0 elsewhere, with the parallel replay (`RX_VK_PARALLEL`) and without coherent host memory | +32% fps in the market on a B580; 2 ms more GPU time a frame | Helps: Arc B580 on Windows. Untested: Arc A-series, Intel iGPUs (mostly held back by the GPU, where the copies' GPU time would cost frames), AMD, NVIDIA, Linux | On as shipped; elsewhere off until a run shows the copies pay there | ## Testing **Tested:** - The game-free checks, `python -I -B tests/run_all.py` with the toolchain's Vulkan headers (`RX_CPM`), on this PR and on `main` 9e6f231d on the same PC (before the rebase onto a00c485c, which changes no code): 540 passed, 18 failed, 35 skipped on both, the same 18 failing the same way on `main` (e.g. `test_env_on` on `RX_VK_PRESENT_AFTER_SIGNAL`, `test_tested_configurations`' CLI pipe on Windows line ends); the 35 skipped run on Linux or need spirv-val or glslang. `tests/test_vk_occlusion_queries.py` passes all four parts, and fails as it should with any of three deliberate bugs put in: the host words not cleared at the reset, an availability word taken without its count, the range not forgotten at a submit. - In the game, Windows 11, Arc B580, driver 101.9154: Castle Town's market, 3 runs with the copies and 3 without (above), no device lost, the same picture each way: nothing missing, nothing extra. Link flashes red in two of the copies' end-of-run screenshots: that is the game's low-health warning (he is on one heart), and nine shots 0.4 s apart caught it each way (2 of 9 with the pool read, 1 of 9 with the copies). The log's `copied into host memory at each recording's end` line with the default, and 0 counts read from the pool after their copy in every run. **Not tested:** Linux (the copies are off there by default; the code uses no platform API), Arc A-series cards, Intel iGPUs, AMD and NVIDIA with `RX_VK_OCC_COPY=1`. The parallel replay keeps the pool read. **Must be tested:** - Linux, any GPU (Test Farm: `@Beanstalk linux`): build this PR, then play Castle Town's market for a minute with `RX_VK_OCC_COPY=1 RX_VK_OCC_LOG=1`, expecting the build to pass, the log's `occlusion queries on` line to end with `copied into host memory at each recording's end (RX_VK_OCC_COPY)`, its `occlusion queries:` lines to show copies with few or no queries read from the pool after their copy, and the picture the same as with `RX_VK_OCC_COPY=0`. ## Risks **What could break:** a count read wrong would hide an object that is in view. The GPU writes a count and its availability word in either order, so a count is taken only when both are there (the test covers both orders), and an unknown count stays "visible" as before. The copies cost GPU time: 2 ms a frame in the market on the B580 at 75% of 720p, which a GPU-bound Arc (an A380, or 4K) would pay in frames. The counts land ~2.2 recordings after their report instead of ~0.6, still well inside the frame or two the game waits (the share read 0 is the same: 53.5% with the copies, 53.9% without). **Turning it off:** `RX_VK_OCC_COPY=0`. **Compatibility:** Nothing changes: no option, cache or save format, and the renderer's interface is the same (`MKWVK_API_VERSION` unchanged). ## Issues See #122 ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Numbers from the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value. - [x] A file in `docs/tested/` for each new measured run (format in `docs/tested-configurations.md`). - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (Built on Windows only; the Test Farm's Linux run under Must be tested builds it.) - [x] Docs updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Intel's Windows driver takes ~3.4 us for each occlusion query read from the
pool (vkGetQueryPoolResults): 7.4 ms of the render thread's 28.8 ms frame in
the market on an Arc B580. At the end of each recording the GPU now copies
its queries' results and availability into a host-cached, coherent buffer
(vkCmdCopyQueryPoolResults after the end barrier, then a transfer -> host
barrier), and occ_collect reads them as plain memory. A count is taken only
once both of its words are written; what a copy didn't have is read from the
pool once its submission is done, as before.

On by default with Intel's Windows driver on a discrete GPU, where it was
measured (3 interleaved pairs: 34.8 -> 45.8 fps average, ~2 ms more GPU time
a frame). RX_VK_OCC_COPY=0 or 1 turns it off or on anywhere; off with the
parallel replay and without coherent host memory.

See #122

Co-Authored-By: Claude Opus 5.5 <[email protected]>
test_recorder_sync records vkCmdCopyQueryPoolResults through the recorder like
the other packed calls (a fake, and a call in its frame); the stream
visibility and FSR retirement fixtures stub occ_part_lo/occ_part_hi and
occ_copy_part. They need the Vulkan headers (RX_CPM's vulkanheaders), so a
run without them skips them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs(tested): the occlusion copy's runs name the code commit after the rebase onto a00c485c
Some checks failed
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Guide screenshots / Guide images and local UI source review (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Failing after 4s
beanstalk/approval not approved yet
74e3c79e2f
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Contributor

Measurements' columns renamed to the 'Before, variant: this PR @ hash' form the description check asks for; both are one build (690d8446 with this PR's diff), as the Baseline line says.

Measurements' columns renamed to the 'Before, variant: this PR @ hash' form the description check asks for; both are one build (690d8446 with this PR's diff), as the Baseline line says.
Author
Contributor

@Beanstalk linux, any GPU: build this PR, then play Castle Town's market (child, by day) for a minute with RX_VK_OCC_COPY=1 RX_VK_OCC_LOG=1. Expected: the build passes; the log's 'occlusion queries on' line ends with 'copied into host memory at each recording's end (RX_VK_OCC_COPY)'; its 'occlusion queries:' lines show copies with few or no queries read from the pool after their copy; the picture is the same as with RX_VK_OCC_COPY=0 (nothing missing, nothing extra). Please also say the fps both ways if you have them: the copies are off by default there and this tells whether they would pay on that GPU.

@Beanstalk linux, any GPU: build this PR, then play Castle Town's market (child, by day) for a minute with RX_VK_OCC_COPY=1 RX_VK_OCC_LOG=1. Expected: the build passes; the log's 'occlusion queries on' line ends with 'copied into host memory at each recording's end (RX_VK_OCC_COPY)'; its 'occlusion queries:' lines show copies with few or no queries read from the pool after their copy; the picture is the same as with RX_VK_OCC_COPY=0 (nothing missing, nothing extra). Please also say the fps both ways if you have them: the copies are off by default there and this tells whether they would pay on that GPU.
Member

@nyannoying only members of BigBeans can start the farm. One of them can approve this request by reacting 👍 to it.

@nyannoying only members of `BigBeans` can start the farm. One of them can approve this request by reacting 👍 to it.
HumanBean changed title from WIP: perf(vk): Arc cards read the occlusion counts from a GPU copy: Castle Town's market from 34.8 to 45.8 fps on a B580 to perf(vk): Arc cards read the occlusion counts from a GPU copy: Castle Town's market from 34.8 to 45.8 fps on a B580 2026-11-15 10:22:46 +00:00
HumanBean deleted branch perf/vk-occ-copy 2026-11-15 10:22:55 +00:00
Some checks failed
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Guide screenshots / Guide images and local UI source review (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Failing after 4s
beanstalk/approval not approved yet
Some workflows are waiting to be reviewed.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!761
No description provided.