Skip to content

fix(agc): stamp the watched blocks of a store into partly watched memory - #6455

Merged
oneandonlydean merged 5 commits into
boykopovar:mainfrom
EagleBearerYS:fix/agc-stamp-partly-watched-stores
Oct 18, 2026
Merged

oneandonlydean merged 5 commits into
boykopovar:mainfrom
EagleBearerYS:fix/agc-stamp-partly-watched-stores

Conversation

@EagleBearerYS

@EagleBearerYS EagleBearerYS commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

A hole in main's write tracker, found by review: a driver store or an own store over a range that is only partly write-watched stamps none of its blocks. milescoviello found a title that hit it on an older main: MXGP 26 (PPSA30839) on Linux. On main 84b0d2f it no longer stops there without this PR (their runs under Tested). And since #6060 the compare-free vouch for settled Windows host imports relies on these stamps. cc @parl0v

The hole. Every GPU write path into guest memory stamps the blocks it wrote through MarkWritten: packet stores and labels, DMA fills and copies, CMASK and DCC metadata, the write-backs of address-based builds, storage-image results and BDA stores. Own stores go through storeOwn (StoreOwnBytes). For a range that isn't wholly watched, both return 0 and only note the store as unstamped, and only NewestStamp and StoredAllSince read that note. The watched blocks such a store wrote kept their old stamps, so UnchangedSince, StoredOver and ChangedBlocks still read them as unchanged after a GPU write that crossed into unwatched memory. Whatever relies on those answers then keeps bytes that were replaced:

