Keep the CPU's newer pages when a remade pool's mirror inherits a retired one #1127
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!1127
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/mirror-inherit-newer-pages"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
A pool remade larger gets a new mirror:
mirror_makecopies guest memory into it whole, then copies each retired pool's mirror over its whole part, so that what the GPU wrote there carries over. A page the CPU wrote after the retired mirror's last sync is newer in guest memory. The old mirror's copy of that page went over the fresh one, and the new mirror then counted it as synced, so it stayed stale until the game wrote that page again.OoT writes its light probes once, when an area comes in. After the pool holding them was remade larger (
vk: pool 0x1089000000..0x1091000000: mirror made with the GPU's contents of 1 retired pool(s)in the log), the fog's compute pass (27ee9883e2a20323, its read-onlyc0+0x880buffer) read the old area's probes for the rest of the session. On screen that is froxel-shaped blocks over the fog: white glow, black blocks over the sky, or magenta/red/green blocks, together with abnormally dark shadows. It shows after 20-60 minutes of play. A save reload does not clear it, a restart does. This looks like part of #993 and like #1120 (9070 XT, after 2 h+).Change
gpu/vk/mirror_inherit.h: the rule. Only the pages not stamped since the retired mirror's last sync (page_gen <= r.synced_gen, clean 2 MB regions in one step) are copied from the retired mirror, merged into runs. The other pages keep guest memory's copy, which the full copy just put there. What the GPU wrote before that sync still carries over, as before.gpu/vk/vk_memory.cpp:mirror_makecopies through the rule and logs, 40 times, how much it kept from guest memory instead.RX_VK_INHERIT_ALL=1restores the old whole-part copy.tests/test_mirror_inherit.py+.cpp: unit test of the rule: clean parts, a newer page splitting a run, pages at the sync's generation, unaligned parts, clean regions. It also checks the source still goes through it.How it was tested
0x8001a0. Local build on top of v2.0.8-rc.1 plus my fork's AMD changes; this PR carries only the fix, rebased onmain.RX_VK_DUMP_FILEandRX_VK_DUMP_SSBO=27ee9883e2a20323,1e2fcee33a0c7209. The probe buffer's GPU copy matched guest memory word for word (0 of 4,194,304 words differ).pool 0x117c000000..0x117e000000: 2436 KB the CPU wrote since retired pool 0x117d000000's last sync kept from guest memory, not its mirror.python -I -B tests/test_mirror_inherit.pypasses (g++ 10). It fails onmainwithout the change.Refs #993, #1120
🤖 Generated with Claude Code
CI note: the one failing check,
tests/test_program_answer_domains.py(answers_inundeclared in its fixture), fails the same way onmainat8b2e1d3(run 42588468881), so it is not from this change. This PR's own test passes on both Ubuntu and Windows.Tested on NVIDIA (RTX 3070, Windows 11), merged onto v2.0.8-rc.5, with the scripted castle-load run.
pool 0x1065000000..0x106a000000: 40504 KB the CPU wrote since retired pool …'s last sync kept from guest memory, not its mirror. There were 13 such lines on one save load, from 52 KB to 40 MB.Thanks a lot for testing this on NVIDIA! Thirteen of those lines on a single save load is a useful data point: the remade-pool case is common on NVIDIA too, not only on AMD. On my RX 9070 XT, the fog blocks, glow and dark shadows have not come back in the sessions since the fix, a few hours of play so far. A long play test on your side would be great whenever you have time.
Nice find, and the diagnosis checks out. Taking a retired mirror's page only when
page_gen <= r.synced_gengives the same result as "inherit everything, then runmirror_syncfromr.synced_gen", which is the right invariant. Pages the CPU wrote after the old mirror's last sync come from the full guest copy, and the capture pass after it still puts captured pages on top as they were at their submit, just likemirror_syncdoes. What I checked:runs(): the clean-region shortcut only applies on region-aligned pages, partial first/last pages stay clamped to [a, b), runs merge correctly, and the dst ranges never overlap, so the multi-regionvkCmdCopyBufferis fine.memory_end_submissionthen raisessynced_genpast those stamps, so they're still inherited. The barrier/retired_valuehandling didn't change.mirror_sync(underpools_mu, atomicpage_gen, plainregion_genread).mirror_makehunk is unchanged there and the PR is mergeable.test_mirror_inherit.pypasses on both runners (auto-discovered by run_all.py). The red "Python source checks" istest_program_answer_domains.py, the same failure main has.Two things inline. The first is the one I'd like fixed or explicitly scoped out before merging. It only matters with
RX_VK_PARALLEL, but it brings back the same "stale for good" bug, this time for GPU words.Overlap: #1184 (no
mirror_makeinside a parallel segment) narrows the first point, though it doesn't close it. #1150 binds read-back pages in guest memory and touches a different hunk ofmirror_make. No textual conflict, and taking guest memory for pages written after the sync is the right behaviour there too.@ -2875,8 +2677,36 @@ bool mirror_make(Pool& pool) {barrier_done = true;With
RX_VK_PARALLEL,r.synced_gencan be behind the old mirror's contents. Ifrwas synced in the current submission (r.synced_epoch == mirror_epoch), it's still inmirror_synced, andsynced_genis only raised inmemory_end_submission. So the bindings that earlier segments of this submission wrote intor.mirrorare stamped abover.synced_gen. When the recording after the segments remakes the pool, its upload buffer runs after those segments, so the old whole-part copy did pick up their writes. This version takes those pages from guest memory instead, and guest memory doesn't have them until the write-back at the end of the submission. Thenp->synced_gen = gen_nowmarks them as synced, so the GPU's words are stale until the game writes the page again.The serial path can't hit this, since each recording ends its own submission. A cheap guard would be to keep the whole-part copy (or use
gen_nowas the threshold) whenr.synced_epoch == mirror_epoch.Nit: with
RX_VK_PROTECT_BARE=1,mirror_synckeeps the mirror's copy of pages a shader wrote through a bare entry (prot_page_since(page, p.synced_gen)) even when the CPU also wrote them. This rule takes those from guest memory, so the experiment now behaves differently depending on whether the pool was remade. Treatingprotect_bare_on() && prot_page_since(page, r.synced_gen)as inherited in thepage_genlambda would keep the two consistent.A first longer run on NVIDIA, as asked: RTX 3070, Windows 11, main
5d1fe99+ this PR (clean merge). 23 minutes of scripted walking back and forth in Hyrule Field from one save:... kept from guest memory, not its mirror);Caveat: the route drifted into the river under a cliff for much of the run, so the sky and the fog were often out of view. Read this as "nothing broke in 23 minutes" rather than proof the blocks are gone. I'll do a proper hour of normal play with the sky in view when I can.
@ -2875,8 +2677,36 @@ bool mirror_make(Pool& pool) {barrier_done = true;Fixed in
a56dfe1. Confirmed: GPU writes go throughmemory_stamp(++gen_now), so in a submission whererwas synced its pages from earlier segments sit above the not-yet-raisedsynced_gen.A retired pool with
r.synced_epoch == mirror_epochnow gets its whole part copied, as before this PR. Nothing newer is lost by that:submit_memory_workcollects the CPU's writes before recording, sor's sync this submission already has them, and anything stamped after that is treated as synced atmemory_end_submissionanyway (same asmirror_sync). The threshold rule still covers the case the fog bug came from: a pool retired in an earlier submission and remade later. I went with the whole copy rather thangen_nowas the threshold because it gives the same result without the page scan.test_mirror_inherit.pynow also checks thatmirror_makekeeps this guard.@ -2875,8 +2677,36 @@ bool mirror_make(Pool& pool) {barrier_done = true;Done in
a56dfe1.mirror_inherit::runsnow takes a per-page predicate (page_current) instead of a generation and threshold.mirror_makepassespage_gen(page) <= synced || (protect_bare_on() && prot_page_since(page, synced)), so a bare-entry page keeps the old mirror's copy, the same waymirror_synckeeps it.prot_page_sinceonly runs for pages newer than the sync, and only with the flag on. I added a test case for a newer page that the predicate keeps.@Nitnek "Nothing broke in 23 minutes" with the rule running 20 times is still useful, since this change could have caused a device loss or a new kind of corruption, and it didn't. An hour with the sky in view would settle the blocks themselves. If you do it, please use the new head (
a56dfe1). It only differs from what you tested withRX_VK_PARALLELorRX_VK_PROTECT_BAREset, so your results so far still apply to the default path.@ -2875,8 +2677,36 @@ bool mirror_make(Pool& pool) {barrier_done = true;Thanks, this looks right. A pool synced in this submission is in
mirror_synced, somemory_end_submissionraises itssynced_gentogen_nowanyway. Copying its whole part gives the same resultmirror_syncwould, and the bug case (a pool retired in an earlier submission and remade later) still goes through the rule. Resolved from my side.@ -2875,8 +2677,36 @@ bool mirror_make(Pool& pool) {barrier_done = true;Looks good. The predicate matches
mirror_sync(dirty && protect_bare_on() && prot_page_since(page, synced_gen), same>=, sameprot_muunderpools_mu), and the new test case covers it.Re-review at
a56dfe1: both points are addressed.RX_VK_PARALLEL: a retired pool withr.synced_epoch == mirror_epochnow gets its whole part copied. That's correct: it's inmirror_synced, somemory_end_submissionsets itssynced_gen = gen_nowanyway. Whatever is stamped after its sync this submission counts as synced either way, which is the same resultmirror_syncgives. The fog case (a pool retired in an earlier submission and remade at the nextmemory_begin_submissionor bind) still has an oldersynced_epoch, so it still goes through the rule.RX_VK_PROTECT_BARE: thepage_currentpredicate matchesmirror_sync(sameprot_page_sincethreshold,prot_mutaken underpools_muas there), and it's only reached for pages newer than the sync with the flag on, so the default path costs nothing extra.The
runs()refactor is mechanical. The new test case (a kept newer page joining the run before it, the next one left to guest memory) checks the right thing. CI is all green now,test_mirror_inherit.pyon both runners included. The PR is mergeable cleanly against main. LGTM, and thanks for the NVIDIA runs, @Nitnek.Thanks for the careful re-review, @hckrman101! And thanks again @Nitnek for the NVIDIA runs.
This landed in the v2.0.12 branch, including the follow-up commit (merged there for testing; it reaches
mainwith that release), so closing. Thanks!Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.