Repository navigation
perf: Hades II hub is bound by the queue-0 packet worker; recording draws on a second thread takes ~30 to ~48 FPS locally (findings, work in progress) #5485
Description
Title and scene
Hades II, Linux, hub after the intro (idle area), same window and settings for every run. Title ID not recorded.
What is slow or what changed
The queue-0 packet worker is the limit in this scene, not the GPU. I have a local change set (not published, no PR yet) in which the worker hands each draw to a second thread that records it, and keeps decoding the next packets while that thread works. This issue records the findings and the state of the work so that it can be compared with related work before anything is posted as a PR.
Machine: AMD Ryzen 9 5950X, NVIDIA GeForce RTX 3080 Ti (driver 610.57.04), Linux 7.2.2, 94 GB RAM. The numbers are the window-title FPS, three readings per run, after the hub has loaded.
Rebased onto main a0f5916 (81 commits on top), all in the same session:
| Build | FPS readings |
|---|---|
main a0f5916 |
30.19, 28.65, 30.75 |
| change, flag off | 32.95, 31.94, 31.12 |
change, APS5_MT_RECORD=1, run 1 |
50.79, 47.23, 48.69 |
change, APS5_MT_RECORD=1, run 2 |
46.78, 48.35, 48.26 |
A second main run produced no readings (the run did not complete), so main has one run, and the machine load was not controlled (load average between 2 and 7 during these runs). Before the rebase, on the older base, the same scene read about 29 inline and 42 to 51 threaded.
Worker time per draw with the thread on (about 219k draws per 10 s, from the [mtrecord] line): capture 10.7 us, key/lookup/validate 9.1, decode 4.7, recompile 2.9, enqueue 2.8, vectors 1.9, precheck 0.9, job build 0.8. The record thread needs about 25 us per job. On the pre-rebase branch the write checker (APS5_MT_RECORD_CHECK=1, predicts every range a draw writes and compares it with what the job wrote) reported 0 uncovered ranges over about 3.3 million jobs; it was not on in the runs above.
Status
Performance traces (APS5_TRACE_*, main against the change, same machine and scene) and the pull requests are on the way. I will add the trace summary lines and digests to this issue, and the PRs will link back to it. The change will be split into several small PRs, each on current main.
Design
- A record thread owns
Graphics::Drawand the label recording of each draw job. The queue holds at most 7 jobs. - The worker publishes the memory each queued draw will write. Worker reads, captures, waits and label reads over a queued range drain first; packets that touch neither guest memory nor the GPU mutex run ahead; anything else drains first.
- Taking
GpuMutexwith jobs pending drains first;try_locksites fail fast. - Off by default (
APS5_MT_RECORD=1);APS5_MT_RECORD_THREAD=0runs the same jobs inline. The golden-stream colour hash with the flag off is unchanged.
Not done and not claimed
- No performance traces as the report form asks.
APS5_TRACE_*captures ofmainand the change on the same machine and scene are the next step; until then these are window-title FPS readings, not protocol numbers. - One title, one machine, Linux, NVIDIA only. Nothing on Windows or other GPUs.
- The newest commits (predicated packets run ahead, deferred labels handed to the record job) have an open review finding: a second label deferred at the same address can have its queued-label entry erased by the cleanup of the first (needs a stamp-matched forget). Not fixed yet.
- A heap-corruption abort at exit seen once and some intermittent test crashes were not reproduced.
- The branch needs rewriting before any PR: Conventional Commit subjects, no comments,
APS5_LOCKED_DRAW_PREPAREshould throw instead of falling back, docs and debt entry. The change is large (about 12k lines) and would be split into several PRs.
Related
Overlap is not yet analysed in detail: #1201 and #1202 (pipelined queue-0 draw commits), #1895 and #1896 (indirect draws), #5345 (single queue worker CPU-bound in another title), #4054, #5407 and #5216 (draw cache), #4889 (incremental pending write snapshots). I would like to know whether the committer in #1201/#1202 is meant to cover the same cut before I split this into PRs.
AI-assisted: yes
Activity
eliyarson commentedon Oct 13, 2026
Trace captures, as the performance report asks. Same machine and session, one game at a time, the Hades II hub,
APS5_TRACE_SECONDS=150:30(30 s starting 150 s after the first present), the game log sent to/dev/null, no otherAPS5_TRACE_*orAPS5_PROFILE_*switch. Each capture is a fresh start of the title; the shader and pipeline caches were warm from earlier runs.mainAPS5_MT_RECORD=1With the flag off the change is the same as
mainwithin noise. With the flag on, p50 frame time drops from 34.8 ms to 23.1 ms (about 1.5x) and the worker is no longer saturated. This is lower than the 47 to 51 FPS read from the window title earlier. I have not explained the gap; the trace itself may cost time, and the title readings were taken in a different window of the run.Caveats: all three summary lines have
"trace_dropped": true(I have not checked what is dropped or how it affects the numbers), one capture per build, one scene, Linux and NVIDIA only, and no frame-dump comparison yet.Summary lines
{"commit":"a0f5916a2930","dirty":false,"env":{"APS5_DRAW_PREPARE_AHEAD":"2","APS5_HOST_IMPORT_MIB":"16384","APS5_TRACE_SECONDS":"150:30"},"gpu":"NVIDIA GeForce RTX 3080 Ti","driver":"NVIDIA 610.57.04","os":"Linux 7.2.2-1-cachyos","resolution":"1920x1080","window":{"spec":"seconds 150:30","first_present":8076,"ms":30008.887},"presents":863,"frame_ms":{"p50":34.863,"p90":36.770,"p99":38.870,"max":40.624},"gpu_busy_ms_per_present":8.861,"worker_busy_pct":{"queue worker 0x0":98.651},"top_phases_ms_per_frame":{"Driver.Packet":34.284,"Driver.Packet.draw":31.728,"Driver.Draw":31.525,"Driver.Draw.shader_compile_and_link":13.968,"Driver.Draw.draw_and_resource_release":13.064,"Graphics.Draw":12.802,"Graphics.Draw.draw_and_resource_release":10.281,"Shader.DrawStage":9.712},"trace_dropped":true,"trace":"aps5-trace-a0f5916a2930-434051.json.gz"} {"commit":"e3340a220f92","dirty":false,"env":{"APS5_DRAW_PREPARE_AHEAD":"2","APS5_HOST_IMPORT_MIB":"16384","APS5_TRACE_SECONDS":"150:30"},"gpu":"NVIDIA GeForce RTX 3080 Ti","driver":"NVIDIA 610.57.04","os":"Linux 7.2.2-1-cachyos","resolution":"1920x1080","window":{"spec":"seconds 150:30","first_present":6021,"ms":30019.858},"presents":867,"frame_ms":{"p50":34.779,"p90":36.202,"p99":38.010,"max":40.000},"gpu_busy_ms_per_present":8.819,"worker_busy_pct":{"queue worker 0x0":96.187},"top_phases_ms_per_frame":{"Driver.Packet":33.287,"Driver.Packet.draw":30.365,"Driver.Draw":27.539,"Driver.Draw.draw_and_resource_release":13.033,"Graphics.Draw":12.770,"Driver.Draw.shader_compile_and_link":11.128,"Graphics.Draw.draw_and_resource_release":10.274,"Shader.DrawStage":6.886},"trace_dropped":true,"trace":"aps5-trace-e3340a220f92-462321.json.gz"} {"commit":"e3340a220f92","dirty":false,"env":{"APS5_DRAW_PREPARE_AHEAD":"2","APS5_HOST_IMPORT_MIB":"16384","APS5_MT_RECORD":"1","APS5_TRACE_SECONDS":"150:30"},"gpu":"NVIDIA GeForce RTX 3080 Ti","driver":"NVIDIA 610.57.04","os":"Linux 7.2.2-1-cachyos","resolution":"1920x1080","window":{"spec":"seconds 150:30","first_present":9887,"ms":30003.025},"presents":1303,"frame_ms":{"p50":23.129,"p90":24.419,"p99":26.348,"max":30.034},"gpu_busy_ms_per_present":8.430,"worker_busy_pct":{"queue worker 0x0":85.788},"top_phases_ms_per_frame":{"Driver.Packet":19.737,"Driver.Packet.draw":17.111,"Driver.Draw":14.996,"Driver.Draw.shader_compile_and_link":11.306,"Shader.DrawStage":6.284,"Shader.DrawStage.capture_resources":3.787,"Driver.Draw.prepare":1.345,"Shader.DrawStage.materialize":1.305},"trace_dropped":true,"trace":"aps5-trace-e3340a220f92-448630.json.gz"}The trace files (
aps5-trace-*.json.gz) are not attached yet; I can attach them on request. Pull requests are still on the way.MarcZX10 commentedon Oct 13, 2026
Thanks, the matched trace capture makes the performance result much stronger: the flag-off branch stays near main (34.8 ms vs 34.9 ms p50), while
APS5_MT_RECORD=1reaches 23.1 ms p50 and lowers worker busy time. I would hold off splitting/posting the implementation until the remaining limitations are closed: all three runs reporttrace_dropped=true, the change still has the same-address deferred-label cleanup race called out in the report, and the write checker/frame-dump equivalence has not been rerun on the rebased code. Could you attach the trace files or summarize what was dropped, and report the rebased checker/frame comparison before proposing the PR split? Those would let reviewers separate the measured speedup from recording loss or output differences.Tamalero commentedon Oct 14, 2026
Another title with the same limit, for comparison: High on Life (PPSA15388, UE 4.27), standing in the home base facing the portal. It runs at about 4–5 FPS there. The queue-0 packet worker is busy the whole frame, while the GPU is idle most of it.
Trace, 10 s at that spot (
APS5_TRACE_SECONDSwindow,APS5_PROFILE_GPU=1):Driver.Packet.memory_wait(waiting for queue 0)Queue worker 0x0, share of its recorded time:
Driver.PacketDriver.DrawGraphics.Draw.draw_and_resource_releaseResources.CompleteDriver.DispatchDriver.Draw.shader_compile_and_linkRecorder.SyncSo queue 0 spends about 98 µs per draw here, roughly twice the ~45 µs per draw of the Hades II hub above (219k draws per 10 s), at a far lower draw count. A record thread like @eliyarson's
APS5_MT_RECORDlooks like the right lever here too. Pinning threads changed nothing: I moved the queue worker to its own P-core and the title's spinning workers to E-cores, live withtaskset, and FPS stayed at 3.0–3.9. When the change is posted I can run the same spot against it.Details
main18c438b10plus feat(agc): sample non-sRGB textures through FORCE_DEGAMMA samplers as sRGB views #4487, feat(libSceAgcDriver): support secondary LOD bias and relax unnormalized coordinate filters #5156, feat(agc): storage image access to depth planes through 2D array views #5003, fix(libScePlayGo): report missing chunk metadata #5494 and local diagnostic switches (the same stack as on AGC: a cube's packed-tail mips rendered per face are not seen by the cube's sampled view; the reflection capture filters a clear value (High on Life PPSA15388, black lighting) #4736 and High on Life (PPSA15388): occlusion-query results hide walls and props in view (all fine when every query reports visible) #5488), sodirtyis true. One of those local switches opens the trace window when a file appears, so the window starts at the portal instead of at a fixed time. The scope names and accounting aremain's.trace_droppedis true. Queue worker 0x0 filled its buffer after about 6.4 s, so the per-scope shares and per-draw numbers above come from those first 6.4 s (31 presents). The presents, frame times, GPU busy and worker busy are from the summary line and cover the whole window.Driver.Draw99–193 ms, frame 166–272 ms.batch2.85 s,draw0.96 s,copy0.22 s over the window.perfat the same spot), the queue worker's samples are spread thin. No function takes more than about 5 %; the biggest areBdaResources,Evaluator::EvaluateWide,InPlaceReads,DrawCopiedWritersandCompletePendingCpuWrites, plus about 9 % inoperator new.Summary line:
{"presents":49,"frame_ms":{"p50":179.155,"p90":271.969,"p99":436.538,"max":436.538},"gpu_busy_ms_per_present":59.257,"worker_busy_pct":{"queue worker 0x0":99.316,"queue worker 0x20":99.257},"top_phases_ms_per_frame":{"Driver.Packet":410.599,"Driver.Packet.memory_wait":196.982,"Driver.Packet.draw":140.028,"Driver.Draw":139.753,"Driver.Draw.draw_and_resource_release":95.453,"Graphics.Draw":95.182,"Graphics.Draw.draw_and_resource_release":79.73,"Resources.Complete":47.244},"trace_dropped":true}Tested and written by Claude (AI assistant) on a contributor's machine.
Tamalero commentedon Oct 14, 2026
More High on Life (PPSA15388, UE 4.27) data, on current
mainthis time. It points at caches whose keys almost never repeat in this title.Setup.
main718ae1355plus #5156, #5494, #5232 and local diagnostics. The occlusion path is the CPU one from #5213. Warm caches.1. Frame trace on an open outdoor map, 20 s (
APS5_TRACE_SECONDS, nothing dropped), 10 s standing still, then 10 s turning:Driver.Drawper drawGraphics.Draw.validate, share of queue worker 0x0Resources.CompleteGraphics.Draw.validateruns about 33 times per frame. Its median is 28 µs, but the 90th percentile is 2 ms and the maximum is 136 ms, so a few calls per frame take most of that share. Queue worker 0x0 is 95 % busy, and the GPU is mostly idle.2.
APS5_PROFILE_DRAW=1, home base, 10 s windows, standing and turning:[rescache] draws)[draw-cache])GuestBuffers 58761 (V# word 58761/0/0/0)So:
validatecalls in the trace are the step itself on a few draws, not memo misses.Per draw packet (
[draw] driver phases, standing): capture 9.7 µs, recompile 6.3, decode 3.0,Graphics::Draw61.6, total 84.8 µs. A draw-cache hit skips decode. Leaving ring-allocated base addresses out of the key, or treating them as data, looks like the lever for UE4 titles. A record thread likeAPS5_MT_RECORDwould then have less to move. #4943 and #5656 look aimed at exactly this draw-cache case (moved user-data pointers, user words a stage doesn't read), so I'll run them at the same spot next and post the hit rates.Details
APS5_TRACE_CAPACITY(local) raised the per-thread buffer so 20 s fit.mainare in my comment above. Per draw was about 98 µs there, in a scene with more cheap draws.[rescache]/[draw-cache]/[draws]lines for both windows are available if useful.Tested and written by Claude (AI assistant) on a contributor's machine.
NOTE: EasyPS5 is ingame for High on Life now (just severe performance issues)
But I have not had a single crash in 30 runs.
eliyarson commentedon Oct 14, 2026
@MarcZX100 on
trace_dropped=true: I read the traces back. The flag is set because the queue-0 worker's event buffer fills early, not because of anything specific toAPS5_MT_RECORD. The summary numbers aren't affected.TraceThread::DefaultCapacityis 262,144 slices per thread. In this scene the queue-0 worker writes about 80,000 slices a second, so its buffer is full about 3.3 s into the 30 s window. After that point the worker has no slices in the trace. The presenter andgputracks run to the end of the window.main. I see it in all 33 traces of this scene, flag on and flag off. Last worker slice, in seconds into the window:maina0f5916main665ad7d (6 runs)APS5_MT_RECORD=1(e3340a220)p50/p90/p99,gpu_busy_ms_per_present,worker_busy_pctand the phase totals come from present timestamps and per-thread totals. Those keep accumulating after the slice buffer is full. The flag would also be set if the 512-entry totals table overflowed. That's very unlikely here, because each thread records only 22–31 distinct scopes in these traces. Still, the summary can't tell those two cases apart, and that's part of what I filed.[mtrecord]counters and not as its own track. I'll bind it before the next measurements.Filed as #5786, with a proposed fix: per-thread drop details in the summary, a marker where a track ends, and an opt-in
APS5_TRACE_EVENTSbuffer size. I'm preparing a PR for it.The traces are about 3 MB each gzipped. I can attach the
mainand the flag-on/flag-off ones if that helps.The other two points still stand: the same-address deferred-label cleanup race, and the write checker and frame-dump comparison on the rebased code. I'll report on both before proposing a split.
eliyarson commentedon Oct 14, 2026
Follow-up on the open items from my last comment, all at
1e9e3ca68(Hades II hub, RTX 3080 Ti, NVIDIA 610.57.04, Linux).Label race (stamp-based cleanup). The new
agc_driver_mt_record_label_stampstest fails on the commit before the fix (e3340a220, with a one-line forwarder so the test compiles: "the cleanup of the first label dropped the second label's queued entry") and passes with it. All 11agc_driver_mt_record*tests ran 300 times each serially and 300 times each with-j8, no failure.Write checker. Three hub runs with
APS5_MT_RECORD=1 APS5_MT_RECORD_CHECK=1, about 4 minutes each in the hub, 4.0 to 5.4 million record jobs each: every run ends withuncovered=0,wildcard-ranges=0, and no assert. Scope: the checker only sees writes that go through the observer, which here is about 9 to 12 thousand per run, mostly storage textures. Label notes, writebacks and opaque-stage writes are excluded by design, so this says no observed write fell outside its prediction, not that every job was checked.Frame dumps. Two runs with the flag and two without,
APS5_DUMP_FRAMESevery 60 presents at quarter scale, compared from the first hub frame on. Each frame with the flag is matched to the closest frame without it; the worst match is 33.0 dB, against 32.7 dB between the two runs without the flag. That rules out gross corruption only: a small wrong region would stay above that threshold, and the title screen is not compared.Heap corruption at exit. Not reproduced. In our own logs "double free" appears twice, once with
APS5_MT_RECORDand once in a run from before it existed. An ASan + UBSan build ran the 11agc_driver_mt_record*tests 50 times each with no report, but that does not exercise the exit path, and I have not run the game under ASan yet. This stays open.Separately, there is an intermittent segfault right after
GUI.pkgopens, before the title screen, with or withoutAPS5_MT_RECORD. Both cases in this round happened while a build was loading the machine; I have not looked into it further.The trace drop explanation is now #5786, with the fix in #5813.
eliyarson commentedon Oct 14, 2026
Exit crash, follow-up: with glibc heap checks on (
libc_malloc_debug.so,glibc.malloc.check=3), two hub runs withAPS5_MT_RECORD=1exited cleanly on SIGTERM, so thedouble free or corruptionabort is still not reproduced. A third run withMALLOC_PERTURB_crashed at exit in the audio mixer, reading a pointer from freed memory whileexit()was running static destructors: #5864. That is a different failure, and nothing in it involvesAPS5_MT_RECORD, but it may be how the double free happens too; I could not confirm it, because the earlier aborts left no stack.eliyarson commentedon Oct 14, 2026
Same-session comparison with #1201 (pipelined queue-0 draw commits), Hades II hub, same machine and method as above (mean of the window-title FPS over a 60 s hold, three interleaved runs per arm, no profiling variables):
1e9e3ca68, flag offAPS5_MT_RECORD=1APS5_PIPELINED_DRAWS=1Both reach the same scene at a similar gain, #1201 a little higher. Note for anyone measuring either:
APS5_PROFILE_DRAW=1together withAPS5_DRAW_PREPARE_AHEAD=2costs this change 7 to 11 FPS here (27.0 / 41.4 with both set), more than #1201 loses with the same environment.Since #1201 already covers this ground and is further along in review, I'm going to help it land instead of opening the PRs this issue promised. Per-run numbers and the ordering cases I found in #1201 are in #1201 (comment). I'll keep this issue for the Hades II data and close it once #1201 is merged.
Tamalero commentedon Oct 15, 2026
High on Life data point on current
main(760ffa683, plus #5828, #5509, #5778 and #5821, none of which touch draw cost). Same machine and spot as my earlier numbers (top of the stairs facing the portal and TV), with the shader cache warm (only hits, no misses in the window):718ae1355-based stack (my earlier posts here)760ffa683+ the PRs aboveSo about 1.5–2× faster at that spot, which lines up with the draw-reuse merges since then (#494, #5906, #6093 and others). One thread still sits at about 90% of a core.
Details
Tested and written by Claude (AI assistant) on a contributor's machine.