When it happens: a GPU write whose range runs from watched memory into memory that has left the watch.

  • On Windows: into an import that is compared (under 1 MiB, through a section alias, or on a device without a settle queue), from a settled import that stays watched or from plain watched memory.
  • On Linux and macOS: into an import that left the watch (APS5_WRITE_WATCH_IMPORTS=unwatch, or the import probe's verdict), or any other unregistered page range.

The fix:

  • Commit 1: both functions now stamp each block whose part of the store starts or ends in watched memory, as the wholly watched path stamps every block, and record the driver's piece of it for StoredOver. They still return 0 and note the store as unstamped, so their callers keep treating the store as one the tracker doesn't vouch for.
  • Commits 3-4: that check is exact on Windows, where the watch excludes whole 64 KiB blocks and the arena's host regions are 64 KiB holes. Linux and macOS unregister page ranges, so a block can be watched only between two unwatched ranges, and a store across all three still left its watched pages unstamped. Off Windows a block is now stamped when the store's part of it overlaps any watched page. That uses a new libc query, GuestWriteWatchOverlaps, which is one lookup in the registered ranges, like GuestWriteWatchCovers. A store that overlaps no watched page at all returns after that one lookup.

Cost: the wholly watched path is unchanged. On the other path:

  • Windows makes two coverage lookups per block of the store: a block table, and the arena's few host regions.
  • Linux and macOS make one overlap lookup for the whole store, and one per block only when the store overlaps watched pages. Commit 1 alone made two per block.

The other write paths:

  • Every GPU write path into imports stamps through MarkWritten or StoreOwnBytes, so the fix covers them all at the source.
  • CPU stores are seen page by page by the host watch (GetWriteWatch, userfaultfd, mprotect). A collect over a partly watched range fails and returns no generation, so nothing can be vouched over it.
  • Two known gaps stay outside this PR, both low:
    • two paths stamp before they note the write pending: labels kept behind completions, and CopyBuffer, whose order is deliberate. A reader on another queue that doesn't wait can see the old bytes at the new generation.
    • BDA stores are stamped only when their batch is reaped.

The commits:

  1. fix(agc): stamp the watched blocks of a partly watched store.
  2. test(agc): check the stamps of a store into partly watched memory.
  3. feat(libc): ask whether any part of a range is write-watched (GuestWriteWatchOverlaps, with its tests).
  4. fix(agc): stamp a block whose watched pages lie inside a partly watched store.
  5. test(agc): check a store across a watched import and one that left the watch (perf(agc): keep Windows host imports of write-watched private memory watched #6060's case).

Overlaps: #6266 and #6308 add tests next to these in tests/WriteTracking.cpp, and the conflict with each is in its list of checks only. #6266's coarse runs are raised in WriteTracker::stamp, which these stamps go through, so they stay consistent with it. #7048 (AlxcBTW) handles another way to reach the same Texture.cpp refusal: a unit with a write generation that becomes pending after its block left the watch. It shares no file with this PR. Its new kept-block check reads the per-block stamps (ChangedBlocks) that this PR adds for stores over partly watched ranges, so the two compose. #7233 (MarcZX100) adds a complete flag to WriteTracker::noteDriverStore, and MarkPossiblyWritten for ranges that may have been written (page-granular BDA reports, writable descriptor ranges). This PR's stampWatchedPart calls noteDriverStore from the partly watched paths of storeOwn and MarkWritten, so whichever lands second passes the flag through: true from storeOwn, and markWritten's own flag from there. That way a possible write into partly watched memory stays a possible write.

Tested

  • agc_write_tracking:
    • CheckPartlyWatchedStore: a driver store and an own store across a watched block and an unwatched one stamp the watched block and no other; StoredOver sees the watched bytes stored and no others; on Linux and macOS, a store across the halves of a split block stamps it, and a store into its unwatched half doesn't.
    • CheckWatchedMiddleStore (Linux and macOS): with only a block's middle pages watched, a driver store and an own store around them stamp the block, and a store into an unwatched end doesn't.
    • CheckStoreAcrossImports: two blocks imported with ImportWatched that stay watched, next to one imported so that it leaves the watch. A driver store and an own store across the boundary stamp the watched block they wrote, are seen over exactly its stored bytes, and leave the other watched block unchanged.
    • CheckWatchOverlaps (Linux): GuestWriteWatchOverlaps around watched pages between unwatched ones, at both edges, and for an empty range.
  • write_watch_overlaps (macOS): the same checks on the macOS watch.
  • GitHub's runners, on my fork:
    • Commits 1-2, on main 0c4816f: with main's GuestMemory.cpp, agc_write_tracking fails on both at "a driver store into partly watched memory left its watched block unchanged"; with the fix it passes 20 of 20 on both (run). In that run's fix arm, Linux's libkernel suite hit a bus error in guest_dynamic_loader, which links only libkernel and libc, both unchanged at that commit; it passed in the run's other arm and at the head.
    • The head, with commit 3's GuestMemory.cpp: the test fails on Linux at "a driver store around the watched middle of a block left it unchanged" (Windows has no such block, since its watch excludes whole blocks); with commit 4 it passes 20 of 20 on both (run).
    • perf(agc): keep Windows host imports of write-watched private memory watched #6060's case alone (commit 5's test on main 0029e62): fails on both at "a GPU write across a watched import and an unwatched one left the watched import's last block unchanged" (run).
    • The libc and libkernel suites pass at the head: 75 and 143 tests on Linux, 96 and 146 on Windows apart from guest_kqueue, which fails on main in the same runs.
    • macOS (macos-15-intel and macos-15): the write-watch suite at the head, each of its 10 tests until-fail:20, passes on both, write_watch_overlaps included (run).
    • ^agc sweeps at the head: Linux 604 of 604 (42 skip; run). Windows 598 of 599 (466 skip without a Vulkan device; run); the one failure, agc_driver_pm4_replay_tool (0xc0000139), and libkernel's guest_kqueue also fail on main on that runner. main doesn't build in full with MinGW since fix(agc): keep the gate run going past indirect dispatches whose arguments stay on the GPU #5747 (alignas(65536) in tests/execution/GpuIndirectArgumentsMisalignedWriterAhead.cpp), so the Windows sweep ran with test(agc): align the indirect arguments block at run time #6452's fix for that test applied, for this check only.
  • python3 tools/check_conventions.py --base origin/main: passed.

MXGP 26 (PPSA30839), run by milescoviello on Linux (Ubuntu 24.04, GCC 13.3, Release, RTX 4070 Ti with NVIDIA 595.99.02, warm shader cache, 120 s runs), on main 4524944 with #6814, #6094, #6160, #6308 and #6184 in both builds (comment):

Update on current main (milescoviello, Linux, RTX 4070 Ti, warm cache, pad input, 120 s, 2 runs per build, comment): on main 84b0d2f with #6184 and #6160, the stop no longer reproduces. Without this PR, with it, and with #7048 instead, 2 of 2 runs each reach the 120 s limit, and none of the six logs the refusal. So MXGP 26 no longer needs this PR on current main, and neither this PR nor #7048 regresses it. #6160 is in every build because with only #6184, main now stops at 48 s on a computed s_swappc_b64 target. Two runs per build are too few to compare frame rates.

Checklist

MarkWritten and StoreOwnBytes return 0 for a range that is not wholly
watched, and they stamped none of its blocks. The watched blocks such a
store wrote kept their old stamps, so UnchangedSince and StoredOver over
them still read as unchanged after a GPU write that crossed into an
unwatched import. A vouched shader table column or a cached range then
kept bytes that had been replaced.

Both now stamp each block whose part of the store starts or ends in
watched memory, as the wholly watched path stamps every block. They still
return 0 and note the store as unstamped. This costs one or two coverage
lookups per block, and only on this path.
A driver store and an own store across a watched block and an unwatched
one must stamp the watched block and no other. On Linux, a store across
the halves of a split block must stamp it, and a store into its unwatched
half alone must not.
GuestWriteWatchCovers says whether a range is watched in full. The
Linux and macOS watches unregister page ranges, so a range can hold
watched pages between unwatched ones, and code that must act on every
watched byte of a store has had no way to find them short of asking
page by page. GuestWriteWatchOverlaps answers with one lookup in the
registered ranges, like Covers.

The macOS write-watch test and agc_write_tracking on Linux check it
around watched pages between unwatched ones, at both edges and for an
empty range.
…ed store

stampWatchedPart stamped a block when the store's part of it started or
ended in watched memory. That is exact on Windows, where the watch
excludes whole 64 KiB blocks and the arena's host regions are 64 KiB
holes. On Linux and macOS the watch unregisters page ranges, so a
block can be watched only between two unwatched ranges, and a store
across all three left its watched pages with their old stamps.

Off Windows the block is now stamped when the store's part of it
overlaps any watched page (GuestWriteWatchOverlaps). A store that
overlaps no watched page at all returns after that one lookup, where
it made two coverage lookups per block before.

The test watches only the middle pages of a block: a driver store and
an own store around them must stamp it, a store into an unwatched end
must not.
…e watch

Since boykopovar#6060 a settled Windows host import of private write-watched
memory stays watched, and its bytes are vouched by the write tracker's
stamps instead of compared. A GPU write that runs from such an import
into a neighbouring import that left the watch reaches the tracker as
one MarkWritten over a partly watched range. The test imports two
blocks that stay watched next to one that leaves the watch, and checks
that a driver store and an own store across the boundary stamp the
watched block they wrote, are seen over exactly its stored bytes, and
leave the other watched block unchanged.
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 6266 core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp L282, L980
PR 6308 core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp L992
PR 7192 core/libs/prx/libc/include/GuestWriteWatch.hpp L14
core/libs/prx/libc/src/GuestWriteWatch.cpp L381, L662
PR 7233 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp L920-921
PR 7371 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp L920-921

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 4448 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 5021 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6044 core/libs/tests/WriteWatch.cpp
core/libs/tests/write_watch/CMakeLists.txt
PR 6188 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6192 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6222 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6273 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp
PR 6292 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6615 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6868 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp
PR 6946 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6947 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 7201 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp
PR 7332 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp

@milescoviello

Copy link
Copy Markdown
Contributor

A title that hits this: MXGP 26 (PPSA30839) on Linux.

Ubuntu 24.04 (kernel 6.8), GCC 13.3, Release, RTX 4070 Ti (NVIDIA 595.99.02), warm shader cache, pad input, 120 s runs. Every build has main 4524944 + #6814, #6094 and #6160 (which the title needs), + #6308, + #6184. #6184 removes a /proc/self/maps hotspot and lets the title reach this point in time; details on #6184.

Build Result
without this PR 2 of 2 runs stop at 30 s: storing storage image results pending over memory outside the write watch without the guest bytes saved before them is not implemented
with this head (merged; the only conflict was tests/WriteTracking.cpp against #6308's test, which the libs don't build) 2 of 2 reach the 120 s limit, presenting up to 29–30 frames per 10 s

Just before the stop, the log has [gpu] storage image 0x1227740000+0xf00000: memory no longer registered; 240 pending ranges dropped for four neighbouring 15 MiB storage images. So the title frees memory under pending results, and the next write-back over the partly watched span throws. #6308 alone (in both builds) does not prevent it on Linux.

Not checked: whether the stored bytes match the console, Windows.

AI-assisted: yes

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@milescoviello thank you for finding a title for this, and for the pair with and without it. I've added MXGP 26 (PPSA30839) and your runs to the description, including that #6308 is in both builds and doesn't prevent the stop on Linux.

@milescoviello

Copy link
Copy Markdown
Contributor

An update to my MXGP 26 (PPSA30839) data above. On current main the write-watch stop no longer reproduces, even without this PR. Linux, RTX 4070 Ti (NVIDIA 595.99), warm cache, pad input, 120 s, 2 runs each:

Build Result Presents per 10 s, last 4 intervals
main 84b0d2f + #6184 + #6160 2 of 2 run to the limit 191, 1, 3 / 17, 17, 17, 15
+ this PR (#6455) 2 of 2 run to the limit 24, 30, 30, 30 / 29, 30, 31, 30
+ #7048 instead 2 of 2 run to the limit 25, 23, 28, 27 / 25, 25, 25, 28

With only #6184, main now stops earlier, at 48 s, on a computed s_swappc_b64 target. That is why #6160 is in every build here. In none of the six runs does storing storage image results pending over memory outside the write watch… appear. So my earlier "2 of 2 stop at 30 s without it" no longer holds as a reason for this PR on MXGP. Neither this PR nor #7048 regresses it. Two runs per build aren't enough to compare the frame rates. All runs also log the non-fatal BDA access failed ... reason=7 (unaligned) from the buffer_atomic_or at odd offsets that #6094 handles.

AI-assisted: yes (Claude Code)

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

Thanks @milescoviello for re-running on current main. I've updated the description: MXGP 26 no longer needs this PR there. It stays open for the write-tracker hole its tests show, and for #6678, which depends on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants