Skip to content

perf(agc-driver): join every range a bound buffer crosses - #5372

Open
TideOPL wants to merge 6 commits into
boykopovar:mainfrom
TideOPL:perf/host-import-join-partial
Open

TideOPL wants to merge 6 commits into
boykopovar:mainfrom
TideOPL:perf/host-import-join-partial

Conversation

@TideOPL

@TideOPL TideOPL commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Minecraft Dungeons II (PPSA16064) gameplay: frame p50 92–97 ms in every run, against 100–131 ms when a run lands in the slow mode. That mode copies one buffer on the CPU about 40,000 times in 25 s.

What

Follow-up to #4517, which is now merged. This branch is four commits rebased on main 3aebaa7, then main merged in twice: by a maintainer at 83bf557, and at bf2c63f (0ccc15d). main now includes #290 (host import windows), #5249 (sparse descriptor heap runs), #5428 (read-only V# across two imports), #7061, and #6457 and #6479 (aligned power-of-two import blocks). The rebase onto #7061 keeps its peekImport in importJoined (an import lookup that does not mark the import as used) and drops only the joined condition, as SleglJan did in their builds below. The merge of bf2c63f conflicted only in the TechnicalDebt entry: it keeps main's block wording and replaces the join and grown-heap sentences with this PR's. The code merged without conflicts, and the PR's diff to main in GuestBufferMemory.cpp is still the two joined conditions and the run.bytes <= window line.

  • The case. The title binds a 1.4–1.5 MiB writable buffer that starts at one registered piece and ends inside a later piece. That later piece is registered a moment after the first one. Examples from five runs (bound buffer over its pieces):
    • 0x305fca0000+0x160000 over 0x110000 + 0x30000 + part of 0x50000;
    • 0x306c410000+0x160000 over 0x110000 + part of 0x70000.
  • What goes wrong. Sometimes the first bind comes before the 8 MiB window around it is fully registered. Then only the first piece is imported, and joinedRun refuses the binding because it covers its pieces only partly. The buffer is then copied on the CPU for every draw that binds it, and each of those draws gets a fresh resource build instead of a cache hit: about 280 builds per frame instead of about 5. Which order a run gets is a race, so about half the runs were slow.
  • joinedRun. It now joins every range the bound region crosses, whole, wherever the binding starts or ends inside them, as long as the joined run is no larger than the import window (8 MiB). perf(agc-driver): join the imports of adjacent ranges of any size that a buffer is bound exactly over #4517's two rules stay as they were: runs of ranges that are each larger than the window, and a buffer bound exactly over its ranges, join at any size. importJoined widens the join to the imports the buffer starts or ends in, so overlapping binds merge into one import instead of retiring each other.
  • importJoined (second commit). It widened only into an adjacent import marked joined. With feat(agc): host import windows, least-recent eviction under the budget #290, a newly registered piece that completes a window is imported as that window, which is not a joined import, so a bind across the older boundary joined only the crossed pieces and retired the window, and the next bind across the newer boundary imported the window again: every alternating bind re-imported (zero copies, but a new import each time). The join now widens over any import the bound buffer starts or ends in, and falls back to the crossed ranges alone when the widened run cannot be joined.
  • perf(agc): keep the cached address space for a read-only V# across two imports #5428's tests (third commit). spaceReadAcrossImportsTests and spaceReadAcrossImportsWrittenTests bind a read-only V# across two adjacent read-only ranges. With this PR those two ranges are joined and the V# is bound in place, so the case they cover, a V# copied across two imports, no longer arises there (and the mock device does not back imports with guest bytes). The third range is now registered writable, so the V# crosses two imports that differ in access, which this PR still copies. Both tests pass that way on main 9a3e2ac as well.
  • Window cap and no fallback (fourth commit, review). A partial join larger than the window fails, and so does a widened join that cannot be made; importJoined does not retry with the crossed ranges alone. A failed join copies the buffer and keeps the imports it would have retired, so it cannot retire an import the next bind re-imports. A heap grown in small pieces under bound buffers is re-imported at most once per piece until its run reaches the window, and copied past it: the bound the 8 MiB window was introduced for (NVIDIA 610.57.04, 615.78.08). MCD2's joins stay under 2 MiB and Ready or Not's under 3 MiB.
  • Debt. The TechnicalDebt entry for host imports describes the new join rule and the window cap, and states the cost: a heap grown in small pieces under bound buffers is re-imported once per new piece until its run reaches the window, and copied past it. perf(agc): bind a sparse descriptor heap's committed run in place from its import #5249's sentence on sparse descriptor heaps is kept as its own sentence after it.
  • Behaviour change. The check from perf(agc-driver): join the imports of adjacent large ranges when a buffer is bound across them #4515 that a 32-byte bind across two small pieces stays apart now expects them joined.

