Skip to content

perf: PPSA28997 gameplay 26.4 ms on RADV REMBRANDT (16.9 ms on the 10-02 snapshot) #7138

Open

Description

@utajum

Title and scene

PPSA28997 (God of War: Sons of Sparta), gameplay: Continue from an existing save, Kratos idle at the Sparta save point. 1920x1080 swapchain, gamescope.
Lenovo Legion Go S: Ryzen Z2 Go (RADV REMBRANDT), Mesa 26.2.0-devel, SteamOS 3.8, Linux 6.18.

What is slow

main e09dbc2 + #6842 (main segfaults at start-up without it): 26.4 ms p50 (38 FPS), GPU busy 18.9 ms per present, queue worker 98 % busy (APS5_TRACE_SUMMARY_EVERY=10, no trace).
Same scene on the AnyPS5 snapshot of about 10-02 (bundled in OverkillLabs3/SoS-PS5): 16.9 ms (59 FPS, vsync), GPU busy 10.5 ms.

Findings (APS5_PROFILE_DRAW=1, per 10 s, separate runs):

  1. Drains at waits that already pass. [draw] pipelined: drains: packet 13235 (4280.1 ms), 0x3c x12304, while [packets] waits satisfied: at entry 15120, polled 0. keepsDrawPipeline drains on every WAIT_REG_MEM that PendingLabel does not settle. Keeping the pipeline when no queued item overlaps the awaited bytes and the wait already passes (diff below) takes the 0x3c drains to 0. The committer then saturates: 108958 commits at 82.4 us each.
  2. Pixel shaders of registration modules (GPL). RADV_DEBUG=shaderstats, all pixel shaders compiled in the run:
build shaders avg instr max instr avg VMEM
main 170 1992 6564 20.3
main, APS5_NO_GPL=1 75 1154 5452 3.2
10-02 snapshot 77 389 2178 3.1

Each ReadConstBuffer dword decodes the runtime V#, checks the guest bounds, then readBuffer checks OpArrayLength inside that branch, so the descriptor load repeats per dword (233 load_global_amd + 94 load_ssbo in the largest FS).
3. GPU time stays near 18 ms per present with the smaller shaders (old: 10.5 ms). APS5_PROFILE_GPU: 43 draw ranges per present, 18.6 ms. 6 storage write-backs per present at flip (143 MiB / 10 s); the old snapshot has none. Not narrowed down.
4. CPU per draw: [draws] avg 106 us recorded (old: 45 us); prepare 1786 ms, lookup 1248 ms, record 1724 ms. [rescache] revalidate 556338 calls 5820 ms, [guestmem] collect 686573 calls 4.1 s.

Traced captures (APS5_TRACE_SECONDS=70:10, warm caches, frame times include the trace cost):

capture p50 ms p90 ms GPU busy ms
c2: main + #6842 38.9 43.2 20.5
c3: c2 + wait diff 37.3 43.6 20.3
c4: c3 + APS5_NO_GPL=1 35.4 40.5 19.6

Untraced, c4's setup runs at 24.5 ms vs 26.4 ms.

Wait diff (PacketExecution.cpp):

         case 0x63: case 0x64: case 0x9f:
             return packet.size() >= 5 && !DrawPipeline::Queue0().Overlaps(packet[1] | (static_cast<std::uint64_t>(packet[2]) << 32u), static_cast<std::size_t>(packet[4]) * 8);
+        case 0x3c: case 0x93: {
+            const std::uint64_t awaited = packet[2] | (static_cast<std::uint64_t>(packet[3]) << 32u);
+            const auto bytes = Pm4::WaitAwaitedBytes(packet);
+            if (DrawPipeline::Queue0().Overlaps(awaited, bytes)) return false;
+            GuestMemory::CheckRange(reinterpret_cast<const void*>(awaited), bytes, bytes);
+            return Pm4::WaitSatisfiedUnchecked(packet);
+        }

Summary lines

{"commit":"e09dbc2b4231","dirty":true,"env":{"APS5_PAD_INPUT_FILE":"<path>","APS5_TRACE_SECONDS":"70:10"},"gpu":"AMD Ryzen Z2 Go (RADV REMBRANDT)","driver":"radv Mesa 26.2.0-devel (git-035ae2f854)","os":"Linux 6.18.50-valve2-1-neptune-618-gc7289a96b14d","resolution":"1920x1080","window":{"spec":"seconds 70:10","first_present":2517,"ms":10020.462},"presents":255,"frame_ms":{"p50":38.855,"p90":43.233,"p99":45.820,"max":46.431},"gpu_busy_ms_per_present":20.482,"worker_busy_pct":{"queue worker 0x0":97.529},"top_phases_ms_per_frame":{"Driver.Packet":38.016,"Driver.Packet.device_lock":19.032,"Driver.Packet.draw":16.291,"Driver.Draw":16.142,"Driver.Draw.shader_compile_and_link":10.971,"Shader.DrawStage":7.282,"Shader.DrawStage.materialize":4.353,"VideoOut.Flip":3.635},"trace_dropped":false,"trace":"aps5-trace-e09dbc2b4231-31734.json.gz"}
{"commit":"e09dbc2b4231","dirty":true,"env":{"APS5_PAD_INPUT_FILE":"<path>","APS5_TRACE_SECONDS":"70:10"},"gpu":"AMD Ryzen Z2 Go (RADV REMBRANDT)","driver":"radv Mesa 26.2.0-devel (git-035ae2f854)","os":"Linux 6.18.50-valve2-1-neptune-618-gc7289a96b14d","resolution":"1920x1080","window":{"spec":"seconds 70:10","first_present":2530,"ms":10019.389},"presents":260,"frame_ms":{"p50":37.269,"p90":43.565,"p99":44.881,"max":45.380},"gpu_busy_ms_per_present":20.316,"worker_busy_pct":{"queue worker 0x0":97.474},"top_phases_ms_per_frame":{"Driver.Packet":36.881,"Driver.Packet.draw":20.977,"Driver.Draw":20.824,"Driver.Draw.shader_compile_and_link":11.940,"Driver.Packet.device_lock":10.285,"Shader.DrawStage":7.987,"Driver.Draw.draw_enqueued":6.100,"Shader.DrawStage.materialize":4.680},"trace_dropped":false,"trace":"aps5-trace-e09dbc2b4231-32303.json.gz"}
{"commit":"e09dbc2b4231","dirty":true,"env":{"APS5_NO_GPL":"1","APS5_PAD_INPUT_FILE":"<path>","APS5_TRACE_SECONDS":"70:10"},"gpu":"AMD Ryzen Z2 Go (RADV REMBRANDT)","driver":"radv Mesa 26.2.0-devel (git-035ae2f854)","os":"Linux 6.18.50-valve2-1-neptune-618-gc7289a96b14d","resolution":"1920x1080","window":{"spec":"seconds 70:10","first_present":2590,"ms":10034.604},"presents":279,"frame_ms":{"p50":35.416,"p90":40.499,"p99":43.202,"max":47.230},"gpu_busy_ms_per_present":19.614,"worker_busy_pct":{"queue worker 0x0":97.115},"top_phases_ms_per_frame":{"Driver.Packet":34.431,"Driver.Packet.draw":17.774,"Driver.Draw":17.623,"Driver.Packet.device_lock":11.139,"Driver.Draw.shader_compile_and_link":9.830,"Shader.DrawStage":6.126,"Driver.Draw.draw_enqueued":5.277,"VideoOut.Flip":3.017},"trace_dropped":false,"trace":"aps5-trace-e09dbc2b4231-32918.json.gz"}

Traces

https://github.com/utajum/AnyPS5/releases/tag/perf-ppsa28997 (c2, c3, c4 .json.gz and the summary lines)

Digest

c2, python3 tools/trace_digest.py:

thread busy wait untraced in slices < 20 us largest
queue worker 0x0 36.8 (94 %) 0.0 (0 %) 2.5 12.3 Driver.Packet 36.5, Driver.EndSubmission 0.3
presenter 1.5 (4 %) 2.2 (6 %) 35.6 0.1 VideoOut.Flip 3.6, Driver.Present 0.0
gpu 20.6 (52 %) 0.0 (0 %) 18.7 0.0 batch 20.6

Largest slices (ms per frame, inclusive, all threads): Driver.Packet 36.5, batch 20.6, Driver.Packet.device_lock 18.8, Driver.Packet.draw 16.2, Driver.Draw 16.1, Driver.Draw.shader_compile_and_link 10.7, Shader.DrawStage 5.4, VideoOut.Flip 3.6, VideoOut.Flip.present 3.4, Driver.Present 3.4

Capture

  • The caches were warm (the scene ran once and the title was restarted)
  • No other APS5_TRACE_* or APS5_PROFILE_* switch was set and the log was not redirected: none set; APS5_PAD_INPUT_FILE drives the menu; stdout/stderr go to files (Steam shortcut).
  • Captures compared here come from the same machine, scene and settings

Activity

  1. MarcZX10 commented on Oct 17, 2026

    @MarcZX10
    Contributor

    I opened #7153 for the satisfied WAIT_REG_MEM drain case. It keeps queue 0 pipelined only when the wait range does not overlap outstanding pipeline writes and the wait already passes, and adds an execution regression for unrelated queued work. The PR cites your same-machine c2/c3 captures as reference evidence, clearly marked as your reported run; I could not build the target locally because this checkout lacks the SDL2 submodule and XRandR development support. It addresses only item 1, not the shader, GPU or remaining CPU findings.

  2. plokmklo commented on Oct 18, 2026

    @plokmklo
    Contributor

    Bisection report (code-level, CPU-only — nothing was run on hardware), with the ranked findings; branch https://github.com/plokmklo/AnyPS5/tree/contrib/debt-pick-2 carries no code change because the proper fix needs the recompiler test suite to validate.

    Window: the "10-02 snapshot" corresponds to 3cb7ccc80; the reporter's build is e09dbc2b4231. 8,260 commits in between, so findings are ranked by mechanism rather than full bisection.

    1. Confirmed — the graphics-pipeline-library "registration modules" series (2026-10-12..15) causes finding 2. The 10-02 tree has no graphicsPipelineLibrary/registrationModules at all; on main, target.registrationModules = state->graphicsPipelineLibrary enables the form by default wherever RADV reports GPL fast linking (APS5_NO_GPL/APS5_NO_LIBRARY_PREBUILD are the only opt-outs) — matching the shaderstats A/B (170/avg 1992 vs 77/389; NO_GPL 75/1154). ddb668066 added EmitRuntimeScalarBufferLoad: each per-dword const-buffer read re-decodes the runtime V#, and the per-component path repeats bounds + OpArrayLength work — the 233 load_global_amd + 94 load_ssbo. It should also account for most of the warm shader_compile_and_link ≈ 10.7 ms/frame. Fix direction: CSE/hoist the V# decode per descriptor across a shader's scalar reads and emit one access per instruction instead of per component.
    2. Probable — 5c42e97f4 writes multisampled color targets through storage images; such writes stay pending and the queue worker flushes them when queue 0 goes idle (StoreIdleResults → FlushAllPending("idle")), i.e. right after each present — matching "6 write-backs per present at flip". Confirm via the [storage] … write-backs by reason line.
    3. Probable (cluster) — finding 4's revalidate/collect costs: the write-watch walked surface set grew across ~160 ShaderResources.cpp + ~60 GuestMemory.cpp window commits. Needs hardware per-commit bisection.
    4. perf(agc): grow the resource cache while the keys it evicts come back #7189 (resource-cache growth) is not in the reporter's build (its merge postdates it), so it can't explain these numbers — but it converts full builds into revalidates, which are already the hottest CPU item; re-measure on top of it.

    Suggested measurements: main vs APS5_NO_GPL=1 + APS5_NO_LIBRARY_PREBUILD=1, comparing shaderstats, shader_compile_and_link, and the [rescache]/[storage] reason lines, to confirm items 1–2 before a descriptor-decode fix lands.

  3. m335671 commented on Oct 19, 2026

    @m335671
    Contributor

    NVIDIA data point for the GPL suggestion: Sword of the Sea (PPSA25344, UE5), title screen, no input, main a373e85e5, Windows 11 Pro 26200, Ryzen 9 7900, RTX 4070 12 GB (NVIDIA 617.42), WinLibs GCC 15.2.0. The device reports pipelineLibrary=1, so the registration-module path is on by default here too.

    D = default, N = APS5_NO_GPL=1 APS5_NO_LIBRARY_PREBUILD=1. Each arm has its own shader cache, warmed by one uncounted boot, then the runs alternated D N D N D N, 150 s each with APS5_PROFILE_DRAW=1. Numbers are the last 60 s of each run (6 [present] windows) and the last [draw] driver phases line. recompile is the shader_compile_and_link mark.

    run presents / 10 s GPU busy / frame idle gaps / frame recompile us per draw packet driver total us per packet
    D1 126–128 55.1 ms 23.9 ms 11.2 218.8
    N1 128–130 54.2 ms 23.4 ms 8.6 214.4
    D2 125–127 55.3 ms 24.2 ms 11.2 218.4
    N2 127–130 54.0 ms 23.9 ms 8.3 214.9
    D3 125–127 55.1 ms 24.4 ms 11.2 216.3
    N3 126–130 54.2 ms 24.1 ms 9.1 211.3

    On this scene and GPU the effect points the same way but is small. Turning GPL off gives about +1.8 % presents and about −1.1 ms GPU per frame, and the compile-and-link phase drops from 11.2 to about 8.7 us per draw packet (−23 %). The [storage] write-backs by reason and the [rescache] draw counters are the same in both arms. For example in run 3, storage refresh 11,475 vs 11,445 and flip 504 vs 500 write-backs, and rescache 7,168 vs 7,179 hits with 4 misses each, so item 2 doesn't show up here. That's a light, static scene: it doesn't say how big the effect is in PPSA28997 gameplay on RADV.

    AI-assisted: yes (Claude Code). The builds, the runs and this comment were done and written by Claude Code on my machine.

  4. akkadbakkad89 commented on Oct 21, 2026

    @akkadbakkad89
    Contributor

    Item 2 scoping (code-level, no hardware): the per-dword repeat is real and sits across IR instructions.

    EmitReadConstBuffer → EmitRuntimeScalarBufferLoad (SpirvRuntimeBuffer.cpp:810) calls descriptor() (line 56), which re-decodes the V# every call — stride/swizzle/valid/invalid/addTid fields, present check, index fix-up — roughly 20 emitted SPIR-V ops, then ~16 for the size/bounds select, then readBuffer → directAccess (line 133) emits a fresh OpAccessChain + OpArrayLength plus the bounds/select/load sequence (~18 more). EmitRuntimeBufferLoad and the stores/atomics share descriptor() across their own components, but nothing carries it across instructions, so a shader that reads N const-buffer dwords pays the full decode + OpAccessChain + OpArrayLength N times. On the gpuDescriptor path the repetition is worse: each dword also runs faultIf + StopBdaInvocationIf and a bdaPointerFunction/bdaProbeFunction call, i.e. the page-table walk that shows up as load_global_amd in shaderstats.

    The cacheable part is exactly the descriptor-args function: keyed on the resolved GetBufferResource arguments + state.currentLabel, the decoded RuntimeDescriptor fields and the directAccess chain/length are safe to reuse within a label — the same invalidation #7747 already landed for bufferLengths (state.bufferLengths.clear() per block in EmitControlFlow). So the natural shape is one label-scoped RuntimeDescriptor/access cache next to bufferLengths, generalizing it rather than adding a parallel mechanism; per-instruction parts (index, offset, bounds, loads) stay per call.

    That leaves a coordination question: this builds directly on #7747's infra, which is mid-review with a direction question still open. If #7747 lands I'd stack the descriptor-decode cache on top; if its scope shrinks to correctness-only, the perf half could ride here instead. I can prototype either way and measure instruction counts on the recompiler test suite plus agc_shader_replay --prepare task-clock — the GoW replay set isn't available to me, so end-to-end compile-time on PPSA28997 would still need a volunteer's capture.

    AI-assisted: yes (Devin).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions