Skip to content

perf(agc): store write-back changes sixteen bytes at a time - #3137

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
johnmaia:perf/agc-write-back-sse2
Oct 11, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
johnmaia:perf/agc-write-back-sse2

Conversation

@johnmaia

@johnmaia johnmaia commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Replaces #2481, which the intake workflow closed automatically; same change, rebased onto current main.

What

GuestMemory::WriteChanged stores only the bytes the GPU changed in a mirrored buffer, so that CPU writes to the bytes around them survive. It found those bytes one at a time inside every changed 256-byte block and stored each run with memcpy. When changed and unchanged bytes interleave (for example, only the low bytes of floats change), every run is a few bytes long, so each costs a byte loop and a memcpy call.

Each 16-byte chunk is now compared with SSE2:

  • no byte changed: skipped;
  • every byte changed: stored whole (_mm_storeu_si128);
  • some changed: stored with MASKMOVDQU (_mm_maskmoveu_si128), which writes only the bytes its mask selects.

An SFENCE after the loop orders the non-temporal masked stores before the stores that follow. A tail shorter than 16 bytes still goes byte by byte. Bytes the GPU did not change are never written, as before, and the reported changed range is the same.

Tested

Linux (Ubuntu 24.04, GCC 13.3), i3-12100, RX 7600 (RADV).

  • New testWriteChangedStoresExactlyTheChangedRuns (agc_driver_pm4_tests): 1037 bytes of random runs (changed, unchanged, and changed with some bytes left equal) over guest bytes that differ from both copies. Checks that exactly the changed bytes are stored and that the 13-byte tail is handled.

  • Rebased onto current main (7250918). Full ctest -j8: all pass except agc_driver_image_pipe_bank_xor, which failed once under parallel GPU load and passes when run alone; the 7 VideoOut display tests skip over SSH. tools/check_conventions.py and tools/check_test_registration.py pass.

  • Microbenchmark, 1 MiB, best of 30, one core:

    pattern before after
    low 2 bytes of every 4 change 0.89 ms 0.20 ms
    every byte changes 0.09 ms 0.06 ms
    1 byte in 64 changes 0.46 ms 0.21 ms
    half of the 4 KiB pages change 0.06 ms 0.05 ms
  • Disney Epic Mickey: Rebrushed (PPSA15657, UE4) after Continue, with APS5_NO_HOST_IMPORT=1 so the game's compute buffers are mirrored. The stretch measured is the intro movie with the level's game logic and compute work running; the level's 3D view does not render yet (Disney Epic Mickey: Rebrushed (PPSA15657) on Linux #2255), so this is a write-back workload, not a level rendering benchmark.

    • Before: WriteChanged took 43% of the GPU submission thread, the bottleneck thread here (the GPU was ~1.4% busy), at about 1.0 ms per MiB written back.
    • After: about 0.26 ms per MiB and 17% of that thread.
    • Average FPS over the same stretch (5-15 min after Continue, 300 samples each): 4.60 before, 6.92 after.
  • Windows 11, i7-13700K, MinGW-w64 GCC 15.2.0 (WinLibs r7), Release: agc_driver_pm4 and agc_driver_pm4_failure pass, including the new test. SSE2 is part of the x86-64 baseline.

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: none
  • AI-assisted: yes

🤖 Generated with Claude Code

GuestMemory::WriteChanged stores only the bytes the GPU changed, so that CPU writes to the bytes
around them survive. It found them a byte at a time inside every changed 256-byte block and
stored each run with memcpy. Where changed and unchanged bytes interleave (the low bytes of
floats), every run is a few bytes long, and that loop took 43% of the GPU submission thread in
Disney Epic Mickey: Rebrushed's first level (0.9-1.0 ms per MiB).

Each 16-byte chunk is now compared with SSE2. A chunk with no change is skipped, a chunk that
changed entirely is stored whole, and a mixed one is stored with MASKMOVDQU, which writes only the
bytes selected by the mask. An SFENCE after the loop orders those non-temporal stores before the
stores that follow. Bytes the GPU did not change are still never written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134RWCyiyydJBbfKz8g9Bwe

@Dextroid17 Dextroid17 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 invariant is the interesting part here, and the shape of the change respects it. WriteChanged exists so GPU-side changes are written back without clobbering CPU writes to the bytes around them, which means a fast path is only safe if it stays byte-exact. Chunking into 16 bytes, skipping untouched chunks and storing fully-changed ones whole, keeps that; the middle case — part of a chunk changed — is where the invariant actually lives, so that is the path worth reading closely, including whatever happens to a trailing chunk shorter than sixteen bytes.

The numbers are in the description, which is not the common case and is worth saying. A microbenchmark over interleaved changes — the low two bytes of every four: 0.89 → 0.20 ms, one byte in sixty-four: 0.46 → 0.21 ms, best of thirty — plus a measurement in a running title, where the write-back falls from 43% to 17% of the submission thread and the frame rate moves from 4.60 to 6.92 FPS. That is the figure a perf change needs, and the only question left is whether the interleaved pattern is representative: the low-bytes-of-a-float case you chose is precisely the one that makes every run short, so it exercises the old cost rather than flattering the new code.

And resubmitting what the intake workflow closed, rebased onto current main with the reason stated, is the right way to handle that gate — it puts the work back in the queue without pretending the earlier PR never existed.

Written with AI assistance.

@Silverash-i8

Copy link
Copy Markdown
Contributor

Windows run, since the PR has no Intel or Windows results yet: Windows 11, WinLibs GCC 15.2.0 r7, Release, merged onto main d31b57a in one build with #3217, #3202, #3167, #3137, #2863, #2849 and #2847, on an RTX 5060 Laptop and the Arrow Lake iGPU (ANYPS5_GPU=Intel).

  • agc_driver_pm4 and agc_driver_pm4_failure (Pm4.cpp) pass on both GPUs.

Full ctest of that build on both GPUs has no failure that isn't also on plain main here (guest_filesystem needs the symlink privilege, #3169; the Intel ones are listed on #2114 and #3207).

AI-assisted (Claude Code); I ran this on my machine.

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

Labels

agc libSceAgc*

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants