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

Draft
GummiNaomi wants to merge 3 commits from GummiNaomi/Longshot:perf/vcore-first into main
First-time 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. 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 frames already over budget it also delays the GPU work enough to make the late-pages wait longer: at Lon Lon Ranch, with the late pages' wait as on main, this switch took 54.2 fps to 59.8.

What changed

  • runtime (host/hos_thread.c only):
    • HOS_VCORE_FIRST=<name part>,... (off by default): threads whose name contains one of the parts rank above every guest priority, for the virtual cores only (vc_prio). A waiter asks a holder for its core at once, and no waiter takes theirs.
    • The guest's own priority (GetThreadPriority, svc 0x0C) and the host thread priority are unchanged.
  • A first thread keeps the console's turn rules by guest priority (vc_turn): its 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. 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 (an audit found it).
  • 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.

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) off off by default for now; on after more testing (for OoT, Presentation). It departs from console scheduling on purpose. frames over budget (54.2 → 59.8 fps at the ranch with the late pages' wait) frames already at 60 (p95 -0.9 ms, median +0.2 ms) AMD CPUs, CPUs with fewer than 8 threads, Linux, long sessions

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

Measurements

WIP: the Longshot A/B (main @ 2e1e2e8c against this PR with HOS_VCORE_FIRST=Presentation) is queued. The numbers below are from Hookshot: the same code, one build, the switch off and on.

Baseline: Hookshot test/game-cpu @ 3434d990, HOS_VCORE_FIRST unset
Why not main: measured before the move to Longshot (the build carried a TEMP measuring 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 (gc-chain-off / gc-chain-first)

baseline (switch unset) HOS_VCORE_FIRST=Presentation
late pages' wait as on main (RX_VK_LATE_FIX=0): fps (3 runs) 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 1.01 ms 0.03 ms

The critical path of a 17.6 ms ranch frame: game code ~4.8 ms, the game's job polls ~2.8, the host scheduler ~1.0, host sync ~0.1, renderer and GPU ~8.8. HOS_CORES=8 changed nothing: the game pins its six executors to cores 0-5 itself.

Testing

Tested:

  • The game-free checks that read host/hos_thread.c: 12 pass. 2 fail on this PC, and fail on main here too: test_invalidate_vcore, test_sdl_pad_diagnostics don't build with this PC's compiler.
  • In the game: 21 ranch runs with no crash, device loss or escape. The flash detector found 0 flashes in 4 runs. A smoke run after the audit fixes: slow stretch 59.1 fps against 57.4, 0 escapes.
  • One independent code audit: no deadlock or race. It found the yield hazard and the creation-name gap, both fixed.

Not tested:

  • A player's session with this exact commit (queued).
  • Other scenes.
  • CPUs with fewer hardware threads, where taking a core from an executor may cost more.
  • Linux.

Needs other testers:

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

Screenshots

Nothing on screen.

Risk and rollback

Off by default: nothing changes unless the switch is set. If it's set, an executor asked for its core gives it up at its next checkpoint, which delays its job batch by a few hundred µs (the +0.2 ms median seen at 60 fps). Unset the switch to turn it off.

Reviewer notes

The vcore code is vc_acquire, vc_yield, vc_prio and vc_turn in host/hos_thread.c. Whether it should become OoT's default is a maintainer question.

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: the checks reading hos_thread.c ran. 2 fail here and fail on main here too (named above). The full run is still to do.
  • 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): 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: the Longshot A/B.
  • 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 (with the Longshot A/B).
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths: built on Windows (emsdk clang) only. Linux builds still to do.
## 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. 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 frames already over budget it also delays the GPU work enough to make the late-pages wait longer: at Lon Lon Ranch, with the late pages' wait as on main, this switch took 54.2 fps to 59.8. ## What changed - runtime (`host/hos_thread.c` only): - `HOS_VCORE_FIRST=<name part>,...` (off by default): threads whose name contains one of the parts rank above every guest priority, for the virtual cores only (`vc_prio`). A waiter asks a holder for its core at once, and no waiter takes theirs. - The guest's own priority (GetThreadPriority, svc 0x0C) and the host thread priority are unchanged. - A first thread keeps the console's turn rules by guest priority (`vc_turn`): its 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. 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 (an audit found it). - 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. ## 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) | off | off by default for now; on after more testing (for OoT, `Presentation`). It departs from console scheduling on purpose. | frames over budget (54.2 → 59.8 fps at the ranch with the late pages' wait) | frames already at 60 (p95 -0.9 ms, median +0.2 ms) | AMD CPUs, CPUs with fewer than 8 threads, Linux, long sessions | Documented in `docs/development.md` beside the other `HOS_` thread switches. ## Measurements **WIP: the Longshot A/B (main @ 2e1e2e8c against this PR with `HOS_VCORE_FIRST=Presentation`) is queued.** The numbers below are from Hookshot: the same code, one build, the switch off and on. Baseline: `Hookshot test/game-cpu @ 3434d990, HOS_VCORE_FIRST unset` Why not main: measured before the move to Longshot (the build carried a TEMP measuring 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 (gc-chain-off / gc-chain-first) | | baseline (switch unset) | `HOS_VCORE_FIRST=Presentation` | |---|---|---| | late pages' wait as on main (`RX_VK_LATE_FIX=0`): fps (3 runs) | 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 | 1.01 ms | 0.03 ms | The critical path of a 17.6 ms ranch frame: game code ~4.8 ms, the game's job polls ~2.8, the host scheduler ~1.0, host sync ~0.1, renderer and GPU ~8.8. `HOS_CORES=8` changed nothing: the game pins its six executors to cores 0-5 itself. ## Testing **Tested:** - The game-free checks that read `host/hos_thread.c`: 12 pass. 2 fail on this PC, and fail on main here too: `test_invalidate_vcore`, `test_sdl_pad_diagnostics` don't build with this PC's compiler. - In the game: 21 ranch runs with no crash, device loss or escape. The flash detector found 0 flashes in 4 runs. A smoke run after the audit fixes: slow stretch 59.1 fps against 57.4, 0 escapes. - One independent code audit: no deadlock or race. It found the yield hazard and the creation-name gap, both fixed. **Not tested:** - A player's session with this exact commit (queued). - Other scenes. - CPUs with fewer hardware threads, where taking a core from an executor may cost more. - Linux. **Needs other testers:** - A 6-core/12-thread or smaller CPU: the ranch with `HOS_VCORE_FIRST=Presentation` against unset. Expect no lower fps and 0 `vcores: ... escapes` (request to open). - Linux: the same check (request to open). ## Screenshots Nothing on screen. ## Risk and rollback Off by default: nothing changes unless the switch is set. If it's set, an executor asked for its core gives it up at its next checkpoint, which delays its job batch by a few hundred µs (the +0.2 ms median seen at 60 fps). Unset the switch to turn it off. ## Reviewer notes The vcore code is `vc_acquire`, `vc_yield`, `vc_prio` and `vc_turn` in `host/hos_thread.c`. Whether it should become OoT's default is a maintainer question. ## 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: the checks reading `hos_thread.c` ran. 2 fail here and fail on main here too (named above). The full run is still to do. - [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): 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: the Longshot A/B.** - [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). - [ ] A row in `docs/tested-configurations.md` for each measured run (with the Longshot A/B). - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths: built on Windows (emsdk clang) only. Linux builds still to do.
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 are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 16s
Website / Website on Cloudflare (pull_request_target) Has been skipped
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 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).
First-time contributor

Code review only; not built or run.

Edited: the Kokiri profile quoted below drew a 5:4 picture, 1800×1440, not 2048×1152 as I first wrote: the PC's display mode had changed that hour. At the usual 16:9 picture the same route runs at about 74 fps. A rerun of the profile there is queued, and I'll update the numbers if they move.

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, 1800×1440 (5:4), uncapped, about 79 fps), with HOS_CHAIN and a sampled profile, the switch unset:

  • Its core waits are small here. The Presentation Thread waits 0.22 ms a frame for a virtual core on the frame's path. About 0.15 ms of that comes from BaseProcExecutors giving cores up, the rest from ModuleSystemWorkers.
  • It has slack. It idles about 3.2 ms a frame waiting for the main thread's hand-off (25.7 % of its samples in ReceiveLightMessageQueue).
  • The main thread's jobs pace the frame. The main thread waits for its jobs 51 % of its time, about 6.4 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 580 waits and 340 kicks a frame, 0 escapes.

In a scene paced like this, the switch would move about 0.2 ms a frame of core time from the chain that paces the frame to a thread that would have waited for the main thread anyway. I'd expect no gain there, and possibly a small loss. Lon Lon Ranch's slow frame pairs may well be shaped differently.

The planned A/B would read best with one uncapped, main-and-jobs-paced scene like this beside Lon Lon Ranch. It would also help to report HOS_CHAIN's per-thread waits along with the vcores line.

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:* the Kokiri profile quoted below drew a 5:4 picture, 1800×1440, not 2048×1152 as I first wrote: the PC's display mode had changed that hour. At the usual 16:9 picture the same route runs at about 74 fps. A rerun of the profile there is queued, and I'll update the numbers if they move. 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, 1800×1440 (5:4), uncapped, about 79 fps), with `HOS_CHAIN` and a sampled profile, the switch unset: - **Its core waits are small here.** The Presentation Thread waits 0.22 ms a frame for a virtual core on the frame's path. About 0.15 ms of that comes from BaseProcExecutors giving cores up, the rest from ModuleSystemWorkers. - **It has slack.** It idles about 3.2 ms a frame waiting for the main thread's hand-off (25.7 % of its samples in `ReceiveLightMessageQueue`). - **The main thread's jobs pace the frame.** The main thread waits for its jobs 51 % of its time, about 6.4 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 580 waits and 340 kicks a frame, 0 escapes. In a scene paced like this, the switch would move about 0.2 ms a frame of core time from the chain that paces the frame to a thread that would have waited for the main thread anyway. I'd expect no gain there, and possibly a small loss. Lon Lon Ranch's slow frame pairs may well be shaped differently. The planned A/B would read best with one uncapped, main-and-jobs-paced scene like this beside Lon Lon Ranch. It would also help to report `HOS_CHAIN`'s per-thread waits along with the vcores line. **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.
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 16s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request is marked as a work in progress.
Some workflows are waiting to be reviewed.
This branch is out-of-date with the base branch
View command line instructions

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
3 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.