WIP: perf(host): HOS_VCORE_FIRST, named threads take a virtual core before any other (off by default) #249

Draft
GummiNaomi wants to merge 4 commits from GummiNaomi/Longshot:perf/vcore-first into main
Contributor

Why

In OoT's busy frames, the Presentation Thread waits about 1 ms a frame for a "virtual core" before it can submit the frame to the GPU. By then the game's six BaseProcExecutors, woken by the same hand-off, have taken all six cores. The game is built for the Switch 2's six fixed cores, and on a PC that wait sits on the frame's critical path. In Hookshot, at Lon Lon Ranch with the late pages' wait as on main, this switch took 54.2 fps to 59.8. On Longshot it hasn't been measured where the gain is claimed yet.

What changed

  • runtime (host/hos_thread.c only):
    • HOS_VCORE_FIRST=<name part>,... (off by default): a thread whose name contains one of the parts is a "first thread". It ranks above every guest priority, for the virtual cores only (vc_prio: its guest priority - 64, so first threads keep their guest order among themselves). A part is a case-sensitive substring of the thread name. Spaces around a part are skipped (vc_first_named).
    • A first waiter asks a worse holder for its core at once. A waiter that isn't a first thread never asks a first thread for its core at once, even one better by guest priority: it waits for the time slice (HOS_VCORE_SLICE_US, 2 ms) and then asks (kick 2).
    • A first thread's turns go by guest priority (vc_turn, vc_yield): on a YieldThread / SleepThread(0), or when a waiter as good by guest priority asks after the time slice, it gives its core to that waiter. Until it takes a core again, it then ranks by its guest priority (vc_turned, set in vc_yield, cleared in vc_take). So the waiter keeps the core for a time slice instead of being asked for it back at once. Without this, a first thread waiting by yielding for a job thread pinned to its own core kept that core until the 2 s escape.
    • The guest's own priority (GetThreadPriority, svc 0x0C) and the host thread priority are unchanged.
    • Names are matched at naming (hos_thread_set_name) and at creation (the boot thread's MainThread, Thread_<entry>). The flag is atomic, because another thread may name it.
    • The escape log shows the order the cores go by (a first thread's guest priority - 64).
  • tests: tests/test_vcore_first.py, a sibling of test_vcore_escape.py. It cuts vc_first_named and the virtual-core block out of the source and compiles them with a single-threaded harness, whose wait hook plays the holder answering. It pins these cases:
    • a first waiter asks a worse holder at once;
    • a first holder hands its core to an equal waiter, both on a yield and on the slice's ask at its checkpoint;
    • after that hand-over, it asks for the core back only after the time slice;
    • first threads keep their guest order;
    • a better guest waiter waits for the slice;
    • the name matching.
  • docs: the docs/development.md row says how a part matches and what the turn rules are. docs/tested-configurations.md gets dukenheimer's two Kokiri Forest rows.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
HOS_VCORE_FIRST Named threads take a virtual core before any other (Presentation for OoT's Presentation Thread); parts are case-sensitive substrings, comma-separated off Off by default for now, and on after more testing (for OoT, Presentation). It departs from console scheduling on purpose. Frames over budget (Hookshot: 54.2 → 59.8 fps at the ranch with the late pages' wait) Frames already at 60 (Hookshot: p95 -0.9 ms, median +0.2 ms); Kokiri Forest on a 24-thread i7-13700K (Longshot: no change outside noise) Lon Lon Ranch on Longshot, AMD CPUs, CPUs with fewer than 8 threads, Linux, long sessions

Documented in docs/development.md beside the other HOS_ thread switches.

Measurements

This head (68df5714) is not measured. Since the runs below, a first thread's turns changed: after it gives its core up by turn, it no longer asks for it back at once. That changes timing only when the Presentation Thread yields or uses up a slice with an equal waiter on its cores. The numbers below show the switch at 19ad5cd8, and Lon Lon Ranch on Longshot, where the gain is claimed, is still to measure.

Longshot (dukenheimer, comment)

Baseline: this PR @ 19ad5cd8, unset
Why not main: the switch is off by default, so one build with the switch off and on isolates it.
this PR: perf/vcore-first @ 19ad5cd8 (before this rebase), built as 3.0.0-rc.3-17-g19ad5cd8; one full build, both arms from the same folder
Hardware: RTX 5090 32 GB, NVIDIA 621.14; Core i7-13700K (8P + 8E, 24 threads), 98 GB; Windows 11 Pro; display 5120x2160 at 165 Hz
Scene: Kokiri Forest from the autosave, a scripted moving route; 2048x1152 in a 1600x900 window. M1152: no frame generation, no game cap (HOS_FPS_MAX=240), mailbox. MP165: DLSS with DLSS frame generation 2x, cap 165, FIFO (fps is the game's own frames).
Method: 2 interleaved rounds (unset, then Presentation, at each setting), 125 s a run. Frame times come from the renderer's frame log, 30–110 s after the first frame. CPU comes from prof: threads in 10 s, and escapes from every vcores: line.
How to reproduce: any save in Kokiri Forest, the settings above, HOS_VCORE_FIRST=Presentation against unset.

M1152 this PR @ 19ad5cd8, unset this PR @ 19ad5cd8, HOS_VCORE_FIRST=Presentation Δ (round 1, round 2)
fps (mean of 2) 71.26 72.67 +1.41 (+3.01, −0.19)
p99 ms 21.07 20.37 −0.70 (−2.11, +0.71)
p95 / median ms 18.16 / 13.82 17.47 / 13.62
frames over 50 ms 0 0
process: cores busy 5.46 5.53 +0.07
vcores: escapes 0 0
MP165 this PR @ 19ad5cd8, unset this PR @ 19ad5cd8, HOS_VCORE_FIRST=Presentation Δ (round 1, round 2)
fps (mean of 2; game frames) 69.56 69.46 −0.09 (−1.38, +1.19)
p99 ms 21.54 21.32 −0.22 (+0.16, −0.60)
p95 / median ms 18.58 / 14.18 18.23 / 14.28
frames over 50 ms (per run) 0.5 0.5
process: cores busy 5.60 5.61 +0.02
vcores: escapes 0 0
lsp249-off-M1152-r0  fps 70.41  p99 22.86     lsp249-on-M1152-r0  fps 73.42  p99 20.75
lsp249-off-M1152-r1  fps 72.11  p99 19.28     lsp249-on-M1152-r1  fps 71.92  p99 19.99
lsp249-off-MP165-r0  fps 70.36  p99 21.20     lsp249-on-MP165-r0  fps 68.98  p99 21.36
lsp249-off-MP165-r1  fps 68.75  p99 21.88     lsp249-on-MP165-r1  fps 69.94  p99 21.28
[hos OptimizeChangeBanc] vcores: 556891 takes, 257868 waits (mean 21 us, longest 18.4 ms), 159894 kicks, 159843 yields, 0 escapes in 5 s   (on, M1152, in play)
[hos ModuleSystemWorker5] vcores: 545998 takes, 252961 waits (mean 22 us, longest 11.5 ms), 156633 kicks, 156005 yields, 0 escapes in 5 s   (unset, the same)

Reading: M1152's +1.4 fps comes entirely from round 1, and run-to-run spread is about ±1.5 fps, so this is no change. MP165 is flat. 0 escapes. This scene is paced by the main thread's jobs, on a CPU with no core shortage, so no gain was expected (dukenheimer's review shows the Presentation Thread waits 0.29 ms a frame for a core there).

Hookshot (the author's, before the move to Longshot)

Baseline: Hookshot @ 3434d990, unset
Why not main: measured in Hookshot, before the move to Longshot (the build carried a TEMP commit that only logs).
Hardware: RTX 4070 SUPER 12 GB, NVIDIA 617.42, i7-14700K, 32 GB, Windows 11 Pro 26200
Scene: Lon Lon Ranch at noon (ranch-day3), 2560x1440 DLAA, cap 60, unattended
Method: frames.csv, presents 3480-3960, 3 interleaved runs a side; the critical path from HOS_CHAIN + HOS_SAMPLE_ALL

Hookshot @ 3434d990, unset Hookshot @ 3434d990, HOS_VCORE_FIRST=Presentation
late pages' wait as on main (RX_VK_LATE_FIX=0, Hookshot only): fps 54.2 (18.39 / 18.44 / 18.50 ms mean) 59.8 (16.74 / 16.68 / 16.73 ms)
the same: late-pages wait a frame 4.76 / 4.80 / 4.81 ms 1.42 / 1.14 / 1.15 ms
with the late pages' fix: fps 59.4 59.8
the same: p95 frame 19.58 / 20.06 / 19.82 ms 18.97 / 18.92 / 19.08 ms
the same: median frame 16.29 / 16.31 / 16.27 ms 16.51 / 16.50 / 16.54 ms
Presentation Thread's core wait on the critical path, a frame 1.01 ms 0.03 ms

Testing

Tested:

  • dukenheimer, on Longshot at 19ad5cd8 (test request T-GZ9B, pass): RTX 5090 / i7-13700K, Windows 11 Pro, Kokiri Forest, 11 runs (warm-ups included). No crash, hang, device loss or VK_ERROR, and 0 vcores: escapes. The luma gate showed 0 near-white shots on either side, and flashes stayed within main's usual range.
  • The author, in Hookshot before the move: 21 ranch runs with no crash, device loss or escape, and 0 flashes in 4 runs; one independent code audit.
  • Code review only for this push (68df5714): the turn fix, the first threads' order and the trimming were read, not run. No tests, checks or builds were run on the PC that made this push. tests/test_vcore_first.py was written and traced by hand, not run. The CI's Python checks (ci.yml) run only on a manual dispatch, so they haven't run on this head either.

Not tested:

  • This head in the game: the turn fix has not been run.
  • Lon Lon Ranch on Longshot (cap 60, HOS_CHAIN), where the gain is claimed.
  • tests/run_all.py and the new check, here or in CI.
  • CPUs with fewer hardware threads, where taking a core from an executor may cost more; AMD; Linux; long sessions.

Needs other testers:

  • A 6-core/12-thread or smaller CPU: the ranch with HOS_VCORE_FIRST=Presentation against unset, expecting no lower fps and 0 vcores: ... escapes (request to open).
  • Linux: the same check, expecting the same (request to open).

Screenshots

Nothing on screen.

Risk and rollback

Off by default: nothing changes unless the switch is set (with it unset, vc_turned is never set and vc_prio is the guest priority, as on main). If it's set, an executor asked for its core gives it up at its next checkpoint. That delays its job batch by a few hundred µs (Hookshot's +0.2 ms median at 60 fps). A game thread better by guest priority than a first thread that shares its cores waits up to the time slice (2 ms) for it, where Horizon would preempt at once. A broad part names many threads (Thread names MainThread and every unnamed Thread_<entry>). Unset the switch to turn it off.

Reviewer notes

The vcore code is vc_acquire, vc_yield, vc_prio, vc_turn and vc_first_named in host/hos_thread.c. The fix for review 1193's point 1 is vc_turned. ReplayPin and host affinity are unaffected: the virtual cores don't touch host affinity, and the replay runs with no core held. tests/test_vcore_escape.py compiles the same block. It needed no change (the struct gains one field), but it hasn't been run on this head. Whether the switch should become OoT's default is a maintainer question. dukenheimer's idea of applying the first rank only against threads at or below the first thread's own guest priority would keep the console's preemptions. It is left for a follow-up once the Presentation Thread's guest priority and mask are known (HOS_TLS_TRACE=1).

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): not run on this head. Nothing was run on the PC that pushed it, and CI's Python checks (ci.yml) run only on a manual dispatch. Left to that dispatch or the author.
  • 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): T-GZ9B passed (Kokiri Forest); the 6c/12t and Linux 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: pending. Only switch-off/on pairs at 19ad5cd8 exist so far. Lon Lon Ranch on Longshot, and this head, are unmeasured.
  • 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.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A row in docs/tested-configurations.md for each measured run (dukenheimer's two Longshot settings; the Hookshot runs predate Longshot).
  • A game-free test added for the fix, failing on main and passing here: tests/test_vcore_first.py added. It hasn't been run yet (it should fail at 19ad5cd8's turn and order cases, and pass here).
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths: not built at this head. The author built an earlier head on Windows only.
  • Docs updated.

🤖 Generated with Claude Code

## Why In OoT's busy frames, the Presentation Thread waits about 1 ms a frame for a "virtual core" before it can submit the frame to the GPU. By then the game's six BaseProcExecutors, woken by the same hand-off, have taken all six cores. The game is built for the Switch 2's six fixed cores, and on a PC that wait sits on the frame's critical path. In Hookshot, at Lon Lon Ranch with the late pages' wait as on main, this switch took 54.2 fps to 59.8. On Longshot it hasn't been measured where the gain is claimed yet. ## What changed - runtime (`host/hos_thread.c` only): - `HOS_VCORE_FIRST=<name part>,...` (off by default): a thread whose name contains one of the parts is a "first thread". It ranks above every guest priority, for the virtual cores only (`vc_prio`: its guest priority - 64, so first threads keep their guest order among themselves). A part is a case-sensitive substring of the thread name. Spaces around a part are skipped (`vc_first_named`). - A first waiter asks a worse holder for its core at once. A waiter that isn't a first thread never asks a first thread for its core at once, even one better by guest priority: it waits for the time slice (`HOS_VCORE_SLICE_US`, 2 ms) and then asks (kick 2). - A first thread's turns go by guest priority (`vc_turn`, `vc_yield`): on a YieldThread / SleepThread(0), or when a waiter as good by guest priority asks after the time slice, it gives its core to that waiter. Until it takes a core again, it then ranks by its guest priority (`vc_turned`, set in `vc_yield`, cleared in `vc_take`). So the waiter keeps the core for a time slice instead of being asked for it back at once. Without this, a first thread waiting by yielding for a job thread pinned to its own core kept that core until the 2 s escape. - The guest's own priority (GetThreadPriority, svc 0x0C) and the host thread priority are unchanged. - Names are matched at naming (`hos_thread_set_name`) and at creation (the boot thread's MainThread, `Thread_<entry>`). The flag is atomic, because another thread may name it. - The escape log shows the order the cores go by (a first thread's guest priority - 64). - tests: `tests/test_vcore_first.py`, a sibling of `test_vcore_escape.py`. It cuts `vc_first_named` and the virtual-core block out of the source and compiles them with a single-threaded harness, whose wait hook plays the holder answering. It pins these cases: - a first waiter asks a worse holder at once; - a first holder hands its core to an equal waiter, both on a yield and on the slice's ask at its checkpoint; - after that hand-over, it asks for the core back only after the time slice; - first threads keep their guest order; - a better guest waiter waits for the slice; - the name matching. - docs: the `docs/development.md` row says how a part matches and what the turn rules are. `docs/tested-configurations.md` gets dukenheimer's two Kokiri Forest rows. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `HOS_VCORE_FIRST` | Named threads take a virtual core before any other (`Presentation` for OoT's Presentation Thread); parts are case-sensitive substrings, comma-separated | off | Off by default for now, and on after more testing (for OoT, `Presentation`). It departs from console scheduling on purpose. | Frames over budget (Hookshot: 54.2 → 59.8 fps at the ranch with the late pages' wait) | Frames already at 60 (Hookshot: p95 -0.9 ms, median +0.2 ms); Kokiri Forest on a 24-thread i7-13700K (Longshot: no change outside noise) | Lon Lon Ranch on Longshot, AMD CPUs, CPUs with fewer than 8 threads, Linux, long sessions | Documented in `docs/development.md` beside the other `HOS_` thread switches. ## Measurements **This head (68df5714) is not measured.** Since the runs below, a first thread's turns changed: after it gives its core up by turn, it no longer asks for it back at once. That changes timing only when the Presentation Thread yields or uses up a slice with an equal waiter on its cores. The numbers below show the switch at 19ad5cd8, and Lon Lon Ranch on Longshot, where the gain is claimed, is still to measure. ### Longshot (dukenheimer, [comment](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15202)) Baseline: this PR @ 19ad5cd8, unset Why not main: the switch is off by default, so one build with the switch off and on isolates it. this PR: perf/vcore-first @ 19ad5cd8 (before this rebase), built as `3.0.0-rc.3-17-g19ad5cd8`; one full build, both arms from the same folder Hardware: RTX 5090 32 GB, NVIDIA 621.14; Core i7-13700K (8P + 8E, 24 threads), 98 GB; Windows 11 Pro; display 5120x2160 at 165 Hz Scene: Kokiri Forest from the autosave, a scripted moving route; 2048x1152 in a 1600x900 window. M1152: no frame generation, no game cap (`HOS_FPS_MAX=240`), mailbox. MP165: DLSS with DLSS frame generation 2x, cap 165, FIFO (fps is the game's own frames). Method: 2 interleaved rounds (unset, then `Presentation`, at each setting), 125 s a run. Frame times come from the renderer's frame log, 30–110 s after the first frame. CPU comes from `prof: threads in 10 s`, and escapes from every `vcores:` line. How to reproduce: any save in Kokiri Forest, the settings above, `HOS_VCORE_FIRST=Presentation` against unset. | M1152 | this PR @ 19ad5cd8, unset | this PR @ 19ad5cd8, `HOS_VCORE_FIRST=Presentation` | Δ (round 1, round 2) | |---|---|---|---| | fps (mean of 2) | 71.26 | 72.67 | +1.41 (+3.01, −0.19) | | p99 ms | 21.07 | 20.37 | −0.70 (−2.11, +0.71) | | p95 / median ms | 18.16 / 13.82 | 17.47 / 13.62 | | | frames over 50 ms | 0 | 0 | | | process: cores busy | 5.46 | 5.53 | +0.07 | | `vcores:` escapes | 0 | 0 | | | MP165 | this PR @ 19ad5cd8, unset | this PR @ 19ad5cd8, `HOS_VCORE_FIRST=Presentation` | Δ (round 1, round 2) | |---|---|---|---| | fps (mean of 2; game frames) | 69.56 | 69.46 | −0.09 (−1.38, +1.19) | | p99 ms | 21.54 | 21.32 | −0.22 (+0.16, −0.60) | | p95 / median ms | 18.58 / 14.18 | 18.23 / 14.28 | | | frames over 50 ms (per run) | 0.5 | 0.5 | | | process: cores busy | 5.60 | 5.61 | +0.02 | | `vcores:` escapes | 0 | 0 | | ``` lsp249-off-M1152-r0 fps 70.41 p99 22.86 lsp249-on-M1152-r0 fps 73.42 p99 20.75 lsp249-off-M1152-r1 fps 72.11 p99 19.28 lsp249-on-M1152-r1 fps 71.92 p99 19.99 lsp249-off-MP165-r0 fps 70.36 p99 21.20 lsp249-on-MP165-r0 fps 68.98 p99 21.36 lsp249-off-MP165-r1 fps 68.75 p99 21.88 lsp249-on-MP165-r1 fps 69.94 p99 21.28 [hos OptimizeChangeBanc] vcores: 556891 takes, 257868 waits (mean 21 us, longest 18.4 ms), 159894 kicks, 159843 yields, 0 escapes in 5 s (on, M1152, in play) [hos ModuleSystemWorker5] vcores: 545998 takes, 252961 waits (mean 22 us, longest 11.5 ms), 156633 kicks, 156005 yields, 0 escapes in 5 s (unset, the same) ``` Reading: M1152's +1.4 fps comes entirely from round 1, and run-to-run spread is about ±1.5 fps, so this is no change. MP165 is flat. 0 escapes. This scene is paced by the main thread's jobs, on a CPU with no core shortage, so no gain was expected (dukenheimer's [review](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-14845) shows the Presentation Thread waits 0.29 ms a frame for a core there). ### Hookshot (the author's, before the move to Longshot) Baseline: Hookshot @ 3434d990, unset Why not main: measured in Hookshot, before the move to Longshot (the build carried a TEMP commit that only logs). Hardware: RTX 4070 SUPER 12 GB, NVIDIA 617.42, i7-14700K, 32 GB, Windows 11 Pro 26200 Scene: Lon Lon Ranch at noon (`ranch-day3`), 2560x1440 DLAA, cap 60, unattended Method: frames.csv, presents 3480-3960, 3 interleaved runs a side; the critical path from HOS_CHAIN + HOS_SAMPLE_ALL | | Hookshot @ 3434d990, unset | Hookshot @ 3434d990, `HOS_VCORE_FIRST=Presentation` | |---|---|---| | late pages' wait as on main (`RX_VK_LATE_FIX=0`, Hookshot only): fps | 54.2 (18.39 / 18.44 / 18.50 ms mean) | 59.8 (16.74 / 16.68 / 16.73 ms) | | the same: late-pages wait a frame | 4.76 / 4.80 / 4.81 ms | 1.42 / 1.14 / 1.15 ms | | with the late pages' fix: fps | 59.4 | 59.8 | | the same: p95 frame | 19.58 / 20.06 / 19.82 ms | 18.97 / 18.92 / 19.08 ms | | the same: median frame | 16.29 / 16.31 / 16.27 ms | 16.51 / 16.50 / 16.54 ms | | Presentation Thread's core wait on the critical path, a frame | 1.01 ms | 0.03 ms | ## Testing **Tested:** - dukenheimer, on Longshot at 19ad5cd8 (test request T-GZ9B, pass): RTX 5090 / i7-13700K, Windows 11 Pro, Kokiri Forest, 11 runs (warm-ups included). No crash, hang, device loss or `VK_ERROR`, and 0 `vcores:` escapes. The luma gate showed 0 near-white shots on either side, and flashes stayed within main's usual range. - The author, in Hookshot before the move: 21 ranch runs with no crash, device loss or escape, and 0 flashes in 4 runs; one independent code audit. - Code review only for this push (68df5714): the turn fix, the first threads' order and the trimming were read, not run. **No tests, checks or builds were run on the PC that made this push.** `tests/test_vcore_first.py` was written and traced by hand, not run. The CI's Python checks (`ci.yml`) run only on a manual dispatch, so they haven't run on this head either. **Not tested:** - This head in the game: the turn fix has not been run. - Lon Lon Ranch on Longshot (cap 60, `HOS_CHAIN`), where the gain is claimed. - `tests/run_all.py` and the new check, here or in CI. - CPUs with fewer hardware threads, where taking a core from an executor may cost more; AMD; Linux; long sessions. **Needs other testers:** - A 6-core/12-thread or smaller CPU: the ranch with `HOS_VCORE_FIRST=Presentation` against unset, expecting no lower fps and 0 `vcores: ... escapes` (request to open). - Linux: the same check, expecting the same (request to open). ## Screenshots Nothing on screen. ## Risk and rollback Off by default: nothing changes unless the switch is set (with it unset, `vc_turned` is never set and `vc_prio` is the guest priority, as on main). If it's set, an executor asked for its core gives it up at its next checkpoint. That delays its job batch by a few hundred µs (Hookshot's +0.2 ms median at 60 fps). A game thread better by guest priority than a first thread that shares its cores waits up to the time slice (2 ms) for it, where Horizon would preempt at once. A broad part names many threads (`Thread` names `MainThread` and every unnamed `Thread_<entry>`). Unset the switch to turn it off. ## Reviewer notes The vcore code is `vc_acquire`, `vc_yield`, `vc_prio`, `vc_turn` and `vc_first_named` in `host/hos_thread.c`. The fix for review 1193's point 1 is `vc_turned`. ReplayPin and host affinity are unaffected: the virtual cores don't touch host affinity, and the replay runs with no core held. `tests/test_vcore_escape.py` compiles the same block. It needed no change (the struct gains one field), but it hasn't been run on this head. Whether the switch should become OoT's default is a maintainer question. dukenheimer's idea of applying the first rank only against threads at or below the first thread's own guest priority would keep the console's preemptions. It is left for a follow-up once the Presentation Thread's guest priority and mask are known (`HOS_TLS_TRACE=1`). ## Issues None. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [ ] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above): not run on this head. Nothing was run on the PC that pushed it, and CI's Python checks (`ci.yml`) run only on a manual dispatch. Left to that dispatch or the author. - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests): T-GZ9B passed (Kokiri Forest); the 6c/12t and Linux 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: **pending.** Only switch-off/on pairs at 19ad5cd8 exist so far. Lon Lon Ranch on Longshot, and this head, are unmeasured. - [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] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A row in `docs/tested-configurations.md` for each measured run (dukenheimer's two Longshot settings; the Hookshot runs predate Longshot). - [ ] A game-free test added for the fix, failing on `main` and passing here: `tests/test_vcore_first.py` added. It hasn't been run yet (it should fail at 19ad5cd8's turn and order cases, and pass here). - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths: not built at this head. The author built an earlier head on Windows only. - [x] Docs updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
OoT's Presentation Thread wakes when the main thread hands it the frame and needs a
virtual core for the guest code before its submit; the six BaseProcExecutors, woken by
the same hand-off, hold all six cores, and it waited ~1.2 ms for one twice in every slow
frame pair at Lon Lon Ranch, on the frame's critical path (HOS_CHAIN).

HOS_VCORE_FIRST=<name part>,...: those threads rank above every guest priority for the
virtual cores only (a waiter asks a holder for its core at once; no waiter takes theirs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit f9404ccd578592525692b7b686e983f3845e96cb)
- A first thread's YieldThread / SleepThread(0) gives its core to a waiter as good
  by guest priority, and after the time slice such a waiter may ask it for the core:
  a first thread waiting by yielding for a job thread pinned to its own core kept
  that core until the 2 s escape.
- The boot thread's MainThread and Thread_<entry> names are matched too.
- vc_first is read and written atomically (another thread may name it).
- The escape log shows the order the cores go by.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 69a7f70d5ede2917da0cd52b793bb92820e57b2c)
docs: HOS_VCORE_FIRST in the switch reference
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 16s
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
19ad5cd861
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
HumanBean added this to the v3.0.0 milestone 2026-11-13 17:49:31 +00:00
HumanBean requested changes 2026-11-13 17:52:58 +00:00
Dismissed
HumanBean left a comment

Thanks. The change is small and easy to follow, and it is well contained. With HOS_VCORE_FIRST unset it is the same as main: vc_first stays 0, so vc_prio is priority. vc_turn(w, t) comes down to the old w->priority <= t->priority. In vc_acquire's slice loop, vc_turn(t, owner) only runs when no holder is worse than t, so there it means the old ==. The cost is one plain load per comparison, plus a getenv at each naming. Matching by name is cheap: it happens at naming and at creation, not in the scheduler. The flag is atomic, the same way serial is. The escape log showing vc_prio is a useful touch. It doesn't overlap with #156 or #157 in code. Both are in nn_os_sync.c, and 156's hunk in hos_thread.c is in sleep_ns, far from here. But 156 changes how a condition-variable signal wakes its waiter, which is the hand-off this PR measures. Whichever lands second should measure again on top of the other.

Must change

  1. The fix for yielding still gives the core straight back to the first thread. vc_turn lets a waiter J take a first thread F's core when F yields, or after F's time slice, if J is as good by guest priority. That happens in vc_yield(F, 0), then vc_release, then vc_grant. But F then goes straight into vc_acquire, and with vc_prio(F) = -1 its first loop asks a non-first holder for its core with vc_kick = 1, without waiting for the slice. If J is the worst holder on F's allowed cores, F asks J. That is certain when F's mask is J's one core, which is exactly the case the audit found. J then gives the core back at its next checkpoint, its next nn::os call, SVC or polled loop header, because F is strictly better (vc_yield(J, 1)). So J no longer waits 2 s, but it runs only one checkpoint's worth of work per round trip through vc_lock and two hand-offs. That is close to a livelock while F waits for J by yielding. When F's mask spans more cores, the same immediate ask lands on some other executor instead. Either way, the line in the description and the docs that "its yields and time slices still follow guest priority" doesn't hold.
    A fix: after F gives its core up by turn (in vc_yield with any true, where the taker isn't strictly better by vc_prio), have F's next vc_acquire rank by guest priority until it gets a core. For example, a vc_turned flag that vc_take clears, and that vc_prio or the kick loop honours.
  2. There is no check for the new scheduling rules. tests/test_vcore_escape.py already compiles the whole block from vc_lock to hos_cv_sleep against a single-threaded harness. A sibling check in that style would pin three cases: a first waiter asks a worse holder at once; a first holder hands its core to an equal-priority waiter on a yield and after the slice; and, after case 1, F doesn't take that core back at once. The audit's hazard went into the code without a test, which is how case 1 got through.
  3. The checklist doesn't hold yet. "Rebased on current main" is ticked, but the fork point is 2e1e2e8c and main is at fbd42024. Nothing conflicts, since main's change is in gpu/vk, but the box is false. The full tests/run_all.py and the Linux build are still open. The PR says so itself, so it stays WIP.

Should change

  • Evidence. All the numbers come from Hookshot, on one 28-thread i7-14700K and one scene. The headline 54.2 → 59.8 fps is under RX_VK_LATE_FIX=0, which doesn't exist in Longshot. With the late pages' fix, the comparable setup, it is 59.4 → 59.8 fps from one figure a side: p95 is 0.7 to 0.9 ms better, and the median is about 0.2 ms worse. The ~1 ms core wait comes from a TEMP logging commit that isn't here. The code comment says "~1.2 ms twice in every slow frame pair" and the description says 1.01 ms: pick one. The Longshot A/B the PR already plans should report the vcore line's waits, kicks and escapes. It should also cover a frame-capped scene where the executors are the bottleneck, since that is where taking a core from an executor costs the most. The Deck or a 6c/12t run is worth asking for, and the switch doesn't change the vcore count (6), so the host is more oversubscribed there.
  • Two first threads lose their guest order. If two named threads both match, they are tied at -1: vc_better breaks the tie by wait time alone, and vc_turn(w, t) is true for any first w, even one worse by guest priority. To fix it, compare (first, priority, since) in vc_better. Matching is a case-sensitive substring, so a broad part names many threads at once. Thread matches MainThread and every unnamed Thread_<entry>. The docs row should say "case-sensitive substring" and warn against broad parts.
  • The header comment on vc_first_named says "no waiter takes theirs", which vc_turn now contradicts. Bring it in line with the rule as it ends up after point 1.
  • The docs row in docs/development.md repeats the claim about yields and slices. Fix it with point 1. The name HOS_VCORE_FIRST fits the other HOS_VCORE* switches, and its place in the table is right.
  • ReplayPin and affinity are unaffected: the virtual cores don't touch host affinity, and the replay runs with no core held. I'd say so in the reviewer notes, so the next reader doesn't have to check.

Not checked: the Presentation Thread's guest core mask and priority, and whether it waits by yielding. Those come from the game. Point 1's cost depends on them, but the hazard is in the code either way. Nothing was built or run for this review.

Thanks. The change is small and easy to follow, and it is well contained. With `HOS_VCORE_FIRST` unset it is the same as main: `vc_first` stays 0, so `vc_prio` is `priority`. `vc_turn(w, t)` comes down to the old `w->priority <= t->priority`. In `vc_acquire`'s slice loop, `vc_turn(t, owner)` only runs when no holder is worse than `t`, so there it means the old `==`. The cost is one plain load per comparison, plus a `getenv` at each naming. Matching by name is cheap: it happens at naming and at creation, not in the scheduler. The flag is atomic, the same way `serial` is. The escape log showing `vc_prio` is a useful touch. It doesn't overlap with https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/156 or https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/157 in code. Both are in `nn_os_sync.c`, and 156's hunk in `hos_thread.c` is in `sleep_ns`, far from here. But 156 changes how a condition-variable signal wakes its waiter, which is the hand-off this PR measures. Whichever lands second should measure again on top of the other. ## Must change 1. **The fix for yielding still gives the core straight back to the first thread.** `vc_turn` lets a waiter J take a first thread F's core when F yields, or after F's time slice, if J is as good by guest priority. That happens in `vc_yield(F, 0)`, then `vc_release`, then `vc_grant`. But F then goes straight into `vc_acquire`, and with `vc_prio(F) = -1` its first loop asks a non-first holder for its core with `vc_kick = 1`, without waiting for the slice. If J is the worst holder on F's allowed cores, F asks J. That is certain when F's mask is J's one core, which is exactly the case the audit found. J then gives the core back at its next checkpoint, its next nn::os call, SVC or polled loop header, because F is strictly better (`vc_yield(J, 1)`). So J no longer waits 2 s, but it runs only one checkpoint's worth of work per round trip through `vc_lock` and two hand-offs. That is close to a livelock while F waits for J by yielding. When F's mask spans more cores, the same immediate ask lands on some other executor instead. Either way, the line in the description and the docs that "its yields and time slices still follow guest priority" doesn't hold. A fix: after F gives its core up by turn (in `vc_yield` with `any` true, where the taker isn't strictly better by `vc_prio`), have F's next `vc_acquire` rank by guest priority until it gets a core. For example, a `vc_turned` flag that `vc_take` clears, and that `vc_prio` or the kick loop honours. 2. **There is no check for the new scheduling rules.** `tests/test_vcore_escape.py` already compiles the whole block from `vc_lock` to `hos_cv_sleep` against a single-threaded harness. A sibling check in that style would pin three cases: a first waiter asks a worse holder at once; a first holder hands its core to an equal-priority waiter on a yield and after the slice; and, after case 1, F doesn't take that core back at once. The audit's hazard went into the code without a test, which is how case 1 got through. 3. **The checklist doesn't hold yet.** "Rebased on current main" is ticked, but the fork point is 2e1e2e8c and main is at fbd42024. Nothing conflicts, since main's change is in `gpu/vk`, but the box is false. The full `tests/run_all.py` and the Linux build are still open. The PR says so itself, so it stays WIP. ## Should change - **Evidence.** All the numbers come from Hookshot, on one 28-thread i7-14700K and one scene. The headline 54.2 → 59.8 fps is under `RX_VK_LATE_FIX=0`, which doesn't exist in Longshot. With the late pages' fix, the comparable setup, it is 59.4 → 59.8 fps from one figure a side: p95 is 0.7 to 0.9 ms better, and the median is about 0.2 ms worse. The ~1 ms core wait comes from a TEMP logging commit that isn't here. The code comment says "~1.2 ms twice in every slow frame pair" and the description says 1.01 ms: pick one. The Longshot A/B the PR already plans should report the vcore line's waits, kicks and escapes. It should also cover a frame-capped scene where the executors are the bottleneck, since that is where taking a core from an executor costs the most. The Deck or a 6c/12t run is worth asking for, and the switch doesn't change the vcore count (6), so the host is more oversubscribed there. - **Two first threads lose their guest order.** If two named threads both match, they are tied at -1: `vc_better` breaks the tie by wait time alone, and `vc_turn(w, t)` is true for any first `w`, even one worse by guest priority. To fix it, compare `(first, priority, since)` in `vc_better`. Matching is a case-sensitive substring, so a broad part names many threads at once. `Thread` matches `MainThread` and every unnamed `Thread_<entry>`. The docs row should say "case-sensitive substring" and warn against broad parts. - **The header comment** on `vc_first_named` says "no waiter takes theirs", which `vc_turn` now contradicts. Bring it in line with the rule as it ends up after point 1. - **The docs row** in `docs/development.md` repeats the claim about yields and slices. Fix it with point 1. The name `HOS_VCORE_FIRST` fits the other `HOS_VCORE*` switches, and its place in the table is right. - **ReplayPin and affinity are unaffected:** the virtual cores don't touch host affinity, and the replay runs with no core held. I'd say so in the reviewer notes, so the next reader doesn't have to check. Not checked: the Presentation Thread's guest core mask and priority, and whether it waits by yielding. Those come from the game. Point 1's cost depends on them, but the hazard is in the code either way. Nothing was built or run for this review.
@ -264,6 +264,7 @@ Pipeline-cache file ownership, temporary storage and failure behavior are descri
| `RX_VK_NAN_GUARD=0` | No NaN guard on float colour outputs (on by default on every card: one NaN texel spreads through bloom into glowing blocks) |
| `RX_VK_FIFO=1`, `RX_VK_IMMEDIATE=1` | V-Sync On (FIFO) or Off (immediate) for one run, over the menu's V-Sync row |
| `HOS_VCORES=0`, `HOS_CORES=<1..8>`, `HOS_RESERVE_CORE=0` | No virtual cores; the cores the game sees (6); no core kept for the replay (one is, with 8 or more logical processors) |
| `HOS_VCORE_FIRST=<name part>,...` | The threads whose name holds one of the parts take a virtual core before any other thread (above every guest priority, for the virtual cores only; their yields and time slices still follow guest priority). For OoT, `Presentation`: its Presentation Thread no longer waits behind the six BaseProcExecutors (off by default) |
Owner

"their yields and time slices still follow guest priority": not yet, see the comment on vc_acquire's kick loop. Please also say it is a case-sensitive substring of the thread name.

"their yields and time slices still follow guest priority": not yet, see the comment on `vc_acquire`'s kick loop. Please also say it is a case-sensitive substring of the thread name.
@ -653,1 +657,4 @@
/* HOS_VCORE_FIRST=<name part>,... (experiment, off by default): the threads whose name holds one of the parts get a
virtual core before any other, as if their priority were above every guest priority (-1; Horizon's are 0-63): a
waiter asks a holder for its core at once, and no waiter takes theirs. The guest's own priority (GetThreadPriority)
Owner

"no waiter takes theirs" no longer holds with vc_turn. Also: "~1.2 ms twice in every slow frame pair" here, "1.01 ms" in the description. Which is it?

"no waiter takes theirs" no longer holds with `vc_turn`. Also: "~1.2 ms twice in every slow frame pair" here, "1.01 ms" in the description. Which is it?
@ -654,0 +669,4 @@
const size_t n = end ? (size_t)(end - p) : strlen(p);
char part[64];
snprintf(part, sizeof part, "%.*s", (int)(n < sizeof part ? n : sizeof part - 1), p);
if (part[0] && strstr(name, part)) return 1;
Owner

This is a case-sensitive substring match, so a broad part names many threads: Thread matches MainThread and every unnamed Thread_<entry>. Worth saying in the docs row.

This is a case-sensitive substring match, so a broad part names many threads: `Thread` matches `MainThread` and every unnamed `Thread_<entry>`. Worth saying in the docs row.
@ -1641,0 +1667,4 @@
/* w may take t's core in a turn (a yield of t's, or after the time slice): as good in that order, or, t being a first
thread, as good by guest priority: a first thread comes first, but its yields and slices stay as they were (a first
thread waiting by yielding for a job thread pinned to its own core kept that core until the 2 s escape) */
static int vc_turn(const HosThread *w, const HosThread *t) {
Owner

This makes the yield hand the core over, but the comment's promise that "its yields and slices stay as they were" fails one call later: see the comment on the kick loop in vc_acquire. Also, when both w and t are first threads, this is true whatever their guest priorities are, and vc_better breaks the tie by wait time alone. Comparing (first, priority, since) would keep the guest order among first threads.

This makes the yield hand the core over, but the comment's promise that "its yields and slices stay as they were" fails one call later: see the comment on the kick loop in `vc_acquire`. Also, when both `w` and `t` are first threads, this is true whatever their guest priorities are, and `vc_better` breaks the tie by wait time alone. Comparing `(first, priority, since)` would keep the guest order among first threads.
@ -1724,3 +1756,3 @@
for (int k = 0; k < vc_cores(); k++) {
HosThread *h = vc_owner[k];
if ((m >> k & 1) && h && h->priority > t->priority && (!worst || h->priority > worst->priority)) worst = h;
if ((m >> k & 1) && h && vc_prio(h) > vc_prio(t) && (!worst || vc_prio(h) > vc_prio(worst))) worst = h;
Owner

A first thread that just gave its core up by turn (vc_yield through vc_turn) comes straight back here with vc_prio = -1 and asks a non-first holder for its core with vc_kick = 1, without waiting for the slice. If that holder is the waiter it just yielded to (always the case when its mask is that one core), the waiter gives the core back at its next checkpoint (vc_yield(J, 1), F being strictly better). So the waiter gets one checkpoint of work per round trip, not its turn. Suggest that F's next acquire ranks by guest priority after a yield by turn (a flag that vc_take clears).

A first thread that just gave its core up by turn (`vc_yield` through `vc_turn`) comes straight back here with `vc_prio = -1` and asks a non-first holder for its core with `vc_kick = 1`, without waiting for the slice. If that holder is the waiter it just yielded to (always the case when its mask is that one core), the waiter gives the core back at its next checkpoint (`vc_yield(J, 1)`, F being strictly better). So the waiter gets one checkpoint of work per round trip, not its turn. Suggest that F's next acquire ranks by guest priority after a yield by turn (a flag that `vc_take` clears).
Contributor

Code review only; not built or run.

Edited: point 2's figures are now from a rerun at the usual 16:9 picture, 2048×1152, on the same PC, scene and settings (M1152) as the A/B below, with the switch unset. The first version quoted a run that drew 5:4 (1800×1440), because the PC's display mode had changed that hour. The figures moved a little, and I've added where the replay meets the main thread (the readbacks); the reading didn't change.

Thanks, it's a small and readable change. HumanBean's review covers the turn rules, the missing check, the order among first threads and the evidence. I have two additions: one behaviour change I didn't see raised, and data from a scene shaped differently from Lon Lon Ranch.

1. A thread better than a first thread by guest priority loses its preemption. In vc_acquire's first loop, a waiter asks only holders worse than it by vc_prio, and a first thread is -1 to everyone. So a waiter with a better guest priority than the Presentation Thread never asks it for its core at once.

  • When it matters: the first thread holds the only core in that waiter's mask, or every other holder there is at least as good as the waiter.
  • What happens: the waiter waits out the slice (HOS_VCORE_SLICE_US, 2 ms) and then gets a turn (kick 2). By HumanBean's point 1, it may then lose the core again at once.
  • Compared with the console: on Horizon it would have preempted at the holder's next checkpoint and kept the core until it blocked.

The PR departs from console scheduling on purpose, but this side of it isn't in the description: up to 2 ms of extra latency for any higher-priority game thread that shares the Presentation Thread's cores.

HumanBean listed the Presentation Thread's mask and priority as not checked. HOS_TLS_TRACE=1 logs each thread's ideal core, mask and priority when it starts, under its Thread_<entry> name before the game names it. That is enough to see which threads this touches, and whether the BaseProcExecutors share the Presentation Thread's guest priority or are above it. If they share it, the first rank could be applied only against threads at or below the first thread's own guest priority. That would keep the gain and the console's preemptions.

2. Data from a main-and-jobs-paced scene. This is from Kokiri Forest on Longshot 1ef93e79 (i7-13700K, RTX 5090, Windows, 2048×1152, uncapped, about 66 fps with the profiler on), with HOS_CHAIN and a sampled profile, the switch unset:

  • Its core waits are small here. The Presentation Thread waits 0.29 ms a frame for a virtual core on the frame's path. About 0.21 ms of that comes from BaseProcExecutors giving cores up, most of the rest from ModuleSystemWorkers.
  • It mostly has slack. It idles about 4.7 ms a frame waiting for the main thread's hand-off (30.7 % of its samples in ReceiveLightMessageQueue). Its replay reaches the main thread's cycle in two places: the frame sync, which the main thread waits on in about one frame in eight (0.09 ms a frame on average), and the game's readbacks, where job workers wait for GPU work the replay submitted (1.1 ms a frame here).
  • The main thread's jobs pace the frame. The main thread waits for its jobs 50 % of its time, about 7.6 ms a frame. The BaseProcExecutors are links in that chain: the ModuleSystemWorkers send them work through a light message queue and wait for their signal.
  • The virtual cores are busy. About 670 waits and 400 kicks a frame, 0 escapes.

In a scene paced like this, the switch would move about 0.3 ms a frame of core time from the chain that paces the frame to the Presentation Thread, and the replay reaches the main thread only through those two waits. I'd expect no gain there, and possibly a small loss. The A/B below found no change outside run-to-run noise in this scene, which fits. Lon Lon Ranch's slow frame pairs may well be shaped differently.

The A/B below covers such a scene. For Lon Lon Ranch, HOS_CHAIN's per-thread waits beside the vcores line would show where its slow frames wait.

Nit: the parts aren't trimmed, so HOS_VCORE_FIRST=Presentation, Main looks for " Main" with its leading space and never matches. Either trim the parts, or say "no spaces" in the docs row.

Code review only; not built or run. *Edited:* point 2's figures are now from a rerun at the usual 16:9 picture, 2048×1152, on the same PC, scene and settings (M1152) as the A/B below, with the switch unset. The first version quoted a run that drew 5:4 (1800×1440), because the PC's display mode had changed that hour. The figures moved a little, and I've added where the replay meets the main thread (the readbacks); the reading didn't change. Thanks, it's a small and readable change. HumanBean's review covers the turn rules, the missing check, the order among first threads and the evidence. I have two additions: one behaviour change I didn't see raised, and data from a scene shaped differently from Lon Lon Ranch. **1. A thread better than a first thread by guest priority loses its preemption.** In `vc_acquire`'s first loop, a waiter asks only holders worse than it by `vc_prio`, and a first thread is -1 to everyone. So a waiter with a better guest priority than the Presentation Thread never asks it for its core at once. - **When it matters:** the first thread holds the only core in that waiter's mask, or every other holder there is at least as good as the waiter. - **What happens:** the waiter waits out the slice (`HOS_VCORE_SLICE_US`, 2 ms) and then gets a turn (kick 2). By HumanBean's point 1, it may then lose the core again at once. - **Compared with the console:** on Horizon it would have preempted at the holder's next checkpoint and kept the core until it blocked. The PR departs from console scheduling on purpose, but this side of it isn't in the description: up to 2 ms of extra latency for any higher-priority game thread that shares the Presentation Thread's cores. HumanBean listed the Presentation Thread's mask and priority as not checked. `HOS_TLS_TRACE=1` logs each thread's ideal core, mask and priority when it starts, under its `Thread_<entry>` name before the game names it. That is enough to see which threads this touches, and whether the BaseProcExecutors share the Presentation Thread's guest priority or are above it. If they share it, the first rank could be applied only against threads at or below the first thread's own guest priority. That would keep the gain and the console's preemptions. **2. Data from a main-and-jobs-paced scene.** This is from Kokiri Forest on Longshot 1ef93e79 (i7-13700K, RTX 5090, Windows, 2048×1152, uncapped, about 66 fps with the profiler on), with `HOS_CHAIN` and a sampled profile, the switch unset: - **Its core waits are small here.** The Presentation Thread waits 0.29 ms a frame for a virtual core on the frame's path. About 0.21 ms of that comes from BaseProcExecutors giving cores up, most of the rest from ModuleSystemWorkers. - **It mostly has slack.** It idles about 4.7 ms a frame waiting for the main thread's hand-off (30.7 % of its samples in `ReceiveLightMessageQueue`). Its replay reaches the main thread's cycle in two places: the frame sync, which the main thread waits on in about one frame in eight (0.09 ms a frame on average), and the game's readbacks, where job workers wait for GPU work the replay submitted (1.1 ms a frame here). - **The main thread's jobs pace the frame.** The main thread waits for its jobs 50 % of its time, about 7.6 ms a frame. The BaseProcExecutors are links in that chain: the ModuleSystemWorkers send them work through a light message queue and wait for their signal. - **The virtual cores are busy.** About 670 waits and 400 kicks a frame, 0 escapes. In a scene paced like this, the switch would move about 0.3 ms a frame of core time from the chain that paces the frame to the Presentation Thread, and the replay reaches the main thread only through those two waits. I'd expect no gain there, and possibly a small loss. The A/B below found no change outside run-to-run noise in this scene, which fits. Lon Lon Ranch's slow frame pairs may well be shaped differently. The A/B below covers such a scene. For Lon Lon Ranch, `HOS_CHAIN`'s per-thread waits beside the vcores line would show where its slow frames wait. **Nit:** the parts aren't trimmed, so `HOS_VCORE_FIRST=Presentation, Main` looks for " Main" with its leading space and never matches. Either trim the parts, or say "no spaces" in the docs row.
Contributor

Measured on Longshot: RTX 5090 and i7-13700K, Windows 11, HOS_VCORE_FIRST=Presentation against unset

No regression, and 0 escapes. On this PC and scene the switch doesn't move fps or p99 outside run-to-run noise.

this PR: 19ad5cd8, built as 3.0.0-rc.3-17-g19ad5cd8. One build, both arms from the same folder; only the switch differs.
Baseline: this PR @ 19ad5cd8 with HOS_VCORE_FIRST unset. Why not main: the switch is off by default, and an on/off pair in one build isolates it, as your Hookshot table did.
Hardware: RTX 5090 32 GB, NVIDIA 621.14; Core i7-13700K (8P + 8E cores, 24 threads), 98 GB; Windows 11 Pro; display 5120x2160 at 165 Hz
Scene: Kokiri Forest from the autosave, a scripted moving route (running, circling and camera pans from 28 s). Output 2048x1152 in a 1600x900 window, two settings:

  • M1152: no frame generation, no game cap (HOS_FPS_MAX=240), mailbox: every frame runs as fast as the critical path allows.
  • MP165: DLSS with DLSS frame generation 2x, cap 165, FIFO. The fps below is the game's own frames; the screen shows about twice as many.

Method:

  • One full build (nx2/make_game.py, shaders prepared), warm-up runs at both settings.
  • 2 interleaved rounds (unset, then Presentation, at each setting), 125 s a run.
  • Frame times from the renderer's frame log, 30–110 s after the first frame.
  • CPU from the game's own prof: threads in 10 s lines: every thread at ≥5% of a core, summed over the lines ending at 40–110 s.
  • Escapes summed from every vcores: line of each run.
    How to reproduce: any save in Kokiri Forest, the settings above, HOS_VCORE_FIRST=Presentation against unset. The route is our bench's scripted input (HOS_INPUT_SCRIPT), not a repo script.
M1152 this PR @ 19ad5cd8, unset this PR @ 19ad5cd8, HOS_VCORE_FIRST=Presentation Δ (round 1, round 2)
fps (mean of 2) 71.26 72.67 +1.41 (+3.01, −0.19)
p99 ms 21.07 20.37 −0.70 (−2.11, +0.71)
p95 / median ms 18.16 / 13.82 17.47 / 13.62
frames over 50 ms 0 0
process: cores busy 5.46 5.53 +0.07 (+0.17, −0.03)
sead::MainThread / Presentation Thread, cores 0.46 / 0.64 0.47 / 0.66
vcores: escapes 0 0
MP165 unset Presentation Δ (round 1, round 2)
fps (mean of 2; game frames) 69.56 69.46 −0.09 (−1.38, +1.19)
p99 ms 21.54 21.32 −0.22 (+0.16, −0.60)
p95 / median ms 18.58 / 14.18 18.23 / 14.28
frames over 50 ms (per run) 0.5 0.5
process: cores busy 5.60 5.61 +0.02 (+0.13, −0.10)
vcores: escapes 0 0
lsp249-off-M1152-r0  fps 70.41  p99 22.86     lsp249-on-M1152-r0  fps 73.42  p99 20.75
lsp249-off-M1152-r1  fps 72.11  p99 19.28     lsp249-on-M1152-r1  fps 71.92  p99 19.99
lsp249-off-MP165-r0  fps 70.36  p99 21.20     lsp249-on-MP165-r0  fps 68.98  p99 21.36
lsp249-off-MP165-r1  fps 68.75  p99 21.88     lsp249-on-MP165-r1  fps 69.94  p99 21.28
[hos OptimizeChangeBanc] vcores: 556891 takes, 257868 waits (mean 21 us, longest 18.4 ms), 159894 kicks, 159843 yields, 0 escapes in 5 s   (on, M1152, in play)
[hos ModuleSystemWorker5] vcores: 545998 takes, 252961 waits (mean 22 us, longest 11.5 ms), 156633 kicks, 156005 yields, 0 escapes in 5 s   (unset, the same)

Reading it:

  • M1152's +1.4 fps is all round 1. Round 2 is −0.2, and run-to-run spread here is about ±1.5 fps, so I read it as no change, not a gain.
  • MP165: flat.
  • Why no gain shows here: it may be the scene or the CPU. This route at 2048x1152 runs ~14 ms frames, and with 24 threads there's no core shortage. The vcores: lines show the same contention in both arms (~250k waits in 5 s, mean 21–24 µs). Your gain was at the ranch, with the late pages' wait as on main and frames over budget at a 60 cap. That case isn't reproduced here. I didn't run HOS_CHAIN, so I can't say how long the Presentation Thread waits for a core on this PC.

Checked, not measured:

  • No crash, hang, device-lost or VK_ERROR line in the 11 runs (warm-ups included).
  • Round-1 shots, luma gate (mean luma of the in-game shots): M1152 64 / 63, MP165 51 / 63. 0 near-white shots on either side.
  • Flashes in the frame log, per 1,000 frames: M1152 55.2 / 53.7, MP165 20.8 / 16.4. Both are within main's usual range.

Not tested:

  • Lon Lon Ranch, a 60 cap, RX_VK_LATE_FIX=0, and HOS_CHAIN.
  • A 6-core/12-thread or smaller CPU (I didn't restrict the game's affinity), AMD, Linux, and long sessions.
## Measured on Longshot: RTX 5090 and i7-13700K, Windows 11, `HOS_VCORE_FIRST=Presentation` against unset No regression, and 0 escapes. On this PC and scene the switch doesn't move fps or p99 outside run-to-run noise. this PR: 19ad5cd8, built as `3.0.0-rc.3-17-g19ad5cd8`. One build, both arms from the same folder; only the switch differs. Baseline: this PR @ 19ad5cd8 with `HOS_VCORE_FIRST` unset. Why not main: the switch is off by default, and an on/off pair in one build isolates it, as your Hookshot table did. Hardware: RTX 5090 32 GB, NVIDIA 621.14; Core i7-13700K (8P + 8E cores, 24 threads), 98 GB; Windows 11 Pro; display 5120x2160 at 165 Hz Scene: Kokiri Forest from the autosave, a scripted moving route (running, circling and camera pans from 28 s). Output 2048x1152 in a 1600x900 window, two settings: - **M1152**: no frame generation, no game cap (`HOS_FPS_MAX=240`), mailbox: every frame runs as fast as the critical path allows. - **MP165**: DLSS with DLSS frame generation 2x, cap 165, FIFO. The fps below is the game's own frames; the screen shows about twice as many. Method: - One full build (`nx2/make_game.py`, shaders prepared), warm-up runs at both settings. - 2 interleaved rounds (unset, then `Presentation`, at each setting), 125 s a run. - Frame times from the renderer's frame log, 30–110 s after the first frame. - CPU from the game's own `prof: threads in 10 s` lines: every thread at ≥5% of a core, summed over the lines ending at 40–110 s. - Escapes summed from every `vcores:` line of each run. How to reproduce: any save in Kokiri Forest, the settings above, `HOS_VCORE_FIRST=Presentation` against unset. The route is our bench's scripted input (`HOS_INPUT_SCRIPT`), not a repo script. | M1152 | this PR @ 19ad5cd8, unset | this PR @ 19ad5cd8, `HOS_VCORE_FIRST=Presentation` | Δ (round 1, round 2) | |---|---|---|---| | fps (mean of 2) | 71.26 | 72.67 | +1.41 (+3.01, −0.19) | | p99 ms | 21.07 | 20.37 | −0.70 (−2.11, +0.71) | | p95 / median ms | 18.16 / 13.82 | 17.47 / 13.62 | | | frames over 50 ms | 0 | 0 | | | process: cores busy | 5.46 | 5.53 | +0.07 (+0.17, −0.03) | | `sead::MainThread` / Presentation Thread, cores | 0.46 / 0.64 | 0.47 / 0.66 | | | `vcores:` escapes | 0 | 0 | | | MP165 | unset | `Presentation` | Δ (round 1, round 2) | |---|---|---|---| | fps (mean of 2; game frames) | 69.56 | 69.46 | −0.09 (−1.38, +1.19) | | p99 ms | 21.54 | 21.32 | −0.22 (+0.16, −0.60) | | p95 / median ms | 18.58 / 14.18 | 18.23 / 14.28 | | | frames over 50 ms (per run) | 0.5 | 0.5 | | | process: cores busy | 5.60 | 5.61 | +0.02 (+0.13, −0.10) | | `vcores:` escapes | 0 | 0 | | ``` lsp249-off-M1152-r0 fps 70.41 p99 22.86 lsp249-on-M1152-r0 fps 73.42 p99 20.75 lsp249-off-M1152-r1 fps 72.11 p99 19.28 lsp249-on-M1152-r1 fps 71.92 p99 19.99 lsp249-off-MP165-r0 fps 70.36 p99 21.20 lsp249-on-MP165-r0 fps 68.98 p99 21.36 lsp249-off-MP165-r1 fps 68.75 p99 21.88 lsp249-on-MP165-r1 fps 69.94 p99 21.28 [hos OptimizeChangeBanc] vcores: 556891 takes, 257868 waits (mean 21 us, longest 18.4 ms), 159894 kicks, 159843 yields, 0 escapes in 5 s (on, M1152, in play) [hos ModuleSystemWorker5] vcores: 545998 takes, 252961 waits (mean 22 us, longest 11.5 ms), 156633 kicks, 156005 yields, 0 escapes in 5 s (unset, the same) ``` **Reading it:** - **M1152's +1.4 fps** is all round 1. Round 2 is −0.2, and run-to-run spread here is about ±1.5 fps, so I read it as no change, not a gain. - **MP165:** flat. - **Why no gain shows here:** it may be the scene or the CPU. This route at 2048x1152 runs ~14 ms frames, and with 24 threads there's no core shortage. The `vcores:` lines show the same contention in both arms (~250k waits in 5 s, mean 21–24 µs). Your gain was at the ranch, with the late pages' wait as on main and frames over budget at a 60 cap. That case isn't reproduced here. I didn't run `HOS_CHAIN`, so I can't say how long the Presentation Thread waits for a core on this PC. **Checked, not measured:** - No crash, hang, device-lost or `VK_ERROR` line in the 11 runs (warm-ups included). - Round-1 shots, luma gate (mean luma of the in-game shots): M1152 64 / 63, MP165 51 / 63. 0 near-white shots on either side. - Flashes in the frame log, per 1,000 frames: M1152 55.2 / 53.7, MP165 20.8 / 16.4. Both are within main's usual range. **Not tested:** - Lon Lon Ranch, a 60 cap, `RX_VK_LATE_FIX=0`, and `HOS_CHAIN`. - A 6-core/12-thread or smaller CPU (I didn't restrict the game's affinity), AMD, Linux, and long sessions.
HumanBean requested changes 2026-11-14 00:38:50 +00:00
Dismissed
HumanBean left a comment

Code review only; nothing built or run. PR 249 at 19ad5cd8, the same head as the earlier review.

Nothing has been pushed since the earlier review, so its must-change points still apply. I checked each one again against 19ad5cd8:

Must change

  1. A first thread that yields its core takes it straight back (earlier review, point 1): still open. In vc_yield(F, 0), vc_turn hands F's core to an equal-guest-priority waiter J. F then goes into vc_acquire with vc_prio(F) = -1. Its first loop finds J as a worse holder and sets vc_kick = 1 without waiting for the slice. J's next checkpoint gives the core back (vc_yield(J, 1), since F is strictly better). So the 2 s escape is gone, but J gets one checkpoint of work per round trip. The comment on vc_turn and the docs row both say "yields and time slices still follow guest priority". That isn't true yet.
  2. No check for the new rules (earlier review, point 2): still open. Nothing under tests/ changed. A sibling of tests/test_vcore_escape.py should cover three cases: a first waiter asks a worse holder at once; a first holder hands its core over on a yield and after the slice; and after that hand-over, F doesn't take the core straight back.
  3. "Rebased on current main" is ticked but false. The PR forks at 2e1e2e8c, and main is now 16047f55, 12 commits ahead. It merges cleanly (git merge-tree), and main's changes to docs/development.md are in other hunks, but the box should be unticked until it's rebased.

Should change

  • Order among first threads, and the header comment (earlier review): both still open. Two matched threads tie at -1, so vc_better falls back to wait time and vc_turn(w, t) is true for any first w. The comment on vc_first_named still says "no waiter takes theirs". It also says "~1.2 ms" where the description says 1.01 ms.
  • Parts aren't trimmed (dukenheimer's nit): still open. See the inline comment.
  • Fold in the Longshot A/B. dukenheimer measured this head on Longshot (i7-13700K, RTX 5090, Kokiri Forest, 2 interleaved rounds): flat, 0 escapes. The description still says "the Longshot A/B ... is queued". Put those numbers in Measurements as Longshot's evidence for now. Say it was an on/off pair in one build rather than against main, and add the docs/tested-configurations.md rows. Lon Lon Ranch on Longshot, where the gain is claimed, is still unmeasured.
  • CI hasn't run. At 19ad5cd8, runs 1786 (Python checks) and 1787 (third-party) are blocked, waiting for approval. Only the description check passed. The run_all.py box rightly stays unticked until the checks run, here or locally.

The HOS_VCORE_FIRST-unset path is still the same as main. That part is fine.

Code review only; nothing built or run. PR 249 at 19ad5cd8, the same head as the earlier review. Nothing has been pushed since the earlier review, so its must-change points still apply. I checked each one again against 19ad5cd8: ## Must change 1. **A first thread that yields its core takes it straight back** (earlier review, point 1): still open. In `vc_yield(F, 0)`, `vc_turn` hands F's core to an equal-guest-priority waiter J. F then goes into `vc_acquire` with `vc_prio(F) = -1`. Its first loop finds J as a worse holder and sets `vc_kick = 1` without waiting for the slice. J's next checkpoint gives the core back (`vc_yield(J, 1)`, since F is strictly better). So the 2 s escape is gone, but J gets one checkpoint of work per round trip. The comment on `vc_turn` and the docs row both say "yields and time slices still follow guest priority". That isn't true yet. 2. **No check for the new rules** (earlier review, point 2): still open. Nothing under `tests/` changed. A sibling of `tests/test_vcore_escape.py` should cover three cases: a first waiter asks a worse holder at once; a first holder hands its core over on a yield and after the slice; and after that hand-over, F doesn't take the core straight back. 3. **"Rebased on current `main`" is ticked but false.** The PR forks at 2e1e2e8c, and `main` is now 16047f55, 12 commits ahead. It merges cleanly (`git merge-tree`), and `main`'s changes to `docs/development.md` are in other hunks, but the box should be unticked until it's rebased. ## Should change - **Order among first threads, and the header comment** (earlier review): both still open. Two matched threads tie at -1, so `vc_better` falls back to wait time and `vc_turn(w, t)` is true for any first `w`. The comment on `vc_first_named` still says "no waiter takes theirs". It also says "~1.2 ms" where the description says 1.01 ms. - **Parts aren't trimmed** (dukenheimer's nit): still open. See the inline comment. - **Fold in the Longshot A/B.** dukenheimer measured this head on Longshot (i7-13700K, RTX 5090, Kokiri Forest, 2 interleaved rounds): flat, 0 escapes. The description still says "the Longshot A/B ... is queued". Put those numbers in Measurements as Longshot's evidence for now. Say it was an on/off pair in one build rather than against `main`, and add the `docs/tested-configurations.md` rows. Lon Lon Ranch on Longshot, where the gain is claimed, is still unmeasured. - **CI hasn't run.** At 19ad5cd8, runs [1786](https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/1786) (Python checks) and [1787](https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/1787) (third-party) are *blocked*, waiting for approval. Only the description check passed. The `run_all.py` box rightly stays unticked until the checks run, here or locally. The `HOS_VCORE_FIRST`-unset path is still the same as `main`. That part is fine.
@ -264,6 +264,7 @@ Pipeline-cache file ownership, temporary storage and failure behavior are descri
| `RX_VK_NAN_GUARD=0` | No NaN guard on float colour outputs (on by default on every card: one NaN texel spreads through bloom into glowing blocks) |
| `RX_VK_FIFO=1`, `RX_VK_IMMEDIATE=1` | V-Sync On (FIFO) or Off (immediate) for one run, over the menu's V-Sync row |
| `HOS_VCORES=0`, `HOS_CORES=<1..8>`, `HOS_RESERVE_CORE=0` | No virtual cores; the cores the game sees (6); no core kept for the replay (one is, with 8 or more logical processors) |
| `HOS_VCORE_FIRST=<name part>,...` | The threads whose name holds one of the parts take a virtual core before any other thread (above every guest priority, for the virtual cores only; their yields and time slices still follow guest priority). For OoT, `Presentation`: its Presentation Thread no longer waits behind the six BaseProcExecutors (off by default) |
Owner

Still says "their yields and time slices still follow guest priority". At 19ad5cd8 that isn't so: after a yield by turn, vc_acquire asks the new holder for the core back at once (see the earlier review's comment on the kick loop). Reword it once point 1 is fixed, and say how a part matches: a case-sensitive substring of the thread name.

Still says "their yields and time slices still follow guest priority". At 19ad5cd8 that isn't so: after a yield by turn, `vc_acquire` asks the new holder for the core back at once (see the earlier review's comment on the kick loop). Reword it once point 1 is fixed, and say how a part matches: a case-sensitive substring of the thread name.
@ -654,0 +669,4 @@
const size_t n = end ? (size_t)(end - p) : strlen(p);
char part[64];
snprintf(part, sizeof part, "%.*s", (int)(n < sizeof part ? n : sizeof part - 1), p);
if (part[0] && strstr(name, part)) return 1;
Owner

The parts aren't trimmed, so HOS_VCORE_FIRST=Presentation, Main looks for " Main", leading space included, and never matches it (dukenheimer's nit, still open). Skip spaces around each part, or say "comma-separated, no spaces, case-sensitive substring" in the docs row.

The parts aren't trimmed, so `HOS_VCORE_FIRST=Presentation, Main` looks for " Main", leading space included, and never matches it (dukenheimer's nit, still open). Skip spaces around each part, or say "comma-separated, no spaces, case-sensitive substring" in the docs row.
HumanBean force-pushed perf/vcore-first from 19ad5cd861
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 16s
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 f20959eca2
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Third-party parts / As their notes say (pull_request) Successful in 6s
PR description / Description follows the template (pull_request_target) Successful in 8s
2026-11-14 00:54:24 +00:00
Compare
Owner

Pushed f20959ec: rebased onto main @ e65cb1e0 (no conflicts), plus one fix commit for review 1193 (and 1143). No tests, checks or builds were run on the PC that pushed it: the change and the new check were read and traced by hand.

Per point:

  1. Yields and slices follow guest priority. When a first thread gives its core up by turn (a yield, or the slice's ask from a waiter as good by guest priority), vc_yield sets vc_turned, and vc_prio then ranks it at its guest priority until vc_take clears the flag. So its next vc_acquire doesn't ask the new holder at once (vc_kick = 1). It asks only after the time slice, and the waiter keeps the core for its slice. With the switch unset, vc_turned is never set, and vc_yield's scan stops where it did.

  2. A check. tests/test_vcore_first.py is a sibling of test_vcore_escape.py: it cuts vc_first_named and the vcore block out of the source and compiles them with stubs and a wait hook. It pins these cases:

    • a first waiter asks a worse holder at once;
    • a first holder hands its core over, both on a yield and on the slice's ask at its checkpoint;
    • after that, it asks for the core back only after the slice;
    • first threads keep their guest order;
    • a better guest waiter waits for the slice;
    • the name matching and the unset order.

    It hasn't been run.

  3. Rebased onto e65cb1e0.

Should-change:

  • First threads' order: vc_prio is now the guest priority - 64 for a first thread, so two first threads keep their guest order in vc_better and vc_turn.
  • Header comment: rewritten to the rules as they are now. It gives 1.01 ms a frame, as the description does.
  • Parts: trimmed of the spaces around them.
  • Docs row: it now says a part is a case-sensitive substring of the thread name, warns against broad parts, and states the turn rules and the better-guest-waiter case truthfully.
  • dukenheimer's Kokiri Forest A/B (at 19ad5cd8): in Measurements, and in two docs/tested-configurations.md rows.

Still WIP:

  • This head hasn't been run in the game.
  • run_all.py, with the new check, hasn't run: CI's Python checks need a manual dispatch.
  • Lon Lon Ranch on Longshot, where the gain is claimed, is unmeasured.

What's needed is a run of this head at the ranch (cap 60), with HOS_VCORE_FIRST=Presentation against unset, ≥2 interleaved rounds, the vcores: lines and HOS_CHAIN. A run against main at e65cb1e0 would settle the Measured box.

Pushed f20959ec: rebased onto `main` @ e65cb1e0 (no conflicts), plus one fix commit for [review 1193](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15511) (and 1143). **No tests, checks or builds were run on the PC that pushed it**: the change and the new check were read and traced by hand. Per point: 1. **Yields and slices follow guest priority.** When a first thread gives its core up by turn (a yield, or the slice's ask from a waiter as good by guest priority), `vc_yield` sets `vc_turned`, and `vc_prio` then ranks it at its guest priority until `vc_take` clears the flag. So its next `vc_acquire` doesn't ask the new holder at once (`vc_kick = 1`). It asks only after the time slice, and the waiter keeps the core for its slice. With the switch unset, `vc_turned` is never set, and `vc_yield`'s scan stops where it did. 2. **A check.** `tests/test_vcore_first.py` is a sibling of `test_vcore_escape.py`: it cuts `vc_first_named` and the vcore block out of the source and compiles them with stubs and a wait hook. It pins these cases: - a first waiter asks a worse holder at once; - a first holder hands its core over, both on a yield and on the slice's ask at its checkpoint; - after that, it asks for the core back only after the slice; - first threads keep their guest order; - a better guest waiter waits for the slice; - the name matching and the unset order. It hasn't been run. 3. **Rebased** onto e65cb1e0. Should-change: - **First threads' order:** `vc_prio` is now the guest priority - 64 for a first thread, so two first threads keep their guest order in `vc_better` and `vc_turn`. - **Header comment:** rewritten to the rules as they are now. It gives 1.01 ms a frame, as the description does. - **Parts:** trimmed of the spaces around them. - **Docs row:** it now says a part is a case-sensitive substring of the thread name, warns against broad parts, and states the turn rules and the better-guest-waiter case truthfully. - **dukenheimer's Kokiri Forest A/B (at 19ad5cd8):** in Measurements, and in two `docs/tested-configurations.md` rows. Still WIP: - This head hasn't been run in the game. - `run_all.py`, with the new check, hasn't run: CI's Python checks need a manual dispatch. - Lon Lon Ranch on Longshot, where the gain is claimed, is unmeasured. What's needed is a run of this head at the ranch (cap 60), with `HOS_VCORE_FIRST=Presentation` against unset, ≥2 interleaved rounds, the `vcores:` lines and `HOS_CHAIN`. A run against `main` at e65cb1e0 would settle the Measured box.
HumanBean force-pushed perf/vcore-first from f20959eca2
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Third-party parts / As their notes say (pull_request) Successful in 6s
PR description / Description follows the template (pull_request_target) Successful in 8s
to 68df571467
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 5s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
PR description / Description follows the template (pull_request_target) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 7s
Website / Website on Cloudflare (pull_request_target) Has been skipped
2026-11-14 00:56:35 +00:00
Compare
Owner

Rebased again onto main @ b7a46d6b, which moved after the last push (an OoT input fix, nothing in these files): the head is now 68df5714, with the same changes as f20959ec. The Measurements' baseline lines now name their columns exactly. Still no tests, checks or builds run on the PC that pushed it.

Rebased again onto `main` @ b7a46d6b, which moved after the last push (an OoT input fix, nothing in these files): the head is now 68df5714, with the same changes as f20959ec. The Measurements' baseline lines now name their columns exactly. Still no tests, checks or builds run on the PC that pushed it.
HumanBean left a comment

Light re-review of the fix commit 68df5714 only, against review 1193. I read and traced it by hand and ran nothing.

The code points are resolved.

  • Yield and slice hand-over. F (first, guest 16) yields to J (16), or answers J's kick 2 at its checkpoint. vc_yield sets vc_turned, so F waits at 16. F's vc_acquire asks J only after the slice (kick 2), and J's own vc_yield hands the core back (vc_turn 16 ≤ 16). vc_take clears the flag on every path that hands out a core: a free core, a grant, or a free core found mid-wait. A holder is never turned. So no thread stays ranked at guest priority, nothing waits that didn't wait before, and no wake-up is lost. vc_turned is written and read only under vc_lock.
  • The flag only matters on yield and slice. It is set only when any && !better, so a strict kick-1 yield to a better first thread leaves it down, as it should.
  • Unset is main's behaviour. vc_first and vc_turned stay 0, so vc_prio is priority and vc_turn is <=. The new loop still stops at the first allowed waiter (!(any && !first)), with the same any as main's.
  • priority - 64. It maps 0..63 to -64..-1, which is below every guest rank, and first threads keep their guest order in vc_better, vc_turn and the worst-holder scan.
  • It compiles. By reading, it is plain C99 with nothing new to include, so llvm-mingw and Linux should both take it. The C++17 fixture takes it too.
  • tests/test_vcore_first.py. All four markers are in 68df5714's source, and they are cut the way test_vcore_escape.py cuts them. getenv is macro'd after the includes, so HOS_VCORES and the slice fall back to their defaults. RUN_ALL_ARGS matches its sibling's, so run_all picks it up. I traced cases 1–5 and the name cases, and they pass. Case 3 has J still inside its own vc_acquire while it holds the core, but the end state comes out right.
  • The docs row, header comment, description and tested-configurations rows match the code and dukenheimer's numbers.

Two non-blocking nits are inline.

Still needed before ready: run_all.py (or CI's dispatch) on this head, the Linux and llvm-mingw builds, and a run of 68df5714 at Lon Lon Ranch (cap 60) with HOS_VCORE_FIRST=Presentation against unset, at least 2 interleaved rounds, with the vcores: lines.

Light re-review of the fix commit 68df5714 only, against [review 1193](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15511). I read and traced it by hand and ran nothing. **The code points are resolved.** - **Yield and slice hand-over.** F (first, guest 16) yields to J (16), or answers J's kick 2 at its checkpoint. `vc_yield` sets `vc_turned`, so F waits at 16. F's `vc_acquire` asks J only after the slice (kick 2), and J's own `vc_yield` hands the core back (`vc_turn` 16 ≤ 16). `vc_take` clears the flag on every path that hands out a core: a free core, a grant, or a free core found mid-wait. A holder is never turned. So no thread stays ranked at guest priority, nothing waits that didn't wait before, and no wake-up is lost. `vc_turned` is written and read only under `vc_lock`. - **The flag only matters on yield and slice.** It is set only when `any && !better`, so a strict kick-1 yield to a better first thread leaves it down, as it should. - **Unset is main's behaviour.** `vc_first` and `vc_turned` stay 0, so `vc_prio` is `priority` and `vc_turn` is `<=`. The new loop still stops at the first allowed waiter (`!(any && !first)`), with the same `any` as main's. - **`priority - 64`.** It maps 0..63 to -64..-1, which is below every guest rank, and first threads keep their guest order in `vc_better`, `vc_turn` and the worst-holder scan. - **It compiles.** By reading, it is plain C99 with nothing new to include, so llvm-mingw and Linux should both take it. The C++17 fixture takes it too. - **`tests/test_vcore_first.py`.** All four markers are in 68df5714's source, and they are cut the way `test_vcore_escape.py` cuts them. `getenv` is macro'd after the includes, so `HOS_VCORES` and the slice fall back to their defaults. `RUN_ALL_ARGS` matches its sibling's, so `run_all` picks it up. I traced cases 1–5 and the name cases, and they pass. Case 3 has J still inside its own `vc_acquire` while it holds the core, but the end state comes out right. - **The docs row, header comment, description and tested-configurations rows** match the code and dukenheimer's numbers. Two non-blocking nits are inline. **Still needed before ready:** `run_all.py` (or CI's dispatch) on this head, the Linux and llvm-mingw builds, and a run of 68df5714 at Lon Lon Ranch (cap 60) with `HOS_VCORE_FIRST=Presentation` against unset, at least 2 interleaved rounds, with the `vcores:` lines.
@ -1657,6 +1702,7 @@ static void vc_take(HosThread *t, int c) {
t->vc_held = c + 1;
t->vc_last = c + 1;
t->vc_kick = 0;
t->vc_turned = 0; /* (a first thread that gave its core up by turn comes first again) */
Owner

Nit: vc_turned isn't cleared on vc_acquire's escape path (line 1793, outside the diff), only here. A first thread whose wait after a turn escapes keeps the flag while it runs without a core. Its next wait, after its next block, then ranks at guest priority, until vc_take. A t->vc_turned = 0; beside t->vc_escaped = 1; keeps "until it takes one" exact. It only matters after a 2 s escape.

Nit: `vc_turned` isn't cleared on `vc_acquire`'s escape path (line 1793, outside the diff), only here. A first thread whose wait after a turn escapes keeps the flag while it runs without a core. Its next wait, after its next block, then ranks at guest priority, until `vc_take`. A `t->vc_turned = 0;` beside `t->vc_escaped = 1;` keeps "until it takes one" exact. It only matters after a 2 s escape.
@ -1791,0 +1840,4 @@
/* a first thread that gives its core up by turn (to a waiter no better by vc_prio) asks for one by guest priority
until it takes one: otherwise its vc_acquire would ask the waiter for the core back at once (vc_kick 1), and the
waiter would run one checkpoint's work per turn instead of its time slice */
if (any && !better && first) t->vc_turned = 1;
Owner

Nit, only with two or more first threads: whether F gives its core up is decided by J, a waiter as good by guest priority. vc_grant then hands the core to the best waiter by vc_prio. That can be a first thread F2 that is worse by guest priority than F (guest 20 → -44, beating J's 16). F2 itself doesn't qualify (vc_turn(F2, F) is false), so F's turn goes to a thread it outranks by guest priority, and F waits at 16 for a slice. It doesn't happen with Presentation alone, so a line in the comment is enough.

Nit, only with two or more first threads: whether F gives its core up is decided by J, a waiter as good by guest priority. `vc_grant` then hands the core to the best waiter by `vc_prio`. That can be a first thread F2 that is *worse* by guest priority than F (guest 20 → -44, beating J's 16). F2 itself doesn't qualify (`vc_turn(F2, F)` is false), so F's turn goes to a thread it outranks by guest priority, and F waits at 16 for a slice. It doesn't happen with `Presentation` alone, so a line in the comment is enough.
HumanBean dismissed HumanBean's review 2026-11-14 01:09:48 +00:00
Reason:

Superseded by the re-review at #249 (comment): the code points this review asked for are resolved. What's left is runs and boxes, listed there.

HumanBean dismissed HumanBean's review 2026-11-14 01:09:48 +00:00
Reason:

Superseded by the re-review at #249 (comment): the code points this review asked for are resolved. What's left is runs and boxes, listed there.

mooki left a comment

Read head 68df5714 against main cf605c85, the complete diff, scheduler and checkpoint callers, all comments and all three earlier reviews; the new test and 11 related checks pass here, with GCC and Clang for all 10 compiled drivers. The Windows log-writer check was skipped, as was the UTF-8 check's Windows executable probe. No game or full build was run.

What's good

  • The scheduling cost exists: a thread must acquire a virtual core before returning to guest code, and main chooses holders and waiters by guest priority. The replay itself still releases its virtual core. This changes the order of guest work, not the amount of replay work.
  • Name matching happens at creation and renaming, not at each checkpoint. Guest priority, host priority and affinity are unchanged. The switch remains opt-in.
  • The description now separates the older Hookshot result, the Longshot switch comparison and the unmeasured current head.

Earlier review points

Against #249 (comment) and #249 (comment):

  1. Immediate reacquisition after yielding: answered for the single-first-thread case. vc_turned lowers the returning thread to guest priority until vc_take. The new test executes both voluntary yielding and the checkpoint response to a slice request, and passes with both compilers.
  2. Missing scheduling test: answered by tests/test_vcore_first.py; it now has an actual passing run. The existing escape and invalidate checks also pass unchanged. Against main's source, the new test stops at the missing vc_first_named marker before compiling, so that control is not a behavioral reproduction of the old turn bug.
  3. Order among first threads, trimming, comments and switch documentation: answered. The test exercises guest order among first threads, space trimming and case-sensitive matching. The header and description agree on the historical wait figure; the replay and affinity note is present.
  4. Longshot evidence: incorporated with its actual measured head, 19ad5cd8. The requested current-head ranch comparison, full suite and Linux/llvm-mingw builds remain open. The latest source-check jobs are skipped, not passing: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/2878.
  5. The two latest inline nits: unchanged. The multi-first-thread grant choice and vc_turned surviving an escape are already recorded at #249 (comment) and #249 (comment); I am not raising them again as new findings.

Must change

No additional merge-blocking code defect found. The outstanding readiness checks above still need to be completed before this leaves WIP.

Measurement and smaller points

  • The published Longshot numbers support the restrained conclusion, not a speed-up: M1152 changes by +3.01 fps in one round and -0.19 in the other; MP165 changes by -1.38 and +1.19. They are interleaved runs of one older build with the switch off and on, not main against this head. The description correctly leaves the Measured box unticked. Use the same scene and settings for at least two alternating runs per side on named current commits, report the median and spread, and retain the core-wait, kick and escape lines. #156 has now merged, so the earlier review's request to measure after that hand-off change applies.
  • Move the additions at docs/tested-configurations.md, lines 17-18, into separate run files under docs/tested/. Main now freezes the old table. Give each measured run its own identifier and full context rather than as above. The real merge check reports a conflict in this file; this is not just the forge's WIP mergeability flag. The rebased checkbox needs updating accordingly.
  • The new fixture uses a simulated clock and no-op locks. It checks the stated scheduling choices, but does not establish behavior under concurrent renaming, contention across several cores or real Windows synchronization.

Not checked

No game, full suite, full host build, Windows execution, AMD or lower-thread-count CPU, long-session or visual testing here. I checked the published run figures, not raw frame captures or screenshots. A current-head Lon Lon Ranch comparison at the 60 fps cap, plus a constrained-CPU run, would decide whether the shorter presentation wait outweighs the time taken from the job workers.

Read head `68df5714` against main `cf605c85`, the complete diff, scheduler and checkpoint callers, all comments and all three earlier reviews; the new test and 11 related checks pass here, with GCC and Clang for all 10 compiled drivers. The Windows log-writer check was skipped, as was the UTF-8 check's Windows executable probe. No game or full build was run. ### What's good - The scheduling cost exists: a thread must acquire a virtual core before returning to guest code, and main chooses holders and waiters by guest priority. The replay itself still releases its virtual core. This changes the order of guest work, not the amount of replay work. - Name matching happens at creation and renaming, not at each checkpoint. Guest priority, host priority and affinity are unchanged. The switch remains opt-in. - The description now separates the older Hookshot result, the Longshot switch comparison and the unmeasured current head. ### Earlier review points Against https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15511 and https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15656: 1. **Immediate reacquisition after yielding:** answered for the single-first-thread case. `vc_turned` lowers the returning thread to guest priority until `vc_take`. The new test executes both voluntary yielding and the checkpoint response to a slice request, and passes with both compilers. 2. **Missing scheduling test:** answered by `tests/test_vcore_first.py`; it now has an actual passing run. The existing escape and invalidate checks also pass unchanged. Against main's source, the new test stops at the missing `vc_first_named` marker before compiling, so that control is not a behavioral reproduction of the old turn bug. 3. **Order among first threads, trimming, comments and switch documentation:** answered. The test exercises guest order among first threads, space trimming and case-sensitive matching. The header and description agree on the historical wait figure; the replay and affinity note is present. 4. **Longshot evidence:** incorporated with its actual measured head, `19ad5cd8`. The requested current-head ranch comparison, full suite and Linux/llvm-mingw builds remain open. The latest source-check jobs are skipped, not passing: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/2878. 5. **The two latest inline nits:** unchanged. The multi-first-thread grant choice and `vc_turned` surviving an escape are already recorded at https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15654 and https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/249#issuecomment-15655; I am not raising them again as new findings. ### Must change No additional merge-blocking code defect found. The outstanding readiness checks above still need to be completed before this leaves WIP. ### Measurement and smaller points - The published Longshot numbers support the restrained conclusion, not a speed-up: M1152 changes by +3.01 fps in one round and -0.19 in the other; MP165 changes by -1.38 and +1.19. They are interleaved runs of one older build with the switch off and on, not main against this head. The description correctly leaves the Measured box unticked. Use the same scene and settings for at least two alternating runs per side on named current commits, report the median and spread, and retain the core-wait, kick and escape lines. https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/156 has now merged, so the earlier review's request to measure after that hand-off change applies. - Move the additions at `docs/tested-configurations.md`, lines 17-18, into separate run files under `docs/tested/`. Main now freezes the old table. Give each measured run its own identifier and full context rather than `as above`. The real merge check reports a conflict in this file; this is not just the forge's WIP mergeability flag. The rebased checkbox needs updating accordingly. - The new fixture uses a simulated clock and no-op locks. It checks the stated scheduling choices, but does not establish behavior under concurrent renaming, contention across several cores or real Windows synchronization. ### Not checked No game, full suite, full host build, Windows execution, AMD or lower-thread-count CPU, long-session or visual testing here. I checked the published run figures, not raw frame captures or screenshots. A current-head Lon Lon Ranch comparison at the 60 fps cap, plus a constrained-CPU run, would decide whether the shorter presentation wait outweighs the time taken from the job workers.
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 5s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
PR description / Description follows the template (pull_request_target) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 7s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request has changes conflicting with the target branch.
  • docs/tested-configurations.md
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 'perf/vcore-first:GummiNaomi-perf/vcore-first'
git switch 'GummiNaomi-perf/vcore-first'
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!249
No description provided.