Skip to content

perf(libc): read private write watches per allocation without a region query - #9098

Merged
oneandonlydean merged 15 commits into
boykopovar:mainfrom
EagleBearerYS:perf/libc-collect-without-region-query
Oct 27, 2026
Merged

oneandonlydean merged 15 commits into
boykopovar:mainfrom
EagleBearerYS:perf/libc-collect-without-region-query

Conversation

@EagleBearerYS

@EagleBearerYS EagleBearerYS commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

On Windows, WindowsMappings::Collect walks a range of guest memory and collects the pages written since the last collect. For the guest arena's private allocations, each step asked VirtualQuery for the region before reading its write watch.

  • The cost in a large commit: Windows computes RegionSize by walking the rest of the region, so a collect deep in a large commit pays for the whole remainder. In one 449 MiB private commit, a query took 379.2 µs and the GetWriteWatch 2.2 µs.
  • The cost in pieces: a region also ends at every protection change. So each 16 KiB piece cost two system calls, and an allocation with several protections was read in several parts.

The change (this head replaces the earlier one; the first commit is fable-void's, under his name):

  1. perf(libc): read private write watches per allocation without a query (fable-void, from his 2760ad8).
    • The mappings keep the extent of every private allocation they make: added when a placeholder is replaced, erased on release.
    • A collect makes one GetWriteWatch per allocation from that map. A read can't span two allocations, so pieces committed one by one still take one read each, but the query per piece is gone.
    • The reported pages are unchanged.
  2. fix(libc): return false from a private collect over memory released behind the mappings.
    • When code that bypasses WindowsMappings releases memory inside a recorded extent, GetWriteWatch fails there.
    • A failed call now queries the region at the cursor. If it's no longer committed private memory, Collect returns false as before; otherwise the failure is still fatal.
  3. test(libc): check private collects against a region query under APS5_VERIFY_DIRECT_COLLECT.
    • The one case the map can't see: a page decommitted in place inside a recorded allocation by code that bypasses WindowsMappings. GetWriteWatch still succeeds over it there.
    • Nothing in the tree decommits that way today.
    • With the switch set, each collect also queries its span, and Collect throws, naming the span, if it succeeded over memory that's no longer committed private.
  4. perf(libc): count private collect calls only in the collect test. The call counters compile only where APS5_COUNT_MAPPING_QUERIES is set, as the address-space query counter does. The runtime's Collect updates no counter.

Linux and macOS are unchanged: WindowsMappings.hpp is Windows-only.

Measured on GitHub's Windows runner (a bench of Resident Evil Village (PPSA01556)'s traced load, 5,765 retired imports over 2,077 refreshes, on fork exp/rewatch-phases-fixed, not part of this PR; runs 39697304565 and 39697362432):

guest memory committed as main this PR's earlier head this head
one 449 MiB placeholder replacement 2,120-2,227 ms ~10 ms 10-12 ms
16 KiB placeholder replacements 130-135 ms 88-105 ms (88-97 beside the earlier head, 104-105 beside main), with no VirtualQuery

Resident Evil Village (PPSA01556) in-game: loading isn't faster.

  • Setup:
  • The import mode decides the cost. Each run landed in one of two import modes, which can't be chosen: high, with about 130,000 MiB of imports made over the load, or low, about 14,000.
  • High mode: C cuts the queue worker's per-frame Driver.Packet.dispatch over the load (per-run table and sources):
    • A: 1,111 ms, in its one high-mode run;
    • B: 1,115 and 1,055 ms;
    • C: 569 ms, in its one high-mode run.
    • These are lower bounds. The per-second summaries list each interval's top 8 phases, and a missing phase counts as 0. Dispatch is missing in 75 of C's 333 intervals, against 5-11 in the other runs.
    • Setting each missing interval to its 8th phase gives the upper bounds (q3b_check.py, its output): C at most 956 ms, A 1,191, and B 1,190 and 1,191.
    • So C is lower than A and B either way, by 14-49 % against A.
  • Low mode: no arm separates (same table). The figures are A 681 ms (run A3), C 735 and D 800 and 732. A's other low-mode run, A2r, read 285 ms, but its load began from a menu-warmed state after a late Yes, so it's left out.
  • Launch to the first gameplay frame: no arm separates in either mode.
    • High: A 42:11, B 39:37 and 45:50, C 44:16.
    • Low: A 35:51 and 39:45, C 37:32, D 41:05 and 38:16.
    • So the cut in dispatch time doesn't shorten the load here, which is bound elsewhere.

A second session, B A A B (2026-10-24) (runs, table and script):

  • Setup: the same machine, driver, warm shader cache and environment as above, with GPU clocks not held. A is d56bdc5 again. B is A plus this PR's earlier change (74dee00 and its follow-up 156fa1f).
  • Import mode: B, A and B ran in the low import mode. The second A ran in the high mode and isn't compared.
  • From Yes to the first lit frame: no measurable difference. B took 23:34 and 26:20; A took 27:46 in its one low-mode run. B's own runs are 2:46 apart.
  • Driver.Packet.dispatch per frame over the load (presents-weighted as above, with the window starting at Yes):
    • B 668 and 771 ms, A 827 ms. These are lower bounds: dispatch is missing from 11 of B's 334 intervals, 10 of its 336, and 9 of A's 404.
    • With each missing interval set to its 8th phase, the upper bounds are B 748 and 854 ms, A 904 ms.
    • B's second run overlaps A, and the first session's low-mode A read 681–736 ms, so the arms don't separate in low mode here either.

Overlap:

Tested

  • agc_private_collect (new, fable-void's): 256 MiB of 16 KiB pieces with scattered writes returns the same pages as a reference read, with at most one system call per piece. One allocation in five protection regions takes one call.
    • It fails without the first commit (32,768 calls for 16,384 pieces) and passes with it (16,384).
  • windows_write_watch: a collected private allocation released with VirtualFree behind the mappings must collect false without throwing. Without the second commit it throws.
  • windows_write_watch_verify_direct: under the switch, a collect after a page is decommitted in place must not succeed. Without the third commit's check it succeeds.
  • Reds and greens:
  • GitHub's runners on this head (run 39703221475):
    • Windows: 8 tests ×10, including agc_private_collect with the guarded counters, windows_shared_map_cost and both write-watch tests;
    • Linux: 4 tests ×10;
    • the libc and libkernel suites pass on both.
  • This head merged with main 7b32a84 (run 39890788684):
    • Windows: agc_private_collect, windows_shared_map_cost, windows_write_watch and windows_write_watch_verify_direct pass ×10. The 8 agc_import_* tests skip, because the runner has no Vulkan device;
    • Linux: agc_import_watch passes ×10;
    • the libc suite (144 on Windows, 117 on Linux) and the libkernel suite (208, 198) pass. They ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners;
    • with main's code, the Windows tests don't build, because they use the new calls.
  • 98fd280, merged with refactor(libc): map a run of shared guest pages as one view on Windows #9505's merge of main 1e20650 (run), with the bookkeeping moved as described under Overlap:
    • Windows: agc_private_collect, windows_shared_map_cost, windows_write_watch, windows_write_watch_verify_direct, guest_memory, guest_shm_open and guest_jit_shared_memory pass ×10: 8 run, 7 pass, 1 skip, 0 fail. agc_import_watch skips, as the runner has no Vulkan device;
    • Linux: agc_import_watch, guest_memory, guest_shm_open and guest_jit_shared_memory pass ×10: 4 run, 4 pass, 0 skip, 0 fail;
    • the libc suite (147 on Windows, 120 on Linux) and the libkernel suite (213, 203) ran with APS5_SKIP_TIMING_TESTS=1. All pass except libkernel's guest_raise_in_system_call on Windows, which timed out after 30 s as the suite's 211th of 213. Run alone 20 times in a row, it and guest_raise_exception pass on this head (run) and on main 1e20650 (run), as refactor(libc): map a run of shared guest pages as one view on Windows #9505 records for its own head.
  • bc0971a, merged with refactor(libc): map a run of shared guest pages as one view on Windows #9505's merge of main 0ea0f25 (run). One conflict, the list of cases in WindowsWriteWatch.cpp's main(), where both sides are kept: this PR's CheckReleasedPrivateAllocation and fix(agc): keep a rewatched import's memory compared once it is shared #9159's four compared-shared cases. This PR's own added and removed lines are unchanged.
    • Windows: agc_private_collect, windows_shared_map_cost, windows_write_watch, windows_write_watch_verify_direct, fix(agc): keep a rewatched import's memory compared once it is shared #9159's agc_rewatched_direct_shared, agc_import_alias_shared and agc_compared_shared_generations, guest_memory, guest_shm_open, guest_jit_shared_memory and guest_raise_exception pass ×10: 12 run, 11 pass, 1 skip (agc_import_watch, no Vulkan device), 0 fail;
    • Linux: agc_import_watch, guest_memory, guest_shm_open, guest_jit_shared_memory and guest_raise_exception pass ×10: 5 run, 5 pass, 0 skip, 0 fail;
    • the libc and libkernel suites pass on both (170 and 221 tests on Windows, 143 and 205 on Linux), run with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
  • check_conventions.py: passes. No comment lines are added.
  • 1f691a3, agc_private_collect_tests built in a plain build (run), after thuanquang's review: the target was added with EXCLUDE_FROM_ALL and nothing turned it back on, so a plain cmake --build build never built it, and a full ctest would report agc_private_collect as Not Run. The flag is dropped. On Windows, a plain build of all builds it, and the test passes 10 runs in a row: 1 run, 1 pass, 0 skip, 0 fail. Linux: 0 run, as the test is registered on Windows only. Suites: libc and libkernel pass on Windows (170 and 221). The suites ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.

Checklist

@github-actions github-actions Bot added perf Performance change perf numbers missing perf pull request without a before and after summary line labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown

This pull request is labeled perf, so it needs the summary line of a capture on main and one with the change, from the same title, scene and machine, as the Performance report form asks. Please paste both aps5-summary.jsonl lines, attach the two traces and add the output of tools/trace_digest.py.

@github-actions

github-actions Bot commented Oct 21, 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 11152 core/libs/tests/WindowsWriteWatch.cpp L648

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 1368 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1395 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1896 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 2434 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4449 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5021 core/libs/CMakeLists.txt
core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5371 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5925 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6187 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6188 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6192 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6209 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6280 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6300 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6412 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6481 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6808 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6924 docs/dev/ENVIRONMENT.md
PR 7048 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 7201 core/libs/CMakeLists.txt
core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 7275 core/libs/CMakeLists.txt
core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 7449 docs/dev/ENVIRONMENT.md
PR 7457 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 7514 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 7604 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 8004 docs/dev/ENVIRONMENT.md
PR 8246 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 8285 core/libs/CMakeLists.txt
PR 8484 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 8542 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 8592 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 8650 core/libs/CMakeLists.txt
PR 9012 docs/dev/ENVIRONMENT.md
PR 9156 core/libs/CMakeLists.txt
PR 9162 core/libs/CMakeLists.txt
PR 9176 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9248 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9284 core/libs/CMakeLists.txt
PR 9287 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9289 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9293 core/libs/CMakeLists.txt
PR 9297 core/libs/CMakeLists.txt
PR 9302 core/libs/CMakeLists.txt
PR 9345 docs/dev/ENVIRONMENT.md
PR 9356 docs/dev/ENVIRONMENT.md
PR 9383 core/libs/CMakeLists.txt
PR 9387 docs/dev/ENVIRONMENT.md
PR 9508 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9536 core/libs/CMakeLists.txt
PR 9571 core/libs/CMakeLists.txt
PR 9676 docs/dev/ENVIRONMENT.md
PR 9748 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 9772 core/libs/CMakeLists.txt
PR 9852 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 9897 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 10026 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 10193 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 10233 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 10442 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 10757 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ENVIRONMENT.md
PR 10773 core/libs/prx/libSceAgcDriver/CMakeLists.txt

@CnRJay CnRJay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR: the cost is isolated and measured (VirtualQuery 379.2 us of a 383.9 us collect inside one 449 MiB commit, because Windows walks the rest of the region), the direct path trusts only the proof Collect already relied on, and the safety argument lists every path that clears it. The large-commit result (2,221 ms to 28.5 ms for 5,765 spans) is clear.

  • The other row of its own table is a regression: 16 KiB placeholder replacements go from 171 ms to 197 ms (+15%), because each span now makes one failed GetWriteWatch call and then the old queries. A title whose arena is committed in many small pieces pays this on every collect. Remember the failure: keep the proof from being used for that allocation (or that range) until the arena's commit state changes, so the failed call happens once, not once per span.
  • The bench isn't part of the PR, and it measures Collect in isolation. The effect on a title is the thing that matters: Village's load with #8926's rewatch, load time or presents, main against this PR, from the same machine, without APS5_PROFILE_*. 2.2 s of collect time over the load is real, but a title measurement shows whether it's on the critical path.
  • "The one unguarded case: a page decommitted in place inside a still-proven write-watch allocation, by code that bypasses WindowsMappings. Nothing in the tree does that." Add a debug check that the direct call's result matches the old path's on a sample (or under a verify switch), so a later caller that does that is caught, rather than relying on nothing doing it.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@CnRJay, thank you for the review.

We ran that title measurement on Resident Evil Village (PPSA01556): main 1ff5c8a with #8926, against the same with this PR, on one machine, warm shader cache, no APS5_PROFILE_*, timed from Yes to the first gameplay frame, in the order B A A B:

  • without this PR: 16:42 and 11:31 (runs 2 and 3);
  • with this PR: 23:33 and 19:57 (runs 1 and 4).

It was slower in both runs, so we don't claim a load-time gain. That's two runs per arm, with a 5-minute spread between the two base runs, and GPU clocks weren't held. A traced minute of each load showed a ~16 s stall with two queue workers in Driver.Packet.memory_wait in this PR's run only, but the two windows fell ~50 s apart in their loads. We're profiling the collect path in the load before saying more.

We'll add both of your other points: remembering a failed GetWriteWatch for that allocation until the arena's commit state changes, and a verify switch that compares the direct call's result with the old path's.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@CnRJay, your first and third points are in:

  • 9e9afb9 remembers a failed direct call until the arena's commit state changes, so a span that crosses allocations pays it once, not on every collect.
  • 47e1497 adds APS5_VERIFY_DIRECT_COLLECT, which checks each direct call against a region query and throws, naming the span, on a mismatch.

Each has its red with only its check reverted (run 39651317856, run 39651193432), and both pass ×10 on Windows (run 39648160729).

One correction and one finding:

  • The 16 KiB gap was mostly runner variance. The 171 against 197 ms in the description compared runs on different runners. In one run (run 39651246424), the 5,765 failed direct calls cost ~6 ms of GetWriteWatch time (118.8 ms with them, 112.9 without). With 9e9afb9 the collect makes the same calls as main's path, and took 217.8 ms in all, against 218.9 with the first commit alone. The description now has the same-run numbers.
  • The in-place decommit case is real. The new verify test shows that GetWriteWatch succeeds over a page decommitted in place inside a proven allocation, so the direct path would report no writes there where the old path returns false. Nothing in the tree decommits that way today, and the switch now catches it if a caller ever does.

Your second point, the Resident Evil Village (PPSA01556) load, follows after the profiled pair.

EagleBearerYS and others added 5 commits October 21, 2026 22:07
windows_shared_map_cost maps 256, 16384 and 65536 pages of shared memory
(4 MiB, 256 MiB and 1 GiB) through WindowsMappings::Map, then releases
them, five times each, and prints the median map and release times, the
time per page and the number of mapped views. It only measures and
always passes, so a build of this commit and of a later one can be
compared on the same machine.
Mapping shared guest memory page by page queried the address space
three times for every 16 KiB page: twice to find the placeholder the
page splits from, and once more to see whether the section page still
had to be committed, which it then committed on its own. For a range of
fresh direct memory that is seven system calls a page, and with shared
imports attached every direct mapping takes this path on the guest
thread that maps it, under the mapping lock.

A mapping that goes page by page now commits its section range once
through one window, and splits each page off the placeholder the
previous page left, whose extent it already knows; only a page after a
resident run or a demotion looks the placeholder up again. A page now
costs a placeholder split, the view and its protection.

Tested: built; windows_write_watch gains a case that maps 256 shared
pages and counts the address-space queries the mapping makes, then
stores to and reads back every page. With the per-page lookups it fails
(775 queries for 256 pages) and with this change it passes.
agc_driver_committed_imports, agc_write_watch_coverage,
agc_private_collect, agc_write_guards, agc_driver_dcc_keys_first_write,
agc_driver_residency_dirty_mirrors, agc_driver_residency_driver,
guest_memory and the host import, window import and alias store tests
pass.
…ections

Committing the section range through one read-write window before the
pages are mapped works for direct and shared memory, whose sections are
committable and at least as long as the mapping. A file mapping passes
the bytes its file still has: the window can run past the end of a short
file, and a read-only file section refuses a read-write window, so every
file mapping would fail. Only mappings that pass no section size commit
the range up front; file mappings keep committing each view as before.

guest_memory's regular file mapping case maps a read-only file and a
file tail shorter than a page on Windows.

Adapts fable-void's 027bff5 to main's file mappings.
Mapping shared direct memory while shared maps are imported placed one
view per 16 KiB page: a placeholder split, a map and a protect for every
page. A 1 GiB map made about 200,000 system calls, held the mapping
thread for about 0.4 s and took and dropped the mappings mutex on every
page, so threads taking write faults queued behind it.

Map now maps each run of shared pages that need a view as one view: one
placeholder split, one map at read-write-execute (the cap later guest
protections need) and one protect per run, and builds the per-page
bookkeeping under the mutex while yielding to waiting faults. The views
move the run's page references instead of copying them, so releasing the
run does not hold the mutex. Protections and write tracking change
subpages inside the view as before. A view is split only where a release
or a remap cuts it: reset unmaps the view, maps the two pieces again over
the same section offsets and restores every 4 KiB protection, so armed
write tracking and guest protections survive the split. An alias of a
page elsewhere and a per-page protection leave the view whole.

Tested: windows_write_watch gains a case that maps 1 GiB of shared
memory (65536 views before, one after), then protects one page, aliases
one page, arms write tracking on one page, releases one page and remaps
another: the run splits only at the released and remapped pages (2 and
4 views), every other page keeps its bytes and protection, and the armed
page still faults and is collected. It fails before ("a shared gibibyte
was mapped as 65536 views instead of one") and passes after.
agc_driver_committed_imports: the 1 GiB shared map and import takes
190-196 ms on the mapping thread instead of 418-427 ms, and the longest
write fault on another thread stays at 46-97 us. Built; the write watch,
committed import, guest memory, write guard, host and window import,
residency and mapping tests pass (58 tests).
A run view maps the section at read-write-execute over the whole run and
commits the run through a read-write window. A file mapping passes the
protection its file allows and the bytes its file still has, so a
read-only file or a file that ends inside the run cannot be mapped that
way. File mappings now take the per-page path they took before the run
views: one view per page at the file's view protection, with the last
page cut at the end of the file. Mappings that pass no section size,
direct and shared memory, still map each run as one view.

guest_memory's regular file mapping case maps a read-only file and a
file tail shorter than a page on Windows.

Adapts fable-void's 3d695fb to main's file mappings.
EagleBearerYS and others added 5 commits October 21, 2026 17:53
The VirtualQuery counter that windows_write_watch_tests reads is now
compiled only into that test, through APS5_COUNT_MAPPING_QUERIES, so
the runtime's WindowsMappings::query does no atomic update per query.
Collecting guest writes over private memory asked VirtualQuery for every
region before reading its write watch, and a region ends at every
protection change, so each 16 KiB piece cost two system calls and an
allocation with several protections was read in several parts. The hot
sweep reads every unguarded private page at every ordering point, so
this cost was paid per hot page per dispatch.

The mappings now keep the extent of every private allocation they make
(added when a placeholder is replaced, erased on release) and collect
one GetWriteWatch per allocation from that map. A write watch read
cannot span two allocations, so pieces committed one by one still take
one read each; the query per piece is gone and the reported pages are
unchanged.

Tested: built; the new agc_private_collect test (256 MiB of 16 KiB
pieces with scattered writes returns the same pages as a reference
read, at most one system call per piece; one allocation in five
protection regions takes one call) fails before (32768 calls for 16384
pieces) and passes after (16384). The new hot-sweep case of
agc_driver_residency_dispatch_work fails before (51324 calls over 25660
pieces with 256 hot runs, about 1500 us per dispatch) and passes after
(25660 calls, about 1160 us). 47 residency, write-watch, guest-memory,
address-space and shared-write tests pass.
…ehind the mappings

Collecting private guest writes from the allocation map trusts the
extent the mappings recorded and calls GetWriteWatch without a query.
When memory in that extent was released by code that bypasses
WindowsMappings, the call fails and Collect threw "collect
private guest writes". Before the map, the query found the memory no
longer committed private and Collect returned false, which its callers
already handle.

A failed GetWriteWatch now queries the region at the cursor. If it is
no longer committed private memory, Collect returns false as before;
otherwise the failure is still fatal.

windows_write_watch releases a collected private allocation with
VirtualFree behind the mappings, then collects it again: it must return
false without throwing. Without the query it throws.

Adapts fable-void's 2760ad8.
…VERIFY_DIRECT_COLLECT

A private collect read from the allocation map makes no query, so it
can't see memory that code bypassing WindowsMappings changed inside a
recorded allocation. A page decommitted in place there is the case it
can't catch: GetWriteWatch still succeeds over it, so the collect
reports no writes where a query would have refused the range.

With APS5_VERIFY_DIRECT_COLLECT set, each such collect also queries its
span's regions, and Collect throws, naming the span and the switch, when
GetWriteWatch succeeded over memory that is no longer committed
private. ENVIRONMENT.md lists the switch.

windows_write_watch_verify_direct (the same test binary with
--verify-direct, the switch set) collects a recorded private allocation,
checks its write is still reported, decommits one page in place and
requires the next collect not to succeed. Without the check the collect
succeeds.

Adapts fable-void's 2760ad8.
The private collect counted its GetWriteWatch calls and pieces on every
call, under the mappings mutex, for agc_private_collect to read. They
are now compiled only where APS5_COUNT_MAPPING_QUERIES is set, as the
address-space query counter is: agc_private_collect_tests sets it, and
the runtime's Collect updates no counter. libc's GuestArenaCollectWork
export, which nothing calls (the test reads its own WindowsMappings),
is removed.

Adapts fable-void's 2760ad8.
EagleBearerYS added a commit to EagleBearerYS/AnyPS5 that referenced this pull request Oct 23, 2026
@EagleBearerYS
EagleBearerYS force-pushed the perf/libc-collect-without-region-query branch from 47e1497 to d2c46d9 Compare October 23, 2026 03:49
@EagleBearerYS EagleBearerYS changed the title perf(libc): collect proven private guest writes without a region query perf(libc): read private write watches per allocation without a region query Oct 23, 2026
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@CnRJay, the Resident Evil Village (PPSA01556) load you asked about is in, and it changed this PR's head.

  • The new head uses fable-void's per-allocation map (13a4e88, his commit, under his name). The mappings keep each private allocation's extent, and a collect makes one GetWriteWatch per allocation with no VirtualQuery. On the bench of Village's traced load, the 449 MiB commit takes 10-12 ms, and 16 KiB pieces take 88-97 ms against the earlier head's 130-135 ms.
  • Your two other asks carry over: a failed call over memory released behind the mappings returns false (f06ae46), and APS5_VERIFY_DIRECT_COLLECT checks each collect against a region query (4d57a20).
  • The title: one four-arm session on one machine, with a warm shader cache and GPU clocks not held. The base, A, was main plus the perf(agc): import adjacent host ranges as aligned blocks and stop re-trying budget refusals #6457 fix. C was A with the new head's first three commits, without refactor(libc): map a run of shared guest pages as one view on Windows #9505.
    • In the high import mode, C lowered the queue worker's per-frame Driver.Packet.dispatch over the load: between 569 and 956 ms, against 1,111-1,191 ms on A and 1,055-1,191 ms on the earlier head. That's one high-mode run each for A and C, and two for the earlier head. The range comes from the per-second summaries, which list only each interval's top eight phases.
    • In the low mode, no arm separates.
    • Launch to the first gameplay frame doesn't separate any arm in either mode. So the load is bound elsewhere, and this PR claims no load-time gain.

The numbers and their sources are in the description.

@github-actions github-actions Bot added the agc libSceAgc* label Oct 23, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 24, 2026
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 24, 2026
EagleBearerYS added a commit to EagleBearerYS/AnyPS5 that referenced this pull request Oct 24, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 24, 2026
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 24, 2026
@EagleBearerYS
EagleBearerYS marked this pull request as ready for review October 25, 2026 06:58
@oneandonlydean oneandonlydean self-assigned this Oct 26, 2026
@thuanquang

Copy link
Copy Markdown
Contributor

@EagleBearerYS one finding from a review of head bc0971afbc, done by reading; nothing was built or run.

agc_private_collect would be Not Run in a full ctest. core/libs/prx/libSceAgcDriver/CMakeLists.txt:514 adds agc_private_collect_tests with EXCLUDE_FROM_ALL, and :520 registers it with add_test. Nothing turns the exclusion back on, as set_target_properties(... PROPERTIES EXCLUDE_FROM_ALL OFF) does for the other excluded test targets in that file (for example :604). No other target depends on it either. So cmake --build build never builds it. A full ctest then reports Unable to find executable and ***Not Run, which ctest counts as a failure. The Windows job in build.yml takes that path on a push to main (a plain cmake --build build, then select_tests.py ctest build over every registered test), and the nightly MSYS2 workflow does too. On this PR the upstream windows check is skipped, and the fork run built named targets, so neither would have shown it. Dropping EXCLUDE_FROM_ALL, or adding the EXCLUDE_FROM_ALL OFF line, fixes it.

On the overlap with #11152: the two only conflict in the list of checks in main() of WindowsWriteWatch.cpp. Keeping both sides' calls resolves it, and WindowsMappings.hpp merges as the exact union of both. #9098's private-watch read never touches the per-page state #11152 removes, so either order works.

AI-assisted: yes (Claude Code).

The target was added with EXCLUDE_FROM_ALL and nothing turned it back
on, so `cmake --build build` never built it, and a full ctest reported
agc_private_collect as Not Run. Declare it like the other test targets
in that block.

Found by thuanquang's review.
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@thuanquang Thanks, good catch. Fixed in 1f691a3: EXCLUDE_FROM_ALL is dropped, so a plain build builds agc_private_collect_tests. On Windows, a plain build of all builds it and the test passes 10 of 10 (run). Thanks for checking the #11152 overlap as well.

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

Labels

agc libSceAgc* build docs libc perf numbers missing perf pull request without a before and after summary line perf Performance change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants