Repository navigation
Keep the CPU's newer pages when a remade pool's mirror inherits a retired one - #1127
Conversation
…he retired mirror's sync
mirror_make fills a new pool's mirror from guest memory, then copies each retired pool's mirror over
its whole part, for what the GPU wrote there. A page the CPU wrote after the retired mirror's last
sync is newer in guest memory, and the old mirror's copy of it went over the fresh one and counted as
synced: stale until the game wrote that page again.
OoT writes its light probes when an area comes in. After the pool holding them was remade larger
("mirror made with the GPU's contents of 1 retired pool(s)"), the fog's compute pass read the old
area's probes for the rest of the session: blocks of white glow, black or coloured blocks over the
fog (froxel-shaped), dark shadows. A save reload did not clear it, a restart did. In a frame dump
with the blocks showing, 86% of the probe buffer (c0+0x880 of 27ee9883e2a20323) differed from guest
memory; in a healthy frame, none of it.
Only the pages not stamped since the retired mirror's last sync are taken from it now
(gpu/vk/mirror_inherit.h); the others keep guest memory's copy. RX_VK_INHERIT_ALL=1: the whole part,
as before. tests/test_mirror_inherit.py checks the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Opus 5.5
|
CI note: the one failing check, |
|
Tested on NVIDIA (RTX 3070, Windows 11), merged onto v2.0.8-rc.5, with the scripted castle-load run.
|
|
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. |
hckrman101
left a comment
There was a problem hiding this comment.
Nice find, and the diagnosis checks out. Taking a retired mirror's page only when page_gen <= r.synced_gen gives the same result as "inherit everything, then run mirror_sync from r.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 like mirror_sync does. 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.- GPU writes in earlier submissions are stamped while they're recorded, and
memory_end_submissionthen raisessynced_genpast those stamps, so they're still inherited. The barrier/retired_valuehandling didn't change. - In serial replay, each recording is its own submission with its upload buffer first, so a draw in the same recording never reached the inherit copy before this PR either. Nothing gets worse there.
- Locking matches
mirror_sync(underpools_mu, atomicpage_gen, plainregion_genread). - The base is 159 commits behind main, but the
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_make inside 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 of mirror_make. No textual conflict, and taking guest memory for pages written after the sync is the right behaviour there too.
| if (inherit_all) | ||
| parts.push_back({a - r.lo, a - p->lo, b - a}); | ||
| else | ||
| from_guest = mirror_inherit::runs(a, b, r.lo, p->lo, r.synced_gen, kPageShift, kRegionPages, |
There was a problem hiding this comment.
With RX_VK_PARALLEL, r.synced_gen can be behind the old mirror's contents. If r was synced in the current submission (r.synced_epoch == mirror_epoch), it's still in mirror_synced, and synced_gen is only raised in memory_end_submission. So the bindings that earlier segments of this submission wrote into r.mirror are stamped above r.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. Then p->synced_gen = gen_now marks 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_now as the threshold) when r.synced_epoch == mirror_epoch.
There was a problem hiding this comment.
Fixed in a56dfe1. Confirmed: GPU writes go through memory_stamp (++gen_now), so in a submission where r was synced its pages from earlier segments sit above the not-yet-raised synced_gen.
A retired pool with r.synced_epoch == mirror_epoch now gets its whole part copied, as before this PR. Nothing newer is lost by that: submit_memory_work collects the CPU's writes before recording, so r's sync this submission already has them, and anything stamped after that is treated as synced at memory_end_submission anyway (same as mirror_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 than gen_now as the threshold because it gives the same result without the page scan.
test_mirror_inherit.py now also checks that mirror_make keeps this guard.
There was a problem hiding this comment.
Thanks, this looks right. A pool synced in this submission is in mirror_synced, so memory_end_submission raises its synced_gen to gen_now anyway. Copying its whole part gives the same result mirror_sync would, and the bug case (a pool retired in an earlier submission and remade later) still goes through the rule. Resolved from my side.
| else | ||
| from_guest = mirror_inherit::runs(a, b, r.lo, p->lo, r.synced_gen, kPageShift, kRegionPages, | ||
| [&](uint64_t region) { return region_gen[region] <= r.synced_gen; }, | ||
| [](uint64_t page) { return page_gen(page); }, |
There was a problem hiding this comment.
Nit: with RX_VK_PROTECT_BARE=1, mirror_sync keeps 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. Treating protect_bare_on() && prot_page_since(page, r.synced_gen) as inherited in the page_gen lambda would keep the two consistent.
There was a problem hiding this comment.
Done in a56dfe1. mirror_inherit::runs now takes a per-page predicate (page_current) instead of a generation and threshold. mirror_make passes page_gen(page) <= synced || (protect_bare_on() && prot_page_since(page, synced)), so a bare-entry page keeps the old mirror's copy, the same way mirror_sync keeps it. prot_page_since only 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.
There was a problem hiding this comment.
Looks good. The predicate matches mirror_sync (dirty && protect_bare_on() && prot_page_since(page, synced_gen), same >=, same prot_mu under pools_mu), and the new test case covers it.
|
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:
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. |
…e; bare-entry pages kept as mirror_sync keeps them Review of NotRexed#1127: - With RX_VK_PARALLEL, a retired pool synced in the current submission still has its old synced_gen (raised only in memory_end_submission), so the GPU's writes from the submission's earlier segments were stamped above it and taken from guest memory, which has them only after the write-back. Such a pool's part is copied whole, as before NotRexed#1127. The CPU's writes were collected before its sync, so nothing newer is in guest memory for it. - With RX_VK_PROTECT_BARE=1, mirror_sync keeps the mirror's copy of a page a shader wrote through a bare entry; the inherit rule now does the same, so the experiment no longer depends on whether the pool was remade. mirror_inherit::runs takes a page predicate (page_current) instead of a generation and its threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@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 with |
hckrman101
left a comment
There was a problem hiding this comment.
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.py on 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 |
|
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. |
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