Vulkan: vk_draw.cpp's per-thread objects without the TLS init check on every use #1035

Merged
jordanella merged 8 commits from render-cpu-fixes into main 2026-11-09 15:52:07 +00:00
jordanella commented 2026-11-09 07:25:42 +00:00 (Migrated from github.com)

With llvm-mingw, every use of a thread_local whose type has a constructor or destructor calls the file's TLS init function. That call saves registers, finds the thread's TLS block and checks a guard byte, however long ago the thread set the object up. vk_draw.cpp has twelve such objects (dw, pass, handle_hits, pushed, bank_cache, ...), used dozens of times per draw. Found with the sampling profiler (HOS_SAMPLE=Presentation + scripts/prof.py): ~2.4% of the replay thread.

They are now TLS_OBJECT(name, type). name() returns the calling thread's object through a plain thread_local pointer, which needs no init function. Each object is made on the thread's first use and deleted when the thread ends. Only the list that owns them has a destructor, and only a first use touches it. The change at the use sites is mechanical (dw. becomes dw().). Because the names are now functions, a use left unchanged fails to compile instead of quietly paying the check again. tests/test_tls_objects.py fails if a namespace-scope thread_local in vk_draw.cpp has a container type, or a struct type of the file holding one.

This PR first also read RX_VK_TAAU_KEYS once instead of on every dispatch. Main has since fixed that in df1e26d (the lookup after the stand-in's early returns), so that part is dropped in favour of main's. tests/test_taau_env_lookup.py's fixture now has dw as a call, like vk_draw.cpp, and so do test_handle_lookup.py, test_pass_hazard.py and test_push_state.py, which read the source.

Measured on a Ryzen 9 3900X with an RTX 3080 on Windows 11, on main 8b2e1d3 (v2.0.8-rc.1) with this branch merged in. Kokiri Forest after loading a save, headless, input timed in game frames (HOS_INPUT_FRAME_CLOCK=1), live numbers over presents 3120-5520, two runs each, in two rounds (the second in reverse order):

build standing fps replay per frame panning fps
main 36.8 / 36.3 22.5 ms 33.9 / 34.9
this branch 37.1 / 37.2 (+1.7%) 21.9 ms 35.0 / 34.9 (+1.7%)

On v2.0.5 the profile showed the init function gone (0.00% of the replay thread, from 2.38%).

python -I -B tests/run_all.py on main 8b2e1d3 with all three of my open PRs merged: the same 12 failures as main itself on this machine (missing tools, and an older g++ that rejects volatile compound assignment), and every other test passes.

🤖 Generated with Claude Code

With llvm-mingw, every use of a `thread_local` whose type has a constructor or destructor calls the file's TLS init function. That call saves registers, finds the thread's TLS block and checks a guard byte, however long ago the thread set the object up. `vk_draw.cpp` has twelve such objects (`dw`, `pass`, `handle_hits`, `pushed`, `bank_cache`, ...), used dozens of times per draw. Found with the sampling profiler (`HOS_SAMPLE=Presentation` + `scripts/prof.py`): ~2.4% of the replay thread. They are now `TLS_OBJECT(name, type)`. `name()` returns the calling thread's object through a plain `thread_local` pointer, which needs no init function. Each object is made on the thread's first use and deleted when the thread ends. Only the list that owns them has a destructor, and only a first use touches it. The change at the use sites is mechanical (`dw.` becomes `dw().`). Because the names are now functions, a use left unchanged fails to compile instead of quietly paying the check again. `tests/test_tls_objects.py` fails if a namespace-scope `thread_local` in `vk_draw.cpp` has a container type, or a struct type of the file holding one. This PR first also read `RX_VK_TAAU_KEYS` once instead of on every dispatch. Main has since fixed that in df1e26d (the lookup after the stand-in's early returns), so that part is dropped in favour of main's. `tests/test_taau_env_lookup.py`'s fixture now has `dw` as a call, like `vk_draw.cpp`, and so do `test_handle_lookup.py`, `test_pass_hazard.py` and `test_push_state.py`, which read the source. Measured on a Ryzen 9 3900X with an RTX 3080 on Windows 11, on main 8b2e1d3 (v2.0.8-rc.1) with this branch merged in. Kokiri Forest after loading a save, headless, input timed in game frames (`HOS_INPUT_FRAME_CLOCK=1`), live numbers over presents 3120-5520, two runs each, in two rounds (the second in reverse order): | build | standing fps | replay per frame | panning fps | |---|---|---|---| | main | 36.8 / 36.3 | 22.5 ms | 33.9 / 34.9 | | this branch | 37.1 / 37.2 (+1.7%) | 21.9 ms | 35.0 / 34.9 (+1.7%) | On v2.0.5 the profile showed the init function gone (0.00% of the replay thread, from 2.38%). `python -I -B tests/run_all.py` on main 8b2e1d3 with all three of my open PRs merged: the same 12 failures as main itself on this machine (missing tools, and an older g++ that rejects `volatile` compound assignment), and every other test passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-09 08:16:22 +00:00
hckrman101 (Migrated from github.com) left a comment

I don't see a correctness problem: the ::mkwvk:: names resolve to the unnamed-namespace thread-locals, each reference is bound per call on the calling thread (the recursive bind_descriptors call binds again), and the static keys is fine. My comments are about the approach and coverage rather than bugs.

I don't see a correctness problem: the `::mkwvk::` names resolve to the unnamed-namespace thread-locals, each reference is bound per call on the calling thread (the recursive `bind_descriptors` call binds again), and the `static` `keys` is fine. My comments are about the approach and coverage rather than bugs.
hckrman101 (Migrated from github.com) commented 2026-11-09 08:16:22 +00:00

Same in build_descriptors: dump_written.clear() / dump_inputs.clear() here run unconditionally through the wrapper even with dumping off, and bank_cache via uniform_range is the hottest remaining TLS access in this function. The "thread-locals once" comment above is only half true as it stands.

Same in `build_descriptors`: `dump_written.clear()` / `dump_inputs.clear()` here run unconditionally through the wrapper even with dumping off, and `bank_cache` via `uniform_range` is the hottest remaining TLS access in this function. The "thread-locals once" comment above is only half true as it stands.
hckrman101 (Migrated from github.com) commented 2026-11-09 08:16:22 +00:00

nit: the locals shadow the thread-locals with the same names (dw, draw_sampled, and handle_hits at 1491), so if one of these bindings is ever removed the code still compiles against the thread-local and the slowdown comes back silently (and -Wshadow warns on each). A distinct name like DescWriter& w = dw;, or the struct above, makes it explicit. The three sites also spell it differently (auto& vs the full type).

nit: the locals shadow the thread-locals with the same names (`dw`, `draw_sampled`, and `handle_hits` at 1491), so if one of these bindings is ever removed the code still compiles against the thread-local and the slowdown comes back silently (and `-Wshadow` warns on each). A distinct name like `DescWriter& w = dw;`, or the struct above, makes it explicit. The three sites also spell it differently (`auto&` vs the full type).
hckrman101 (Migrated from github.com) commented 2026-11-09 08:16:22 +00:00

The underlying cost is that every namespace-scope thread_local with a non-trivial constructor/destructor goes through the TLS init wrapper on each use, and binding a reference at the top of three functions fixes just those three. Other hot users still pay it: uniform_range's bank_cache (once per uniform binding per draw, ~1584), gfx_sampled = draw_sampled in the draw path (~4809), draw_hazards, and so on. The next profile will likely want the same patch in more places. Putting the per-thread descriptor state in one struct reached through a trivially initialised thread_local Tls* tls = nullptr; (allocated on first use) would remove the wrapper for every user in one go. What do you think?

The underlying cost is that every namespace-scope `thread_local` with a non-trivial constructor/destructor goes through the TLS init wrapper on each use, and binding a reference at the top of three functions fixes just those three. Other hot users still pay it: `uniform_range`'s `bank_cache` (once per uniform binding per draw, ~1584), `gfx_sampled = draw_sampled` in the draw path (~4809), `draw_hazards`, and so on. The next profile will likely want the same patch in more places. Putting the per-thread descriptor state in one struct reached through a trivially initialised `thread_local Tls* tls = nullptr;` (allocated on first use) would remove the wrapper for every user in one go. What do you think?
hckrman101 (Migrated from github.com) commented 2026-11-09 08:16:22 +00:00

pushed is a thread-local with vector members too, so this line still goes through the wrapper (twice on the fallback path that re-enters bind_descriptors), one line below the dw it just fixed.

`pushed` is a thread-local with vector members too, so this line still goes through the wrapper (twice on the fallback path that re-enters `bind_descriptors`), one line below the `dw` it just fixed.
hckrman101 (Migrated from github.com) commented 2026-11-09 08:16:23 +00:00

minor: this now reads the env once, but it still duplicates taau_key's RX_VK_TAAU_KEYS lookup with a different default (fd029a317a9a4b94 here vs - there), and the static guard is checked before the taau_key early return on every dispatch. One shared taau_keys() helper, read after the early return, would remove both.

minor: this now reads the env once, but it still duplicates `taau_key`'s `RX_VK_TAAU_KEYS` lookup with a different default (`fd029a317a9a4b94` here vs `-` there), and the static guard is checked before the `taau_key` early return on every dispatch. One shared `taau_keys()` helper, read after the early return, would remove both.
jordanella (Migrated from github.com) reviewed 2026-11-09 09:36:42 +00:00
jordanella (Migrated from github.com) commented 2026-11-09 09:36:42 +00:00

Agreed, and done in 04743e0. With the references, the TLS init function was still 1.06% of the replay thread (in draw, begin_pass, draw_hazards, dynamic_state and texture_view_of); with all twelve objects behind TLS_OBJECT it's 0.00%. Fps doesn't move measurably beyond the first version, since this part is ~0.2 ms a frame, but nothing pays it anymore, and tests/test_tls_objects.py (7033e84) fails if a new one appears.

Agreed, and done in 04743e0. With the references, the TLS init function was still 1.06% of the replay thread (in `draw`, `begin_pass`, `draw_hazards`, `dynamic_state` and `texture_view_of`); with all twelve objects behind `TLS_OBJECT` it's 0.00%. Fps doesn't move measurably beyond the first version, since this part is ~0.2 ms a frame, but nothing pays it anymore, and `tests/test_tls_objects.py` (7033e84) fails if a new one appears.
jordanella (Migrated from github.com) reviewed 2026-11-09 09:36:48 +00:00
jordanella (Migrated from github.com) commented 2026-11-09 09:36:48 +00:00

pushed is TLS_OBJECT(pushed, std::array<Pushed, 2>) now, like the rest.

`pushed` is `TLS_OBJECT(pushed, std::array<Pushed, 2>)` now, like the rest.
jordanella (Migrated from github.com) reviewed 2026-11-09 09:36:49 +00:00
jordanella (Migrated from github.com) commented 2026-11-09 09:36:49 +00:00

All of them are TLS_OBJECTs now: dump_written, dump_sampled, dump_inputs and bank_cache among the twelve.

All of them are `TLS_OBJECT`s now: `dump_written`, `dump_sampled`, `dump_inputs` and `bank_cache` among the twelve.
jordanella (Migrated from github.com) reviewed 2026-11-09 09:36:51 +00:00
jordanella (Migrated from github.com) commented 2026-11-09 09:36:51 +00:00

The references are gone. The names are functions now, so a use left as it was doesn't compile, rather than silently going back through the wrapper.

The references are gone. The names are functions now, so a use left as it was doesn't compile, rather than silently going back through the wrapper.
jordanella (Migrated from github.com) reviewed 2026-11-09 09:36:53 +00:00
jordanella (Migrated from github.com) commented 2026-11-09 09:36:53 +00:00

Done in fb50ab2: one taau_keys(), read only after taau_key has matched. The stand-in's copy only fed typed_upscale's log line, which now shows the list in effect.

Done in fb50ab2: one `taau_keys()`, read only after `taau_key` has matched. The stand-in's copy only fed `typed_upscale`'s log line, which now shows the list in effect.
hckrman101 (Migrated from github.com) approved these changes 2026-11-09 10:46:51 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice, this addresses everything I raised. All twelve objects are behind TLS_OBJECT, the old names are functions so a leftover use won't compile, pushed/dump_*/bank_cache are covered, and taau_keys() is read only after a match. I also checked first-use init (new T() zeroes the same way, the BankCache defaults can't give a false hit), per-thread cleanup at thread exit, and that no object is reached from a different thread than before. Looks good.

Non-blocking suggestions if you touch it again:

  • tests/test_tls_objects.py is a heuristic with some holes. Types from headers (Program, std::mutex, std::function<...>), aliases/auto, std::optional<Pass>, user-written destructors and multi-line declarations all pass it. Putting constinit on the remaining plain thread_locals, plus static_assert(std::is_trivially_destructible_v<T>), would get the compiler to enforce it on every build.
  • reinterpret_cast<void**>(&slot) with *it->slot = nullptr writes a T* through a void* lvalue, which is technically a strict-aliasing violation. A type-erased deleter that takes T** and clears it ([](void* s){ T*& p = *static_cast<T**>(s); delete p; p = nullptr; }) avoids that.
  • In the hottest loops (build_descriptors' dw() calls, end_pass's pass() uses), a local DescWriter& w = dw(); at the top would save reloading and null-checking the slot on every use, without bringing back the shadowing.
Nice, this addresses everything I raised. All twelve objects are behind `TLS_OBJECT`, the old names are functions so a leftover use won't compile, `pushed`/`dump_*`/`bank_cache` are covered, and `taau_keys()` is read only after a match. I also checked first-use init (`new T()` zeroes the same way, the `BankCache` defaults can't give a false hit), per-thread cleanup at thread exit, and that no object is reached from a different thread than before. Looks good. Non-blocking suggestions if you touch it again: - `tests/test_tls_objects.py` is a heuristic with some holes. Types from headers (`Program`, `std::mutex`, `std::function<...>`), aliases/`auto`, `std::optional<Pass>`, user-written destructors and multi-line declarations all pass it. Putting `constinit` on the remaining plain thread_locals, plus `static_assert(std::is_trivially_destructible_v<T>)`, would get the compiler to enforce it on every build. - `reinterpret_cast<void**>(&slot)` with `*it->slot = nullptr` writes a `T*` through a `void*` lvalue, which is technically a strict-aliasing violation. A type-erased deleter that takes `T**` and clears it (`[](void* s){ T*& p = *static_cast<T**>(s); delete p; p = nullptr; }`) avoids that. - In the hottest loops (`build_descriptors`' `dw()` calls, `end_pass`'s `pass()` uses), a local `DescWriter& w = dw();` at the top would save reloading and null-checking the slot on every use, without bringing back the shadowing.
blushOFF commented 2026-11-09 11:31:40 +00:00 (Migrated from github.com)

Measured this PR on Windows / NVIDIA, in case it helps: RTX 3070 8 GB, i7-14700KF, Windows 11, 32 GB.

Build: origin/main 8b2e1d3b with this PR merged in (plus an unrelated audio change, #1063). Two hunks conflicted with
current main in gpu/vk/vk_draw.cpp (the TAAU keys: getenv("RX_VK_TAAU_KEYS") against taau_keys(), and dw.imgs
against dw().imgs); I took this PR's side for both.

Kokiri Forest, A/B in ABBA order (same save, same settings; 30 s standing, then 40 s walking; median fps):

Pair Standing: main / this PR Walking: main / this PR
1 45.1 / 52.9 40.9 / 49.5
2 47.9 / 52.2 43.4 / 47.6
3 52.1 / 52.8 46.8 / 50.7

This PR is ahead in all 6 comparisons: +4.3 fps standing and +5.6 walking on average. No flicker in either.

A tour of 26 debug-teleport destinations (#785's tools, 20 s each, one run per build, so noisier than the A/B
above): no device lost, no flicker. Overworld areas are mostly faster: Kokiri Forest +11.2, Hyrule Field +11.7, Zora's
River +8.9, Death Mountain Trail +7.0, Hyrule Castle +6.4, Kakariko +5.3. A few areas late in the tour were slower
(Spirit Temple -15.8, Forest Temple -5.5), but main's numbers for those came from a shorter tour that started fresh, so
I would not read much into them.

Measured this PR on Windows / NVIDIA, in case it helps: RTX 3070 8 GB, i7-14700KF, Windows 11, 32 GB. **Build:** origin/main 8b2e1d3b with this PR merged in (plus an unrelated audio change, #1063). Two hunks conflicted with current main in `gpu/vk/vk_draw.cpp` (the TAAU keys: `getenv("RX_VK_TAAU_KEYS")` against `taau_keys()`, and `dw.imgs` against `dw().imgs`); I took this PR's side for both. **Kokiri Forest, A/B in ABBA order** (same save, same settings; 30 s standing, then 40 s walking; median fps): | Pair | Standing: main / this PR | Walking: main / this PR | |---|---|---| | 1 | 45.1 / 52.9 | 40.9 / 49.5 | | 2 | 47.9 / 52.2 | 43.4 / 47.6 | | 3 | 52.1 / 52.8 | 46.8 / 50.7 | This PR is ahead in all 6 comparisons: +4.3 fps standing and +5.6 walking on average. No flicker in either. **A tour of 26 debug-teleport destinations** (#785's tools, 20 s each, one run per build, so noisier than the A/B above): no device lost, no flicker. Overworld areas are mostly faster: Kokiri Forest +11.2, Hyrule Field +11.7, Zora's River +8.9, Death Mountain Trail +7.0, Hyrule Castle +6.4, Kakariko +5.3. A few areas late in the tour were slower (Spirit Temple -15.8, Forest Temple -5.5), but main's numbers for those came from a shorter tour that started fresh, so I would not read much into them.
jordanella commented 2026-11-09 15:32:56 +00:00 (Migrated from github.com)

Main is merged in. Main's df1e26d took care of the TAAU key lookups, so I dropped my version of that and kept main's, with its test (its fixture now has dw as a call). What's left is the TLS_OBJECT change. Re-measured on main 8b2e1d3 (v2.0.8-rc.1): +1.7% standing and panning. Main is merged in again since (016d582: two statistics counters it removed sat on lines this branch had changed). Title and body are updated to match.

Main is merged in. Main's df1e26d took care of the TAAU key lookups, so I dropped my version of that and kept main's, with its test (its fixture now has `dw` as a call). What's left is the `TLS_OBJECT` change. Re-measured on main 8b2e1d3 (v2.0.8-rc.1): +1.7% standing and panning. Main is merged in again since (016d582: two statistics counters it removed sat on lines this branch had changed). Title and body are updated to match.
Commenting is not possible because the repository is archived.
No description provided.