perf(vk): age the texture spans once a present, not at every submission #2420

Merged
hclaria merged 1 commit from perf/tex-spans-once-a-frame into main 2026-11-11 20:26:20 +00:00
hclaria commented 2026-11-11 18:17:58 +00:00 (Migrated from github.com)

On a slow CPU the renderer spends about 1 ms of every frame walking the list of texture spans again and again: the walk runs at each submission, about 20 times a frame, though what it looks for can only change once a frame. This makes it run once a frame.

What changed

publish_tex_spans (gpu/vk/vk_memory.cpp) is called from memory_begin_submission, so at the start of every submission's recording. Each call walked all of tex_spans (306 spans in Kokiri Forest here) under shadow_mu to drop the spans unused for 1800 presents.

A span's age is counted in presents (texture_frame_no()), so within one present a second walk finds nothing the first did not. The walk now runs only when the present number has changed. The rest of the function is as it was: a span noted between two submissions of one present (tex_spans_changed) is still merged and published by the next call.

No switch, and nothing changes in what is dropped or published, only when the spans are looked over.

Where the time was

Timers around the five parts of memory_begin_submission, Steam Deck, Kokiri Forest, average of 2000 calls:

part ms a call
reap_mirrors 0.0005
retired_imports_give_back 0.008
the clears 0.0001
imports_give_back 0.0005
publish_tex_spans 0.049

At about 20 calls a frame that is about 1 ms a frame. std::_Rb_tree_increment was the second hottest symbol of the presentation thread in perf (5.6% of its user time) before the change.

How it was tested

Steam Deck LCD (Van Gogh APU, 4 cores / 8 threads, Mesa RADV, SteamOS), headless runs on a save in Kokiri Forest looking at the village, about 2940 draws a frame, 110 s measured per run. Before and after are the same renderer source with and without this change, runs alternated:

run before after
pair 1 20.68 fps 21.15 fps
pair 2 20.77 fps 20.97 fps
pair 3 20.84 fps 20.96 fps
replay time a frame 40.0 ms 39.3 ms

Hyrule Field, standing still, about 1600 draws, one pair: 20.46 to 20.81 fps.

The gain is small (about 0.3 fps here) and should be smaller on a faster CPU, where the walk costs less.

Flashes: one run with the camera turning, a 160x90 screenshot of every frame, 800 frames: no one-frame brightness step over 1.5 (largest 0.6).

Checks:

  • New tests/test_tex_spans_once_a_present.py: cuts publish_tex_spans out of the source and counts how often the spans are gone through. 20 submissions of one present go through them once; a span noted mid-present is published; spans unused for 1800 presents are dropped with their shadow range, a later-used one is kept. It fails on current main and passes with the change.
  • python -I -B tests/run_all.py on Linux (a build container on SteamOS): 322 passed, 15 failed, 60 skipped with the change; 321 passed, 15 failed, 60 skipped on main at 7759a6f3. The 15 failures are the same on both; the one more pass is the new test.

Not tested: Windows, and any GPU other than the Steam Deck's. The measurements are from a renderer built from main at e19d969 with a few local changes, with and without this one; the function is the same on current main.

On a slow CPU the renderer spends about 1 ms of every frame walking the list of texture spans again and again: the walk runs at each submission, about 20 times a frame, though what it looks for can only change once a frame. This makes it run once a frame. ## What changed `publish_tex_spans` (`gpu/vk/vk_memory.cpp`) is called from `memory_begin_submission`, so at the start of every submission's recording. Each call walked all of `tex_spans` (306 spans in Kokiri Forest here) under `shadow_mu` to drop the spans unused for 1800 presents. A span's age is counted in presents (`texture_frame_no()`), so within one present a second walk finds nothing the first did not. The walk now runs only when the present number has changed. The rest of the function is as it was: a span noted between two submissions of one present (`tex_spans_changed`) is still merged and published by the next call. No switch, and nothing changes in what is dropped or published, only when the spans are looked over. ## Where the time was Timers around the five parts of `memory_begin_submission`, Steam Deck, Kokiri Forest, average of 2000 calls: | part | ms a call | |---|---| | `reap_mirrors` | 0.0005 | | `retired_imports_give_back` | 0.008 | | the clears | 0.0001 | | `imports_give_back` | 0.0005 | | `publish_tex_spans` | **0.049** | At about 20 calls a frame that is about 1 ms a frame. `std::_Rb_tree_increment` was the second hottest symbol of the presentation thread in `perf` (5.6% of its user time) before the change. ## How it was tested Steam Deck LCD (Van Gogh APU, 4 cores / 8 threads, Mesa RADV, SteamOS), headless runs on a save in Kokiri Forest looking at the village, about 2940 draws a frame, 110 s measured per run. Before and after are the same renderer source with and without this change, runs alternated: | run | before | after | |---|---|---| | pair 1 | 20.68 fps | 21.15 fps | | pair 2 | 20.77 fps | 20.97 fps | | pair 3 | 20.84 fps | 20.96 fps | | replay time a frame | 40.0 ms | 39.3 ms | Hyrule Field, standing still, about 1600 draws, one pair: 20.46 to 20.81 fps. The gain is small (about 0.3 fps here) and should be smaller on a faster CPU, where the walk costs less. Flashes: one run with the camera turning, a 160x90 screenshot of every frame, 800 frames: no one-frame brightness step over 1.5 (largest 0.6). Checks: - New `tests/test_tex_spans_once_a_present.py`: cuts `publish_tex_spans` out of the source and counts how often the spans are gone through. 20 submissions of one present go through them once; a span noted mid-present is published; spans unused for 1800 presents are dropped with their shadow range, a later-used one is kept. It fails on current `main` and passes with the change. - `python -I -B tests/run_all.py` on Linux (a build container on SteamOS): 322 passed, 15 failed, 60 skipped with the change; 321 passed, 15 failed, 60 skipped on `main` at `7759a6f3`. The 15 failures are the same on both; the one more pass is the new test. Not tested: Windows, and any GPU other than the Steam Deck's. The measurements are from a renderer built from `main` at `e19d969` with a few local changes, with and without this one; the function is the same on current `main`.
Joey-Magnus (Migrated from github.com) approved these changes 2026-11-11 20:08:33 +00:00
Joey-Magnus (Migrated from github.com) left a comment

Looks good to me. A span's age is counted in presents (texture_frame_no()), and the only caller (memory_begin_submission, gpu/vk/vk_memory.cpp:2992) passes the current present number, so a second walk within the same present can't drop anything the first one didn't. The rest of the function is unchanged: a span noted between two submissions still sets tex_spans_changed and gets merged and published by the next call. swept is a function-local static, but it's only read and written under shadow_mu, which the function already holds, so there's no race. I ran tests/test_tex_spans_once_a_present.py at the PR's head on Linux (g++), and it passes.

Two minor notes, neither blocking:

  • minor gpu/vk/vk_memory.cpp:1036: swept outlives anything that resets the present counter. If texture_frame_no() ever restarts (for example, a renderer re-init in the same process) and happens to land on the stale value, that one present skips its sweep. The next present sweeps again, and the 1800-present bound makes a one-present delay harmless. I'm only mentioning it so nobody later relies on swept for anything more exact.
  • minor: CI hasn't run on this PR yet (only the title check), so the Windows run of the new fixture is still pending. Like many existing fixture tests, it defaults to g++ and declares no RUN_ALL_ARGS. If you want it to follow the runner's compiler choice and get skipped (or fail under --strict) when there isn't one, add RUN_ALL_ARGS = ["--cxx", "{cxx}"] after the imports, as tests/test_menu_keys.py does.
Looks good to me. A span's age is counted in presents (`texture_frame_no()`), and the only caller (`memory_begin_submission`, `gpu/vk/vk_memory.cpp:2992`) passes the current present number, so a second walk within the same present can't drop anything the first one didn't. The rest of the function is unchanged: a span noted between two submissions still sets `tex_spans_changed` and gets merged and published by the next call. `swept` is a function-local static, but it's only read and written under `shadow_mu`, which the function already holds, so there's no race. I ran `tests/test_tex_spans_once_a_present.py` at the PR's head on Linux (g++), and it passes. Two minor notes, neither blocking: - **minor** `gpu/vk/vk_memory.cpp:1036`: `swept` outlives anything that resets the present counter. If `texture_frame_no()` ever restarts (for example, a renderer re-init in the same process) and happens to land on the stale value, that one present skips its sweep. The next present sweeps again, and the 1800-present bound makes a one-present delay harmless. I'm only mentioning it so nobody later relies on `swept` for anything more exact. - **minor**: CI hasn't run on this PR yet (only the title check), so the Windows run of the new fixture is still pending. Like many existing fixture tests, it defaults to `g++` and declares no `RUN_ALL_ARGS`. If you want it to follow the runner's compiler choice and get skipped (or fail under `--strict`) when there isn't one, add `RUN_ALL_ARGS = ["--cxx", "{cxx}"]` after the imports, as `tests/test_menu_keys.py` does.
Commenting is not possible because the repository is archived.
No description provided.