The first version of this PR also fixed UploadFinish binding a region past the end of an import that covers only its start. That fix went in with #4517, so it is no longer part of this diff.

Tested

Windows 11, WinLibs GCC 15.2, Release.

  • importJoinTests (agc_driver_graphics):

  • Suites: on this head (0ccc15d, main bf2c63f), ctest -R "^agc_driver|^guest_memory|^guest_allocations" passes 593 of 597. agc_driver_sparse_dma_buf_import and agc_driver_stencil_ref_export are skipped, as on main. agc_driver_msaa_dcc_clear_4 and agc_driver_pass_input_hazard fail here with main's GuestBufferMemory.cpp as well (RTX 5070, Windows), so they aren't from this PR. agc_driver_graphics, which holds this PR's tests, passes. Earlier, on main 3aebaa7, the same selection passed 538/538. Before the first rebase, on main 4eb91ea with every test executable rebuilt, the full ctest passed 1298/1299. The one left is agc_driver_gpu_indirect_arguments_misaligned_writer_ahead, from main, which does not build with WinLibs GCC 15.2 (requested alignment '65536' exceeds object file maximum 8192); this PR does not touch it.

  • Conventions: tools/check_conventions.py passes.

  • MCD2, Squid Coast spawn:

    Run before p50 / p90 / p99 ms, presents after p50 / p90 / p99 ms, presents
    1 crashed while loading (see below) 92.0 / 96.2 / 103.3, 162
    2 130.7 / 137.7 / 172.7, 113 92.9 / 96.5 / 105.8, 161
    3 112.9 / 119.2 / 161.5, 130 96.2 / 101.8 / 109.9, 155
    4 99.6 / 106.8 / 114.0, 150 97.5 / 102.9 / 122.3, 153
    • GPU busy is 42–44 ms per frame in every run. Graphics.Draw drops from 47–63 to 28–30 ms per frame in the slow runs.
    • An earlier interleaved A/B of the joinedRun change alone gave the same split: before 94.3 / 115.4 / 114.8 / 93.3 ms p50, after 93.3 / 93.6 / 93.9 ms.
    • Host-import churn did not change: 1,200–2,100 imports retired and 31–33 joined at bind per run, before and after. In the three logged runs with the change there were no import failures and no device loss, and the largest joined import was 16 MiB. MCD2 does not grow a heap piece by piece under bound buffers, so that case has not been run on NVIDIA.
    • APS5_TRACE_BIGBUF=1 slows loading and made the slow order happen in 5 of 5 runs before; after, 3 of 3 were fast.
    • MCD2 sometimes segfaults on its async loading thread at dungeons2.exe+0x4cf10b. It happened both before (run 1) and after (a warm-up boot), and on older builds, so it is not from this change.
  • Second title: Nesyth reports the same straddle in Ready or Not (PPSA18605) on an RX 7900 XTX; their numbers are in the comments below.

  • Third title, AMD: alfalfa-star tested the first three commits in Kena: Bridge of Spirits (PPSA01802) on Linux (Arch, kernel 7.2.9) with an RX 9070 XT (RADV, Mesa 26.2.4), on their Kena stack (not plain main): over 120 s runs, CPU copies outside imports went from 3,000–72,496 per run (1.8–55 GiB, 17 runs) to 0 (7 runs), the builds that copied went from 380–880 µs to 37–39 µs, and there were no GPU resets. They reran with the capped head (68f7a25) against the previous head: no join hit the 8 MiB cap, 0 copies outside imports on both, frame rates within run-to-run noise, no GPU reset. One capped run aborted while loading with a RADV out-of-memory command submission and device loss; the same kernel error then stopped another title on a build without this PR, so they attribute it to their system that day. Details in the comments below.

  • MCD2 on other machines, this head (e539061):

  • Not run: Linux and AMD here (alfalfa-star's Kena run above is on RADV); a title that grows a heap in pieces on NVIDIA (the window cap now bounds that case as the window does on main). The traces are not attached; I can share them on request.

Checklist

  • Based on current main; no other open PR implements the same functions (perf(memory): restrict registry work to affected ranges #1324 changes other functions in the same file)
  • 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 under Silent stubs in their library's technical debt file
  • 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: none
  • Unblocks: none
  • GPUs tested: NVIDIA GeForce RTX 5070, driver 610.88, Windows 11
  • AI-assisted: yes. Written with AI assistance (Claude Code); I reviewed and tested it.

@Nesyth

Nesyth commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Same bug in a second title: Ready or Not (PPSA18605, UE5), Windows 11, RX 7900 XTX.

The case. A 768 KiB buffer the title binds on nearly every draw starts at a 640 KiB registered range and runs 128 KiB into the next one, a 256 KiB range (0x3290df0000+0xc0000 over 0xa0000 | 0x40000 in one session). Both lie in one 8 MiB window that is only partly registered, so joinedRun refuses the bind. Where the buffer lands changes from launch to launch; it straddled in 9 of my last 12 sessions.

What it costs, scene, 30 s profile windows, my bring-up build (main 0214069 + #4515 + #4517 + other local changes):

  • Without a join: 18,000-23,000 draw builds per 10 s fail on that buffer, and each one copies its 768 KiB, 85-93 GB per session. About 45% of draws reuse their resources. 2.67 and 2.90 FPS.
  • With the straddle joined (I used a narrower version of your rule that joins only inside one 8 MiB window): 10 MiB copied, about 98% reused. 4.53, 4.43 and 4.47 FPS.

With this PR (2 sessions, driver built from your head):

  • 0 MiB copied outside imports. That includes a second straddle my narrower rule still copies, a buffer across a window boundary (0x3245dc0000+0x280000 over 0x210000 | 0x1f0000), about 2.7 GB per session. So the broader rule pays off here.
  • Both sessions happened to get the layout without the hot straddle, and I ran them with a heavy import trace, so I don't have a clean FPS number for this PR yet.
  • A unit test with that geometry passes on your branch: a view 2.5 pieces long from the start of a 2-piece range into a 1-piece range, inside a partly registered window. It binds in place with nothing copied.

One question. Joins here have no size bound, and a join is re-imported whole each time a bound buffer crosses into a new piece. The host-import debt entry says the 8 MiB window was introduced because runs re-imported on every growth made the NVIDIA driver (610.57.04, 615.78.08) refuse imports or lose the device. Ready or Not's joins stay under 3 MiB, so it doesn't exercise that. Has this run on NVIDIA with a title that grows a heap in pieces? If a cap turns out to be needed, the window-bounded check is one line in joinedRun, and I can share it with its tests.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

The full build of this merged with current main fails agc_driver_graphics on Linux and Windows at the new check in BdaResources.cpp:312: a bind into a piece registered after the joined pieces did not grow their import. Please rebase and look at it with #290 in. Also, Nesyth's question is still open: with no size bound, has this run on NVIDIA with a title that grows a heap in pieces?

@TideOPL
TideOPL force-pushed the perf/host-import-join-partial branch from 201218a to eb0e2e6 Compare October 14, 2026 05:52
@TideOPL

TideOPL commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@oneandonlydean thanks, reproduced. With #290 in, a new piece that completes a window is imported as that window, which isn't a joined import, so importJoined didn't widen over it: a bind across the older boundary joined pieces 1–2 and retired the window, the next bind across 2–3 imported the window again, and so on, with a fresh import on every alternating bind. Rebased on main 0b6d033 and added a commit (eb0e2e6): the join now widens over any import the bound buffer starts or ends in, and falls back to the crossed ranges when that wider join can't be made. The test now requires the binds to settle on one import across both boundaries. Full ctest passes 1133/1133 on Windows; Linux not run.

@Nesyth on NVIDIA: tested only on an RTX 5070 (driver 610.88, Windows 11). In MCD2 there were no import failures or device loss, with joins up to about 16 MiB. Not tested: Linux NVIDIA, and a title that grows a heap in many small pieces under bound buffers, which is the case that made the 8 MiB window necessary. If you can share the window-bounded cap and its tests, I'm happy to take it into this PR so a join never outgrows the window.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 14, 2026
@TideOPL
TideOPL force-pushed the perf/host-import-join-partial branch from eb0e2e6 to 2764743 Compare October 14, 2026 12:47
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 14, 2026
@alfalfa-star

Copy link
Copy Markdown
Contributor

Kena: Bridge of Spirits (PPSA01802) hits the same case. Tested on Linux (Arch, kernel 7.2.9), RX 9070 XT (RADV, Mesa 26.2.4), with the three commits cherry-picked onto a local Kena stack (main from 2026-10-14 plus unmerged GPU PRs and stand-ins Kena needs to reach gameplay; plain main does not reach the title scene, so this is not a plain-main capture).

Same save, 120 s runs, APS5_PROFILE_DRAW=1, last [buffers] ... completed CPU-copy operations line of each run:

runs copies outside imports per run bytes
without #5372 17 3,000 to 72,496 1.8 to 55 GiB
with #5372 7 0 0

Example lines from one run each:

without: [buffers] 86000 completed CPU-copy operations: ... 54971 outside imports (30800 MiB): 0x2020000000:2906/7931MiB 0x2040000000:25394/6746MiB 0x20d0000000:2681/4225MiB ...
with:    [buffers] 43000 completed CPU-copy operations: ... 0 outside imports (0 MiB)

In the slow windows the builds that copied cost 380 to 880 µs each ([draws] ... build 883 (1242)), against 37 to 39 µs with the PR, and guest read in [buffers] went from 2.7 to 3.7 s per 10 s to 7 to 272 ms. Gameplay windows went from about 90 to 115 to 150 to 178 presents per 10 s together with other local changes; the copies were the largest single part of it. No GPU resets in any run.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

Thanks for the rebase. The agc_driver_graphics failure is gone with the third range registered writable, so the V# again crosses two imports that stay apart. Two things are still open:

  • importJoined still retries with the crossed ranges when the wider join gives no run (if (run.ranges.empty() && (from != begin || to != end)) run = joinedRun(lease, begin, end);). That is a fallback. Please choose the run before importing (the wider one when it can be joined, otherwise say which case takes the crossed ranges and why) and let a failed join stay a failure.
  • joinedRun no longer has any size bound, and the debt entry now says a heap grown in pieces under bound buffers is re-imported once per piece. That is the pattern that made NVIDIA (610.57.04, 615.78.08) refuse imports or lose the device, and it has not been run. Please keep the join within the import window (or a stated cap), or show a run of a title that grows a heap in pieces on NVIDIA without import failures.

@TideOPL
TideOPL force-pushed the perf/host-import-join-partial branch from 2764743 to 68f7a25 Compare October 14, 2026 18:28
@TideOPL

TideOPL commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@oneandonlydean thanks, both done in 68f7a25 (rebased on main 32e810d):

  • No fallback. importJoined widens over the imports the bound buffer starts or ends in and joins that run, or fails. There is no second try with the crossed ranges: when the widened run can't be joined, the buffer is copied and the imports it would have retired are kept, so a failed join can't retire a window import that the next bind would re-import.
  • Size bound. joinedRun keeps perf(agc-driver): join the imports of adjacent ranges of any size that a buffer is bound exactly over #4517's two rules as they were (runs of ranges each larger than the window, and a buffer bound exactly over its ranges) and otherwise joins only runs no larger than the import window (8 MiB). A heap grown in small pieces under bound buffers is therefore re-imported at most once per piece until its run reaches the window, and copied past it, the same bound the window gives today. The debt entry says so. The cases in this PR stay inside it: MCD2's binds join under 2 MiB, Ready or Not's under 3 MiB.
  • Test. The grown-heap check now uses a heap one piece larger than the window: a bind into the new piece stays in place on its window import, a bind across the older boundary is copied without retiring it, and alternating binds never re-import. It fails on the previous head (a bind across a grown heap joined more than the import window or retired the newer import) and passes here. Full ctest passes 1209/1209 on Windows; Linux not run.

@TideOPL

TideOPL commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@alfalfa-star thank you for the Kena runs, that's the first RADV result for this and a third title with the same straddle. I've added it to the description. One note: since your run, the join is capped at the 8 MiB import window (68f7a25, from the review above), so a Kena join larger than that would be copied again. If you get a chance to rerun with the current head, it would show whether any of Kena's joins hit the cap.

@alfalfa-star

alfalfa-star commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

@TideOPL I reran Kena: Bridge of Spirits (PPSA01802) with the current head. No join hit the 8 MiB cap.

Setup as before: Linux (Arch, kernel 7.2.9), RX 9070 XT (RADV, Mesa 26.2.4), my local Kena stack (main from 2026-10-14 plus unmerged GPU PRs and stand-ins; plain main does not reach the title scene). On that stack I added 68f7a25 on top of the earlier three commits; the join code (joinedRun, importJoined) now matches the PR head. The control is the same stack with the previous head (9c64e198a). Runs alternated, all from the same save, 120 s each, APS5_PROFILE_DRAW=1. Copies are from the last [buffers] ... completed CPU-copy operations line.

run head copies outside imports presents / 10 s after loading
2 current (68f7a25) 0 (0 MiB) 56 46 67 67 78 79 103 98
2 previous 0 (0 MiB) 57 44 53 50 53 84 97 37
3 current – (aborted at ~33 s, see below) –
3 previous 0 (0 MiB) 28 62 65 94 101 120 102 102
  • A first pair, run before I noticed that a reboot had reset my udmabuf limits, also had 0 copies on both heads.
  • Frame rates are within run-to-run noise between the two heads.
  • No GPU reset in any run.

One current-head run (run 3) aborted during loading. RADV printed radv/amdgpu: Not enough memory for command submission. and the next vkQueueSubmit returned VK_ERROR_DEVICE_LOST. I haven't seen that in any earlier Kena run, but one occurrence in three runs isn't enough to tie it to this change, and the other current-head runs were clean. Update: twenty minutes later the same kernel error (amdgpu: Not enough memory for command submission!) stopped RIDE 4 (PPSA01599) on main plus #5781, a build without this PR. So the abort isn't caused by this change; it looks like a system-side condition on my machine today. It never appeared in yesterday's runs.

AI-assisted: yes (Claude Code)

# Conflicts:
#	docs/dev/debt/libSceAgcDriver.md
@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 removed the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
moong15 added a commit to moong15/AnyPS5 that referenced this pull request Oct 20, 2026
…t window

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

moong15 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Stellar Blade, playable area, RX 9070 XT (AMD 26.8.1, Windows), 3840x2160. One main-vs-change pair, APS5_TRACE_SECONDS=400:90, same machine and scene, warm shader cache.

The change side is this PR's join logic ported onto main e90a1d1, not the PR head: the head's power-of-two block imports are not on that base. The port is joinedRun returning the crossed run when it is at most the window, plus importJoined widening over any import the buffer starts or ends in.

The joins fire. In queue 0, over the same 90 s window:

phase main e90a1d1 with the join
Resources.Complete 21,198 calls / 7.69 s 12,887 / 0.70 s
Recorder.Sync 9.21 s 5.08 s
Vulkan.Dispatch.shader_resources 8.15 s 1.84 s
Driver.Packet.dispatch 19.24 s 13.36 s

Before the change, about 11.5k dispatches per 90 s missed the resource cache, synced and copied a 1–2.5 MiB buffer that started in one registered piece and ended in the next. Older profiled runs show these copies with the reason "window incomplete".

Frame rate does not move on this title: 563 presents (6.26 fps) on main, 559 (6.21 fps) with the change. GPU busy is 84.9 vs 84.7 ms per present. Queue 0 stays about 98% busy in both runs, because its draw packets took the time back (Driver.Packet.draw 33.4 → 39.5 s, on 5% more draws per present in that window). Stellar Blade is draw-bound on queue 0, so this removes about 6 s of dispatch CPU per 90 s without raising fps here.

Focused tests with ANYPS5_REQUIRE_VULKAN=1 ANYPS5_NO_SHADER_CACHE=1 VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation, run on main and on the change on the same machine: identical results and no VUID- errors. agc_driver_bda_device segfaults at startup on main as well on this Windows build, so the BdaResources tests were not exercised here.

main:   {"commit":"e90a1d180268","presents":563,"frame_ms":{"p50":152.762,"p90":191.331,"p99":279.508,"max":430.103},"gpu_busy_ms_per_present":84.852,"worker_busy_pct":{"queue worker 0x0":98.604,"queue worker 0x20":99.748}}
change: {"commit":"7a3240f9dbae","presents":559,"frame_ms":{"p50":155.826,"p90":180.22,"p99":226.521,"max":380.523},"gpu_busy_ms_per_present":84.703,"worker_busy_pct":{"queue worker 0x0":98.382,"queue worker 0x20":99.826}}

Traces (about 40 MB each) are available if useful.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

The numbers show the join doing its job on a Windows AMD device: Resources.Complete 7.69 → 0.70 s, Recorder.Sync 9.21 → 5.08 s and Vulkan.Dispatch.shader_resources 8.15 → 1.84 s per 90 s, with presents unchanged (563 vs 559) because queue 0 is draw-bound. A main/change pair on the same machine, scene and warm cache is what makes that usable.

One scope note for the record: the change side is the join logic ported onto main e90a1d1, not this head, since the head's power-of-two block imports aren't on that base. So this is evidence for the join; the head itself still stands on its own test and gate runs.

@embersas

embersas commented Oct 20, 2026 •

Copy link
Copy Markdown

I found a way for the widened join to bypass the agreed 8 MiB partial-join cap on head 0ccc15d0af9afcfe906b539d8f404cbc40f743df.

The synthetic case registers four adjacent 4 MiB pieces, imports the first and last, then binds 12.5 MiB starting at the first piece. The binding ends inside the fourth piece. importJoined widens its bounds to those imports; joinedRun then treats the widened request as exact and joins all 16 MiB, even with an 8 MiB window.

A five-line correction retains the original binding bounds and checks the final widened run. The exact-whole-run exception requires the original binding to cover that final run; the exception for ranges each larger than the window is preserved. A rejected widened join does not retry a smaller run or retire the existing imports.

I independently rebuilt and ran the final synthetic test directly against this exact PR head, then against the standalone correction based solely on that head. This comparison does not depend on the earlier current-main integration:

  • Before: 0ccc15d0af9afcfe906b539d8f404cbc40f743df plus the final test. It fails at the 8 MiB control. A separate diagnostic rerun confirms a successful 16 MiB import marked joined at bind, then the same assertion failure.
  • After: local review commit 545a8a3f9eed6c27984e760d62bb3a10a6ee44f3. Both the normal run and separate diagnostic rerun pass all three cases:
Window / original binding Corrected result
8 MiB / partial 12.5 MiB 13,107,200 bytes copied; initial contents preserved
8 MiB / exact whole 16 MiB zero copied bytes; GPU fill and surrounding bytes preserved
16 MiB / partial 12.5 MiB zero copied bytes; GPU fill and surrounding bytes preserved

The test checks the entire 32 MiB backing allocation, including the unused tail inside the joined import and the surrounding guards. The fallback case checks copying and unchanged initial contents; the two direct-import cases execute GPU fills through the returned buffer view and offset.

The same five existing tests pass before and after: agc_driver_graphics (mock), agc_driver_bda_device, agc_driver_bda_span_reads, agc_driver_guest_memory_lifetime, and agc_driver_host_import_windows. The mock suite also covers the larger-piece exception and retention/reuse of imports when a grown-heap join is refused.

Setup: Ryzen 9 3900X, RTX 3080 Ti 12 GB, NVIDIA 610.88, Windows 11 Pro 10.0.26300, supported WinLibs GCC 15.2.0 r7, Release. Both sides used ANYPS5_REQUIRE_VULKAN=1, ANYPS5_NO_SHADER_CACHE=1, and VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation. Loader diagnostics confirm validation 1.4.363 was actually inserted for every GPU test. Full logs contain no skips, missing-layer warnings or VUID- errors. The new test takes under half a second here.

The patch below applies to your exact head and contains only five added production lines and the 120-line synthetic test. Conventions and whitespace checks pass against those exact Git objects. It leaves the default window at 8 MiB. The fixture contains no game data or captures. SF6 speedup, gameplay correctness and prevention of real-game device loss remain unproven; no title benchmark is claimed.

AI-assisted code, review and testing.

Complete proposed patch, based directly on 0ccc15d
From 545a8a3f9eed6c27984e760d62bb3a10a6ee44f3 Mon Sep 17 00:00:00 2001
From: embersas
Date: Tue, 20 Oct 2026 18:01:15 +1000
Subject: [PATCH] fix(agc): preserve the join bound before import widening

---
 .../Graphics/src/GuestBufferMemory.cpp        |   5 +
 .../tests/execution/HostImportJoinWindow.cpp  | 120 ++++++++++++++++++
 2 files changed, 125 insertions(+)
 create mode 100644 core/libs/prx/libSceAgcDriver/tests/execution/HostImportJoinWindow.cpp

diff --git a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
index e863ee351..070e75c78 100644
--- a/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
+++ b/core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp
@@ -1629,10 +1629,15 @@ const HostImport* importAllocation(const Context& context, HostImports& state, s
 }
 
 const HostImport* importJoined(const Context& context, HostImports& state, std::uint64_t begin, std::uint64_t end, const GuestAllocations::Lease& lease) {
+    const auto boundBegin = begin;
+    const auto boundEnd = end;
     if (const auto* low = peekImport(state, begin, begin + 1); low != nullptr) begin = low->base;
     if (const auto* high = peekImport(state, end - 1, end); high != nullptr) end = high->base + high->bytes;
     auto run = joinedRun(lease, begin, end);
     if (run.ranges.empty()) return nullptr;
+    const auto window = importWindow.load(std::memory_order_relaxed);
+    const bool exact = boundBegin == run.base && boundEnd == run.base + run.bytes;
+    if (!exact && run.bytes > window && !std::all_of(run.ranges.begin(), run.ranges.end(), [window](const auto& range) { return range->bytes > window; })) return nullptr;
     if (const auto failed = state.failedJoins.find(run.base); failed != state.failedJoins.end() && sameRun(failed->second, run)) return nullptr;
     return importRunAt(context, state, std::move(run), true, begin, lease, end, true, ImportRequester::Join);
 }
diff --git a/core/libs/prx/libSceAgcDriver/tests/execution/HostImportJoinWindow.cpp b/core/libs/prx/libSceAgcDriver/tests/execution/HostImportJoinWindow.cpp
new file mode 100644
index 000000000..0afe06fcc
--- /dev/null
+++ b/core/libs/prx/libSceAgcDriver/tests/execution/HostImportJoinWindow.cpp
@@ -0,0 +1,120 @@
+#include "VulkanTestDevice.hpp"
+#include "prx/libSceAgcDriver/Execution/include/GuestMemory.hpp"
+#include "prx/libSceAgcDriver/Graphics/include/GuestBufferMemory.hpp"
+#include "prx/libSceAgcDriver/Graphics/include/Resources.hpp"
+#include "prx/libc/include/GuestAllocations.hpp"
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <exception>
+#include <iostream>
+#include <mutex>
+#include <new>
+
+namespace {
+
+namespace Graphics = AgcDriver::Graphics;
+using Graphics::Require;
+
+constexpr std::size_t MiB = 1u << 20u;
+constexpr std::size_t PieceBytes = 4 * MiB;
+constexpr std::size_t AllocationBytes = 8 * PieceBytes;
+constexpr std::size_t BoundBytes = 12 * MiB + MiB / 2;
+
+class Pieces {
+public:
+    explicit Pieces(const Graphics::Context& context) : context(context) {
+        block = static_cast<std::uint32_t*>(::operator new(AllocationBytes, std::align_val_t{4 * PieceBytes}));
+        std::memset(block, 0x11, AllocationBytes);
+        GuestAllocations::Mutation mutation;
+        for (std::size_t index = 1; index <= 4; ++index) mutation.Add(reinterpret_cast<std::uint8_t*>(block) + index * PieceBytes, PieceBytes, true, true, true);
+    }
+
+    ~Pieces() {
+        Graphics::ClearHostImports(context.device);
+        {
+            GuestAllocations::Mutation mutation;
+            for (std::size_t index = 1; index <= 4; ++index) mutation.Remove(reinterpret_cast<std::uint8_t*>(block) + index * PieceBytes);
+        }
+        ::operator delete(block, std::align_val_t{4 * PieceBytes});
+    }
+
+    Pieces(const Pieces&) = delete;
+    Pieces& operator=(const Pieces&) = delete;
+
+    std::uint64_t Address() const { return reinterpret_cast<std::uintptr_t>(block) + PieceBytes; }
+
+    void Check(std::uint32_t pattern, std::size_t boundBytes) const {
+        for (std::size_t index = 0; index < AllocationBytes / sizeof(*block); ++index) {
+            const auto byte = index * sizeof(*block);
+            const auto expected = byte >= PieceBytes && byte < PieceBytes + boundBytes ? pattern : 0x11111111u;
+            Require(block[index] == expected, "a partial joined buffer lost GPU output or changed bytes outside its binding");
+        }
+    }
+
+private:
+    Graphics::Context context;
+    std::uint32_t* block = nullptr;
+};
+
+void CheckWindow(const Graphics::Context& context, std::size_t window, std::size_t boundBytes, bool imported) {
+    Graphics::ClearHostImports(context.device);
+    Graphics::SetHostImportWindow(window);
+    Pieces pieces(context);
+    const auto address = pieces.Address();
+    Require(Graphics::HostImportFor(context, address, 16) != nullptr, "the first registered piece was not imported");
+    Require(Graphics::HostImportFor(context, address + 4 * PieceBytes - 16, 16) != nullptr, "the last registered piece was not imported");
+    Graphics::GuestBufferMemory memory(context);
+    memory.AddWritable(address, boundBytes);
+    memory.Upload(false);
+    const auto copied = memory.CopiedBytes();
+    std::uint32_t adjustment = 0;
+    const auto view = memory.Descriptor(address, boundBytes, adjustment);
+    const auto* direct = Graphics::ExistingHostImportFor(context, address, boundBytes);
+    if (imported) {
+        Require(copied == 0 && direct != nullptr, "an eligible large binding over four pieces was copied");
+        Require(view.buffer == direct->buffer && view.offset + adjustment == address - direct->base, "the partial binding does not refer to its joined import");
+    } else {
+        Require(copied == boundBytes && direct == nullptr, "an 8 MiB window joined a partial binding spanning 16 MiB");
+        memory.WriteBack();
+        pieces.Check(0x11111111u, boundBytes);
+        std::printf("join window %zu MiB, binding %.1f MiB: %zu copied bytes; initial bytes preserved\n", window / MiB, static_cast<double>(boundBytes) / MiB, static_cast<std::size_t>(copied));
+        return;
+    }
+    constexpr std::uint32_t pattern = 0xa5b6c7d8u;
+    Graphics::CommandBatch batch(context);
+    const auto commands = batch.Handle();
+    context.Function<PFN_vkCmdFillBuffer>("vkCmdFillBuffer")(commands, view.buffer, view.offset + adjustment, boundBytes, pattern);
+    VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
+    barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
+    barrier.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
+    context.Function<PFN_vkCmdPipelineBarrier>("vkCmdPipelineBarrier")(commands, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 1, &barrier, 0, nullptr, 0, nullptr);
+    batch.SubmitAndWait();
+    memory.WriteBack();
+    pieces.Check(pattern, boundBytes);
+    std::printf("join window %zu MiB, binding %.1f MiB: %zu copied bytes; GPU output and surrounding bytes preserved\n", window / MiB, static_cast<double>(boundBytes) / MiB, static_cast<std::size_t>(copied));
+}
+
+}
+
+int main() {
+    try {
+        auto device = OpenVulkanTestDevice();
+        if (!device) return VulkanTestSkipped;
+        const auto context = device->graphicsContext();
+        if (context.hostImportAlignment == 0) {
+            std::puts("skipped, the device does not support host imports");
+            return VulkanTestSkipped;
+        }
+        std::lock_guard lock(AgcDriver::GuestMemory::GpuMutex());
+        CheckWindow(context, 8 * MiB, BoundBytes, false);
+        CheckWindow(context, 8 * MiB, 4 * PieceBytes, true);
+        CheckWindow(context, 16 * MiB, BoundBytes, true);
+        Graphics::SetHostImportWindow(8 * MiB);
+        std::puts("host import join window tests passed");
+        return 0;
+    } catch (const std::exception& error) {
+        std::cerr << error.what() << '\n';
+        return 1;
+    }
+}
-- 
2.56.0.windows.2

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@TideOPL, @embersas's case checks out against the code on 0ccc15d0af. importJoined widens begin/end to the neighbouring imports before calling joinedRun, and joinedRun's "exact whole run" exception compares against those widened bounds. So a binding that only partly covers a run of small pieces, but has imports at both ends, is treated as exact and joined past the 8 MiB window.

Could you fold in the correction from that comment (keep the binding's original bounds for the exactness check, keep the exception for runs whose pieces are each larger than the window) together with its HostImportJoinWindow test? The new head then needs CI again before the gate.

This branch has not been deployed

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

Labels

agc libSceAgc* docs needs rebase Conflicts with main, rebase needed 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.