Skip to content

fix(agc): import GPU-mapped guest memory when it is mapped - #1961

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
Raghav314:fix/agc-import-gpu-memory-at-map
Oct 10, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
Raghav314:fix/agc-import-gpu-memory-at-map

Conversation

@Raghav314

@Raghav314 Raghav314 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

An address-based build imports every registered allocation with GPU access that it leases, whole, the first time it builds with it. Pinning a large allocation takes about 0.3 s per GiB here (the kernel faults in and zeroes each page), and it runs on the queue that first uses the allocation, under the device lock.

  • The registry calls an observer (GuestAllocationsSetGpuMapObserver_nid_postfix) on the mapping thread, after the registry lock is released, with the ranges a kernel mapping gave GPU access: direct and flexible maps with it (Mutation::NoteGpuMapping), and fixed maps with it over a registered range such as a reservation. The mutation looks the ranges up while it still holds the lock and hands them over as weak pointers, with the registry generation it ended at, so the observer never keeps a range alive (a concurrent unmap does not wait for it). AnyPS5's own libc heap is registered with GPU access but is not reported.
  • The driver imports those ranges on its current device (ImportMappedRanges), the same whole-range import an address-based build makes, without reconciling the other imports with the registry, so the work per mapping does not depend on how many imports there are. It skips a range that is already gone, and every range when the imports were reconciled with a newer registry since; the next lookup imports those as before. It installs the pin waiter as the other import paths do, so unmapping a range imported at map retires its import (fix(agc): retire host imports before guest unmap #2575); without it, unmapping a range imported at map before any lookup waited for APS5_PIN_WAIT_MS (60 s) and failed. The live import bytes are a running total instead of a sum over every import. With no device yet, nothing happens and the first use imports as before. A failed import is logged per range and handled as a failed import is today.
  • Once the import table belongs to the current device, the import takes only the table's lock. The first import on a device (it runs the write-watch probe) and a device that was just replaced go through the device lock, and a device that is no longer current is skipped. Clearing the observer waits for calls already running.
  • Only new mappings are covered. A range that gains GPU access through sceKernelMprotect is still imported on first use.
  • Mapping a large allocation now takes as long as pinning it, about 0.3 s per GiB here, and holds the import table's lock meanwhile (a lookup on another thread waits for it). Before, the same pin happened at first use under the device lock. A title that maps GPU memory it never uses from the GPU now pins it, up to the import budget; on the console direct memory mapped for the GPU is resident and reachable from the GPU as soon as it is mapped.

Sources:

  • shadPS4 src/core/memory.cpp calls rasterizer->MapMemory when a mapping with GPU access is made, and KytyPS5 src/kernel/memory.cpp MapGpuRange calls RenderContext::MapMemory at map. Both only record the range, because their buffer caches upload on use. AnyPS5's address-based builds bind host memory in place, so the matching work at map is the import.

PPSA36082

While loading it maps 512 MiB, 6 GiB and 128 MiB of direct memory with 0x32. Its 512 MiB upload pool is freed only after a GPU fence. Without this change, its first address-based build pins those allocations on the queue the loader's fence waits on, and the pool overflows at about 2.0 s, before any import finishes (0/3). With it, the imports run on the mapping thread (done by about 0.9 s, 2.8 s and 2.8 s) and nothing is imported on the fence path. Together with #1959 (formatted buffer accesses) and #1960 (imports leave pages unprotected), it reaches the main menu in 10/10 runs (40 s each, pool peak 340-391 MiB of 512); without this change, 0/3 overflow at about 2.0 s. Three of the ten later stopped at "prepared shader artifact is missing for the requested static ABI", a separate issue after the menu.

PPSA36082 also needs an import budget of at least 6784 MiB, which this change does not need: with main's 6 GiB, the 6 GiB allocation is refused at map (and logged) because 512 MiB is already imported. #290 sets the budget to max(6 GiB, RAM/2); these runs used APS5_HOST_IMPORT_MIB=8192 in its place. With #290's import windows, a request that covers a whole registered range, as this one does, still imports the whole range, which is what an address-based build imports. The runs were on main f145139 with #861, #1367, #1249, #1905, #1959 and #1960.

Tested

Linux (CachyOS), x86-64, GCC, 7800X3D / RTX 4090.

  • guest_memory: a libc heap allocation is not reported; a direct map with 3 is not reported; one with 0x32 is reported once, with its address and size, and the observer can take a registry lease; an mprotect to 0x13 is not reported. Over a reservation, a fixed map with 3 is not reported and one with 0x32 is reported once with its address and size. Clearing the observer while a call runs returns only after it finished. Never reporting, reporting maps without GPU access, reporting the heap, not reporting direct maps, reporting with the registry still locked (it deadlocks; the test times out), not reporting fixed GPU maps, reporting every fixed map, and clearing without waiting each fail it.
  • agc_driver_host_import_at_map (new): a mapping imported before any lookup is retired when it is unmapped; a mapped allocation is imported on that device, also after another mapping changed and when a range is mapped again; a mapping unregistered before its import, and an import older than the last reconcile, are not imported; with the device lock held by another thread, a mapping on a device that already imports is imported. Not installing the pin waiter, dropping the generation check, and always taking the device lock each fail it.
  • Per mapping, on top of N imports of 64 KiB (average of 50 maps): 114 µs before and 39 µs after at 1,000 imports, 520 and 49 µs at 4,000, 2,134 and 58 µs at 16,000.
  • The driver's registration has no test (the driver's device isn't reachable from one); the PPSA36082 runs above compare it on and off.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: fix(agc): address-based builds map only memory mapped for the GPU #1905 (merged; address-based builds map only memory mapped for the GPU)
  • AI-assisted: yes

@MarcZX10 MarcZX10 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.

The new observer misses an important map path. DoMapDirect/DoMapAnon first call RemapFixedIntoRegistered for a fixed mapping over an existing reservation; that path calls Mutation::Protect(..., gpu, ...) rather than Mutation::Add. GuestAllocationsProtect_nid_postfix updates the range but never appends it to MutationState::gpuMapped, so the observer does not import a reserved range when it is mapped with GPU access. The new test only covers Add, so it does not exercise this path. Please notify the observer for GPU-enabled fixed remaps (while preserving the documented deferred behavior for plain sceKernelMprotect, if intended) and add a reserve-then-fixed-map regression test. I will re-review the updated head.

@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from 9a402fb to a050a7c Compare October 9, 2026 02:47
@Raghav314

Copy link
Copy Markdown
Contributor Author

Thanks, A fixed map over a reservation goes through RemapFixedIntoRegistered, which uses Protect instead of Add, so the observer never saw it. Fixed remaps with GPU access now get reported too (Mutation::NoteGpuMapping). Plain sceKernelMprotect still isn't reported, so that stays deferred to first use like the description says.

I added the regression test you suggested for in guest_memory, it reserves two pages and maps them fixed and checks that only the GPU-mapped one is reported

@MarcZX10 MarcZX10 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.

Re-reviewed the updated head and the follow-up against the earlier request. GPU-enabled fixed remaps over reservations now call NoteGpuMapping after the registry mutation, while ordinary sceKernelMprotect remains deferred. The regression test covers reserve → fixed CPU map → fixed GPU map, verifies only the GPU range is observed, and also checks the observer can lease the registry after the lock is released. Linux and Windows CI and Conventions pass. This change depends on #1905 as documented; no remaining blocker found.

@popcoder565

Copy link
Copy Markdown
Contributor

Native Windows run, since the description only covers Windows under Wine.

Setup: Windows 10 (19045), RTX 3060 Laptop (NVIDIA 616.92), MinGW-w64 GCC 15.2.0 (WinLibs r7), Release. Head a050a7c6 merged onto main d9484c7 together with #1905 (cf6ae853), which it depends on.

  • Full ctest: 495/495, twice, with the GPU tests on the RTX 3060.
  • agc_driver_host_import_at_map, guest_memory and agc_driver_graphics: 5/5 each.

No title was run.

🤖 Addressed by Claude Code

@Celegans12

Copy link
Copy Markdown
Contributor

I tried this with Ghost of Yōtei (PPSA26344) on Windows with an RTX 3080 Laptop GPU. It was a backport onto our older pre-#639 test tree, not current main, so treat the numbers as indicative. The change does what it says: the largest early-world address rebuild went from 19.9 s to 25 ms. But the run as a whole got a lot slower. The world showed up at 549 s instead of 278 s, and the first 23 world frames averaged 15.9 s against 7.2 s on the same build with the change switched off.

The cost is per mapping. Yōtei makes about 43.7k GPU mappings of 128 KiB each, and every ImportGuestMemory call takes the GPU mutex and goes through HostImportFor. Because each new mapping bumps the registry generation, every call takes a fresh lease of the whole registry and runs refreshImports over all ~40k imports before importing the one range. That came to about 6 ms per mapping. The mapping thread held the GPU mutex for 272 s over the run and waited another 260 s for it, and once streaming ran into the frames the render queue paid the same reload there. Current main's HostImportFor has the same stale-check → lease → refreshImports path, so I'd expect the same there.

Importing the range the hook is handed directly, or reconciling once per batch of mappings rather than on every call, would probably keep the gain without the cost. I'm happy to rerun a revised version.

Numbers (same build, switch on/off)
#1905 + #1961 both off #1905 only
World reached at 549 s 278 s 257 s
World frames 1-11 27.4 s/frame 11.1 12.5
World frames 1-23 15.9 s/frame 7.2 8.0
Calm frames vs off +40% – −1.6% (+0.3% over frames 12-29)
Import peak 11,113 MiB 11,824 MiB 11,113 MiB
GPU mutex held, all threads 13.7 s/frame 4.3 4.7

On its own, #1905 is neutral for speed here: only 182 of about 40.5k ranges lack GPU access. It does lower the import peak by about 711 MiB, which is nice since we sit right at the host-import ceiling. No new skips or errors in any of the runs.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 9, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator

Celegans12's run above shows the cost of importing at map: each GPU mapping takes a fresh registry lease and runs refreshImports over every import, about 6 ms per 128 KiB map, and PPSA26344 reached its world at 549 s instead of 278 s. Please make the per-mapping work independent of the number of imports (import the range the observer is handed, or reconcile once per batch) and rebase onto main (conflict in tests/BdaResources.cpp).

@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from a050a7c to 7b62142 Compare October 10, 2026 02:44
@Raghav314

Raghav314 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the numbers @Celegans12, the cause was what you described. Reworked, and rebased on main together with #1905 (@oneandonlydean):

  • The observer doesn't take a lease of the whole registry anymore: the mapping hands it just the ranges it mapped (weakly, with the registry generation it ended at).

  • The import at map doesn't reconcile the other imports, so its work doesn't depend on how many imports there are. A range that's gone, overlaps an existing import, or comes from before the last reconcile is left to the next lookup, like on main. The live import bytes are a running total now instead of a sum over every import.

  • Once the device has its import table, the import at map takes only that table's lock, not the device lock, so the mapping thread doesn't wait for the render thread anymore. The first import on a device and a device that was just replaced still go through the device lock.

  • AnyPS5's own libc heap doesn't trigger it anymore, only kernel GPU mappings do.

  • Per mapping, on top of N existing 64 KiB imports here (RTX 4090, Linux, average of 50 maps):

Imports Before After
1,000 114 µs 39 µs
4,000 520 µs 49 µs
16,000 2,134 µs 58 µs

What's left is mostly the Vulkan import itself. The draw-time reconcile after a registry change is main's and still walks every import.

If you can rerun Yotei with this I'd like to see how it compares.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from 7b62142 to de9239f Compare October 10, 2026 06:50
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 10, 2026
@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from de9239f to 78466e9 Compare October 10, 2026 08:18
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from 78466e9 to 90094af Compare October 10, 2026 08:57
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@Raghav314
Raghav314 force-pushed the fix/agc-import-gpu-memory-at-map branch from 90094af to 1156317 Compare October 10, 2026 09:04
@SleglJan

Copy link
Copy Markdown
Contributor

007 First Light (PPSA11386 v01.018.000), start-up only, on the reworked head 1156317. Linux (CachyOS, kernel 7.2.9, GCC 16.2.1, Ryzen 9 7950X3D, RTX 3080 with NVIDIA 615.71.09), Release with ANYPS5_ENABLE_SPIRV_TOOLS=OFF.

Setup:

  • Base. The head merges cleanly onto main a29ef59. fix(agc): address-based builds map only memory mapped for the GPU #1905 is already in that main, so the two builds differ only by 1156317.
  • Stack. Both builds carry the 007 First Light (PPSA11386) on Linux: start-up stops in order and the PRs that pass them #2523 stack that gets 007 to its first flip. That includes the stack's local ShaderInfo::MaxImages 64 → 128 experiment but not its sampled-heap one (main has 64 since fix(recompiler): hold 64 images in a sampled image heap #2347). In every run of both builds, 32 shaders fail preparation with shader image heap capacity exceeded; this is logged and not fatal.
  • What is measured. 007 presents no frame yet: it stops while presenting its first flip, on a bindless image table limit. The measure is the process lifetime from exec to that stop, with exit polled every 0.2 s. That covers boot, shader preparation (from a cold cache, over 1,600 misses by the cache's last report before the stop) and the first draws (APS5_TRACE_DRAWS=1). It says nothing about in-world frame times, which is what the PPSA26344 run above measured on the earlier head.
  • Run order. 8 cold runs alternated A B B A A B B A. Then one more cold main + stack run filled the cache, and 9 warm runs (the first with this PR) reused it (0 cache writes, 32 misses each).
Build Shader cache Runs Seconds Median
main + stack cold 5 27.0 27.2 27.6 27.6 28.2 27.6
+ this PR cold 4 25.5 25.6 26.0 26.8 25.8
main + stack warm 4 23.1 24.1 25.0 26.2 24.6
+ this PR warm 5 23.1 23.5 24.1 24.5 24.7 24.1
  • From a cold cache, 007 reaches the stop 1.8 s sooner with this PR (medians; 1.6 s without the extra cold run). The slowest run with it (26.8 s) is faster than the fastest without it (27.0 s). From a warm cache, the difference (0.5 s) is within the spread.
  • Both builds stop with the same message. With this PR the stop follows the 40th traced draw in all 9 runs; without it, the 43rd in 8 of 9 and the 46th in one.
  • Without this PR, every run logs guest memory changed while GPU results were pending; keeping the CPU's blocks once for a storage image. With it, none does.
  • No unit tests were run here.

AI-assisted: yes (Claude Code)

@klosixgaming-arch

Copy link
Copy Markdown
Contributor

Independent validation on real hardware

Built and ran the PR head 11563171 on my own box:

  • Windows 11 Pro (26200), WinLibs MinGW-w64 GCC 15.2.0 (r7), Release, BUILD_TESTING=ON.
  • GPU: NVIDIA GeForce RTX 5090.

Results:

  • agc_driver_host_import_at_map (the new dedicated test) — PASSES (exit 0) on the 5090.
  • The PR's new guest_memory additions all pass on my box: CheckDirectMemoryGpuProtBits (prot-bit reporting), CheckGpuAccessFollowsProtection, CheckGpuMapsAreObserved, and CheckClearingTheObserverWaitsForItsCalls all run and pass — the test proceeds well past them.

One failure I tracked down (not a regression from this PR): the full guest_memory binary aborts on my Windows box inside CheckReadsIntoSharedWriteTracking at the Require(refused) check (a sceKernelPread into a page just mprotected to PROT_READ is expected to throw). I confirmed this is pre-existing on my box, not caused by #1961: I checked out the PR's base 3d1a7d90 (which lacks this PR's CheckGpuMapsAreObserved/CheckGpuAccessFollowsProtection/CheckClearingTheObserverWaitsForItsCalls) and the same check also fails there (GuestMemory.cpp:992, the identical Require(refused) pread assertion). So on my Windows/MinGW setup that shared-write-tracking/AIO pread-refusal check does not hold, with or without this PR. Worth a maintainer look, but I don't believe it's this PR's doing.

Honest limits of what I verified:

  • I did not run PPSA36082 or any other title (no game/ROM here), so the pool-overflow / main-menu behavior and the APS5_HOST_IMPORT_MIB budget interaction are unverified on my end.
  • I only ran on one GPU (the 5090); I did not test the 7800X3D/RTX 4090 combo you used, lavapipe, or Linux.
  • The per-mapping import-latency numbers (µs at 1k/4k/16k imports) are a performance measurement I did not reproduce.

Written with AI assistance.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
The registry calls an observer on the mapping thread, after the registry
lock is released, with the ranges a kernel mapping gave GPU access:
direct and flexible maps with it, and fixed remaps with it over a
registered range (a reservation). The mutation looks them up while it
still holds the lock and hands them over weakly, with the registry
generation it ended at, so the observer does not keep a range alive.
The driver imports those ranges on its current device, so an
address-based build finds the import instead of pinning the memory on
the queue that first uses it. A range that gains GPU access through
sceKernelMprotect, and AnyPS5's own libc heap, are still imported on
first use.

The import at map does not reconcile the other imports with the
registry, so its cost does not grow with the number of imports. It skips
a range that is gone, and any range when the imports were reconciled
with a newer registry since; the next lookup imports those as before.
It installs the pin waiter as the other import paths do, so unmapping a
range imported at map retires its import. The live import bytes are kept as a
running total instead of being summed over every import. Once the import
table belongs to the current device the import takes only the table's
lock; the first import on a device (which runs the write-watch probe)
and a device that was just replaced go through the GPU lock. Clearing
the observer waits for calls already running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriadam10

Copy link
Copy Markdown
Collaborator

Rebased onto current main; the only conflict was in core/libs/tests/GuestMemory.cpp (the new observer tests next to CheckHintedVirtualReservation), resolved by keeping both. Scoped build and ctest of libs, libc, libkernel and AgcDriver are green; agc_driver_host_import_at_map and guest_memory pass, and both fail without the change. Merging once CI is green.

@adriadam10
adriadam10 force-pushed the fix/agc-import-gpu-memory-at-map branch from 81fac84 to be1f501 Compare October 10, 2026 22:30
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 10, 2026
@adriadam10
adriadam10 merged commit 4fb937a into boykopovar:main Oct 10, 2026
14 checks passed
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.

8 participants