WIP: perf(linux): the Vulkan back end's thread-local data in the static TLS block, through TLS descriptors #232

Draft
dukenheimer wants to merge 10 commits from dukenheimer/Longshot:opus/static-tls into main
First-time contributor

Why

On Linux the Vulkan back end (longshot_vk.so) is loaded with dlopen, so every thread_local access in it went through __tls_get_addr, a call into the dynamic loader, thousands of times a frame on the replay's render thread. On a Steam Deck that call was 2.4% of the render thread, with more in the accessors around it. This puts the back end's thread-local data in the static TLS block, where an access is a constant offset.

Measured on the Steam Deck alone: it works as designed and gains no fps (below): the frame there is set by the readback and GPU waits, not by the replay's TLS accesses. Recommendation: not worth merging as it is. The restart at every Linux start, the GLIBC_TUNABLES handling, the TLS descriptors and the link-time check are machinery for a gain nobody has measured, so I suggest closing it. The one part with a measured effect and no start-up behaviour is the accessor change (TLS_OBJECT always inlined, tls_make cold: in a Deck profile dw()'s 5.1% of the render thread folded away and the frame's instructions fell 5%); if maintainers want it, it can go as a small PR of its own, on every platform and the default TLS model.

What changed

  • build (Linux x86-64): nx2/build.py compiles the back end with -mtls-dialect=gnu2 (TLS descriptors; vk_tls_flags). RX_VK_TLS=gd: the default model, as before; another machine than x86-64 keeps the default model, with a line saying so. At the link, nx2/elf_tls.py reads the linked longshot_vk.so's PT_TLS and prints it; the build fails when the segment (rounded to its alignment) plus 8 KB kept for the drivers' own initial-exec TLS doesn't fit HOS_STATIC_TLS_ROOM, naming the fixes (the data on the heap through TLS_OBJECT, a bigger room, or RX_VK_TLS=gd).
  • host (Linux): static_tls_room() in host/hos_main.c, first thing in main: the game restarts itself once (execv of its own path, read from /proc/self/exe, so the process keeps the name longshot and its PID) with glibc.rtld.optional_static_tls=98304 added to GLIBC_TUNABLES, so the loader places longshot_vk.so's TLS in the static block and its descriptors resolve to a constant offset. Started again, it puts GLIBC_TUNABLES back as it was (HOS_STATIC_TLS_WAS) and drops its markers, so what the game starts (xdg-open, a browser) doesn't get the room on its threads; glibc read the tunable at the start already, and a Restart now (host/hos_relaunch.c, from /proc/self/environ) keeps it.
  • host (Linux): HOS_STATIC_TLS follows the switches' rule (env_value_off: 0, false, off, no, empty) and is read from longshot_env.txt (or HOS_ENV_FILE's file) as well as the environment: env_file_peek reads that one line before load_longshot_env loads the file, the environment first as for every switch.
  • host + vk (Linux): the log says which path the back end got. A static TLS: line once the log is open (started again with the room; or why not: the switch, a GLIBC_TUNABLES that sets the tunable already, a failed execv with its error, a GLIBC_TUNABLES too long to add to), and at the renderer's start vk: TLS: <n> bytes, in the static TLS block or NOT in the static TLS block: every thread_local access ... goes through glibc's dynamic path (tls_place in gpu/vk/vk_main.cpp: a thread_local's distance below the thread pointer, and the module's PT_TLS from dl_iterate_phdr).
  • vk (all platforms): sampler_for's 128 KB per-thread table (gpu/vk/vk_draw.cpp) is on the heap, a namespace-scope TLS_OBJECT(sampler_table, SamplerTable) freed at thread exit with the others: the back end's TLS segment goes from ~178 KB to 47,936 bytes (46.8 KB), which fits the room above.
  • vk (all platforms): the TLS_OBJECT accessors (dw(), pass() and the rest, vk_draw.cpp) are always inlined, and their first-use allocation (tls_make) is out of line and cold. Inlined, the new made every accessor a full function that saved six registers around the TLS lookup, ~50 calls a draw; a Steam Deck profile had dw() alone at 5.1% of the render thread. This part helps whatever TLS model is used.
  • tests: tests/test_static_tls.py (below); tests/test_tls_objects.py checks the accessor's shape; tests/test_sampler_admission.py builds sampler_for with its table; tests/display_startup_failure_test.cpp gets a static_tls_room stub, since the main it cuts out now calls it.
  • docs: docs/development.md has HOS_STATIC_TLS (with the switches) and RX_VK_TLS (under Building).

No translator file changes (vk_shader.cpp, vk_bank.cpp, vk_state.h, vk_api.h, eden/, sirit/, compat/): the shader caches stay valid.

How TLS descriptors fall back (from the review): when glibc has no room left, a TLSDESC module is placed in a block of its own and its descriptors take _dl_tlsdesc_dynamic; dlopen does not fail ("cannot allocate memory in static TLS block" is initial-exec's error, which this doesn't add). So the risk is silent slowness, never a game that won't start, and that path is slower than main's default model (hckrman101 measured TLS descriptors 8-14% slower on a PC in a CPU-only replay benchmark). Hence the build check and the two log lines.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
HOS_STATIC_TLS (environment or longshot_env.txt) Off (0, false, off, no, empty): no restart for static TLS room; the back end's TLS then takes glibc's dynamic descriptor path, slower than main on (Linux) keep on; off is for an A/B Steam Deck (as part of a combined build, below) Windows (no effect) Linux desktops, other distributions' glibc
RX_VK_TLS=gd (build) Build the back end with the default TLS model, as main does descriptors on Linux x86-64 keep descriptors only together with the restart; gd is main's baseline for an A/B - - Linux on other than x86-64 (descriptors are off there)

Both are in docs/development.md. HOS_STATIC_TLS_DONE and HOS_STATIC_TLS_WAS are the restart's own markers, set by the game for the process it starts and removed by it, not switches to set. HOS_ENV_FILE is the existing switch (another file than longshot_env.txt), read here too for HOS_STATIC_TLS.

Measurements

Steam Deck OLED, this PR alone (Deck agent, 2026-11-13): no fps change.

main: d964886 + #225 (the Steam Deck profile, which the Deck needs: plain main runs out of memory there), e298bd4
this PR: opus/static-tls @ fc96d3e, merged on that base (a full Linux build)
Hardware: Steam Deck OLED (AMD Custom GPU 0932, RADV VANGOGH, Mesa 26.2.0-devel), SteamOS 3.9.5, 16 GB (swapping in every run)
Scene: Kokiri Forest, the moving route (125 s, measured 30-110 s), the user's options (Deck profile: 2048x1152 output, FSR 3.1 ~50%, frame generation 2x), stock clocks, memory guard on
Method: one warm-up start each, then interleaved base, this PR, ..., this PR, base; fps from the Deck agent's frame log ("real": the game's frames); threads from /proc/<pid>/status every 5 s
How to reproduce: the Deck agent's harness, data in deck-s3 (X232-, LB-x)

main @ d964886 + #225 this PR @ fc96d3e
real fps (runs) 15.8, 14.7 15.6, 15.1
shown fps 30.2, 27.6 29.7, 27.7
p95 frame time 79, 84 ms 65, 82 ms
frames over 100 ms 74, 72 48, 69
RSS peak 8,074, 8,365 MB 8,341, 8,403 MB
threads (most) 127 127
static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data
vk: TLS: 48000 bytes, in the static TLS block (89554 below the thread pointer): each access a constant offset

The warm-up kept every program key (47,797: the stamp is unchanged). Real fps is within the run-to-run spread (about ±1). The static reserve costs no thread and no measurable memory.

The TLS segment, measured at the link: longshot_vk.so: TLS segment 47936 bytes (aligned to 16): fits the 98304 bytes of static TLS room (42176 to spare, 8192 kept for drivers').

Earlier, on a Hookshot-based build: render-thread perf before and after the accessor change: dw() (5.1%) folded away into its callers (build_descriptors + dw() 12.6% → 9.1% together), the TLS descriptor stub 3.4% → 2.1%, instructions a frame −5%.

Testing

Tested:

  • python -I -B tests/run_all.py, Linux (Ubuntu 22.04 container, clang 23, the CPM Vulkan headers) and Windows (llvm-mingw): this head @ 3ccb48a, rebased on main 5a8ae7e, fails exactly what main @ 5a8ae7e fails: Linux 5 (test_cpp_preflight, test_dev_packs, test_pad_layout, test_prof_clock, the byte-compile without .git), Windows 4 (test_bake_portability, test_fpx_sticky, test_pad_layout, test_spawn_emit); 511 / 468 passed.
  • tests/test_static_tls.py with gcc 13 / g++ 13 (Ubuntu 24.04, glibc 2.39) and in the suite's container (glibc 2.35): vk_tls_flags on x86-64 / aarch64 / RX_VK_TLS=gd / Windows; nx2/elf_tls.py on ELF files made in the test (a segment that fits, one past the margin, one pushed out by its alignment, none, not ELF) and the link's call of it; static_tls_room cut out and run with execv stubbed in 16 cases (the switch's values from the environment, from longshot_env.txt with a BOM, spaces and later lines, from HOS_ENV_FILE, the environment over the file, a tunable already set, an empty and an existing GLIBC_TUNABLES, a failed exec, the game started again with and without a GLIBC_TUNABLES before it), each with its log note; and tls_place cut out into a module with a 48 KB TLS segment, built with -mtls-dialect=gnu2 and dlopen'ed: "dynamic" without the room, "static" with it.
  • In the game: the Steam Deck OLED alone at fc96d3e (above, 2 interleaved pairs): the restart and the static block confirmed by the log, threads and RSS as main's.

Not tested:

  • Linux desktops (other glibc versions, other launchers that set GLIBC_TUNABLES), Flatpak/AppImage starts.
  • Windows in the game (only the accessor and the heap table change there).

Needs other testers:

  • Linux desktop (NVIDIA or AMD): the game starts, its process is named longshot, the log has static TLS: started again ... and vk: TLS: ... in the static TLS block; fps this head against main (request to open)

Screenshots

Nothing on screen.

Risk and rollback

  • Silent slowness, not a failure: without the room the back end runs on the dynamic descriptor path, slower than main. The build check stops the segment from outgrowing the room unnoticed, and the log names the path taken; a launcher's or user's own optional_static_tls that is too small shows as vk: TLS: ... NOT in the static TLS block.
  • The reserve adds to every thread: the static TLS block is part of each thread's stack mapping, and the back end's 47,936 bytes (46.8 KB) is copied and zeroed into every thread, including driver, audio and SDL threads that never touch the renderer. The game's own threads have at least 256 KB of stack, so they're fine; a third-party thread created with a small explicit stack (under about 100 KB plus the guard) would now fail pthread_create with EINVAL. None is known; the Deck's thread count and RSS are among the measurements owed.
  • The restart: it runs before anything else and keeps the PID (execv), so a launcher waiting on it sees the same process. GLIBC_TUNABLES is put back once started again, so children don't inherit the room.
  • Rollback: HOS_STATIC_TLS=0 disables the restart, a build with RX_VK_TLS=gd restores main's model, and reverting the commits removes it all.

Reviewer notes

Read static_tls_room and env_file_peek in host/hos_main.c first, then vk_tls_flags and the link's check in nx2/build.py with nx2/elf_tls.py, then tls_place in gpu/vk/vk_main.cpp, then TLS_OBJECT and sampler_table in gpu/vk/vk_draw.cpp.

Issues

None.

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). (Requests to open.)
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • A row in docs/tested-configurations.md for each measured run.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths.
## Why On Linux the Vulkan back end (`longshot_vk.so`) is loaded with `dlopen`, so every `thread_local` access in it went through `__tls_get_addr`, a call into the dynamic loader, thousands of times a frame on the replay's render thread. On a Steam Deck that call was 2.4% of the render thread, with more in the accessors around it. This puts the back end's thread-local data in the static TLS block, where an access is a constant offset. **Measured on the Steam Deck alone: it works as designed and gains no fps** (below): the frame there is set by the readback and GPU waits, not by the replay's TLS accesses. **Recommendation: not worth merging as it is.** The restart at every Linux start, the `GLIBC_TUNABLES` handling, the TLS descriptors and the link-time check are machinery for a gain nobody has measured, so I suggest closing it. The one part with a measured effect and no start-up behaviour is the accessor change (`TLS_OBJECT` always inlined, `tls_make` cold: in a Deck profile `dw()`'s 5.1% of the render thread folded away and the frame's instructions fell 5%); if maintainers want it, it can go as a small PR of its own, on every platform and the default TLS model. ## What changed - build (Linux x86-64): `nx2/build.py` compiles the back end with `-mtls-dialect=gnu2` (TLS descriptors; `vk_tls_flags`). `RX_VK_TLS=gd`: the default model, as before; another machine than x86-64 keeps the default model, with a line saying so. At the link, `nx2/elf_tls.py` reads the linked `longshot_vk.so`'s `PT_TLS` and prints it; the build **fails** when the segment (rounded to its alignment) plus 8 KB kept for the drivers' own initial-exec TLS doesn't fit `HOS_STATIC_TLS_ROOM`, naming the fixes (the data on the heap through `TLS_OBJECT`, a bigger room, or `RX_VK_TLS=gd`). - host (Linux): `static_tls_room()` in `host/hos_main.c`, first thing in `main`: the game restarts itself once (`execv` of its own path, read from `/proc/self/exe`, so the process keeps the name `longshot` and its PID) with `glibc.rtld.optional_static_tls=98304` added to `GLIBC_TUNABLES`, so the loader places `longshot_vk.so`'s TLS in the static block and its descriptors resolve to a constant offset. Started again, it **puts `GLIBC_TUNABLES` back** as it was (`HOS_STATIC_TLS_WAS`) and drops its markers, so what the game starts (`xdg-open`, a browser) doesn't get the room on its threads; glibc read the tunable at the start already, and a Restart now (`host/hos_relaunch.c`, from `/proc/self/environ`) keeps it. - host (Linux): `HOS_STATIC_TLS` follows the switches' rule (`env_value_off`: `0`, `false`, `off`, `no`, empty) and is read from `longshot_env.txt` (or `HOS_ENV_FILE`'s file) as well as the environment: `env_file_peek` reads that one line before `load_longshot_env` loads the file, the environment first as for every switch. - host + vk (Linux): **the log says which path the back end got.** A `static TLS:` line once the log is open (started again with the room; or why not: the switch, a `GLIBC_TUNABLES` that sets the tunable already, a failed `execv` with its error, a `GLIBC_TUNABLES` too long to add to), and at the renderer's start `vk: TLS: <n> bytes, in the static TLS block` or `NOT in the static TLS block: every thread_local access ... goes through glibc's dynamic path` (`tls_place` in `gpu/vk/vk_main.cpp`: a thread_local's distance below the thread pointer, and the module's `PT_TLS` from `dl_iterate_phdr`). - vk (all platforms): `sampler_for`'s 128 KB per-thread table (`gpu/vk/vk_draw.cpp`) is on the heap, a namespace-scope `TLS_OBJECT(sampler_table, SamplerTable)` freed at thread exit with the others: the back end's TLS segment goes from ~178 KB to 47,936 bytes (46.8 KB), which fits the room above. - vk (all platforms): the `TLS_OBJECT` accessors (`dw()`, `pass()` and the rest, `vk_draw.cpp`) are always inlined, and their first-use allocation (`tls_make`) is out of line and cold. Inlined, the `new` made every accessor a full function that saved six registers around the TLS lookup, ~50 calls a draw; a Steam Deck profile had `dw()` alone at 5.1% of the render thread. This part helps whatever TLS model is used. - tests: `tests/test_static_tls.py` (below); `tests/test_tls_objects.py` checks the accessor's shape; `tests/test_sampler_admission.py` builds `sampler_for` with its table; `tests/display_startup_failure_test.cpp` gets a `static_tls_room` stub, since the `main` it cuts out now calls it. - docs: `docs/development.md` has `HOS_STATIC_TLS` (with the switches) and `RX_VK_TLS` (under Building). No translator file changes (`vk_shader.cpp`, `vk_bank.cpp`, `vk_state.h`, `vk_api.h`, `eden/`, `sirit/`, `compat/`): the shader caches stay valid. **How TLS descriptors fall back** (from the review): when glibc has no room left, a TLSDESC module is placed in a block of its own and its descriptors take `_dl_tlsdesc_dynamic`; `dlopen` does not fail ("cannot allocate memory in static TLS block" is initial-exec's error, which this doesn't add). So the risk is silent slowness, never a game that won't start, and that path is slower than main's default model (hckrman101 measured TLS descriptors 8-14% slower on a PC in a CPU-only replay benchmark). Hence the build check and the two log lines. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `HOS_STATIC_TLS` (environment or `longshot_env.txt`) | Off (`0`, `false`, `off`, `no`, empty): no restart for static TLS room; the back end's TLS then takes glibc's dynamic descriptor path, slower than main | on (Linux) | keep on; off is for an A/B | Steam Deck (as part of a combined build, below) | Windows (no effect) | Linux desktops, other distributions' glibc | | `RX_VK_TLS=gd` (build) | Build the back end with the default TLS model, as main does | descriptors on Linux x86-64 | keep descriptors only together with the restart; `gd` is main's baseline for an A/B | - | - | Linux on other than x86-64 (descriptors are off there) | Both are in `docs/development.md`. `HOS_STATIC_TLS_DONE` and `HOS_STATIC_TLS_WAS` are the restart's own markers, set by the game for the process it starts and removed by it, not switches to set. `HOS_ENV_FILE` is the existing switch (another file than `longshot_env.txt`), read here too for `HOS_STATIC_TLS`. ## Measurements **Steam Deck OLED, this PR alone (Deck agent, 2026-11-13): no fps change.** main: d964886 + #225 (the Steam Deck profile, which the Deck needs: plain main runs out of memory there), e298bd4 this PR: opus/static-tls @ fc96d3e, merged on that base (a full Linux build) Hardware: Steam Deck OLED (AMD Custom GPU 0932, RADV VANGOGH, Mesa 26.2.0-devel), SteamOS 3.9.5, 16 GB (swapping in every run) Scene: Kokiri Forest, the moving route (125 s, measured 30-110 s), the user's options (Deck profile: 2048x1152 output, FSR 3.1 ~50%, frame generation 2x), stock clocks, memory guard on Method: one warm-up start each, then interleaved base, this PR, ..., this PR, base; fps from the Deck agent's frame log ("real": the game's frames); threads from `/proc/<pid>/status` every 5 s How to reproduce: the Deck agent's harness, data in `deck-s3` (X232-*, LB-x*) | | main @ d964886 + #225 | this PR @ fc96d3e | |---|---|---| | real fps (runs) | 15.8, 14.7 | 15.6, 15.1 | | shown fps | 30.2, 27.6 | 29.7, 27.7 | | p95 frame time | 79, 84 ms | 65, 82 ms | | frames over 100 ms | 74, 72 | 48, 69 | | RSS peak | 8,074, 8,365 MB | 8,341, 8,403 MB | | threads (most) | 127 | 127 | ``` static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data vk: TLS: 48000 bytes, in the static TLS block (89554 below the thread pointer): each access a constant offset ``` The warm-up kept every program key (47,797: the stamp is unchanged). Real fps is within the run-to-run spread (about ±1). The static reserve costs no thread and no measurable memory. The TLS segment, measured at the link: `longshot_vk.so: TLS segment 47936 bytes (aligned to 16): fits the 98304 bytes of static TLS room (42176 to spare, 8192 kept for drivers')`. Earlier, on a Hookshot-based build: render-thread `perf` before and after the accessor change: `dw()` (5.1%) folded away into its callers (`build_descriptors` + `dw()` 12.6% → 9.1% together), the TLS descriptor stub 3.4% → 2.1%, instructions a frame −5%. ## Testing **Tested:** - `python -I -B tests/run_all.py`, Linux (Ubuntu 22.04 container, clang 23, the CPM Vulkan headers) and Windows (llvm-mingw): this head @ 3ccb48a, rebased on main 5a8ae7e, fails exactly what main @ 5a8ae7e fails: Linux 5 (`test_cpp_preflight`, `test_dev_packs`, `test_pad_layout`, `test_prof_clock`, the byte-compile without `.git`), Windows 4 (`test_bake_portability`, `test_fpx_sticky`, `test_pad_layout`, `test_spawn_emit`); 511 / 468 passed. - `tests/test_static_tls.py` with gcc 13 / g++ 13 (Ubuntu 24.04, glibc 2.39) and in the suite's container (glibc 2.35): `vk_tls_flags` on x86-64 / aarch64 / `RX_VK_TLS=gd` / Windows; `nx2/elf_tls.py` on ELF files made in the test (a segment that fits, one past the margin, one pushed out by its alignment, none, not ELF) and the link's call of it; `static_tls_room` cut out and run with `execv` stubbed in 16 cases (the switch's values from the environment, from `longshot_env.txt` with a BOM, spaces and later lines, from `HOS_ENV_FILE`, the environment over the file, a tunable already set, an empty and an existing `GLIBC_TUNABLES`, a failed exec, the game started again with and without a `GLIBC_TUNABLES` before it), each with its log note; and `tls_place` cut out into a module with a 48 KB TLS segment, built with `-mtls-dialect=gnu2` and dlopen'ed: "dynamic" without the room, "static" with it. - In the game: the Steam Deck OLED alone at fc96d3e (above, 2 interleaved pairs): the restart and the static block confirmed by the log, threads and RSS as main's. **Not tested:** - Linux desktops (other glibc versions, other launchers that set `GLIBC_TUNABLES`), Flatpak/AppImage starts. - Windows in the game (only the accessor and the heap table change there). **Needs other testers:** - Linux desktop (NVIDIA or AMD): the game starts, its process is named `longshot`, the log has `static TLS: started again ...` and `vk: TLS: ... in the static TLS block`; fps this head against main (request to open) ## Screenshots Nothing on screen. ## Risk and rollback - **Silent slowness, not a failure:** without the room the back end runs on the dynamic descriptor path, slower than main. The build check stops the segment from outgrowing the room unnoticed, and the log names the path taken; a launcher's or user's own `optional_static_tls` that is too small shows as `vk: TLS: ... NOT in the static TLS block`. - **The reserve adds to every thread:** the static TLS block is part of each thread's stack mapping, and the back end's 47,936 bytes (46.8 KB) is copied and zeroed into every thread, including driver, audio and SDL threads that never touch the renderer. The game's own threads have at least 256 KB of stack, so they're fine; a third-party thread created with a small explicit stack (under about 100 KB plus the guard) would now fail `pthread_create` with `EINVAL`. None is known; the Deck's thread count and RSS are among the measurements owed. - **The restart:** it runs before anything else and keeps the PID (`execv`), so a launcher waiting on it sees the same process. `GLIBC_TUNABLES` is put back once started again, so children don't inherit the room. - **Rollback:** `HOS_STATIC_TLS=0` disables the restart, a build with `RX_VK_TLS=gd` restores main's model, and reverting the commits removes it all. ## Reviewer notes Read `static_tls_room` and `env_file_peek` in `host/hos_main.c` first, then `vk_tls_flags` and the link's check in `nx2/build.py` with `nx2/elf_tls.py`, then `tls_place` in `gpu/vk/vk_main.cpp`, then `TLS_OBJECT` and `sampler_table` in `gpu/vk/vk_draw.cpp`. ## Issues None. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). (Requests to open.) - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] A row in `docs/tested-configurations.md` for each measured run. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths.
hookshot_vk.so is dlopen'ed, so every thread_local access in it went through __tls_get_addr: 2.4% of the Steam Deck's render thread in that call alone, more in the accessors around it. The back end is now built with -mtls-dialect=gnu2 on Linux (RX_VK_TLS=gd: as before), sampler_for's 128 KB per-thread table is on the heap (the TLS segment 178 -> ~48 KB), and the game starts itself again once with glibc.rtld.optional_static_tls room, so the loader places that segment in the static block where the descriptors are constant offsets (HOS_STATIC_TLS=0: not). Test: tests/test_static_tls.py.

Co-authored-by: Copilot App <[email protected]>
(cherry picked from commit e09ef4afd974e9d70881b08bb9dc062a48536c35)
execv(/proc/self/exe) named the restarted process exe: pgrep -x hookshot, earlyoom's log and the Deck harness no longer found the game.

Co-authored-by: Copilot App <[email protected]>
(cherry picked from commit 952db8ebd037f349bff476d909697cb4866fb9d4)
Co-authored-by: Copilot App <[email protected]>
(cherry picked from commit 69850016d4e06e0dc73e88c6d8d61c63b99d31f5)
Inlined, tls_make's new and push_back made dw(), pass() and the other accessors full functions: six registers saved and restored around the TLS lookup at each of a draw's ~50 calls. On a Steam Deck (perf of the render thread, user build U53abbb8): dw() alone 5.1% of the thread's samples, the TLS descriptor stub 3.4%. tests/test_tls_objects.py checks the shape.

Co-authored-by: Copilot App <[email protected]>
(cherry picked from commit b510f045eae2a010a6d26a6a8ad3009b2c115971)
Co-authored-by: Copilot App <[email protected]>
Co-authored-by: Copilot App <[email protected]>
fix(tests): test_static_tls's fixture has env_on, as the restart's done marker now reads it
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 17s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
9909b9ef47
Co-authored-by: Copilot App <[email protected]>
HumanBean left a comment

Thanks: the idea holds up. Moving sampler_for's 128 KB table off the TLS segment is what makes static placement possible at all. The cold, out-of-line tls_make is a clean fix whatever TLS model is used. And the restart is careful: it keeps the PID, uses the real path so the name stays longshot, appends to an existing GLIBC_TUNABLES and puts it back if the exec fails. One thing worth knowing: TLS descriptors fail gracefully. When glibc has no room, a TLSDESC module goes to the dynamic path. It doesn't fail dlopen with "cannot allocate memory in static TLS block" (that error is for initial-exec TLS, which this PR doesn't add). So the risk is silent slowness, not a game that won't start. The points below follow from that.

Must change

  1. The fallback is slower than main, nothing says which path you got, and the planned A/B leaves main out. With -mtls-dialect=gnu2 always on, the module's TLS can miss the static block. That happens with HOS_STATIC_TLS=0, with a GLIBC_TUNABLES that already names a smaller optional_static_tls, when the exec fails, or once the TLS segment grows past the room. Each access then goes through _dl_tlsdesc_dynamic. That is the path hckrman101 measured 8 to 14% slower than the default model. Nothing is logged in any of these cases. Please:

    • log once at back-end start whether its TLS landed in the static block, for example from dl_iterate_phdr's dlpi_tls_data or the distance of a thread_local from the thread pointer;
    • log why the restart was skipped (the switch, a tunable already set, a failed exec).

    Also, the A/B in the description is HOS_STATIC_TLS=0 against the default on the same build. That compares gnu2-dynamic with gnu2-static and leaves out main's gd build, which is the real baseline. The measurement owed is this branch against main (or against an RX_VK_TLS=gd build) at named commits, with several interleaved runs on the Deck and on a Linux desktop.

  2. Nothing checks the TLS size against the room, at build time or at run time. "~48 KB" against a 96 KB HOS_STATIC_TLS_ROOM is stated, not measured in the PR, and nothing stops it from growing. One new thread_local array, like the 128 KB table this PR moves, would push the module back to the slow path with no warning. test_static_tls.py only checks that sampler_for's table isn't a TLS array. Please have nx2/build.py read PT_TLS (p_memsz and alignment) from the linked longshot_vk.so, print it, and fail or warn loudly when it doesn't fit HOS_STATIC_TLS_ROOM with some margin. Then put the measured size in the description.

  3. HOS_STATIC_TLS=0 in longshot_env.txt does nothing, and only the exact value 0 turns it off. static_tls_room runs before load_longshot_env(), so the switch works only from the real environment. That isn't how players set switches. It also compares with strcmp(off, "0") rather than the project's one rule (env_value_off: false, off, no, empty). If the switch has to be read before the file, say so where the switch is documented. Use env_value_off either way.

  4. "Rebased on current main" is ticked but false. main@origin has moved on: 08771847 (#78) and 1ef93e79 (#54, which changes host/hos_main.c in memory_notice_text). There's no textual conflict, but rebase or untick the box.

Should change

  • GLIBC_TUNABLES leaks to child processes. After the restart, GLIBC_TUNABLES and HOS_STATIC_TLS_DONE stay in environ. So anything the game spawns (share_spawn's xdg-open, and a browser it may start) also gets a 96 KB optional static TLS reserve on every thread. glibc reads tunables only at process start. You could restore the old GLIBC_TUNABLES in environ once the restarted process is up. hos_relaunch reads /proc/self/environ, so it still sees the start-up value and a Restart now keeps the tunable.
  • The reserve adds to every thread. The static TLS size counts toward each thread's stack mapping, and the back end's ~48 KB is copied and zeroed into every thread, including driver, audio and SDL threads that never touch the renderer. Our own threads are all at least 256 KB, so they're fine. But a third-party thread created with a small explicit stack (less than about 100 KB plus the guard) would now fail pthread_create with EINVAL. Worth a line in Risk, and a look at the thread count and RSS on the Deck run.
  • sampler_for's static thread_local std::unique_ptr<Table> now costs a guard check and a __cxa_thread_atexit registration, plus a second TLS access on each call where there used to be one. It also goes against the file's TLS_OBJECT convention, which avoids exactly this. Consider a namespace-scope TLS_OBJECT(sampler_table, SamplerTable); the thread exit then frees it with the others. tests/test_tls_objects.py doesn't catch function-local ones, by design.
  • Gate -mtls-dialect=gnu2 on x86-64. The flag is x86-64 only (clang rejects it on aarch64). Linux builds are x86-64 today, and deps.sh pins LLVM 23, which accepts it. Still, a platform.machine() check costs nothing and leaves a clear error if that ever changes.
  • tests/display_startup_failure_test.cpp: the stub's line now ends with driver_cache_folder's old comment (// (host/hos_main.c: the folder __GL_SHADER_DISK_CACHE_PATH names, made)), and driver_cache_folder has lost it.
  • docs/development.md still needs HOS_STATIC_TLS and RX_VK_TLS (the box is unticked, which is correct).

Evidence

There are no Deck numbers for this branch alone. 15.1 against 13.6 fps comes from a combined build, one run each, against a Hookshot-based build. The perf breakdown (descriptor stub 3.4% to 2.1%, dw() folded away) supports the accessor change on its own merits. It doesn't show that static TLS beats main's gd. Windows is affected only by the accessor attributes and the heap table. Both look right for clang/llvm-mingw, but they're untested in the game there, which the description already says.

What I couldn't check: I built and ran nothing. The real PT_TLS size of longshot_vk.so is unverified (no built .so here), and so is how third-party libraries' threads behave with the bigger static TLS.

Thanks: the idea holds up. Moving `sampler_for`'s 128 KB table off the TLS segment is what makes static placement possible at all. The cold, out-of-line `tls_make` is a clean fix whatever TLS model is used. And the restart is careful: it keeps the PID, uses the real path so the name stays `longshot`, appends to an existing `GLIBC_TUNABLES` and puts it back if the exec fails. One thing worth knowing: TLS descriptors fail gracefully. When glibc has no room, a TLSDESC module goes to the dynamic path. It doesn't fail `dlopen` with "cannot allocate memory in static TLS block" (that error is for initial-exec TLS, which this PR doesn't add). So the risk is silent slowness, not a game that won't start. The points below follow from that. ## Must change 1. **The fallback is slower than main, nothing says which path you got, and the planned A/B leaves main out.** With `-mtls-dialect=gnu2` always on, the module's TLS can miss the static block. That happens with `HOS_STATIC_TLS=0`, with a `GLIBC_TUNABLES` that already names a smaller `optional_static_tls`, when the exec fails, or once the TLS segment grows past the room. Each access then goes through `_dl_tlsdesc_dynamic`. That is the path hckrman101 measured 8 to 14% slower than the default model. Nothing is logged in any of these cases. Please: - log once at back-end start whether its TLS landed in the static block, for example from `dl_iterate_phdr`'s `dlpi_tls_data` or the distance of a `thread_local` from the thread pointer; - log why the restart was skipped (the switch, a tunable already set, a failed exec). Also, the A/B in the description is `HOS_STATIC_TLS=0` against the default on the same build. That compares gnu2-dynamic with gnu2-static and leaves out main's `gd` build, which is the real baseline. The measurement owed is this branch against main (or against an `RX_VK_TLS=gd` build) at named commits, with several interleaved runs on the Deck and on a Linux desktop. 2. **Nothing checks the TLS size against the room, at build time or at run time.** "~48 KB" against a 96 KB `HOS_STATIC_TLS_ROOM` is stated, not measured in the PR, and nothing stops it from growing. One new `thread_local` array, like the 128 KB table this PR moves, would push the module back to the slow path with no warning. `test_static_tls.py` only checks that `sampler_for`'s table isn't a TLS array. Please have `nx2/build.py` read `PT_TLS` (`p_memsz` and alignment) from the linked `longshot_vk.so`, print it, and fail or warn loudly when it doesn't fit `HOS_STATIC_TLS_ROOM` with some margin. Then put the measured size in the description. 3. **`HOS_STATIC_TLS=0` in `longshot_env.txt` does nothing, and only the exact value `0` turns it off.** `static_tls_room` runs before `load_longshot_env()`, so the switch works only from the real environment. That isn't how players set switches. It also compares with `strcmp(off, "0")` rather than the project's one rule (`env_value_off`: `false`, `off`, `no`, empty). If the switch has to be read before the file, say so where the switch is documented. Use `env_value_off` either way. 4. **"Rebased on current `main`" is ticked but false.** `main@origin` has moved on: 08771847 (#78) and 1ef93e79 (#54, which changes `host/hos_main.c` in `memory_notice_text`). There's no textual conflict, but rebase or untick the box. ## Should change - **`GLIBC_TUNABLES` leaks to child processes.** After the restart, `GLIBC_TUNABLES` and `HOS_STATIC_TLS_DONE` stay in `environ`. So anything the game spawns (`share_spawn`'s `xdg-open`, and a browser it may start) also gets a 96 KB optional static TLS reserve on every thread. glibc reads tunables only at process start. You could restore the old `GLIBC_TUNABLES` in `environ` once the restarted process is up. `hos_relaunch` reads `/proc/self/environ`, so it still sees the start-up value and a Restart now keeps the tunable. - **The reserve adds to every thread.** The static TLS size counts toward each thread's stack mapping, and the back end's ~48 KB is copied and zeroed into every thread, including driver, audio and SDL threads that never touch the renderer. Our own threads are all at least 256 KB, so they're fine. But a third-party thread created with a small explicit stack (less than about 100 KB plus the guard) would now fail `pthread_create` with `EINVAL`. Worth a line in Risk, and a look at the thread count and RSS on the Deck run. - **`sampler_for`'s `static thread_local std::unique_ptr<Table>`** now costs a guard check and a `__cxa_thread_atexit` registration, plus a second TLS access on each call where there used to be one. It also goes against the file's TLS_OBJECT convention, which avoids exactly this. Consider a namespace-scope `TLS_OBJECT(sampler_table, SamplerTable)`; the thread exit then frees it with the others. `tests/test_tls_objects.py` doesn't catch function-local ones, by design. - **Gate `-mtls-dialect=gnu2` on x86-64.** The flag is x86-64 only (clang rejects it on aarch64). Linux builds are x86-64 today, and `deps.sh` pins LLVM 23, which accepts it. Still, a `platform.machine()` check costs nothing and leaves a clear error if that ever changes. - **`tests/display_startup_failure_test.cpp`:** the stub's line now ends with `driver_cache_folder`'s old comment (`// (host/hos_main.c: the folder __GL_SHADER_DISK_CACHE_PATH names, made)`), and `driver_cache_folder` has lost it. - **`docs/development.md`** still needs `HOS_STATIC_TLS` and `RX_VK_TLS` (the box is unticked, which is correct). ## Evidence There are no Deck numbers for this branch alone. 15.1 against 13.6 fps comes from a combined build, one run each, against a Hookshot-based build. The perf breakdown (descriptor stub 3.4% to 2.1%, `dw()` folded away) supports the accessor change on its own merits. It doesn't show that static TLS beats main's `gd`. Windows is affected only by the accessor attributes and the heap table. Both look right for clang/llvm-mingw, but they're untested in the game there, which the description already says. What I couldn't check: I built and ran nothing. The real `PT_TLS` size of `longshot_vk.so` is unverified (no built `.so` here), and so is how third-party libraries' threads behave with the bigger static TLS.
@ -2593,0 +2595,4 @@
// (longshot) on the heap, a thread's own: 128 KB of zeros in the module's TLS segment made it too big for the static TLS room
// the game reserves (host/hos_main.c static_tls_room), and every thread_local of the back end went through __tls_get_addr
struct Table { Entry e[2][4096]{}; }; // (gen 0: never filled; the generation starts at 1)
static thread_local std::unique_ptr<Table> table;
Owner

A function-local thread_local with a destructor: each call now checks a guard and does a second TLS access, and the first one registers with __cxa_thread_atexit. The file's TLS_OBJECT convention exists to avoid exactly this. Consider a namespace-scope TLS_OBJECT(sampler_table, SamplerTable).

A function-local thread_local with a destructor: each call now checks a guard and does a second TLS access, and the first one registers with `__cxa_thread_atexit`. The file's TLS_OBJECT convention exists to avoid exactly this. Consider a namespace-scope `TLS_OBJECT(sampler_table, SamplerTable)`.
host/hos_main.c Outdated
@ -2056,0 +2060,4 @@
module's ~48 KB in the static block, where its TLS descriptors (nx2/build.py, -mtls-dialect=gnu2) resolve to a constant offset.
So the game starts itself again once, first of all, with the tunable added to GLIBC_TUNABLES. Kept as it is: HOS_STATIC_TLS=0, a
GLIBC_TUNABLES that names the tunable already, or an exec that failed (the game goes on without it) */
#define HOS_STATIC_TLS_ROOM 98304
Owner

Nothing ties this to the module's real TLS size. Could nx2/build.py read PT_TLS p_memsz and alignment from the linked longshot_vk.so, print it, and warn or fail when it doesn't fit here with a margin? Otherwise one new thread_local array quietly undoes this PR.

Nothing ties this to the module's real TLS size. Could `nx2/build.py` read `PT_TLS` p_memsz and alignment from the linked longshot_vk.so, print it, and warn or fail when it doesn't fit here with a margin? Otherwise one new `thread_local` array quietly undoes this PR.
host/hos_main.c Outdated
@ -2056,0 +2062,4 @@
GLIBC_TUNABLES that names the tunable already, or an exec that failed (the game goes on without it) */
#define HOS_STATIC_TLS_ROOM 98304
static void static_tls_room(char **argv) {
const char *off = getenv("HOS_STATIC_TLS");
Owner

This runs before load_longshot_env(), so HOS_STATIC_TLS=0 in longshot_env.txt has no effect. Also, strcmp(off, "0") misses off, false, no and empty; please use env_value_off (gpu/vk/compat/longshot_env.h), the switches' one rule.

This runs before `load_longshot_env()`, so `HOS_STATIC_TLS=0` in longshot_env.txt has no effect. Also, `strcmp(off, "0")` misses `off`, `false`, `no` and empty; please use `env_value_off` (gpu/vk/compat/longshot_env.h), the switches' one rule.
host/hos_main.c Outdated
@ -2056,0 +2065,4 @@
const char *off = getenv("HOS_STATIC_TLS");
if ((off && !strcmp(off, "0")) || env_on("HOS_STATIC_TLS_DONE")) return;
const char *was = getenv("GLIBC_TUNABLES");
if (was && strstr(was, "glibc.rtld.optional_static_tls")) return;
Owner

A tunable that's already set (a launcher's or a user's smaller optional_static_tls) is kept silently. With gnu2 always built in, the back end then runs on the dynamic descriptor path, which hckrman101 measured slower than main's default model. Please log this, and log once at back-end start whether the module's TLS actually landed in the static block (for example dl_iterate_phdr's dlpi_tls_data against the thread pointer).

A tunable that's already set (a launcher's or a user's smaller `optional_static_tls`) is kept silently. With gnu2 always built in, the back end then runs on the dynamic descriptor path, which hckrman101 measured slower than main's default model. Please log this, and log once at back-end start whether the module's TLS actually landed in the static block (for example `dl_iterate_phdr`'s `dlpi_tls_data` against the thread pointer).
host/hos_main.c Outdated
@ -2056,0 +2079,4 @@
const ssize_t len = readlink("/proc/self/exe", self, sizeof self - 1);
const char *path = "/proc/self/exe";
if (len > 0) self[len] = 0, path = self;
execv(path, argv);
Owner

After a successful restart, GLIBC_TUNABLES (and HOS_STATIC_TLS_DONE) stay in environ and are inherited by xdg-open and whatever it starts. glibc reads tunables only at start-up, so the restarted process could put the old value back. hos_relaunch reads /proc/self/environ, so Restart now would still keep the tunable.

After a successful restart, `GLIBC_TUNABLES` (and `HOS_STATIC_TLS_DONE`) stay in `environ` and are inherited by `xdg-open` and whatever it starts. glibc reads tunables only at start-up, so the restarted process could put the old value back. `hos_relaunch` reads /proc/self/environ, so Restart now would still keep the tunable.
nx2/build.py Outdated
@ -1184,0 +1184,4 @@
# was a call of __tls_get_addr: 2.4% of the Steam Deck's render thread, with the accessors around it more. A descriptor of a
# module whose TLS the loader placed in the static block (host/hos_main.c static_tls_room) is a constant offset, as the
# executable's own are. RX_VK_TLS=gd: the default model, as before)
vk_tls = ["-mtls-dialect=gnu2"] if LINUX and os.environ.get("RX_VK_TLS", "gnu2") != "gd" else []
Owner

-mtls-dialect=gnu2 is x86-64 only (clang errors on aarch64). Builds are x86-64 today, but gating this on platform.machine() costs nothing.

`-mtls-dialect=gnu2` is x86-64 only (clang errors on aarch64). Builds are x86-64 today, but gating this on `platform.machine()` costs nothing.
@ -102,2 +102,3 @@
static void driver_cache_room() {} // (host/hos_main.c: the drivers' shader caches' size, #2033)
static void driver_cache_folder() {} // (host/hos_main.c: the folder __GL_SHADER_DISK_CACHE_PATH names, made)
static void driver_cache_folder() {}
static void static_tls_room(char**) {} // (host/hos_main.c: the static TLS restart, first in main) // (host/hos_main.c: the folder __GL_SHADER_DISK_CACHE_PATH names, made)
Owner

driver_cache_folder's comment ended up at the end of this line; it belongs on the line above.

`driver_cache_folder`'s comment ended up at the end of this line; it belongs on the line above.
dukenheimer force-pushed opus/static-tls from 9909b9ef47
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 17s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 1b4cca2e9c
Some checks failed
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 25s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-13 17:17:45 +00:00
Compare
dukenheimer force-pushed opus/static-tls from 1b4cca2e9c
Some checks failed
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 25s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to fc96d3ed56
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-13 17:58:30 +00:00
Compare
Author
First-time contributor

Thanks for the review, and for the point that TLSDESC falls back silently rather than failing: that shaped the changes. Point by point, at fc96d3e (rebased on main fbd4202):

Must change

  1. The fallback, and what's logged:
    • At the renderer's start the back end logs vk: TLS: <n> bytes, in the static TLS block (<d> below the thread pointer), or ... NOT in the static TLS block: every thread_local access ... goes through glibc's dynamic path (tls_place in gpu/vk/vk_main.cpp). It takes a thread_local's distance below the thread pointer (%fs:0) and the module's PT_TLS from dl_iterate_phdr.
    • Once the log is open, host/hos_main.c logs a static TLS: line. It says either that the game started again with the room, or why it didn't: the switch (with its value), a GLIBC_TUNABLES that sets the tunable already (with its value), a failed execv (with path and strerror), or a GLIBC_TUNABLES too long to add to.
    • tests/test_static_tls.py cuts tls_place into a module with a 48 KB TLS segment, built with -mtls-dialect=gnu2, and dlopens it. It reads "dynamic" without the room and "static" with it, on glibc 2.35 (the suite's container) and 2.39.
    • The A/B: the description now names the owed measurement as this head against main, or against RX_VK_TLS=gd on the same code, with several interleaved runs on the Deck and a Linux desktop. HOS_STATIC_TLS=0 against default is no longer offered as the baseline.
  2. Size against the room: nx2/elf_tls.py reads PT_TLS (p_memsz, p_align) from the linked longshot_vk.so. nx2/build.py prints it after the link and fails the build when the segment, rounded to its alignment, plus 8 KB kept for the drivers' own initial-exec TLS, doesn't fit HOS_STATIC_TLS_ROOM. The room is read from host/hos_main.c, so there is one number. The error names the fixes: a TLS_OBJECT, a bigger room, or RX_VK_TLS=gd. Measured on this head in the Linux build container: longshot_vk.so: TLS segment 47936 bytes (aligned to 16): fits the 98304 bytes of static TLS room (42176 to spare, 8192 kept for drivers'), matching readelf -lW (MemSiz 0x00bb40). The test checks the reader on ELF files it makes (one that fits, one past the margin, one pushed out by its alignment, none, not ELF) and checks that the link calls it.
  3. HOS_STATIC_TLS in longshot_env.txt, and the rule: it's read with env_value_off (0, false, off, no, empty, any case). When the environment doesn't set it, env_file_peek reads its last line from longshot_env.txt (or HOS_ENV_FILE's file) before load_longshot_env, with the same BOM and whitespace handling, and the environment first. docs/development.md says it's read before the rest. Tested from the environment, from the file (BOM, spaces, later lines winning, similar names not matching), from HOS_ENV_FILE, and with the environment over the file.
  4. Rebased: on current main fbd4202 (after #54, #78 and the 8 since), with no conflicts.

Should change

  • GLIBC_TUNABLES leaking to children: before the exec the old value goes into HOS_STATIC_TLS_WAS. The restarted game puts GLIBC_TUNABLES back (or unsets it) and drops both markers, so xdg-open and what it starts don't get the room. hos_relaunch still reads the start's /proc/self/environ, so Restart now keeps the tunable and the restarted game restores it again. A failed exec now clears the markers too. Tested with and without an earlier GLIBC_TUNABLES.
  • The reserve on every thread: this is under Risk (small explicit stacks under ~100 KB plus the guard would get EINVAL; the 46.8 KB copied and zeroed into every thread), and the Deck's thread count and RSS against main are among the measurements owed.
  • sampler_for's table: it's now a namespace-scope TLS_OBJECT(sampler_table, SamplerTable), freed at thread exit with the others, and sampler_for reads sampler_table().e[...]. tests/test_sampler_admission.py builds sampler_for with it.
  • gnu2 on x86-64 only: vk_tls_flags() in nx2/build.py gives -mtls-dialect=gnu2 only on Linux x86-64 without RX_VK_TLS=gd. Another machine gets a line saying so and the default model. The test runs it for x86_64, AMD64, aarch64, gd and Windows.
  • display_startup_failure_test.cpp: both comments are back on their own lines.
  • docs/development.md: HOS_STATIC_TLS is with the switches and RX_VK_TLS is in the Building table.

Tests (python -I -B tests/run_all.py): on Linux, fc96d3e and main @ fbd4202 fail the same 4 (test_cpp_preflight, test_dev_packs, test_prof_clock, the byte-compile without .git); on Windows, 453 passed and the same 5 failed as on main (test_fpx_sticky, test_bake_portability, test_check_third_party, test_spawn_emit, test_release). test_shader_stamp_cache runs a slice of nx2/build.py, so it needed vk_tls_flags in its namespace (fc96d3e).

Not done: no run in the game. This version hasn't run on any machine, and the measurement is owed as above, so it stays WIP.

Thanks for the review, and for the point that TLSDESC falls back silently rather than failing: that shaped the changes. Point by point, at fc96d3e (rebased on main fbd4202): **Must change** 1. **The fallback, and what's logged:** - At the renderer's start the back end logs `vk: TLS: <n> bytes, in the static TLS block (<d> below the thread pointer)`, or `... NOT in the static TLS block: every thread_local access ... goes through glibc's dynamic path` (`tls_place` in `gpu/vk/vk_main.cpp`). It takes a thread_local's distance below the thread pointer (`%fs:0`) and the module's `PT_TLS` from `dl_iterate_phdr`. - Once the log is open, `host/hos_main.c` logs a `static TLS:` line. It says either that the game started again with the room, or why it didn't: the switch (with its value), a `GLIBC_TUNABLES` that sets the tunable already (with its value), a failed `execv` (with path and `strerror`), or a `GLIBC_TUNABLES` too long to add to. - `tests/test_static_tls.py` cuts `tls_place` into a module with a 48 KB TLS segment, built with `-mtls-dialect=gnu2`, and dlopens it. It reads "dynamic" without the room and "static" with it, on glibc 2.35 (the suite's container) and 2.39. - **The A/B:** the description now names the owed measurement as this head against main, or against `RX_VK_TLS=gd` on the same code, with several interleaved runs on the Deck and a Linux desktop. `HOS_STATIC_TLS=0` against default is no longer offered as the baseline. 2. **Size against the room:** `nx2/elf_tls.py` reads `PT_TLS` (`p_memsz`, `p_align`) from the linked `longshot_vk.so`. `nx2/build.py` prints it after the link and **fails the build** when the segment, rounded to its alignment, plus 8 KB kept for the drivers' own initial-exec TLS, doesn't fit `HOS_STATIC_TLS_ROOM`. The room is read from `host/hos_main.c`, so there is one number. The error names the fixes: a `TLS_OBJECT`, a bigger room, or `RX_VK_TLS=gd`. Measured on this head in the Linux build container: `longshot_vk.so: TLS segment 47936 bytes (aligned to 16): fits the 98304 bytes of static TLS room (42176 to spare, 8192 kept for drivers')`, matching `readelf -lW` (`MemSiz 0x00bb40`). The test checks the reader on ELF files it makes (one that fits, one past the margin, one pushed out by its alignment, none, not ELF) and checks that the link calls it. 3. **`HOS_STATIC_TLS` in `longshot_env.txt`, and the rule:** it's read with `env_value_off` (`0`, `false`, `off`, `no`, empty, any case). When the environment doesn't set it, `env_file_peek` reads its last line from `longshot_env.txt` (or `HOS_ENV_FILE`'s file) before `load_longshot_env`, with the same BOM and whitespace handling, and the environment first. `docs/development.md` says it's read before the rest. Tested from the environment, from the file (BOM, spaces, later lines winning, similar names not matching), from `HOS_ENV_FILE`, and with the environment over the file. 4. **Rebased:** on current main fbd4202 (after #54, #78 and the 8 since), with no conflicts. **Should change** - **`GLIBC_TUNABLES` leaking to children:** before the exec the old value goes into `HOS_STATIC_TLS_WAS`. The restarted game puts `GLIBC_TUNABLES` back (or unsets it) and drops both markers, so `xdg-open` and what it starts don't get the room. `hos_relaunch` still reads the start's `/proc/self/environ`, so Restart now keeps the tunable and the restarted game restores it again. A failed exec now clears the markers too. Tested with and without an earlier `GLIBC_TUNABLES`. - **The reserve on every thread:** this is under Risk (small explicit stacks under ~100 KB plus the guard would get `EINVAL`; the 46.8 KB copied and zeroed into every thread), and the Deck's thread count and RSS against main are among the measurements owed. - **`sampler_for`'s table:** it's now a namespace-scope `TLS_OBJECT(sampler_table, SamplerTable)`, freed at thread exit with the others, and `sampler_for` reads `sampler_table().e[...]`. `tests/test_sampler_admission.py` builds `sampler_for` with it. - **gnu2 on x86-64 only:** `vk_tls_flags()` in `nx2/build.py` gives `-mtls-dialect=gnu2` only on Linux x86-64 without `RX_VK_TLS=gd`. Another machine gets a line saying so and the default model. The test runs it for x86_64, AMD64, aarch64, `gd` and Windows. - **`display_startup_failure_test.cpp`:** both comments are back on their own lines. - **`docs/development.md`:** `HOS_STATIC_TLS` is with the switches and `RX_VK_TLS` is in the Building table. **Tests** (`python -I -B tests/run_all.py`): on Linux, fc96d3e and main @ fbd4202 fail the same 4 (`test_cpp_preflight`, `test_dev_packs`, `test_prof_clock`, the byte-compile without `.git`); on Windows, 453 passed and the same 5 failed as on main (`test_fpx_sticky`, `test_bake_portability`, `test_check_third_party`, `test_spawn_emit`, `test_release`). `test_shader_stamp_cache` runs a slice of `nx2/build.py`, so it needed `vk_tls_flags` in its namespace (fc96d3e). Not done: no run in the game. This version hasn't run on any machine, and the measurement is owed as above, so it stays WIP.
Author
First-time contributor

Steam Deck: works as designed, no measurable change in fps.

Setup:

  • Hardware and OS: Steam Deck OLED, SteamOS 3.9.5 (kernel 7.2.7-valve2), RADV Mesa 26.2.0-devel.

  • Base: main d964886 + the Steam Deck profile (#225), because plain main runs out of memory on the Deck. This PR is merged on that base.

  • Settings: the user's options, stock clocks, the moving Kokiri Forest route, warm caches, memory guard on.

  • Order: one warm-up start each, then base, #231, #232, #156, then #156, #232, #231, base, interleaved.

  • On this Deck and route one run spreads about ±1 real fps.

  • TLS lines:

    • static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data
    • vk: TLS: 48000 bytes, in the static TLS block (89554 below the thread pointer): each access a constant offset
  • Threads: 127 at most in every run, with and without the PR (/proc//status, sampled every 5 s).

real fps shown fps p95 hitches RSS peak
base, run 1 15.8 30.2 79 ms 74 8074 MB
base, run 2 14.7 27.6 84 ms 72 8365 MB
#232, warm-up 15.0 27.4 89 ms 89 8291 MB
#232, run 1 15.6 29.7 65 ms 48 8341 MB
#232, run 2 15.1 27.7 82 ms 69 8403 MB

The warm-up kept every program key (47797, stamp unchanged). The Deck's frame is bound by the readback and GPU waits, so a cheaper TLS access doesn't show as fps here.

**Steam Deck: works as designed, no measurable change in fps.** **Setup:** - Hardware and OS: Steam Deck OLED, SteamOS 3.9.5 (kernel 7.2.7-valve2), RADV Mesa 26.2.0-devel. - Base: main d964886 + the Steam Deck profile (#225), because plain main runs out of memory on the Deck. This PR is merged on that base. - Settings: the user's options, stock clocks, the moving Kokiri Forest route, warm caches, memory guard on. - Order: one warm-up start each, then base, #231, #232, #156, then #156, #232, #231, base, interleaved. - On this Deck and route one run spreads about ±1 real fps. - **TLS lines:** - `static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data` - `vk: TLS: 48000 bytes, in the static TLS block (89554 below the thread pointer): each access a constant offset` - **Threads:** 127 at most in every run, with and without the PR (/proc/<pid>/status, sampled every 5 s). | | real fps | shown fps | p95 | hitches | RSS peak | |---|---|---|---|---|---| | base, run 1 | 15.8 | 30.2 | 79 ms | 74 | 8074 MB | | base, run 2 | 14.7 | 27.6 | 84 ms | 72 | 8365 MB | | **#232**, warm-up | 15.0 | 27.4 | 89 ms | 89 | 8291 MB | | **#232**, run 1 | 15.6 | 29.7 | 65 ms | 48 | 8341 MB | | **#232**, run 2 | 15.1 | 27.7 | 82 ms | 69 | 8403 MB | The warm-up kept every program key (47797, stamp unchanged). The Deck's frame is bound by the readback and GPU waits, so a cheaper TLS access doesn't show as fps here.
dukenheimer force-pushed opus/static-tls from fc96d3ed56
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 3ccb48a8f0
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 10s
Website / Website on Cloudflare (pull_request_target) Has been skipped
2026-11-14 02:00:19 +00:00
Compare
Author
First-time contributor

Steam Deck, this PR alone: it works as designed, and gains no fps. These are the Deck agent's runs (comment above) on a Steam Deck OLED, main d964886 + #225, 2 interleaved pairs:

  • Real fps: 15.6 / 15.1 against the base's 15.8 / 14.7.
  • Mechanism: the log confirms the restart and vk: TLS: 48000 bytes, in the static TLS block.
  • Cost: threads (127) and RSS are as on main.

Recommendation: not worth merging as it is; I suggest closing it.

  • The restart at every Linux start, the GLIBC_TUNABLES handling, the TLS descriptors and the link-time check are machinery for a gain nobody has measured. The Deck's frame is set by readback and GPU waits, not by TLS accesses.
  • The one part with a measured effect and no start-up behaviour is the accessor change: TLS_OBJECT always inlined and tls_make cold. In a Deck profile, dw()'s 5.1% folded away and the frame's instructions fell 5%. If maintainers want it, it can go as a small PR of its own, on every platform and with the default TLS model.

Also in this update:

  • Rebased on main 5a8ae7e (head 3ccb48a). The only conflict was in docs/development.md, where #238's new paragraph sits; I kept both.
  • Suites fail exactly main's set (Linux 5, Windows 4).
  • docs/tested-configurations.md has the Deck runs.
**Steam Deck, this PR alone: it works as designed, and gains no fps.** These are the Deck agent's runs (comment above) on a Steam Deck OLED, main d964886 + #225, 2 interleaved pairs: - **Real fps:** 15.6 / 15.1 against the base's 15.8 / 14.7. - **Mechanism:** the log confirms the restart and `vk: TLS: 48000 bytes, in the static TLS block`. - **Cost:** threads (127) and RSS are as on main. **Recommendation: not worth merging as it is; I suggest closing it.** - The restart at every Linux start, the `GLIBC_TUNABLES` handling, the TLS descriptors and the link-time check are machinery for a gain nobody has measured. The Deck's frame is set by readback and GPU waits, not by TLS accesses. - The one part with a measured effect and no start-up behaviour is the accessor change: `TLS_OBJECT` always inlined and `tls_make` cold. In a Deck profile, `dw()`'s 5.1% folded away and the frame's instructions fell 5%. If maintainers want it, it can go as a small PR of its own, on every platform and with the default TLS model. Also in this update: - Rebased on main 5a8ae7e (head 3ccb48a). The only conflict was in docs/development.md, where #238's new paragraph sits; I kept both. - Suites fail exactly main's set (Linux 5, Windows 4). - `docs/tested-configurations.md` has the Deck runs.
Contributor

Measured on a 6-core desktop, Linux: a small gain in the Market, at the edge of noise

Both runs of this PR have a lower replay cost per draw than all four runs of its base and of #231 alongside it: about 1% off the Presentation thread's replay, +0.5% fps. The frame there is not the replay alone (23.8 ms against 18.5 ms of replay and 12.8 ms of GPU), so the gain in fps is smaller than the replay's.

main: 5a8ae7e3 (this PR's merge base), built as longshot abc-base
this PR: 3ccb48a8, built as longshot abc-232
Baseline: main @ 5a8ae7e3
Hardware: RX 9070 XT 16 GB, Mesa RADV 26.2.6 (GFX1201); Ryzen 5 5600X (6 cores, 12 threads), 31 GB; Fedora 45, kernel 7.3.3, KDE Plasma (Wayland)
Settings: output 2560x1440 in a 1280x720 window (mailbox), AMD FSR 4 Native AA (fsr4-vk, 4.1.1 FP8), HDR Expanded, sharpness 50%, AF 16x, VRAM budget 11 GB, frame generation off; HOS_FPS_MAX=240 (uncapped), HOS_PRESENT_TRACE on for every run
Scene: the Market (child) from one save, the same time of day each run, standing still: no input at all
Method: one full build per side from the Linux container (scripts/linux/docker.sh, then the AppImage's --cli --fsr), each in its own folder; a warm-up start of each; then six runs through the control bridge (scripts/longshot_ctl.py launch --save --slot, A until in play, the Market loaded and no event, 10 s, then 120 s measured, quit), interleaved base, #231, this PR, this PR, #231, base. Medians of the two runs a side.
How to reproduce: a child Market save; longshot_ctl.py launch <game> --save <save folder> --slot <NN> --env HOS_FPS_MAX=240 --env HOS_PRESENT_TRACE=<csv>; frame times from the trace between two status frames 120 s apart

main @ 5a8ae7e3 (run 1 / run 6) this PR @ 3ccb48a8 (run 3 / run 4) Δ (medians)
fps, average 41.6 / 41.7 41.7 / 42.0 +0.5%
fps, 1% low 32.1 / 32.2 32.0 / 32.8 +0.8%
frame time, median 23.92 / 23.84 ms 23.85 / 23.72 ms -0.10 ms
frame time, p99 29.48 / 29.54 ms 29.32 / 29.19 ms -0.26 ms
replay submit a frame (Presentation thread) 18.68 / 18.72 ms 18.45 / 18.57 ms -0.19 ms (-1.0%)
replay submit per 1000 draws 7.10 / 7.10 ms 6.97 / 7.01 ms -0.11 ms (-1.5%)
draws a frame 2626 / 2637 2634 / 2633 same scene
GPU time a frame 12.8 / 12.8 ms 12.8 / 12.7 ms none

#231 in the same session (runs 2 and 5): 41.6 / 41.8 fps, 7.08 / 7.06 ms per 1000 draws, within this PC's run-to-run spread of the base.

The change took effect:

[hos host] static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data ("vk: TLS" says where it landed)
[hos host] vk: TLS: 48520 bytes, in the static TLS block (97586 below the thread pointer): each access a constant offset

A status line from each side, in the measured window:

main @ 5a8ae7e3 (run 1)
[hos Presentation Thread] vk: 4920 presents, 41.6 fps; a frame: 2642 draws (0 skipped), 121 dispatches, 175 passes, 161 barriers, 6 submits, 0.0 uploads (0.0 MB), 82 ub updates (57 overlays), GPU-bound wait 0.1 ms
[hos Presentation Thread] vk: a frame's replay time (ms): submit 18.42, present 0.07, draw 10.43, descriptors 2.76, texture lookups 0.11, uploads 0.00, pipelines 0.46, programs 1.35, commands 4.90, dispatch 1.13, memory sync 2.37, queue submit 1.55
[hos Presentation Thread] vk: GPU time 12.9 ms a frame (the longest submission 3.3 ms); 0.6 textures made a frame

this PR @ 3ccb48a8 (run 3)
[hos Presentation Thread] vk: 4800 presents, 41.9 fps; a frame: 2620 draws (0 skipped), 121 dispatches, 175 passes, 161 barriers, 6 submits, 0.0 uploads (0.0 MB), 82 ub updates (57 overlays), GPU-bound wait 0.1 ms
[hos Presentation Thread] vk: a frame's replay time (ms): submit 18.45, present 0.07, draw 10.26, descriptors 2.64, texture lookups 0.11, uploads 0.00, pipelines 0.44, programs 1.36, commands 4.80, dispatch 1.12, memory sync 2.52, queue submit 1.57
[hos Presentation Thread] vk: GPU time 12.9 ms a frame (the longest submission 3.3 ms); 0.4 textures made a frame

Also checked: python -I -B tests/run_all.py on this PR: 469 passed, 2 failed, 74 skipped. The two (tests/test_menu_keys.py, tests/test_pad_layout.py) fail on main @ 5a8ae7e3 on this PC too, with tests/test_display_pointer.py once more there. No device lost in any run.

## Measured on a 6-core desktop, Linux: a small gain in the Market, at the edge of noise Both runs of this PR have a lower replay cost per draw than all four runs of its base and of #231 alongside it: about 1% off the Presentation thread's replay, +0.5% fps. The frame there is not the replay alone (23.8 ms against 18.5 ms of replay and 12.8 ms of GPU), so the gain in fps is smaller than the replay's. main: 5a8ae7e3 (this PR's merge base), built as `longshot abc-base` this PR: 3ccb48a8, built as `longshot abc-232` Baseline: main @ 5a8ae7e3 Hardware: RX 9070 XT 16 GB, Mesa RADV 26.2.6 (GFX1201); Ryzen 5 5600X (6 cores, 12 threads), 31 GB; Fedora 45, kernel 7.3.3, KDE Plasma (Wayland) Settings: output 2560x1440 in a 1280x720 window (mailbox), AMD FSR 4 Native AA (fsr4-vk, 4.1.1 FP8), HDR Expanded, sharpness 50%, AF 16x, VRAM budget 11 GB, frame generation off; `HOS_FPS_MAX=240` (uncapped), `HOS_PRESENT_TRACE` on for every run Scene: the Market (child) from one save, the same time of day each run, standing still: no input at all Method: one full build per side from the Linux container (`scripts/linux/docker.sh`, then the AppImage's `--cli --fsr`), each in its own folder; a warm-up start of each; then six runs through the control bridge (`scripts/longshot_ctl.py launch --save --slot`, A until in play, the Market loaded and no event, 10 s, then 120 s measured, `quit`), interleaved base, #231, this PR, this PR, #231, base. Medians of the two runs a side. How to reproduce: a child Market save; `longshot_ctl.py launch <game> --save <save folder> --slot <NN> --env HOS_FPS_MAX=240 --env HOS_PRESENT_TRACE=<csv>`; frame times from the trace between two `status` frames 120 s apart | | main @ 5a8ae7e3 (run 1 / run 6) | this PR @ 3ccb48a8 (run 3 / run 4) | Δ (medians) | |---|---|---|---| | fps, average | 41.6 / 41.7 | 41.7 / 42.0 | +0.5% | | fps, 1% low | 32.1 / 32.2 | 32.0 / 32.8 | +0.8% | | frame time, median | 23.92 / 23.84 ms | 23.85 / 23.72 ms | -0.10 ms | | frame time, p99 | 29.48 / 29.54 ms | 29.32 / 29.19 ms | -0.26 ms | | replay submit a frame (Presentation thread) | 18.68 / 18.72 ms | 18.45 / 18.57 ms | -0.19 ms (-1.0%) | | replay submit per 1000 draws | 7.10 / 7.10 ms | 6.97 / 7.01 ms | -0.11 ms (-1.5%) | | draws a frame | 2626 / 2637 | 2634 / 2633 | same scene | | GPU time a frame | 12.8 / 12.8 ms | 12.8 / 12.7 ms | none | #231 in the same session (runs 2 and 5): 41.6 / 41.8 fps, 7.08 / 7.06 ms per 1000 draws, within this PC's run-to-run spread of the base. The change took effect: ``` [hos host] static TLS: started again with glibc.rtld.optional_static_tls=98304 for the Vulkan back end's thread-local data ("vk: TLS" says where it landed) [hos host] vk: TLS: 48520 bytes, in the static TLS block (97586 below the thread pointer): each access a constant offset ``` A status line from each side, in the measured window: ``` main @ 5a8ae7e3 (run 1) [hos Presentation Thread] vk: 4920 presents, 41.6 fps; a frame: 2642 draws (0 skipped), 121 dispatches, 175 passes, 161 barriers, 6 submits, 0.0 uploads (0.0 MB), 82 ub updates (57 overlays), GPU-bound wait 0.1 ms [hos Presentation Thread] vk: a frame's replay time (ms): submit 18.42, present 0.07, draw 10.43, descriptors 2.76, texture lookups 0.11, uploads 0.00, pipelines 0.46, programs 1.35, commands 4.90, dispatch 1.13, memory sync 2.37, queue submit 1.55 [hos Presentation Thread] vk: GPU time 12.9 ms a frame (the longest submission 3.3 ms); 0.6 textures made a frame this PR @ 3ccb48a8 (run 3) [hos Presentation Thread] vk: 4800 presents, 41.9 fps; a frame: 2620 draws (0 skipped), 121 dispatches, 175 passes, 161 barriers, 6 submits, 0.0 uploads (0.0 MB), 82 ub updates (57 overlays), GPU-bound wait 0.1 ms [hos Presentation Thread] vk: a frame's replay time (ms): submit 18.45, present 0.07, draw 10.26, descriptors 2.64, texture lookups 0.11, uploads 0.00, pipelines 0.44, programs 1.36, commands 4.80, dispatch 1.12, memory sync 2.52, queue submit 1.57 [hos Presentation Thread] vk: GPU time 12.9 ms a frame (the longest submission 3.3 ms); 0.4 textures made a frame ``` Also checked: `python -I -B tests/run_all.py` on this PR: 469 passed, 2 failed, 74 skipped. The two (`tests/test_menu_keys.py`, `tests/test_pad_layout.py`) fail on main @ 5a8ae7e3 on this PC too, with `tests/test_display_pointer.py` once more there. No device lost in any run.
mooki left a comment

Read head 3ccb48a8 against its base 5a8ae7e3 and current main cf605c85: the complete diff, description, four comments, the earlier review and its seven inline comments, and the startup, restart, build and sampler consumers. Checked the published arithmetic and the actual merge conflicts. No tests, game or renderer build were run; the description recommends closing this version.

What's good

  • The cost exists in the old code: the renderer is a shared library, and the sampler table occupies 128 KiB of thread-local storage. The accessor change is small and independent of the Linux restart.
  • The follow-up answers most of the earlier review. The TLS-size check reads the linked segment, the startup explains skipped restarts, and the switch follows the existing off-value rule.
  • The Deck result is reported as no demonstrated FPS gain, rather than turning a profile improvement into a frame-rate claim.

Must change if this version proceeds

  1. Keep a failed sampler-cache allocation recoverable. gpu/vk/vk_draw.cpp, lines 2724-2730, replaces the fixed TLS table with a first-use allocation through tls_make. If that 128 KiB allocation throws, sampler_for exits before it can look up an already-owned sampler or return a failed handle. Neither build_descriptors nor the replay worker catches that exception, so it terminates the process on the threaded replay path. Main's table did not allocate at that lookup, and sampler_make already treats failures of its other acceleration caches as recoverable. Keep that policy for this cache too, falling back to sampler_make when its storage cannot be obtained. Add an allocation-failure case using the real heap-backed accessor, including a sampler already owned globally: tests/test_sampler_admission.py, lines 38-44, currently replaces it with a fixed TLS array and cannot exercise the new failure. This is a source finding, not an observed game crash.

Earlier review, at this head

The review being followed up is #232 (comment).

Earlier point Current result
Log the fallback and compare with main, not dynamic TLSDESC alone Startup and placement logs are present. Both the Deck and the newer desktop report compare against a named baseline without this change. The gd log wording still needs the smaller correction below.
Check the real TLS segment against the reserve Implemented after the link: p_memsz, alignment, the host's reserve and an 8 KiB margin. The description supplies the build's measured size. I did not inspect that binary.
Read HOS_STATIC_TLS from the file and accept all off values Implemented before the ordinary environment-file load, with environment precedence and env_value_off.
Rebase or correct the checklist Rebased onto 5a8ae7e3, but main has moved again. There are now real conflicts in host/hos_main.c and docs/tested-configurations.md, not merely the WIP status.
Stop passing the added tunable to children The restarted process restores the previous value and removes the markers. The menu restart still takes its environment from /proc/self/environ.
Disclose the reserve's cost on every thread Added to Risks, with Deck thread counts and RSS. Those samples do not test third-party small-stack failures.
Use the existing TLS_OBJECT convention for the sampler table Done, including thread-exit ownership. The allocation-failure case above remains.
Gate gnu2 on x86-64 Done in vk_tls_flags; Windows and other host architectures keep the old flags.
Restore the fixture comments Done: each comment is on its own stub again.
Document both switches Done in docs/development.md.

Measurement and smaller points

  • There is now a desktop result at #232 (comment). Its displayed FPS medians are 41.65 and 41.85, a 0.48% difference; the two PR runs span 0.3 fps. The replay medians are 18.70 and 18.51 ms, about 1% lower. That supports the report's cautious “at the edge of noise”, not a general speed-up or an isolated benefit from static TLS. The description's “Steam Deck alone” and “a gain nobody has measured” need to acknowledge this newer evidence.
  • The Deck medians are 15.25 and 15.35 real fps, only 0.10 fps apart, against within-side ranges of 1.1 and 0.5 fps. That supports “no measurable change” on that setup. Both reports name the hardware, scene, settings, warm-up and two runs per side, but their orders bracket consecutive candidate runs rather than alternating main / PR / main / PR. Neither separates the accessor change from the TLS model. An accessor-only build on the default model would settle whether the restart buys anything beyond the smaller change already proposed.
  • gpu/vk/vk_main.cpp, lines 239-245, always describes a dynamic placement as the slower descriptor path, even in an RX_VK_TLS=gd build. Carry the selected model into the diagnostic, so the intended baseline does not warn that it is slower than itself.
  • Move the added docs/tested-configurations.md row into separate run files under docs/tested/; current main freezes the old table. Resolve the startup conflict while preserving both the new layout initialization and the requirement to reserve TLS before other startup work.

Not checked

No failure injection, GCC/Clang fixture execution, Windows check, game, full suite or performance measurement here. I checked the published tables and quoted logs, not their underlying captures. A fault-injection test of the real sampler accessor would settle the new failure path; a controlled accessor-only versus full-change comparison would settle the extra TLS machinery's value. The source-check jobs are blocked, not passing: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/3412.

Read head `3ccb48a8` against its base `5a8ae7e3` and current main `cf605c85`: the complete diff, description, four comments, the earlier review and its seven inline comments, and the startup, restart, build and sampler consumers. Checked the published arithmetic and the actual merge conflicts. No tests, game or renderer build were run; the description recommends closing this version. ### What's good - The cost exists in the old code: the renderer is a shared library, and the sampler table occupies 128 KiB of thread-local storage. The accessor change is small and independent of the Linux restart. - The follow-up answers most of the earlier review. The TLS-size check reads the linked segment, the startup explains skipped restarts, and the switch follows the existing off-value rule. - The Deck result is reported as no demonstrated FPS gain, rather than turning a profile improvement into a frame-rate claim. ### Must change if this version proceeds 1. **Keep a failed sampler-cache allocation recoverable.** `gpu/vk/vk_draw.cpp`, lines 2724-2730, replaces the fixed TLS table with a first-use allocation through `tls_make`. If that 128 KiB allocation throws, `sampler_for` exits before it can look up an already-owned sampler or return a failed handle. Neither `build_descriptors` nor the replay worker catches that exception, so it terminates the process on the threaded replay path. Main's table did not allocate at that lookup, and `sampler_make` already treats failures of its other acceleration caches as recoverable. Keep that policy for this cache too, falling back to `sampler_make` when its storage cannot be obtained. Add an allocation-failure case using the real heap-backed accessor, including a sampler already owned globally: `tests/test_sampler_admission.py`, lines 38-44, currently replaces it with a fixed TLS array and cannot exercise the new failure. This is a source finding, not an observed game crash. ### Earlier review, at this head The review being followed up is https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/232#issuecomment-13981. | Earlier point | Current result | |---|---| | Log the fallback and compare with main, not dynamic TLSDESC alone | Startup and placement logs are present. Both the Deck and the newer desktop report compare against a named baseline without this change. The `gd` log wording still needs the smaller correction below. | | Check the real TLS segment against the reserve | Implemented after the link: `p_memsz`, alignment, the host's reserve and an 8 KiB margin. The description supplies the build's measured size. I did not inspect that binary. | | Read `HOS_STATIC_TLS` from the file and accept all off values | Implemented before the ordinary environment-file load, with environment precedence and `env_value_off`. | | Rebase or correct the checklist | Rebased onto `5a8ae7e3`, but main has moved again. There are now real conflicts in `host/hos_main.c` and `docs/tested-configurations.md`, not merely the WIP status. | | Stop passing the added tunable to children | The restarted process restores the previous value and removes the markers. The menu restart still takes its environment from `/proc/self/environ`. | | Disclose the reserve's cost on every thread | Added to Risks, with Deck thread counts and RSS. Those samples do not test third-party small-stack failures. | | Use the existing `TLS_OBJECT` convention for the sampler table | Done, including thread-exit ownership. The allocation-failure case above remains. | | Gate gnu2 on x86-64 | Done in `vk_tls_flags`; Windows and other host architectures keep the old flags. | | Restore the fixture comments | Done: each comment is on its own stub again. | | Document both switches | Done in `docs/development.md`. | ### Measurement and smaller points - There is now a desktop result at https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/232#issuecomment-17274. Its displayed FPS medians are 41.65 and 41.85, a 0.48% difference; the two PR runs span 0.3 fps. The replay medians are 18.70 and 18.51 ms, about 1% lower. That supports the report's cautious “at the edge of noise”, not a general speed-up or an isolated benefit from static TLS. The description's “Steam Deck alone” and “a gain nobody has measured” need to acknowledge this newer evidence. - The Deck medians are 15.25 and 15.35 real fps, only 0.10 fps apart, against within-side ranges of 1.1 and 0.5 fps. That supports “no measurable change” on that setup. Both reports name the hardware, scene, settings, warm-up and two runs per side, but their orders bracket consecutive candidate runs rather than alternating main / PR / main / PR. Neither separates the accessor change from the TLS model. An accessor-only build on the default model would settle whether the restart buys anything beyond the smaller change already proposed. - `gpu/vk/vk_main.cpp`, lines 239-245, always describes a dynamic placement as the slower descriptor path, even in an `RX_VK_TLS=gd` build. Carry the selected model into the diagnostic, so the intended baseline does not warn that it is slower than itself. - Move the added `docs/tested-configurations.md` row into separate run files under `docs/tested/`; current main freezes the old table. Resolve the startup conflict while preserving both the new layout initialization and the requirement to reserve TLS before other startup work. ### Not checked No failure injection, GCC/Clang fixture execution, Windows check, game, full suite or performance measurement here. I checked the published tables and quoted logs, not their underlying captures. A fault-injection test of the real sampler accessor would settle the new failure path; a controlled accessor-only versus full-change comparison would settle the extra TLS machinery's value. The source-check jobs are blocked, not passing: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/3412.
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 10s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request has changes conflicting with the target branch.
  • docs/tested-configurations.md
  • host/hos_main.c
  • nx2/build.py
  • tests/test_shader_stamp_cache.py
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'opus/static-tls:dukenheimer-opus/static-tls'
git switch 'dukenheimer-opus/static-tls'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!232
No description provided.