vk: descriptor slotgen and sampler cache #1330

Merged
bibigurl-yum merged 1 commit from pr/vk-descriptor-slotgen into main 2026-11-10 03:46:05 +00:00
bibigurl-yum commented 2026-11-09 21:54:04 +00:00 (Migrated from github.com)

What

Replay-thread optimizations for descriptor and sampler resolution, default off and switchable by menu row and environment variable (one shared code path).

1. Descriptor slotgen (RX_VK_DESC_SLOTGEN=1, menu: Graphics → Descriptor reuse)

Descriptor reuse currently keys on the global registrations' generation, so any texture registration anywhere (the game streams constantly) rebuilds every draw's descriptors. A draw now keeps its descriptors while the texture slots it actually samples are unchanged: the build records (slot, version) for every handle it resolved and the same draw revalidates by slot version instead of the global generation. Draws that sampled an unregistered slot keep the old global-generation check (a registration is the only thing that could make such a handle resolve). An eviction sweep (destroy_later) retires views behind the slots' backs without bumping any slot or sampler version, so the keep-check revalidates texture_destroy_generation() as well — this also covers the texture_view_of staleness question.

2. Sampler cache (RX_VK_SAMPLER_CACHE, menu: Graphics → Sampler cache)

Each texture of each draw resolved its sampler through reg_mu + a copy of the registration + hash + map lookup (~48k times per frame at 3000 draws). Sampler ids now answer from a per-thread table keyed on the id's slot version (the samp_versions table in vk_bank, like the texture slots' slot_versions): resolving an id that was not registered anew takes no lock. One switch, documented at the declaration: RX_VK_SAMPLER_CACHE=0 turns off both the outer id table and the inner parameters' cache; =1 or the menu row turns the inner cache on (the outer table stays on, exactly as it is without the option — so the menu row never silently changes the old default-on path).

Measurements (RTX 4090, 5120x1440, TOTK)

Same binary, environment variables off = the old path (A/B identity):

replay ms/frame peak RSS
items 1 + 2 on 4.64 ms 11.6 GB
items 1 + 2 off 11.87 ms 43.6 GB

(median "a frame's replay time" over 240 s scripted gameplay runs, ~3200 draws/frame; the replay thread is the fps ceiling — at 1000/4.64 ms it moves from ~84 to ~215 fps of headroom. Item-2-alone numbers follow in a comment.)

Response to the review

  • Eviction: texture_destroy_generation() now participates in the slotgen keep-check, so a destroy_later sweep invalidates the kept descriptors on its own.
  • seen map in sampler_make: dropped the unreachable size() > 4096 clear (ids are < 4096, the map cannot outgrow them).
  • Rebased on current main; merged with the mesh-fallback descriptor fix (#792) in draw().
## What Replay-thread optimizations for descriptor and sampler resolution, default off and switchable by menu row **and** environment variable (one shared code path). ### 1. Descriptor slotgen (`RX_VK_DESC_SLOTGEN=1`, menu: Graphics → Descriptor reuse) Descriptor reuse currently keys on the global registrations' generation, so any texture registration anywhere (the game streams constantly) rebuilds every draw's descriptors. A draw now keeps its descriptors while **the texture slots it actually samples** are unchanged: the build records `(slot, version)` for every handle it resolved and the same draw revalidates by slot version instead of the global generation. Draws that sampled an unregistered slot keep the old global-generation check (a registration is the only thing that could make such a handle resolve). An eviction sweep (`destroy_later`) retires views behind the slots' backs without bumping any slot or sampler version, so the keep-check revalidates `texture_destroy_generation()` as well — this also covers the `texture_view_of` staleness question. ### 2. Sampler cache (`RX_VK_SAMPLER_CACHE`, menu: Graphics → Sampler cache) Each texture of each draw resolved its sampler through `reg_mu` + a copy of the registration + hash + map lookup (~48k times per frame at 3000 draws). Sampler ids now answer from a per-thread table keyed on the id's slot version (the `samp_versions` table in vk_bank, like the texture slots' `slot_versions`): resolving an id that was not registered anew takes no lock. One switch, documented at the declaration: `RX_VK_SAMPLER_CACHE=0` turns off **both** the outer id table and the inner parameters' cache; `=1` or the menu row turns the inner cache **on** (the outer table stays on, exactly as it is without the option — so the menu row never silently changes the old default-on path). ## Measurements (RTX 4090, 5120x1440, TOTK) Same binary, environment variables off = the old path (A/B identity): | | replay ms/frame | peak RSS | |---|---|---| | items 1 + 2 on | **4.64 ms** | **11.6 GB** | | items 1 + 2 off | 11.87 ms | 43.6 GB | (median "a frame's replay time" over 240 s scripted gameplay runs, ~3200 draws/frame; the replay thread is the fps ceiling — at 1000/4.64 ms it moves from ~84 to ~215 fps of headroom. Item-2-alone numbers follow in a comment.) ## Response to the review - Eviction: `texture_destroy_generation()` now participates in the slotgen keep-check, so a `destroy_later` sweep invalidates the kept descriptors on its own. - `seen` map in `sampler_make`: dropped the unreachable `size() > 4096` clear (ids are < 4096, the map cannot outgrow them). - Rebased on current main; merged with the mesh-fallback descriptor fix (#792) in `draw()`.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:47:45 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the measured numbers are nice. Two things up front.

This is two unrelated PRs. The descriptor slotgen + sampler cache are a Vulkan replay-thread optimization (vk_bank/vk_draw/vk_replay/vk_state). The Frame rate row is a pacing / game-clock change (host/nn_vi.c, nx2/oot_patches.py, the menu row, tests/test_present_trace.py). Nothing in one needs the other. Please split them: the first is easy to review and bisect on its own, and the second is the riskier one (see below). It is also currently conflicting with main (14 behind, dirty) in vk_menu.cpp / vk_menu.h, so it needs a rebase anyway. CI is green. Option id 10 is free on main, no collision (0-9, 16-22, 24-26, 32-35, 4096+ all untouched), and the ini keys derived from the row names (descriptor_reuse, sampler_cache) match what menu_env_on reads.

Sampler cache / slotgen (looks correct to me).

  • Sampler lifetime is fine: samplers are never destroyed, and seen holds nvn_samp_par by value, so nothing dangles.
  • Key completeness is fine: the VkSampler key still hashes every byte of the params plus compare and aniso_override(); the new seen table is keyed by id and validated by samp_versions[id], which is bumped inside reg_mu after the write. A reader that gets the new params under an old version just refetches next time, so races only err on the safe side. Unregistered ids / ids >= 4096 are never cached.
  • Slotgen: the versions are read before the resolve, so a registration in between makes the next draw rebuild. Unregistered slots are covered (a later registration bumps that slot's version), handle 0 and storage-pointer handles fall back to the global generation, samplers use the global sampler generation. I couldn't find a path where it keeps a stale descriptor that the old reg_gen check would not.
  • One thing I could not rule out: texture_view_of also keys on texture_destroy_generation() (eviction in vk_texture.cpp calls destroy_later, which bumps destroy_gen but not reg_gen or any slot version). That is the same for the old check, so not a regression, but with slotgen the churn that used to force rebuilds is gone, so if you have a view of how long last_desc_* can live across an eviction sweep, it's worth confirming it is reset there.
  • Expectation check: sampler_for already has a per-thread table keyed on sampler_registrations_generation() (default on), which only changes when sampler contents change. The new seen cache only helps on that table's miss path. The 4.64 vs 11.87 ms number is for items 1+2 together; can you give item 2 alone? Also RX_VK_SAMPLER_CACHE now means two things with opposite defaults (outer table on unless =0, new inner cache off unless =1/menu). Nit: the "Sampler cache" menu Off position does not turn the old table off.
  • Nit: if (seen.size() > 4096) seen.clear(); can never fire (ids are < 4096).

Frame rate row (needs work before it ships).

  1. The realtime_step rewrite changes the default path for everyone, not just users of the new row. Before, the step averaged up to 5 frames and clamped the written rate to 15..60. Now it is a 100 ms count with no upper clamp, and *slot = fps is written for anything up to 9999 even when HOS_UNL_STEP is off. Per your own comment the engine stores 60/fps with an integer divide, so any fps > 60 (say the smoothed measure rounding to 61 on a paced 60 loop) gives 60/61 = 0 in those rate fields. Without the float patch nothing guards that. Please clamp to 60 unless the unlimited step is active, so Off stays the old behaviour.
  2. The "Unlimited" menu row does not enable HOS_UNL_STEP; that is only read from the environment (getenv("HOS_UNL_STEP") in realtime_step). A user choosing Unlimited in the menu gets fps > 60 written to the engine without the host-float step, i.e. the broken fast-forward the description explains. The row should turn this on itself (and the oot_patches patch should be mentioned as required: it only exists in a rebuilt lift output).
  3. Description says "all default off and individually switchable", but the pacing logic above is not behind any switch.
  4. Tests: nothing covers patch_unlimited_step (applies once, idempotent, leaves other game versions alone) or fps_lock_wanted; tests/test_present_trace.py only gets a stub. Please add a small test for the patch next to the other oot_patches tests and register it in tests/run_all.py.
  5. Nits: comments in nn_vi.c call the row "Frame lock" while it is "Frame rate"; the per-300-frame hos_log dereferences hos_unl_step_probe2 + 0xd58 as a guest pointer, which is fine only on the version the patch matched.

So: the slotgen/sampler half looks good to me once split out; the Frame rate half I would hold until the default-path change and the Unlimited-row wiring are sorted.

Thanks, the measured numbers are nice. Two things up front. **This is two unrelated PRs.** The descriptor slotgen + sampler cache are a Vulkan replay-thread optimization (vk_bank/vk_draw/vk_replay/vk_state). The Frame rate row is a pacing / game-clock change (host/nn_vi.c, nx2/oot_patches.py, the menu row, tests/test_present_trace.py). Nothing in one needs the other. Please split them: the first is easy to review and bisect on its own, and the second is the riskier one (see below). It is also currently conflicting with main (14 behind, `dirty`) in vk_menu.cpp / vk_menu.h, so it needs a rebase anyway. CI is green. Option id 10 is free on main, no collision (0-9, 16-22, 24-26, 32-35, 4096+ all untouched), and the ini keys derived from the row names (`descriptor_reuse`, `sampler_cache`) match what `menu_env_on` reads. **Sampler cache / slotgen (looks correct to me).** - Sampler lifetime is fine: `samplers` are never destroyed, and `seen` holds `nvn_samp_par` by value, so nothing dangles. - Key completeness is fine: the VkSampler key still hashes every byte of the params plus `compare` and `aniso_override()`; the new `seen` table is keyed by id and validated by `samp_versions[id]`, which is bumped inside `reg_mu` after the write. A reader that gets the new params under an old version just refetches next time, so races only err on the safe side. Unregistered ids / ids >= 4096 are never cached. - Slotgen: the versions are read before the resolve, so a registration in between makes the next draw rebuild. Unregistered slots are covered (a later registration bumps that slot's version), handle 0 and storage-pointer handles fall back to the global generation, samplers use the global sampler generation. I couldn't find a path where it keeps a stale descriptor that the old reg_gen check would not. - One thing I could not rule out: `texture_view_of` also keys on `texture_destroy_generation()` (eviction in vk_texture.cpp calls `destroy_later`, which bumps destroy_gen but not reg_gen or any slot version). That is the same for the old check, so not a regression, but with slotgen the churn that used to force rebuilds is gone, so if you have a view of how long `last_desc_*` can live across an eviction sweep, it's worth confirming it is reset there. - Expectation check: `sampler_for` already has a per-thread table keyed on `sampler_registrations_generation()` (default on), which only changes when sampler contents change. The new `seen` cache only helps on that table's miss path. The 4.64 vs 11.87 ms number is for items 1+2 together; can you give item 2 alone? Also `RX_VK_SAMPLER_CACHE` now means two things with opposite defaults (outer table on unless `=0`, new inner cache off unless `=1`/menu). Nit: the "Sampler cache" menu Off position does not turn the old table off. - Nit: `if (seen.size() > 4096) seen.clear();` can never fire (ids are < 4096). **Frame rate row (needs work before it ships).** 1. The `realtime_step` rewrite changes the default path for everyone, not just users of the new row. Before, the step averaged up to 5 frames and clamped the written rate to 15..60. Now it is a 100 ms count with no upper clamp, and `*slot = fps` is written for anything up to 9999 even when `HOS_UNL_STEP` is off. Per your own comment the engine stores `60/fps` with an integer divide, so any `fps > 60` (say the smoothed measure rounding to 61 on a paced 60 loop) gives 60/61 = 0 in those rate fields. Without the float patch nothing guards that. Please clamp to 60 unless the unlimited step is active, so Off stays the old behaviour. 2. The "Unlimited" menu row does not enable `HOS_UNL_STEP`; that is only read from the environment (`getenv("HOS_UNL_STEP")` in realtime_step). A user choosing Unlimited in the menu gets fps > 60 written to the engine without the host-float step, i.e. the broken fast-forward the description explains. The row should turn this on itself (and the oot_patches patch should be mentioned as required: it only exists in a rebuilt lift output). 3. Description says "all default off and individually switchable", but the pacing logic above is not behind any switch. 4. Tests: nothing covers `patch_unlimited_step` (applies once, idempotent, leaves other game versions alone) or `fps_lock_wanted`; tests/test_present_trace.py only gets a stub. Please add a small test for the patch next to the other oot_patches tests and register it in tests/run_all.py. 5. Nits: comments in nn_vi.c call the row "Frame lock" while it is "Frame rate"; the per-300-frame `hos_log` dereferences `hos_unl_step_probe2 + 0xd58` as a guest pointer, which is fine only on the version the patch matched. So: the slotgen/sampler half looks good to me once split out; the Frame rate half I would hold until the default-path change and the Unlimited-row wiring are sorted.
bibigurl-yum commented 2026-11-09 23:25:56 +00:00 (Migrated from github.com)

Split as requested: this PR is now only the slotgen + sampler cache (rebased on current main, merged with the #792 fallback fix in draw()); the Frame rate row is #1367.

Your points, in this branch:

  • Eviction / texture_view_of staleness: the keep-check now also revalidates texture_destroy_generation() — a destroy_later sweep bumps only that, so kept descriptors cover it. last_desc_* needs no reset across a sweep for the same reason: the versions the keep-check reads (texture_slot_version) survive eviction, and anything the sweep retired shows up in the destroy generation.
  • seen.clear(): dropped, ids are < 4096.
  • RX_VK_SAMPLER_CACHE: one switch, stated at the declaration: =0 turns off both the outer id table and the inner parameters' cache; =1 or the menu row turns the inner cache on and leaves the outer table exactly as it is without the option (the row therefore never changes the old default-on path).
  • Item-2-alone numbers: running now (sweep config with RX_VK_DESC_SLOTGEN=0, RX_VK_SAMPLER_CACHE=1), I will post them here.
Split as requested: this PR is now only the slotgen + sampler cache (rebased on current main, merged with the #792 fallback fix in `draw()`); the Frame rate row is #1367. Your points, in this branch: - **Eviction / `texture_view_of` staleness**: the keep-check now also revalidates `texture_destroy_generation()` — a `destroy_later` sweep bumps only that, so kept descriptors cover it. `last_desc_*` needs no reset across a sweep for the same reason: the versions the keep-check reads (`texture_slot_version`) survive eviction, and anything the sweep retired shows up in the destroy generation. - **`seen.clear()`**: dropped, ids are < 4096. - **`RX_VK_SAMPLER_CACHE`**: one switch, stated at the declaration: `=0` turns off both the outer id table and the inner parameters' cache; `=1` or the menu row turns the inner cache on and leaves the outer table exactly as it is without the option (the row therefore never changes the old default-on path). - **Item-2-alone numbers**: running now (sweep config with `RX_VK_DESC_SLOTGEN=0, RX_VK_SAMPLER_CACHE=1`), I will post them here.
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 03:40:52 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for splitting this out, and for the rebase. This one is now only slotgen + the sampler cache, and the Frame rate row lives in #1367, so none of my pacing points apply here.

Checked against my last round:

  • Eviction: fixed. desc_slots_kept now also checks texture_destroy_generation(), and build_descriptors reads it at the start of the build. A destroy_later sweep forces a rebuild now. Your reasoning about last_desc_* holds too: last_desc_valid is reset at every recording start anyway, so a kept set never outlives the recording it was built in. That also means skipping texture_view_of's per-submission texture_touch on reuse is no different from what main already does.
  • seen.clear(): dropped. Good.
  • RX_VK_SAMPLER_CACHE: the two meanings are written down at the declaration now, and =0 turns off both layers consistently (sampler_for's on and menu_env_on both read it). That works for me.
  • Item-2-alone numbers: still pending, which is fine. They don't block anything.

I re-read the rebased diff as a whole. The new texel-buffer, texture and image handle reads all go through desc_fp_note before they resolve. Storage-pointer handles count as 0, so they fall back to the global generation. The #792 fallback condition is still in same_desc. Neither PR uses an option id: the rows are read straight from hookshot_options.ini by menu_env_on, and the keys descriptor_reuse and sampler_cache match what ini_key writes. The id 10 that fps_lock used is in #1367, which is closed, and none of the open PRs uses id 10 either. CI is green and the PR merges cleanly.

Two small nits inline. Approving.

Thanks for splitting this out, and for the rebase. This one is now only slotgen + the sampler cache, and the Frame rate row lives in #1367, so none of my pacing points apply here. Checked against my last round: - Eviction: fixed. `desc_slots_kept` now also checks `texture_destroy_generation()`, and `build_descriptors` reads it at the start of the build. A `destroy_later` sweep forces a rebuild now. Your reasoning about `last_desc_*` holds too: `last_desc_valid` is reset at every recording start anyway, so a kept set never outlives the recording it was built in. That also means skipping `texture_view_of`'s per-submission `texture_touch` on reuse is no different from what main already does. - `seen.clear()`: dropped. Good. - `RX_VK_SAMPLER_CACHE`: the two meanings are written down at the declaration now, and `=0` turns off both layers consistently (`sampler_for`'s `on` and `menu_env_on` both read it). That works for me. - Item-2-alone numbers: still pending, which is fine. They don't block anything. I re-read the rebased diff as a whole. The new texel-buffer, texture and image handle reads all go through `desc_fp_note` before they resolve. Storage-pointer handles count as 0, so they fall back to the global generation. The #792 fallback condition is still in `same_desc`. Neither PR uses an option id: the rows are read straight from `hookshot_options.ini` by `menu_env_on`, and the keys `descriptor_reuse` and `sampler_cache` match what `ini_key` writes. The id 10 that `fps_lock` used is in #1367, which is closed, and none of the open PRs uses id 10 either. CI is green and the PR merges cleanly. Two small nits inline. Approving.
hckrman101 (Migrated from github.com) commented 2026-11-10 03:40:52 +00:00

Nit (perf only): this bumps on every rewrite, including the identical ones the same check above exists for. So the seen cache in sampler_make misses after every rewrite of the same parameters. If the game rewrites its samplers often, as the comment above suggests, bumping only when !same would let the cache hit more. It is still correct either way, because a reader refetches on a miss.

Nit (perf only): this bumps on every rewrite, including the identical ones the `same` check above exists for. So the `seen` cache in `sampler_make` misses after every rewrite of the same parameters. If the game rewrites its samplers often, as the comment above suggests, bumping only when `!same` would let the cache hit more. It is still correct either way, because a reader refetches on a miss.
hckrman101 (Migrated from github.com) commented 2026-11-10 03:40:52 +00:00

Nit: the rebase dropped the issue 640 / #792 comment that explained (!p.fallback || dw().prog == &p), but the condition itself is still here. Could you keep that comment? Without it the clause is easy to "simplify" away later.

Nit: the rebase dropped the issue 640 / #792 comment that explained `(!p.fallback || dw().prog == &p)`, but the condition itself is still here. Could you keep that comment? Without it the clause is easy to "simplify" away later.
hckrman101 commented 2026-11-10 13:03:49 +00:00 (Migrated from github.com)

I measured Descriptor reuse and Sampler cache in Ocarina of Time, to see whether they should be on by default. In this game neither makes a measurable difference, and neither broke anything.

Setup: RTX 3090, Ryzen 9 7900X, Linux, main at 854c34c, 1920x1080. A scripted route from a save: Kokiri Forest, into a house and back out (a scene change), then camera turns around the forest. Presents 1100 to 6500, about 2 to 2.5 minutes of play. Four configurations, three runs each, run back to back in rotating order. Runs that overlapped other GPU work were dropped. "Off" means the variables are unset; see note 2.

Both off Reuse only Cache only Both on
Replay thread CPU, ms/frame 12.50 ± 0.08 12.47 ± 0.09 12.09 ± 0.39 12.66 ± 0.37
Replay draw work, mean / p95 / p99 ms 4.62 / 6.71 / 8.02 4.61 / 6.66 / 8.09 4.55 / 6.69 / 8.00 4.64 / 6.73 / 8.06
Descriptor building, ms/frame 2.23 2.21 2.20 2.23
Frame time p50 / p95 / p99 ms 18.2 / 26.2 / 33.0 18.2 / 25.9 / 33.4 18.0 / 25.7 / 32.6 18.2 / 26.0 / 32.5
Frames over 33 ms 52 ± 10 56 ± 14 46 ± 10 44 ± 5
Descriptor builds per frame 1642 1636 1613 1635
Draws keeping their descriptors per frame 58.8 58.4 56.9 59.0
...kept only because of Descriptor reuse 0 1.0 0 1.0
Replay thread voluntary context switches per frame 5.1 5.0 4.9 4.9

The same configuration varied by up to 1.6 ms between rounds. "Cache only" looks about 0.4 ms faster, but "both on" includes the cache and is not faster, so I read it as noise.

Why there's little to gain here:

  • Descriptor reuse: in OoT a draw can only keep the descriptors of the draw just before it when the program, bindings and uniforms match. That happens for about 58 of the roughly 1640 draws per frame. The option adds about one more kept draw per frame, so it has nothing to save. The TOTK numbers in the description probably come from a very different draw pattern.
  • Sampler cache: the existing id table, on by default, already answers almost every sampler lookup, so the new inner cache rarely runs. Voluntary context switches on the replay thread, used as a stand-in for lock waits, are about 5 per frame in every configuration. I couldn't measure lock wait time directly.

Correctness: no validation errors, device loss, crashes or hangs in 25 runs, and the "ready" log lines appear exactly when each option is on. The game doesn't replay frame-exactly, so I compared the best-aligned frame (inside the house) across five runs. Every configuration differs from off by about as much as two off runs differ from each other: about 1% of pixels, from the hearts' rotation. I saw no stale or wrong textures. Reading the code, I found no problem in the keep-check (eviction generation, unregistered slots) or in the inner cache's read order.

Two notes:

  1. A default flip later would miss most players. The menu writes every row to hookshot_options.ini, so anyone who has saved options since this PR has descriptor_reuse=0 and sampler_cache=0 written. Changing the default would only reach new installs, unless the key is renamed or migrated.
  2. RX_VK_SAMPLER_CACHE=0 is slower than leaving it unset. It also turns off the outer sampler table that is on by default (vk_draw.cpp, near line 1739). Anyone comparing "off" with =0 gets a slower baseline than real players have. It might be worth making =0 turn off only the inner cache.

These may well help in other games. For OoT, leaving both off by default looks right.

I measured Descriptor reuse and Sampler cache in Ocarina of Time, to see whether they should be on by default. In this game neither makes a measurable difference, and neither broke anything. **Setup:** RTX 3090, Ryzen 9 7900X, Linux, main at 854c34c, 1920x1080. A scripted route from a save: Kokiri Forest, into a house and back out (a scene change), then camera turns around the forest. Presents 1100 to 6500, about 2 to 2.5 minutes of play. Four configurations, three runs each, run back to back in rotating order. Runs that overlapped other GPU work were dropped. "Off" means the variables are unset; see note 2. | | Both off | Reuse only | Cache only | Both on | |---|---|---|---|---| | Replay thread CPU, ms/frame | 12.50 ± 0.08 | 12.47 ± 0.09 | 12.09 ± 0.39 | 12.66 ± 0.37 | | Replay draw work, mean / p95 / p99 ms | 4.62 / 6.71 / 8.02 | 4.61 / 6.66 / 8.09 | 4.55 / 6.69 / 8.00 | 4.64 / 6.73 / 8.06 | | Descriptor building, ms/frame | 2.23 | 2.21 | 2.20 | 2.23 | | Frame time p50 / p95 / p99 ms | 18.2 / 26.2 / 33.0 | 18.2 / 25.9 / 33.4 | 18.0 / 25.7 / 32.6 | 18.2 / 26.0 / 32.5 | | Frames over 33 ms | 52 ± 10 | 56 ± 14 | 46 ± 10 | 44 ± 5 | | Descriptor builds per frame | 1642 | 1636 | 1613 | 1635 | | Draws keeping their descriptors per frame | 58.8 | 58.4 | 56.9 | 59.0 | | ...kept only because of Descriptor reuse | 0 | 1.0 | 0 | 1.0 | | Replay thread voluntary context switches per frame | 5.1 | 5.0 | 4.9 | 4.9 | The same configuration varied by up to 1.6 ms between rounds. "Cache only" looks about 0.4 ms faster, but "both on" includes the cache and is not faster, so I read it as noise. **Why there's little to gain here:** - **Descriptor reuse:** in OoT a draw can only keep the descriptors of the draw just before it when the program, bindings and uniforms match. That happens for about 58 of the roughly 1640 draws per frame. The option adds about one more kept draw per frame, so it has nothing to save. The TOTK numbers in the description probably come from a very different draw pattern. - **Sampler cache:** the existing id table, on by default, already answers almost every sampler lookup, so the new inner cache rarely runs. Voluntary context switches on the replay thread, used as a stand-in for lock waits, are about 5 per frame in every configuration. I couldn't measure lock wait time directly. **Correctness:** no validation errors, device loss, crashes or hangs in 25 runs, and the "ready" log lines appear exactly when each option is on. The game doesn't replay frame-exactly, so I compared the best-aligned frame (inside the house) across five runs. Every configuration differs from off by about as much as two off runs differ from each other: about 1% of pixels, from the hearts' rotation. I saw no stale or wrong textures. Reading the code, I found no problem in the keep-check (eviction generation, unregistered slots) or in the inner cache's read order. **Two notes:** 1. **A default flip later would miss most players.** The menu writes every row to `hookshot_options.ini`, so anyone who has saved options since this PR has `descriptor_reuse=0` and `sampler_cache=0` written. Changing the default would only reach new installs, unless the key is renamed or migrated. 2. **`RX_VK_SAMPLER_CACHE=0` is slower than leaving it unset.** It also turns off the outer sampler table that is on by default (`vk_draw.cpp`, near line 1739). Anyone comparing "off" with `=0` gets a slower baseline than real players have. It might be worth making `=0` turn off only the inner cache. These may well help in other games. For OoT, leaving both off by default looks right.
Commenting is not possible because the repository is archived.
No description provided.