Vulkan: the synchronous replay on the reserved core while it replays, and that core the fastest kind (follow-up to #1034) #1158

Merged
jordanella merged 3 commits from replay-pin into main 2026-11-09 23:09:59 +00:00
jordanella commented 2026-11-09 15:32:46 +00:00 (Migrated from github.com)

Follow-up to #1034, after its review (thanks @hckrman101). #1034 pins the "Presentation Thread", by name, to the reserved core for everything it does. That has four problems, all raised in the review: host code names one game's thread, the other queues' submits are left out, RX_VK_ASYNC is parsed again by hand, and on Intel's hybrid CPUs the reserved core is an E-core.

Now the Vulkan back end moves whichever thread is replaying onto the reserved core for as long as it replays, then puts it back on its own processors. That covers run_submit, plus the synchronous sections of submit_q and present, which take the submission's capture. No thread is picked by name, nothing changes with RX_VK_ASYNC=1 (the back end's own async_on() decides), and a non-Vulkan back end is untouched. The Presentation Thread's pin (HOS_PIN_RENDER) goes. RX_VK_REPLAY_PIN=0 turns the new pin off, and the log says once which processors it uses.

Windows only. On Linux a new thread inherits the affinity of the thread that creates it. Several of the back end's threads start on first use inside a replay: the capture's copy helpers, the shader workers, the pool imports, and others. They would all have stayed on the reserved core. On Linux nothing is pinned in synchronous mode, as before #1034. That also removes #1034's permanent pin on Linux, which had the same problem.

hos_reserved_cpus() now picks the last core of the fastest kind, using GetLogicalProcessorInformationEx's EfficiencyClass:

  • On Intel's hybrid CPUs the last core is an E-core (logical 16-19 on an i7-12700K), the slowest place for the replay.
  • On a CPU with one kind of core it's the last core, as before.
  • A core is kept only if the CPU has 4+ cores of the fastest kind, besides the 8+ logical processors as before. A 2 P-core laptop CPU (i5-1235U, i3-1215U) would otherwise keep one of its two P-cores from the game, so none is kept. A CPU of one kind with 8+ logical processors always has 4+ cores.
  • Only cores in the calling thread's processor group are considered, because the affinity masks are relative to that group.
  • This also affects RX_VK_ASYNC=1, whose replay thread is pinned to the same core.

tests/test_reserved_core.py checks the choice for uniform CPUs (including under 8 logical processors), the 12700K and 13900K, E-cores listed first, Lunar Lake, and the 2-P-core laptop CPUs. It's listed in run_all.py's table (g++).

Measured by others on Intel hybrid CPUs (thanks!)

  • @Brett-D, i7-13700K: the same EfficiencyClass choice took Kokiri Forest from 48.7 fps (reserved core on an E-core) to 60 fps vsync-capped, with 0% of frames over 30 ms.
  • @blushOFF, i7-14700KF: v2.0.8's pin landed on an E-core and cost ~1-2.4 fps, with much more run-to-run spread than no pin.
  • @Nitnek, Zen 3 (no hybrid cores), Castle Town: no change in average or median fps, fewer long frames (within noise), no hang, matching picture.

Measured here

Ryzen 9 3900X (12 cores / 24 threads) with an RTX 3080 on Windows 11, on main 8b2e1d3 (v2.0.8-rc.1, before #1034 was merged). #1034's pin is measured there as HOS_PIN=Presentation=C00000, which is what it sets. Kokiri Forest after loading a save, headless, input timed in game frames (HOS_INPUT_FRAME_CLOCK=1): standing still, and panning the camera (right stick held). Live numbers over presents 3120-5520, two runs each, in two rounds (the second in reverse order). Stutter is from RX_VK_SLOW_LOG=35.

build standing fps replay per frame panning fps 1% low frames > 50 ms
without either pin 36.8 / 36.3 22.5 ms 33.9 / 34.9 13.8 51 / 61
#1034's pin by name 38.7 / 38.5 20.9 ms 36.1 / 36.0 14.8 32 / 34
this PR 39.2 / 39.1 20.6 ms 36.1 / 36.2 16.3 24 / 29
this PR, the core only preferred (ideal processor, every processor allowed) 36.4 / 36.7 22.6 ms 33.9 / 34.5 14.5 56 / 42

Over #1034, the average frame rate is a little higher standing (+1.3%) and even while panning. Slow frames go down: fewer frames over 50 ms, and a 1% low of 16.3 fps against 14.8. Only preferring the core, as the review suggested to keep other processors available, gains nothing at all, so that mode isn't kept.

Not measured: Linux, and hybrid CPUs with few P-cores, where no core is now reserved. On the 3900X the reserved core is unchanged (0xc00000).

Merged with main ac3bd44, the build compiles and a run in game logs the pin on the same core. python -I -B tests/run_all.py: the same failures as main itself on this machine (missing tools, and an older g++ that rejects volatile compound assignment). Everything else passes, including test_reserved_core.py.

🤖 Generated with Claude Code

Follow-up to #1034, after its review (thanks @hckrman101). #1034 pins the "Presentation Thread", by name, to the reserved core for everything it does. That has four problems, all raised in the review: host code names one game's thread, the other queues' submits are left out, `RX_VK_ASYNC` is parsed again by hand, and on Intel's hybrid CPUs the reserved core is an E-core. Now the Vulkan back end moves whichever thread is replaying onto the reserved core for as long as it replays, then puts it back on its own processors. That covers `run_submit`, plus the synchronous sections of `submit_q` and `present`, which take the submission's capture. No thread is picked by name, nothing changes with `RX_VK_ASYNC=1` (the back end's own `async_on()` decides), and a non-Vulkan back end is untouched. The Presentation Thread's pin (`HOS_PIN_RENDER`) goes. `RX_VK_REPLAY_PIN=0` turns the new pin off, and the log says once which processors it uses. **Windows only.** On Linux a new thread inherits the affinity of the thread that creates it. Several of the back end's threads start on first use inside a replay: the capture's copy helpers, the shader workers, the pool imports, and others. They would all have stayed on the reserved core. On Linux nothing is pinned in synchronous mode, as before #1034. That also removes #1034's permanent pin on Linux, which had the same problem. `hos_reserved_cpus()` now picks the last core of the fastest kind, using `GetLogicalProcessorInformationEx`'s `EfficiencyClass`: - On Intel's hybrid CPUs the last core is an E-core (logical 16-19 on an i7-12700K), the slowest place for the replay. - On a CPU with one kind of core it's the last core, as before. - A core is kept only if the CPU has **4+ cores of the fastest kind**, besides the 8+ logical processors as before. A 2 P-core laptop CPU (i5-1235U, i3-1215U) would otherwise keep one of its two P-cores from the game, so none is kept. A CPU of one kind with 8+ logical processors always has 4+ cores. - Only cores in the calling thread's processor group are considered, because the affinity masks are relative to that group. - This also affects `RX_VK_ASYNC=1`, whose replay thread is pinned to the same core. `tests/test_reserved_core.py` checks the choice for uniform CPUs (including under 8 logical processors), the 12700K and 13900K, E-cores listed first, Lunar Lake, and the 2-P-core laptop CPUs. It's listed in `run_all.py`'s table (g++). ### Measured by others on Intel hybrid CPUs (thanks!) - @Brett-D, i7-13700K: the same `EfficiencyClass` choice took Kokiri Forest from 48.7 fps (reserved core on an E-core) to 60 fps vsync-capped, with 0% of frames over 30 ms. - @blushOFF, i7-14700KF: v2.0.8's pin landed on an E-core and cost ~1-2.4 fps, with much more run-to-run spread than no pin. - @Nitnek, Zen 3 (no hybrid cores), Castle Town: no change in average or median fps, fewer long frames (within noise), no hang, matching picture. ### Measured here Ryzen 9 3900X (12 cores / 24 threads) with an RTX 3080 on Windows 11, on main 8b2e1d3 (v2.0.8-rc.1, before #1034 was merged). #1034's pin is measured there as `HOS_PIN=Presentation=C00000`, which is what it sets. Kokiri Forest after loading a save, headless, input timed in game frames (`HOS_INPUT_FRAME_CLOCK=1`): standing still, and panning the camera (right stick held). Live numbers over presents 3120-5520, two runs each, in two rounds (the second in reverse order). Stutter is from `RX_VK_SLOW_LOG=35`. | build | standing fps | replay per frame | panning fps | 1% low | frames > 50 ms | |---|---|---|---|---|---| | without either pin | 36.8 / 36.3 | 22.5 ms | 33.9 / 34.9 | 13.8 | 51 / 61 | | #1034's pin by name | 38.7 / 38.5 | 20.9 ms | 36.1 / 36.0 | 14.8 | 32 / 34 | | this PR | **39.2 / 39.1** | 20.6 ms | **36.1 / 36.2** | **16.3** | **24 / 29** | | this PR, the core only preferred (ideal processor, every processor allowed) | 36.4 / 36.7 | 22.6 ms | 33.9 / 34.5 | 14.5 | 56 / 42 | Over #1034, the average frame rate is a little higher standing (+1.3%) and even while panning. Slow frames go down: fewer frames over 50 ms, and a 1% low of 16.3 fps against 14.8. Only preferring the core, as the review suggested to keep other processors available, gains nothing at all, so that mode isn't kept. Not measured: Linux, and hybrid CPUs with few P-cores, where no core is now reserved. On the 3900X the reserved core is unchanged (`0xc00000`). Merged with main ac3bd44, the build compiles and a run in game logs the pin on the same core. `python -I -B tests/run_all.py`: the same failures as main itself on this machine (missing tools, and an older g++ that rejects `volatile` compound assignment). Everything else passes, including `test_reserved_core.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-09 17:31:38 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for following up on the #1034 comments, this is a much tighter version.

What checks out (reading it against Windows):

  • ReplayPin asks async_on() and is a no-op in async mode.
  • The "Presentation Thread" name match and HOS_PIN_RENDER are gone, and hos_thread_pin is back to only keeping threads off the reserved core.
  • It goes through the existing host->reserved_cpus hook, so no MkwVkHost ABI change and the non-Vulkan back ends are untouched.
  • RAII restore on early returns, a thread-local depth so nested sections don't re-pin, the old mask restored only if the pin succeeded, and it pins after sync_order_mu/replay_mu so at most one thread is pinned at a time. ~10 SetThreadAffinityMask pairs a frame is negligible.
  • The GetLogicalProcessorInformationEx walk (size query, e->Size with the zero guard, fallback to the old call, all-class-0 falling back to the old pick, SMT siblings kept together) and the CPU table in the test look right.

The main gaps are on Linux (1 and 2 inline); 3 and 4 are about hybrid CPUs with few P-cores and >64-CPU machines. The hybrid and 8-thread cases are still unmeasured, so it'd be good if someone with such a CPU could give it a try.

Small nit: tests/test_reserved_core.py defaults to --cxx g++ but isn't added to the table of tests with tool needs in tests/run_all.py, so it fails instead of skipping on a machine without g++. It also only covers pick_reserved_core, not the record walk or the 8-processor threshold.

Thanks for following up on the #1034 comments, this is a much tighter version. What checks out (reading it against Windows): - `ReplayPin` asks `async_on()` and is a no-op in async mode. - The "Presentation Thread" name match and `HOS_PIN_RENDER` are gone, and `hos_thread_pin` is back to only keeping threads off the reserved core. - It goes through the existing `host->reserved_cpus` hook, so no `MkwVkHost` ABI change and the non-Vulkan back ends are untouched. - RAII restore on early returns, a thread-local depth so nested sections don't re-pin, the old mask restored only if the pin succeeded, and it pins after `sync_order_mu`/`replay_mu` so at most one thread is pinned at a time. ~10 `SetThreadAffinityMask` pairs a frame is negligible. - The `GetLogicalProcessorInformationEx` walk (size query, `e->Size` with the zero guard, fallback to the old call, all-class-0 falling back to the old pick, SMT siblings kept together) and the CPU table in the test look right. The main gaps are on Linux (1 and 2 inline); 3 and 4 are about hybrid CPUs with few P-cores and >64-CPU machines. The hybrid and 8-thread cases are still unmeasured, so it'd be good if someone with such a CPU could give it a try. Small nit: `tests/test_reserved_core.py` defaults to `--cxx g++` but isn't added to the table of tests with tool needs in `tests/run_all.py`, so it fails instead of skipping on a machine without g++. It also only covers `pick_reserved_core`, not the record walk or the 8-processor threshold.
@ -1807,0 +1830,4 @@
#endif
}
thread_local int replay_pin_depth = 0;
thread_local DWORD_PTR replay_pin_prev = 0;
hckrman101 (Migrated from github.com) commented 2026-11-09 17:31:38 +00:00

On Linux a thread created with pthread_create/std::thread (and the shim's CreateThread) inherits its creator's affinity (Windows gives new threads the process mask, so it's fine there). Several back-end threads start lazily inside pinned sections:

  • sync_watch via call_once inside submit_q's pin (~3486)
  • the memory-capture copy helpers (copy_pages, 2 by default, vk_memory.cpp ~1293, from memory_capture_submit() in submit_q)
  • the shader/pipeline workers (vk_jobs.cpp:66, on first push) and the jobs_push_serial builder
  • bg_main for pool imports
  • framegen's display_main (vk_framegen.cpp:425, from do_present)
  • with RX_VK_PARALLEL, par_worker_main (~1379 in run_submit)

None of them resets its affinity, so on Linux they'd all live on the reserved core, competing with the replay it's kept for (the parallel capture copy becomes serial on one core, shader compiles run there). Partly pre-existing (#1034's permanent pin, the async replay thread), but now it applies to whichever thread replays first. Maybe have those threads set their affinity to the process mask minus the reserved core at start, spawn them outside the pin, or have the posix thread-start path clear the pin?

On Linux a thread created with `pthread_create`/`std::thread` (and the shim's `CreateThread`) inherits its creator's affinity (Windows gives new threads the process mask, so it's fine there). Several back-end threads start lazily inside pinned sections: - `sync_watch` via `call_once` inside `submit_q`'s pin (~3486) - the memory-capture copy helpers (`copy_pages`, 2 by default, `vk_memory.cpp` ~1293, from `memory_capture_submit()` in `submit_q`) - the shader/pipeline workers (`vk_jobs.cpp:66`, on first push) and the `jobs_push_serial` builder - `bg_main` for pool imports - framegen's `display_main` (`vk_framegen.cpp:425`, from `do_present`) - with `RX_VK_PARALLEL`, `par_worker_main` (~1379 in `run_submit`) None of them resets its affinity, so on Linux they'd all live on the reserved core, competing with the replay it's kept for (the parallel capture copy becomes serial on one core, shader compiles run there). Partly pre-existing (#1034's permanent pin, the async replay thread), but now it applies to whichever thread replays first. Maybe have those threads set their affinity to the process mask minus the reserved core at start, spawn them outside the pin, or have the posix thread-start path clear the pin?
@ -558,12 +558,73 @@ static const NameSpec *match_name_spec(const NameSpec *s, int n, const char *nam
/* The last physical core (its logical processors' mask), kept for the Vulkan back end's replay thread (gpu/vk
hckrman101 (Migrated from github.com) commented 2026-11-09 17:31:38 +00:00

The 8-logical-processor threshold now counts E-cores, so on hybrids with few P-cores one of very few P-cores gets reserved: i3-1215U (2P+4E = 8 logical) and i5-1235U/1245U (2P+8E = 12) reserve 1 of 2 P-cores, Lunar Lake (4P+4E) 1 of 4. On the 2P parts the game's other threads are left with 1 P-core plus E-cores (before, they only lost one E-core). That may be the right trade for the replay, but it's unmeasured. What about requiring a minimum number of fast-class cores (say 4+) before reserving one, and otherwise no reservation or the old pick?

The 8-logical-processor threshold now counts E-cores, so on hybrids with few P-cores one of very few P-cores gets reserved: i3-1215U (2P+4E = 8 logical) and i5-1235U/1245U (2P+8E = 12) reserve 1 of 2 P-cores, Lunar Lake (4P+4E) 1 of 4. On the 2P parts the game's other threads are left with 1 P-core plus E-cores (before, they only lost one E-core). That may be the right trade for the replay, but it's unmeasured. What about requiring a minimum number of fast-class cores (say 4+) before reserving one, and otherwise no reservation or the old pick?
hckrman101 (Migrated from github.com) commented 2026-11-09 17:31:38 +00:00

Edge case: this takes group 0's cores only, while the old GetLogicalProcessorInformation returned the calling thread's group. SetThreadAffinityMask/GetProcessAffinityMask masks are relative to the thread's/process's own group, and on Windows 11 / Server 2022 the primary group can be 1+. With asymmetric groups (Threadripper/Xeon) the reservation can land on the wrong kind of core or past the end of that group, and the threshold counts only group 0. Filtering by the current group (GetThreadGroupAffinity) instead of Group == 0 would keep the old behaviour.

Edge case: this takes group 0's cores only, while the old `GetLogicalProcessorInformation` returned the calling thread's group. `SetThreadAffinityMask`/`GetProcessAffinityMask` masks are relative to the thread's/process's own group, and on Windows 11 / Server 2022 the primary group can be 1+. With asymmetric groups (Threadripper/Xeon) the reservation can land on the wrong kind of core or past the end of that group, and the threshold counts only group 0. Filtering by the current group (`GetThreadGroupAffinity`) instead of `Group == 0` would keep the old behaviour.
@ -567,2 +626,4 @@
}
#endif
SYSTEM_LOGICAL_PROCESSOR_INFORMATION info[256];
DWORD len = sizeof info;
hckrman101 (Migrated from github.com) commented 2026-11-09 17:31:38 +00:00

On Linux this still takes the old path: the EfficiencyClass logic is #if defined(_WIN32) only, so hos_reserved_cpus goes through the posix shim's GetLogicalProcessorInformation (host/posix/win32_posix.c ~577), which builds masks from sysfs core_id without core types and picks the highest mask. On a hybrid Intel CPU under Linux (e.g. a 12700K, CPUs 16-19) the reserved core is still an E-core, and with this PR every synchronous replay, the capture and the present run there, which is wider than #1034 (that pinned only the Presentation Thread).

The description says "the fastest kind" without mentioning it's Windows-only. Could it pick by /sys/devices/system/cpu/cpuN/cpu_capacity (or the cpu_core/cpu_atom PMU cpus lists, or cpuinfo_max_freq), or skip the replay pin on Linux when core types differ?

On Linux this still takes the old path: the EfficiencyClass logic is `#if defined(_WIN32)` only, so `hos_reserved_cpus` goes through the posix shim's `GetLogicalProcessorInformation` (`host/posix/win32_posix.c` ~577), which builds masks from sysfs `core_id` without core types and picks the highest mask. On a hybrid Intel CPU under Linux (e.g. a 12700K, CPUs 16-19) the reserved core is still an E-core, and with this PR every synchronous replay, the capture and the present run there, which is wider than #1034 (that pinned only the Presentation Thread). The description says "the fastest kind" without mentioning it's Windows-only. Could it pick by `/sys/devices/system/cpu/cpuN/cpu_capacity` (or the `cpu_core`/`cpu_atom` PMU `cpus` lists, or `cpuinfo_max_freq`), or skip the replay pin on Linux when core types differ?
blushOFF commented 2026-11-09 17:37:39 +00:00 (Migrated from github.com)

Data point: on an i7-14700KF the reserved core is an E-core

i7-14700KF (P-cores = logical processors 0-15, E-cores = 16-27), RTX 3070, Windows 11, v2.0.8. The log says threads: Presentation Thread on the reserved core (logical processors 0x8000000; HOS_PIN_RENDER=0: off), so logical processor 27, an E-core.

Kokiri Forest, standing then walking, frame generation 2x, ABBA order (pinned, HOS_PIN_RENDER=0, HOS_PIN_RENDER=0, pinned):

Standing fps Walking fps
Pinned (default) 48.5 / 41.0 41.5 / 34.3
HOS_PIN_RENDER=0 46.1 / 45.4 39.8 / 40.8

The mean cost is small (about 1 fps standing, 2.4 walking), but the pinned runs vary much more: an earlier ABBA also had pinned v2.0.8 at 40.5 / 39.8 standing. Pinning to a core of the fastest kind, as this PR does, looks right for Intel's hybrid CPUs.

**Data point: on an i7-14700KF the reserved core is an E-core** i7-14700KF (P-cores = logical processors 0-15, E-cores = 16-27), RTX 3070, Windows 11, v2.0.8. The log says `threads: Presentation Thread on the reserved core (logical processors 0x8000000; HOS_PIN_RENDER=0: off)`, so logical processor 27, an E-core. Kokiri Forest, standing then walking, frame generation 2x, ABBA order (pinned, `HOS_PIN_RENDER=0`, `HOS_PIN_RENDER=0`, pinned): | | Standing fps | Walking fps | |---|---|---| | Pinned (default) | 48.5 / 41.0 | 41.5 / 34.3 | | `HOS_PIN_RENDER=0` | 46.1 / 45.4 | 39.8 / 40.8 | The mean cost is small (about 1 fps standing, 2.4 walking), but the pinned runs vary much more: an earlier ABBA also had pinned v2.0.8 at 40.5 / 39.8 standing. Pinning to a core of the fastest kind, as this PR does, looks right for Intel's hybrid CPUs.
Brett-D commented 2026-11-09 17:44:45 +00:00 (Migrated from github.com)

can confirm this and applied this fix( well ai came up with it I was about to commit this then it found this pr)

Data point: i7-13700K, the fastest-kind choice gets the forest to 60 fps

i7-13700K (P-cores = logical processors 0-15, E-cores = 16-23), Windows 11, on main 85dfa27. Same EfficiencyClass selection as this PR in hos_reserved_cpus() (Presentation Thread pin kept). Kokiri Forest after loading a save, 1600×900 window, frame generation off, 100% resolution, HOS_INPUT_FRAME_CLOCK=1, same spot (~2200 draws a frame):

build reserved core fps frames > 30 ms
before 85dfa27 — 41.9 / 47.0 3.18% / 1.49%
85dfa27 as is 0x800000 (LP 23, E-core) 48.7 —
85dfa27, HOS_PIN_RENDER=0 — 60.0 0.06%
85dfa27 + fastest-kind core 0xc000 (LP 14-15, P-core) 60.0 / 60.0 0%

On the E-core the replay took ~17-18 ms a frame while the GPU took ~6.5 ms, so the frame rate was CPU-bound on that one core. With the core picked by EfficiencyClass it's vsync-capped at 60.

can confirm this and applied this fix( well ai came up with it I was about to commit this then it found this pr) **Data point: i7-13700K, the fastest-kind choice gets the forest to 60 fps** i7-13700K (P-cores = logical processors 0-15, E-cores = 16-23), Windows 11, on main 85dfa27. Same `EfficiencyClass` selection as this PR in `hos_reserved_cpus()` (Presentation Thread pin kept). Kokiri Forest after loading a save, 1600×900 window, frame generation off, 100% resolution, `HOS_INPUT_FRAME_CLOCK=1`, same spot (~2200 draws a frame): | build | reserved core | fps | frames > 30 ms | |---|---|---|---| | before 85dfa27 | — | 41.9 / 47.0 | 3.18% / 1.49% | | 85dfa27 as is | `0x800000` (LP 23, E-core) | 48.7 | — | | 85dfa27, `HOS_PIN_RENDER=0` | — | 60.0 | 0.06% | | 85dfa27 + fastest-kind core | `0xc000` (LP 14-15, P-core) | **60.0 / 60.0** | **0%** | On the E-core the replay took ~17-18 ms a frame while the GPU took ~6.5 ms, so the frame rate was CPU-bound on that one core. With the core picked by `EfficiencyClass` it's vsync-capped at 60.
Nitnek commented 2026-11-09 18:29:57 +00:00 (Migrated from github.com)

Tested on Windows 11, Ryzen (Zen 3, no hybrid cores) + RTX 3070, merged onto v2.0.10 (clean merge). Scripted Castle Town walk: load the save at the town gate, walk into the market for ~30 s. Frame times from RX_VK_FRAME_LOG, two runs each, alternating:

Build avg FPS p50 p99 >100 ms frames
v2.0.10 36.0 / 34.7 25.6 / 26.2 ms 91.8 / 81.6 ms 10 / 9
v2.0.10 + #1158 34.8 / 36.0 25.9 / 26.2 ms 82.5 / 51.6 ms 5 / 7

No change in the average or median on this CPU. That is expected without E-cores. The fewer long frames are within run-to-run noise here. No hang, no device lost, the picture matches.

For context, in this scene the replay takes ~14 ms a frame and the GPU ~11 ms (the log's own lines), while frames take ~26 ms. So the town is held back by the game's own threads, not by the replay thread's placement.

Tested on Windows 11, Ryzen (Zen 3, no hybrid cores) + RTX 3070, merged onto v2.0.10 (clean merge). Scripted Castle Town walk: load the save at the town gate, walk into the market for ~30 s. Frame times from `RX_VK_FRAME_LOG`, two runs each, alternating: | Build | avg FPS | p50 | p99 | >100 ms frames | |---|---|---|---|---| | v2.0.10 | 36.0 / 34.7 | 25.6 / 26.2 ms | 91.8 / 81.6 ms | 10 / 9 | | v2.0.10 + #1158 | 34.8 / 36.0 | 25.9 / 26.2 ms | 82.5 / 51.6 ms | 5 / 7 | No change in the average or median on this CPU. That is expected without E-cores. The fewer long frames are within run-to-run noise here. No hang, no device lost, the picture matches. For context, in this scene the replay takes ~14 ms a frame and the GPU ~11 ms (the log's own lines), while frames take ~26 ms. So the town is held back by the game's own threads, not by the replay thread's placement.
jordanella (Migrated from github.com) reviewed 2026-11-09 20:15:31 +00:00
@ -567,2 +626,4 @@
}
#endif
SYSTEM_LOGICAL_PROCESSOR_INFORMATION info[256];
DWORD len = sizeof info;
jordanella (Migrated from github.com) commented 2026-11-09 20:15:31 +00:00

Right, the EfficiencyClass choice is Windows-only, and the description didn't say so. Given your next comment, ffeaed6 makes the replay pin itself Windows-only (replay_pin_mask is 0 elsewhere). On Linux nothing is pinned in synchronous mode, as before #1034, so the replay never lands on an E-core there. The Linux pick still matters for RX_VK_ASYNC=1, unchanged from main. Choosing by cpu_capacity or the cpu_core PMU list looks like the right way to do it on Linux, but I can't run it on a Linux hybrid machine, so I'd rather leave it for a follow-up someone there can test. The description now says Windows-only.

Right, the `EfficiencyClass` choice is Windows-only, and the description didn't say so. Given your next comment, ffeaed6 makes the replay pin itself Windows-only (`replay_pin_mask` is 0 elsewhere). On Linux nothing is pinned in synchronous mode, as before #1034, so the replay never lands on an E-core there. The Linux pick still matters for `RX_VK_ASYNC=1`, unchanged from main. Choosing by `cpu_capacity` or the `cpu_core` PMU list looks like the right way to do it on Linux, but I can't run it on a Linux hybrid machine, so I'd rather leave it for a follow-up someone there can test. The description now says Windows-only.
jordanella (Migrated from github.com) reviewed 2026-11-09 20:15:33 +00:00
@ -1807,0 +1830,4 @@
#endif
}
thread_local int replay_pin_depth = 0;
thread_local DWORD_PTR replay_pin_prev = 0;
jordanella (Migrated from github.com) commented 2026-11-09 20:15:33 +00:00

Good catch, and it's why the pin is Windows-only now (ffeaed6). Windows gives a new thread the process mask, so threads started inside a pinned replay are fine there. On Linux every one of those lazily started threads would have kept the reserved core. This also takes away #1034's permanent Presentation Thread pin on Linux, which had the same inheritance problem for everything that thread starts. Resetting the affinity at the start of each back-end thread would make a Linux version possible, again best done by someone who can run it there.

Good catch, and it's why the pin is Windows-only now (ffeaed6). Windows gives a new thread the process mask, so threads started inside a pinned replay are fine there. On Linux every one of those lazily started threads would have kept the reserved core. This also takes away #1034's permanent Presentation Thread pin on Linux, which had the same inheritance problem for everything that thread starts. Resetting the affinity at the start of each back-end thread would make a Linux version possible, again best done by someone who can run it there.
jordanella (Migrated from github.com) reviewed 2026-11-09 20:15:36 +00:00
@ -558,12 +558,73 @@ static const NameSpec *match_name_spec(const NameSpec *s, int n, const char *nam
/* The last physical core (its logical processors' mask), kept for the Vulkan back end's replay thread (gpu/vk
jordanella (Migrated from github.com) commented 2026-11-09 20:15:36 +00:00

Done in ffeaed6: pick_reserved_core now keeps a core only with 4+ cores of the fastest kind (besides the 8+ logical processors), and otherwise none. I chose no reservation over the old pick because the two hybrid data points on this PR show the replay pinned to an E-core is slower than not pinned at all. Lunar Lake keeps 1 of 4, per your 4. CPUs of one kind are unchanged, since 8+ logical processors always means 4+ cores. test_reserved_core.py now covers the i5-1235U, i3-1215U and Lunar Lake layouts, a 6-core CPU without SMT, and a 4-core one with SMT.

Done in ffeaed6: `pick_reserved_core` now keeps a core only with 4+ cores of the fastest kind (besides the 8+ logical processors), and otherwise none. I chose no reservation over the old pick because the two hybrid data points on this PR show the replay pinned to an E-core is slower than not pinned at all. Lunar Lake keeps 1 of 4, per your 4. CPUs of one kind are unchanged, since 8+ logical processors always means 4+ cores. `test_reserved_core.py` now covers the i5-1235U, i3-1215U and Lunar Lake layouts, a 6-core CPU without SMT, and a 4-core one with SMT.
jordanella (Migrated from github.com) reviewed 2026-11-09 20:15:38 +00:00
@ -558,12 +558,73 @@ static const NameSpec *match_name_spec(const NameSpec *s, int n, const char *nam
/* The last physical core (its logical processors' mask), kept for the Vulkan back end's replay thread (gpu/vk
jordanella (Migrated from github.com) commented 2026-11-09 20:15:37 +00:00

Done in ffeaed6: the walk keeps the cores of the calling thread's group (GetThreadGroupAffinity), so the masks match what the affinity calls expect.

Done in ffeaed6: the walk keeps the cores of the calling thread's group (`GetThreadGroupAffinity`), so the masks match what the affinity calls expect.
jordanella commented 2026-11-09 20:15:39 +00:00 (Migrated from github.com)

Thanks for the careful review; all four points and the nit are in ffeaed6, with details inline. In short: the replay pin is Windows-only, a core is kept only with 4+ of the fastest kind, the cores are the calling thread's processor group's, and test_reserved_core.py is in run_all.py's table and covers the thresholds. The record walk itself still isn't covered, since it's Windows API calls.

And thanks @blushOFF, @Brett-D and @Nitnek for testing. The 13700K going from 48.7 to a vsync-capped 60 fps with the fastest-kind core, and the 14700KF numbers for the E-core pin, are exactly the hybrid data this PR was missing. They're credited in the updated description. Merged with main ac3bd44, it builds, and a run here pins the same core as before on the 3900X.

Thanks for the careful review; all four points and the nit are in ffeaed6, with details inline. In short: the replay pin is Windows-only, a core is kept only with 4+ of the fastest kind, the cores are the calling thread's processor group's, and `test_reserved_core.py` is in `run_all.py`'s table and covers the thresholds. The record walk itself still isn't covered, since it's Windows API calls. And thanks @blushOFF, @Brett-D and @Nitnek for testing. The 13700K going from 48.7 to a vsync-capped 60 fps with the fastest-kind core, and the 14700KF numbers for the E-core pin, are exactly the hybrid data this PR was missing. They're credited in the updated description. Merged with main ac3bd44, it builds, and a run here pins the same core as before on the 3900X.
hckrman101 (Migrated from github.com) approved these changes 2026-11-09 23:07:50 +00:00
hckrman101 (Migrated from github.com) left a comment

All four earlier points and the test nit are fixed in ffeaed6:

  • the replay pin is Windows-only now (0 elsewhere), which covers both Linux points;
  • a core is reserved only with 4+ cores of the fastest class (I checked the test table against the code);
  • the cores come from the calling thread's own processor group;
  • tests/test_reserved_core.py is in run_all.py.

ReplayPin reads correctly: it does nothing in async mode, only the outermost pin applies, and it restores only if the pin worked. Two nits, no action needed: replay_pin_mask() isn't intersected with the process affinity mask, and the reserved core is chosen from the first caller's processor group. The 13700K/14700KF data backs the E-core choice. CI is green. LGTM.

All four earlier points and the test nit are fixed in ffeaed6: - the replay pin is Windows-only now (0 elsewhere), which covers both Linux points; - a core is reserved only with 4+ cores of the fastest class (I checked the test table against the code); - the cores come from the calling thread's own processor group; - `tests/test_reserved_core.py` is in run_all.py. `ReplayPin` reads correctly: it does nothing in async mode, only the outermost pin applies, and it restores only if the pin worked. Two nits, no action needed: `replay_pin_mask()` isn't intersected with the process affinity mask, and the reserved core is chosen from the first caller's processor group. The 13700K/14700KF data backs the E-core choice. CI is green. LGTM.
Commenting is not possible because the repository is archived.
No description provided.