Skip to content

Vulkan: cheaper write-back bookkeeping (report words, write-back pages) - #1036

Merged
hckrman101 merged 11 commits into
NotRexed:mainfrom
jordanella:memory-bookkeeping
Nov 9, 2026
Merged

hckrman101 merged 11 commits into
NotRexed:mainfrom
jordanella:memory-bookkeeping

Conversation

@jordanella

Copy link
Copy Markdown
Contributor

Two pieces of bookkeeping in vk_memory.cpp that run at every write-back, and so at every recording split (~20 a frame in Kokiri Forest), cost ~4-6% of the replay thread in the sampling profiler (HOS_SAMPLE=Presentation). Both now keep exactly the same contents and answers, at less cost:

  1. cpu_words (the words the replay writes on the CPU, cut out of mirror write-backs). Each merge appended every new word to the sorted vector, ran inplace_merge over all of it (up to 65536 words), then de-duplicated the whole thing again. Report words arrive at new addresses all the time, so this ran in full at most write-backs. Now words already kept are skipped first, and new ones go into a second, small sorted set. That set is merged into the big one only once it reaches an eighth of the big one's size (at least 4096 words), and the write-back cut walks both sets in order. Same words, same 65536 limit.
  2. wb_pages (each guest page a write-back covers, with the timeline value a CPU read or a capture waits for) was an unordered_map with one insert per page of every write-back. It's now a map of 2 MB regions, each with a 512-entry page array, and the last region is cached for the next page: one lookup per 512 pages instead of a hash insert per page. Same values (0: none; 1: written back long ago, which the pruning sets), same pruning at 200000 pages.

Measured on a Ryzen 9 3900X with an RTX 3080 on Windows 11. Kokiri Forest, standing still after loading a save, headless, input timed in game frames (HOS_INPUT_FRAME_CLOCK=1), live numbers over presents 3120-5520:

build runs fps replay per frame
main 2 34.7 / 35.1 23.6 / 23.4 ms
this branch 2 35.7 / 35.6 22.9 / 22.9 ms

How much this saves depends on the scene: on how much GPU-written memory goes back each frame, and how many counter reports the game makes. I expect more where GPU-driven effects and occlusion queries are heavier, and little in simple interiors.

The numbers were measured on v2.0.5 (232eecb). The branch is rebased onto main 3b4347d; the rebase did not change the patches (git range-diff), and vk_memory.cpp has no upstream changes since. python -I -B tests/run_all.py: 104 passed, 3 failed, 13 skipped on Windows. The three failures (test_changelog.py, test_fps_overlay_order.py, test_precompile_compute_guard.py) fail the same way on main 3b4347d on this machine. This PR also updates tests/test_report_words.py, which compiles cpu_words_merge and cut_cpu_words out of the source into a fixture: it now takes sorted_merge and the second set, and adds cases where kept and new words are cut together. With the cut's walk over the recent set removed, the fixture fails.

🤖 Generated with Claude Code

@jordanella

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. Three commits address it: cd70a0a, bca9997 and 6f66a61; details inline. I re-measured on the same setup (Ryzen 9 3900X, Kokiri Forest, standing still, two runs): 35.8 / 36.0 fps, against 35.7 / 35.6 for the previous version and 34.7 / 35.1 for main. tests/run_all.py gives the same result as main: 104 passed, plus the same three failures that main also has on this machine.

jordanella and others added 3 commits November 9, 2026 00:20
…list without moving all of it each time

cpu_words (the words the replay writes on the CPU, cut out of mirror write-backs) took every write-back's new words with an
inplace_merge over the whole sorted vector, up to 65536 words, and a unique over it again: at each write-back, so at every
recording split (~20 a frame). Words already kept are now left out first, and the new ones go into a second, small sorted
set that goes into the big one only once it is an eighth of its size (at least 4096); the cut walks both in order. The same
words, the same 65536 limit. ~2.4-4% of the replay thread on a Ryzen 9 3900X in Kokiri (HOS_SAMPLE).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…map entry a page

wb_pages (each guest page a mirror write-back covers, with the timeline value a CPU read or a capture waits for) took one
unordered_map insert for every page of every write-back, at every recording split: ~1.9% of the replay thread on a Ryzen 9
3900X in Kokiri (HOS_SAMPLE). Now a map of 2 MB regions with a page array each, the last region kept for the next page: one
lookup for 512 pages. The same values (0: none; 1: written back long ago, as the pruning sets them), the same pruning at
200000 pages.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runs against both

The fixture takes sorted_merge too and declares cpu_words_recent; check() can start with words kept from an earlier
merge (in the big set) beside the new ones, so the cut walks both sets: interleaved, a new word already kept, all of
them kept. With the cut's walk over the recent set taken out, the fixture fails.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jordanella

Copy link
Copy Markdown
Contributor Author

Thanks for the approval. I took all five nits:

  • WbPages comment (44208c9): it now says what the table holds and why values are never lowered, and names the three readers: memory_writeback_value's ever and its value against the GPU's progress, and the capture's wait.
  • Skipping null chunks (44208c9): memory_writeback_value reads its range through WbPages::max_in, which steps over a whole chunk that was never made. The new tests/test_wb_pages.py builds WbPages from the source with small chunks and checks values per page, ranges over missing chunks, both chunk edges, the table's end and the ever flag. A range read that stops short of a chunk's or the range's last page fails it, through a timeout, since such a loop never moves on.
  • The dead clear in memory_cpu_owned (30f2b01): removed. It couldn't fire on main either.
  • Repeats (9baca70): check_repeats merges a batch containing the same word twice, then a word that's already in the recent set. Taking out either the unique or the recent set's binary_search now makes the fixture fail.
  • RX_VK_WB_REPORTS (9baca70): the fixture now runs with it removed from the environment, so check_clear passes even when the caller has it set.

tests/run_all.py: 105 passed (the new test included), plus the same three failures main has on this machine. One run in game on the same setup showed nothing different: readbacks behaved as before, and fps was within the earlier runs' range.

jordanella and others added 7 commits November 9, 2026 00:52
…erations, and no pruning

As the review of NotRexed#1036 suggests: the table follows the page generations' layout in this file (gen_chunks: a vector of
kChunkPages chunks over the guest's pages, each made when one of its pages is first written back), in place of a hash
map of 2 MB regions and its last-region cache. No hash, no constants of its own; write-backs are of imported pools only,
so few chunks are ever made (the GPU windows' 12 GB are 48 of them at most).

The pruning is gone. Once the table held 200000 pages (it never shrinks) it ran at almost every write-back, a walk over
the whole table under wb_pages_mu. It never changed an answer: it set values the GPU was past to 1, and both readers
(memory_writeback_value's callers and the capture's wait) only compare a value with the GPU's progress, so a value it is
past reads the same as 1: written back, nothing to wait for.

Ryzen 9 3900X, Kokiri Forest, standing still, two runs: 35.8 / 36.0 fps (the hash-of-regions version 35.7 / 35.6, main
34.7 / 35.1).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e new

The earlier comment described the single sorted vector this replaced; it is folded into the new one (as the review of
NotRexed#1036 points out), keeping its history.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the 65536 clear

As the review of NotRexed#1036 asks: after every cut the two sets must be sorted, without repeats, with no word in both,
nothing left noted, and together exactly the words given (a new word already kept used to pass with the de-duplication
removed: the cut hid it). check_promotion crosses both thresholds (4096 and an eighth of the big set); check_clear goes
through memory_cpu_owned past 65536 words with the recent set left recent, so both must be forgotten. Each fails with its
rule taken out of vk_memory.cpp: no de-duplication, no promotion, only the big set cleared.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…range reads skip chunks never made

As the approving review of NotRexed#1036 suggests: the comment described the table by the map and pruning it replaced, and spoke of
two readers where there are three (memory_writeback_value's "ever written back" and its value against the GPU's progress,
and the capture's wait); it now says what the table holds and why values are never lowered. memory_writeback_value reads its
range through WbPages::max_in, which steps over a chunk never made whole instead of looking at each of its pages.

tests/test_wb_pages.py builds WbPages from the source with small chunks: values per page, ranges over chunks never made,
both edges of a chunk, the table's end, the "ever" flag. A range read that stops short of a chunk's or the range's last
page fails it (by its timeout, as it then never moves on).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cpu_words_merge always leaves cpu_words_new empty, so the cpu_words_new clear right after it never ran (on main either), as
the review of NotRexed#1036 points out.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent set, and ignores RX_VK_WB_REPORTS

As the review of NotRexed#1036 suggests: check_repeats merges a batch with a word in it twice, then a word that is in the recent set
already; with either the unique or the recent set's binary_search taken out of cpu_words_merge the fixture fails. The fixture
runs without RX_VK_WB_REPORTS from the caller's environment, which would turn memory_cpu_owned off and fail check_clear.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main took narrower versions of the same two changes meanwhile: report words already kept left out of the merge
(4ca115a) and the write-back page map hashed by the page number (da44bd4). This branch's versions are kept, as they
include the first and replace the map the second sped up: the recent set holds main's filter, and the chunks need no
hash (Common::FastHash's include goes with the map). The capture's wait takes main's late list (ab68f35) with the
chunks' lookup. tests/test_report_words.py keeps both tests: main's randomized merge checks both sets together now, over
800 rounds of 16384 addresses, so the recent set is promoted on the way.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jordanella

Copy link
Copy Markdown
Contributor Author

Main is merged in. Meanwhile main took narrower versions of both changes: 4ca115a leaves report words already kept out of the merge, and da44bd4 hashes the write-back page map by page number. I kept this branch's versions, since the recent set already includes 4ca115a's filter and the chunks need no hash at all. The capture's wait takes main's new late-page list (ab68f35) with the chunks' lookup. test_report_words.py keeps both sets of tests: main's randomized merge test now checks both sets together over 800 rounds, enough that the recent set is promoted along the way.

Re-measured on main 8b2e1d3 (v2.0.8-rc.1), Ryzen 9 3900X, Kokiri Forest, two runs each in two rounds: 38.6 / 37.8 fps standing against main's 36.8 / 36.3 (+4.5%, replay 21.3 against 22.5 ms a frame), and 35.5 / 34.1 panning against 33.9 / 34.9 (+1.2%). tests/run_all.py: the same 12 failures as main on this machine (missing tools and an older g++), and everything else passes. Main moved again after the measurement and is merged in too (016d582). Its write-back diff (55036ef) has memory_cpu_owned call shadow_written, so the report-word fixture, which builds that function for check_clear, stubs it.

@hckrman101
hckrman101 merged commit 61d452d into NotRexed:main Nov 9, 2026
10 checks passed
main's write-back diff (55036ef) has memory_cpu_owned tell the shadows its pages (shadow_written, kPageShift): the
report-word test's fixture, which builds memory_cpu_owned for check_clear, stubs both.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant