perf(host): keep the game's threads on the 3D V-Cache cores of two-CCD X3D CPUs #1638

Merged
SadTransGirl merged 6 commits from perf/x3d-cache-cores into main 2026-11-10 21:00:42 +00:00
SadTransGirl commented 2026-11-10 11:48:50 +00:00 (Migrated from github.com)

On Ryzen X3D CPUs with two CCDs (7900X3D, 7950X3D, 9900X3D, 9950X3D), busy places such as Castle Town's market run under 60 fps. The game's threads are spread over both halves of the CPU and only one half has the big cache.

What happens now

A 7900X3D, 7950X3D, 9900X3D or 9950X3D has the big L3 on one CCD only (96 MB against 32 MB). Windows moves a game there only when its Game Bar knows the program as a game. It does not know Hookshot, so the game's threads run on both CCDs, and the replay's reserved core (the last core of the fastest kind) is on the CCD without the cache.

The change

Where the CPU's L3s differ in size and the largest is shared by 8 or more logical processors, the game's threads are kept to those cores (hos_cache_cpus, pick_cache_cores), and no core is kept apart for the replay there.

  • They are 8 cores at most. With one of them reserved the replay got faster still (9.8-10.0 ms) but the game's threads had 7 cores and the frame rate did not rise (56.9-57.4 fps).
  • The renderer's own workers (shader translation, pipelines) are not held to them.
  • One L3 for all, L3s of one size (a 7950X), or fewer than 8 processors on the largest: as before.
  • HOS_CACHE_CORES=0: every core, as before. HOS_RESERVE_CORE=1 keeps the replay's core all the same.

Numbers

Ryzen 9 9950X3D (SMT off), RTX 4070 SUPER, 2560x1440, unattended runs with the same scripted input in every run. Built from 4a7ae1b, HOS_CACHE_CORES=0 against the default.

Castle Town's market, DLSS Quality, about 2,400 draws, 23-25 two-second windows a run:

fps slowest window replay ms a frame
every core (two runs) 57.0, 56.7 55.1, 53.4 11.84, 12.47
with this (two runs) 59.8, 59.6 58.2, 58.0 10.82, 10.49

Hyrule Field, DLAA, about 2,150 draws (the frame rate is at its 60 cap in both): the replay takes 8.09 ms a frame on every core and 6.61 ms with this.

On v2.0.11's code the same comparison gave 56.9-58.0 fps against 59.8-59.9 over four runs each, and 59.6-59.9 with the process held to the first CCD by hand.

Not measured

SMT on (16 logical processors on the cache CCD), the 12-core parts, and whether the replay would do better on a core of its own when the cache CCD has more than 8.

Test

python -I -B tests/test_reserved_core.py (new cases for pick_cache_cores: 9950X3D with and without SMT, 7900X3D, the cache on the second CCD, a 7950X, one L3 for all, two L3s of the largest size).

🤖 Generated with Claude Code

On Ryzen X3D CPUs with two CCDs (7900X3D, 7950X3D, 9900X3D, 9950X3D), busy places such as Castle Town's market run under 60 fps. The game's threads are spread over both halves of the CPU and only one half has the big cache. ### What happens now A 7900X3D, 7950X3D, 9900X3D or 9950X3D has the big L3 on one CCD only (96 MB against 32 MB). Windows moves a game there only when its Game Bar knows the program as a game. It does not know Hookshot, so the game's threads run on both CCDs, and the replay's reserved core (the last core of the fastest kind) is on the CCD without the cache. ### The change Where the CPU's L3s differ in size and the largest is shared by 8 or more logical processors, the game's threads are kept to those cores (`hos_cache_cpus`, `pick_cache_cores`), and no core is kept apart for the replay there. - They are 8 cores at most. With one of them reserved the replay got faster still (9.8-10.0 ms) but the game's threads had 7 cores and the frame rate did not rise (56.9-57.4 fps). - The renderer's own workers (shader translation, pipelines) are not held to them. - One L3 for all, L3s of one size (a 7950X), or fewer than 8 processors on the largest: as before. - `HOS_CACHE_CORES=0`: every core, as before. `HOS_RESERVE_CORE=1` keeps the replay's core all the same. ### Numbers Ryzen 9 9950X3D (SMT off), RTX 4070 SUPER, 2560x1440, unattended runs with the same scripted input in every run. Built from 4a7ae1b, `HOS_CACHE_CORES=0` against the default. Castle Town's market, DLSS Quality, about 2,400 draws, 23-25 two-second windows a run: | | fps | slowest window | replay ms a frame | |---|---|---|---| | every core (two runs) | 57.0, 56.7 | 55.1, 53.4 | 11.84, 12.47 | | with this (two runs) | 59.8, 59.6 | 58.2, 58.0 | 10.82, 10.49 | Hyrule Field, DLAA, about 2,150 draws (the frame rate is at its 60 cap in both): the replay takes 8.09 ms a frame on every core and 6.61 ms with this. On v2.0.11's code the same comparison gave 56.9-58.0 fps against 59.8-59.9 over four runs each, and 59.6-59.9 with the process held to the first CCD by hand. ### Not measured SMT on (16 logical processors on the cache CCD), the 12-core parts, and whether the replay would do better on a core of its own when the cache CCD has more than 8. ### Test `python -I -B tests/test_reserved_core.py` (new cases for `pick_cache_cores`: 9950X3D with and without SMT, 7900X3D, the cache on the second CCD, a 7950X, one L3 for all, two L3s of the largest size). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-10 16:02:06 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the numbers are convincing for the 9950X3D and the detection code itself looks right to me: L3 entries filtered to the calling thread's group, a process mask that doesn't overlap the cache cores falls back to the old behaviour, HOS_PIN / HOS_PIN_BY_CORE still win because they're applied after, and the renderer's own workers are left alone. CI is green and the new cases run through the existing tests/test_reserved_core.py entry in run_all.py.

The main thing I'd want changed before merging is the trigger condition, because "L3s of different sizes, largest shared by 8+ logical processors" also matches CPUs that aren't X3D (inline). Smaller points:

  • On a 7900X3D with SMT on this halves the game to 6 physical cores, and with SMT off the same chip isn't touched at all, since the 8 is counted in logical processors. The PR says the 12-core parts weren't measured; I'd either measure one or count physical cores (8+ on the cache CCD) so only the 16-core parts are affected until there's data.
  • This is Windows-only (cache = 0 off _WIN32), so Linux on an X3D still reserves the last core, which is on the non-cache CCD. That's fine as a scope, but please say so in the title or description, since right now it reads as if it applies everywhere.
  • With HOS_RESERVE_CORE=1 on one of these CPUs you get the last fastest core, which is normally on the other CCD, not the cache-CCD core you measured at 9.8-10.0 ms. That's fine if that's what you want, but the comment makes it sound like the measured setup.
  • Heads-up: #1569 rewrites the same last_fastest_core / pick_reserved_core area and also touches tests/test_reserved_core.py, so whichever lands second will need a rebase.
Thanks, the numbers are convincing for the 9950X3D and the detection code itself looks right to me: L3 entries filtered to the calling thread's group, a process mask that doesn't overlap the cache cores falls back to the old behaviour, HOS_PIN / HOS_PIN_BY_CORE still win because they're applied after, and the renderer's own workers are left alone. CI is green and the new cases run through the existing `tests/test_reserved_core.py` entry in run_all.py. The main thing I'd want changed before merging is the trigger condition, because "L3s of different sizes, largest shared by 8+ logical processors" also matches CPUs that aren't X3D (inline). Smaller points: - On a 7900X3D with SMT on this halves the game to 6 physical cores, and with SMT off the same chip isn't touched at all, since the 8 is counted in logical processors. The PR says the 12-core parts weren't measured; I'd either measure one or count physical cores (8+ on the cache CCD) so only the 16-core parts are affected until there's data. - This is Windows-only (`cache = 0` off `_WIN32`), so Linux on an X3D still reserves the last core, which is on the non-cache CCD. That's fine as a scope, but please say so in the title or description, since right now it reads as if it applies everywhere. - With `HOS_RESERVE_CORE=1` on one of these CPUs you get the last fastest core, which is normally on the *other* CCD, not the cache-CCD core you measured at 9.8-10.0 ms. That's fine if that's what you want, but the comment makes it sound like the measured setup. - Heads-up: #1569 rewrites the same `last_fastest_core` / `pick_reserved_core` area and also touches `tests/test_reserved_core.py`, so whichever lands second will need a rebase.
hckrman101 (Migrated from github.com) commented 2026-11-10 16:02:06 +00:00

This condition also fires on Strix Point. A Ryzen AI 9 HX 370 has 4 Zen 5 cores on a 16 MB L3 and 8 Zen 5c cores on an 8 MB L3. With SMT on, the big L3 is shared by 8 logical processors, so every game thread on a 12-core laptop gets squeezed onto 4 cores, and the reserved core goes away too. The other hybrid layouts with a separate smaller L3 hit the same case.

On the X3D parts the L3s cover the same number of logical processors (one CCD each), and only the size differs. Something like "every L3 shares as many logical processors as the largest" would separate the two cases (or require the cache set to be at least half of all the processors). Could you add an HX 370 case to the test, {{0xFF, 16*MB}, {0xFFFF00, 8*MB}} == 0?

This condition also fires on Strix Point. A Ryzen AI 9 HX 370 has 4 Zen 5 cores on a 16 MB L3 and 8 Zen 5c cores on an 8 MB L3. With SMT on, the big L3 is shared by 8 logical processors, so every game thread on a 12-core laptop gets squeezed onto 4 cores, and the reserved core goes away too. The other hybrid layouts with a separate smaller L3 hit the same case. On the X3D parts the L3s cover the same number of logical processors (one CCD each), and only the size differs. Something like "every L3 shares as many logical processors as the largest" would separate the two cases (or require the cache set to be at least half of all the processors). Could you add an HX 370 case to the test, `{{0xFF, 16*MB}, {0xFFFF00, 8*MB}} == 0`?
@ -627,0 +673,4 @@
const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX *e = (const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX *)(buf + at);
if (!e->Size) break;
if (e->Relationship == RelationCache && e->Cache.Level == 3 && e->Cache.GroupMask.Group == group) {
masks[n] = (uint64_t)e->Cache.GroupMask.Mask;
hckrman101 (Migrated from github.com) commented 2026-11-10 16:02:06 +00:00

Nit: "8 at most" only holds with SMT off. With SMT on, the cache CCD of a 9950X3D is 16 logical processors, which the PR lists as not measured. Maybe say "8 cores" and note that the SMT-on case is untested.

Nit: "8 at most" only holds with SMT off. With SMT on, the cache CCD of a 9950X3D is 16 logical processors, which the PR lists as not measured. Maybe say "8 cores" and note that the SMT-on case is untested.
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 20:52:54 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, this covers what I raised.

Fixed:

  • Strix Point: pick_cache_cores now requires every L3 to be shared by the same number of logical processors, so the HX 370 layout (8 vs 16 logical) returns 0. The new tests cover it with SMT on and off, plus the case where the widths match but the largest L3 has only 4 cores.
  • The 7900X3D / 9900X3D: the 8 is now counted in physical cores, so those parts are left alone with or without SMT until there is data.
  • The Windows-only scope, the SMT-off-only measurement and the HOS_RESERVE_CORE=1 behaviour (the last fastest core is normally on the other CCD) are now spelled out in the comments.

I re-read the new Windows code (group filter, the two buffer walks, the fallback when the process mask doesn't overlap the cache cores, the HOS_PIN paths still applied after) and found nothing wrong.

CI: the two red Python source checks (nx2/test_draw_parameters.py, tests/test_antilag.py, and on Windows test_wayland_libdecor_hint.py) are in code this PR doesn't touch, and main's own recent runs of that workflow are failing too. The Windows back end build, AppImage and builder jobs pass. Still worth a rebase if #1569 lands first, as it touches the same reserved-core area and test file.

Thanks, this covers what I raised. Fixed: - Strix Point: `pick_cache_cores` now requires every L3 to be shared by the same number of logical processors, so the HX 370 layout (8 vs 16 logical) returns 0. The new tests cover it with SMT on and off, plus the case where the widths match but the largest L3 has only 4 cores. - The 7900X3D / 9900X3D: the 8 is now counted in physical cores, so those parts are left alone with or without SMT until there is data. - The Windows-only scope, the SMT-off-only measurement and the `HOS_RESERVE_CORE=1` behaviour (the last fastest core is normally on the other CCD) are now spelled out in the comments. I re-read the new Windows code (group filter, the two buffer walks, the fallback when the process mask doesn't overlap the cache cores, the HOS_PIN paths still applied after) and found nothing wrong. CI: the two red Python source checks (`nx2/test_draw_parameters.py`, `tests/test_antilag.py`, and on Windows `test_wayland_libdecor_hint.py`) are in code this PR doesn't touch, and main's own recent runs of that workflow are failing too. The Windows back end build, AppImage and builder jobs pass. Still worth a rebase if #1569 lands first, as it touches the same reserved-core area and test file.
Commenting is not possible because the repository is archived.
No description provided.