fix(vcores): a thread starved of its core gets it after 1 s, not an escape after 2 s (#262) #594
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8015c9a
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!594
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:fix/vcore-escape-262b"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: fix · Risk: medium · Tested on: Linux, RTX 4090, Core i9-13900K (windowless Kokiri Forest save loads; the game-free fixture with g++ and clang++); Windows only a syntax check of the fixture · Needs: Windows with an AMD or NVIDIA card (T-9R2R)
Why
While a save loads, some of the game's worker threads wait the full 2 seconds for the virtual core they are pinned to and then run without one, and the log says
ran without a core after 2000 ms(#262). The core is not stuck: better threads take it tens of thousands of times a second and the waiting thread cannot ask any of them to give it up. This lets such a thread have the core after 1 second instead.What is measured: the cause, by counting every take of the core during the waits (26 waits of 1 s or more in 12 starts, 24 of them this pattern and none a single long hold), and the fix, by 10 starts with it against 10 starts of the same build with the old rule (0 of 10 starts with an escape against 4 of 10; no core wait past 1.002 s against waits of 2.0 s). Not measured: Windows, any other GPU or CPU, play after loading, and how much work a starved thread gets done in its turn.
How to reproduce
Seen on: Linux, Core i9-13900K, 96 GB RAM, RTX 4090 24 GB with NVIDIA 615.78.08, Arch Linux 7.2.15, no window. The same escape line is in the lines posted on the issue (22 of 22), in a player's log of a 16 GB Linux PC with an RX 9070 XT (#91), and in rows in
docs/tested-configurations.mdfrom a Windows PC with a Ryzen 7 5700X3D, which list 3 and 4 core escapes in runs that loaded a save.Settings and switches: Kokiri Forest (child), a copied save loaded from the title to play, 1920x1080, Custom preset, render 100%, the game's own upscaler, frame generation off, no frame cap, shaders prepared; the scheduler as on main, which is
HOS_VCORE_STARVE_US=0.Steps: copy the game folder, put the change's
host/hos_thread.con the commit under test, build, load a Kokiri Forest save from the title, quit once play begins, and countran without a coreinlongshot_log.txt.Expected: no wait of 2 seconds for a core and no
ran without a coreline: the 2 s escape is meant only as a deadlock breaker, and the core is not stuck.Actual: while the save loads, some of the game's worker threads wait the full 2 seconds for the virtual core they are pinned to and run without one, and the log says
vcores: bake::ModuleMgr (priority 28) ran without a core after 2000.2 ms: core 1: BaseProcCreateAndDeleteThread (23);. With the old rule 4 of 10 starts had an escape line (7 lines); main's own binary, ten starts earlier the same day, had 3 of 10 starts with an escape and 8 escape lines.What changed
A thread that has waited
HOS_VCORE_STARVE_US(1 s by default) for its virtual core is marked starved: it goes before every other waiter at the next release of a core it may use and asks the worst holder of those cores to give the core up, whatever the holder's priority. Below the starve time the order is unchanged, the 2 s escape and its two switches are unchanged, andHOS_VCORE_STARVE_US=0turns the rule off, which is main's behaviour. The 5-secondvcores:line now counts the grants to starved waiters, andtests/test_vcore_escape.pygets new cases in the harness that compiles the real virtual-core block.host/hos_thread.c,vc_acquire: a waiter that has waitedHOS_VCORE_STARVE_US(1 s by default) is marked starved. A starved waiter asks the worst holder of the cores it may use to give the core up, whatever the holder's priority, the way a better waiter already asks a worse holder.vc_better(used byvc_grant): a starved waiter goes before every other waiter at the next release of a core it may use, the one waiting longest first among starved ones. Below the starve time the order is unchanged: lower priority value first, then the one waiting longest.vc_yield: a holder asked to give up its core does so for a starved waiter, as it does for a better one.vc_takeand the escape path clear the mark.vcores:line counts the grants to starved waiters (starved grants, before the escapes).docs/development.md: the new switchHOS_VCORE_STARVE_US.tests/test_vcore_escape.py: new cases in the harness that compiles the real virtual-core block (details under Testing). The existing cases are unchanged and pass. The fixture now defines_CRT_SECURE_NO_WARNINGSbefore its first include, because the block reads its switches withgetenvand the Windows-target syntax check refuses the fixture without it (main's own fixture has the same fault).docs/tested/2026-11-14-vcore-starve-mooki-run01.mdandrun02.md: the two arms of the comparison below, one row each.HOS_VCORE_ESCAPE_US,HOS_VCORE_ESCAPE_EQ_US), the time slice (HOS_VCORE_SLICE_US),HOS_VCORES, what happens after an escape, and every thread's priority and mask.The chain of causes
hos_cv_sleep,hos_sleep_ns,vc_yieldat a checkpoint). A free core goes to the best waiter allowed on it (vc_grant).vc_acquire). A worker pinned to one core with a priority of 25 to 29 (PreActorModelCompose, BakeProbe, bake::ModuleMgr, tera::FileResourceMgr, aglTextureDataMgr::Compaction and PlayReporter are the ones seen) can ask nobody on that core.vc_acquirelets the waiter run without a core, beside the holder, so two threads share the per-core state of that core. The escape is meant only as a deadlock breaker (see the comment abovevc_lock); here it fires on a wait that is not a deadlock, and it is what the log line reports.The holder named at an escape is better than the waiter in every line anyone has posted: 22 of 22 on the issue, 44 of 44 in a player's log of a 16 GB Linux PC with an RX 9070 XT (#91, the attachment
longshot_log.txt, which also hasvcores: 1352 takes, 866 waits (mean 64434 us, longest 2001.0 ms), 184 kicks, 172 yields, 13 escapes in 5 s; that log is of a machine under memory pressure and 1.7 frames a second, so it shows the same line and the same kind of holder, not the same count), and 8 of 8 in the first ten plain starts here. Rows indocs/tested-configurations.mdfrom a Windows PC with a Ryzen 7 5700X3D also list 3 and 4 core escapes in runs that loaded a save.Reviewer notes:
vc_better, the starved branch ofvc_acquireandvc_yield, then the cases at the end oftests/test_vcore_escape.py(starvation()).int vc_held, vc_last, vc_queued, vc_escaped,vc_allowed,vc_free_forand the harness lines oftests/test_vcore_escape.pythat this also edits: textual conflicts, and a mask-0 thread that may use every core is simply a starved waiter on all of them. #249 (HOS_VCORE_FIRST, work in progress) rewritesvc_betterand addsvc_prio: a textual conflict there, and a first thread would rank below a starved waiter after 1 s. The pull requests for macOS (#288, #477, #490) have no hunk in the virtual-core block. A trial merge of this branch with main as of today (82886e89b) has no conflicts.Measurements
Verdict: the escape is gone and no core wait lasted past 1.002 s with the change: 0 of 10 starts had a
ran without a coreline against 4 of 10 with the old rule (HOS_VCORE_STARVE_US=0on the same binary), and the old rule had waits of 2.0 s in 4 of 10 starts. The count alone is weak (Fisher's exact test, p = 0.087 two-sided); the cap is the stronger evidence. No frame time or loading time is claimed (start to play did not move: median 43.1 s against 43.2 s, in ordinary runs on a shared PC). Not measured: Windows, any other GPU or CPU, and play after loading.Branch state: on main
82886e89b970daf89a07535eaec083c6018e32ae; head0e5fcecd5c66dd7733b42a051c3a6553d9c47465; whole game-free suite on this head:552 passed, 9 failed, 16 skipped(main82886e89b970daf89a07535eaec083c6018e32ae:552 passed, 9 failed, 16 skipped). The 9 failing tests fail on main the same way:tests/test_appimage_player_build.py,tests/test_cpm_discovery.py,tests/test_env_on.py,tests/test_lift_cleanup_error.py,tests/test_linux_preflight.py,tests/test_make_game_cache.py,tests/test_readback_late.py,tests/test_vram_budget_row.py,tests/test_walk_key.py. The commits are unchanged by rebases apart from the results table.main:
26a2eab003(2026-11-14)this PR: fix/vcore-escape-262-m543846eb @
53a80e735Hardware: Core i9-13900K, 96 GB RAM, RTX 4090 24 GB with NVIDIA 615.78.08, Arch Linux 7.2.15, no window
Scene: Kokiri Forest (child), a copied save loaded from the title to play, 1920x1080, Custom preset, render 100%, the game's own upscaler, frame generation off, no frame cap, shaders prepared
Method: 10 starts of each arm, interleaved start by start (three starts to one hold), 5 s standing after play began; the counts come from
longshot_log.txt(ran without a corelines, the largestlongestin the 5 svcores:lines,starved grants). Ordinary runs on a shared PC with other work around (load 7 to 49 at the starts), so no frame time or loading time is claimedHow to reproduce: copy the game folder, put the change's
host/hos_thread.con the commit under test, build, load a Kokiri Forest save from the title, quit once play begins, and countran without a coreinlongshot_log.txt; the second arm is the same folder withHOS_VCORE_STARVE_US=0Baseline: there is no main column. The before column is the same build with
HOS_VCORE_STARVE_US=0, which makes the scheduler decide as main does. It was used because the two arms could be interleaved start by start on one binary. The two builds are this change applied to main26a2eab00(not rebuilt on53a80e735:host/hos_thread.c's scheduler block is the same on both bases). Main's own binary, ten starts earlier the same day: 3 of 10 starts with an escape, 8 escape lines.26a2eab0053a80e735ran without a corelineFisher's exact test on starts with an escape (0 of 10 against 4 of 10) gives p = 0.087 two-sided, so that count alone is weak. The cap is the stronger evidence: every wait of every start of this PR ended by 1.002 s, and the old rule had waits of 2.0 s in 4 of 10 starts. The diagnostic build's counts above say why. Every run exited 0 with no fatal line and no kernel line. The first diagnostic build had 0 escapes in 12 starts and I cannot say why (its second version, with the same scheduler, had 15 in 5 of 12).
Screenshots
Nothing on screen. For the record, the last frame of one start of each arm (this change, 10 grants and no escape; old rule, 4 escapes): the same Kokiri Forest view, nothing different:
Flags and settings
HOS_VCORE_STARVE_US=<microseconds>is new, documented indocs/development.md. 0 turns the rule off, which is main's behaviour.HOS_VCORE_STARVE_USTesting
Tested: the new cases in
tests/test_vcore_escape.pyboth ways, with g++ and with clang++: on main'shost/hos_thread.cfour checks fail (the starved thread escapes after 2 s in both fixtures, so it neither gets the core nor takes the other one), on this branch the file printsok; the same fixture withHOS_VCORE_STARVE_US=0still escapes, which the test requires, and before the starve time the better waiter still goes first. Main's own test file passes unchanged against the new code. The whole game-free suite once on this head: 554 passed, 1 failed, 16 skipped in 153 s; the one failure,tests/test_walk_key.py(a link error,undefined reference to hos_oot_autoload_input), fails the same way on main543846ebband is unrelated to this change. The Windows-target syntax check of the changed fixture (clang from llvm-mingw 20261104) passes; the fixture was not run on Windows. In the game: 12 starts of a diagnostic build, 10 plain starts of main, and the 20 interleaved starts above, windowless on an RTX 4090 with NVIDIA 615.78.08 on Arch Linux. The last frame of one start of each arm shows the same Kokiri Forest view.Built for Windows and Linux at the current head, not run: At
0e5fcecd5c66dd7733b42a051c3a6553d9c47465, the Windows x86-64 Vulkan renderer DLL and all 50 selected host translation units were cross-built on Linux with llvm-mingw 20261104 (Clang 23.1.5), using the project's production flags and pinned dependencies (renderer: 35 newly compiled / 170 reused; host: 50 newly compiled / 0 reused; lifted C: 0 newly compiled / 80 reused; all Windows units: 85 newly compiled / 564 reused from matching object-cache entries), the renderer DLL linked and the full game executable linked; nothing was run on Windows or under Wine, and this is neither the project's hosted builder run nor a Windows UTF-8 run-time path test.The exact same head
0e5fcecd5c66dd7733b42a051c3a6553d9c47465also compiled and linked its full native Linux executable and renderer with Clang 23.1.3 through the documented resume path (86 newly compiled units, 405 matching Linux-cache units, source stamp781eb374), using the verified target-neutral C from lift414f56e226d2be90a288ff2eb2fa172f7d4e6c93rather than carrying the patch onto another commit; neither Linux binary was run.CPU target (this revision builds the game for the build PC's own CPU, #524; the two builds differ on purpose): the Windows cross-build keeps the builder's pinned
-march=x86-64-v3 -ffp-contract=off(488 of its 649 compile commands carry it; the other 161 carry no-marchflag; none carries-march=native,-mtuneor-DLONGSHOT_NATIVE_CPU), so the Windows binaries are not built for this PC's CPU. The native Linux build used the revision's default-march=native, which clang resolved on this PC (13th Gen Intel(R) Core(TM) i9-13900K) toraptorlake_raptorlake_5fe3e3650f(build log line:build: the game is built for this PC's CPU (raptorlake_raptorlake_5fe3e3650f)), so the Linux binaries are built for that CPU's instruction set and tuning; neither binary was run.Renderer profile (this revision has the committed renderer play profile, #569,
assets/profiles/longshot_vk.profdata.xz, 159448 bytes, git blob01238f45954c): both runners chose the profile with the revision's ownmake_game.vk_profile_for, as the project's build does, and passed it asRX_VK_PGO. Windows renderer (llvm-mingw Clang): built with it: 43 of its 649 compile commands (the renderer's own sources, not Eden's recompiler or sirit) carry-fprofile-instr-use=<the unpacked profile>and-DLONGSHOT_VK_PROFILE_SHA1=d965e746b947f357, all exit 0; build log line:the Vulkan back end: built with the profile longshot_vk.shipped.profdata. Linux renderer (native Clang): built with it (build log line:the Vulkan back end: built with the profile longshot_vk.shipped.profdata). The game's own lifted code is built with the olderlongshot_pieces.profprofile as before; neither binary was run.Not tested: Windows (rows in
docs/tested-configurations.mdfrom a Windows PC list core escapes too); AMD and Intel GPUs and other CPUs; a handheld; play after the save has loaded (the runs stop 5 s after play begins); how far a starved thread gets in its turn (a turn lasts until the holders ask for the core back, as for any worse thread); that nothing else slows down (only start to play was looked at, and it did not move); the effect on a real frame time, which needs the paired method and an exclusive hold.Must be tested:
longshot_log.txtforran without a core, expecting none with the default and some withHOS_VCORE_STARVE_US=0(test request T-9R2R; by hand)Risks
What could break: A starved thread now runs in a core that a better thread was using, so a better thread can wait for a starved thread to reach its next checkpoint (a few microseconds to a few milliseconds, once per second per starved waiter, and only after a wait of more than 1 s). The 2 s escape is unchanged and still ends a wait on a holder that never reaches a checkpoint.
Turning it off:
HOS_VCORE_STARVE_US=0turns the rule off; reverting the commit is enough.Compatibility:
longshot_options.ini, the shader and pipeline caches and the vk ABI: the text says nothing; the diff changes onlyhost/hos_thread.c,docs/development.md, the newdocs/tested/rows andtests/test_vcore_escape.py, so it adds no ini key (the new switch is an environment switch), touches no save or cache code and nothing undergpu/vk/.vcores:line inlongshot_log.txtgains astarved grantscount; the 2 s escape and its switches are unchanged.Issues
Fixes #262
Checklist
Everyone
type(scope): description) of the status line's type.main.python -I -B tests/run_all.pypasses (any failure that also fails onmainis named).third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.docs/tested/for each new measured run (format indocs/tested-configurations.md; no appended row or assembled view).mainand passing here.tests/that reads changed source updated in this PR, not weakened.0e5fcecd5c66dd7733b42a051c3a6553d9c47465(under Testing); nothing was run on Windows at this head, so UTF-8 path behavior at run time is not claimed.New on this forge; not opened on GitHub.
Carried onto current main
82886e89b(main changedhost/hos_thread.csince in #522; the change applies on top of it). Head0e5fcecd5; whole game-free suite on it: 552 passed, 9 failed, 16 skipped. The builds for both systems follow.53a80e73510e5fcecd5cWIP: fix(vcores): a thread starved of its core gets it after 1 s, not an escape after 2 s (#262)to fix(vcores): a thread starved of its core gets it after 1 s, not an escape after 2 s (#262)Built at the current head
0e5fcecd5c66dd7733b42a051c3a6553d9c47465for Windows (llvm-mingw cross-build) and Linux (native); both linked without errors, nothing was run. The build box is ticked for this head and the details are under Testing. Every box is done, so this is ready for review.I ran the new fixture and the local evidence. The mechanism reproduces exactly:
6c9edffe's vcore block it exits with the four new failures (turn taking: the starved thread gets the core, no escape,and not before it has waited 1 s,a holder that runs on gives the core to the starved thread,and takes the other core after it, not before 1 s); on this branch it printsok/ok, including theHOS_VCORE_STARVE_US=0arm. Same under clang++ and g++.vc_betterclause, or thevc_yieldclause each breaks it. The test is genuine, not a reimplementation.Two evidence points from local runs:
AudioFrameworkThread (4) ... ran without a core after 2000.2 ms: core 2: BootupBackgroundWorkThread (30) asked. It is not one of the posted issue logs, so it does not refute the posted “every line” claim literally; it does show why that sentence should be scoped if it is meant beyond those issue logs. The 1 s rule does not reach that case: the existing worse-holder ask already applies and the holder did not yield; the risk section already says a holder that never reaches a checkpoint still escapes.vc_grant. A starved waiter that takes a free core directly invc_acquireviavc_takeis not counted; with fixed masks that is near-unreachable, so19is probably exact there. It needs a mask change to all-cores while the waiter is queued (or a full queue) to become reachable, not just a static mask-0. “Starved handoffs” would be exact.One risk I would call out: the starved holder scan has no
vc_lock_depthfilter. With the held-mutex deferral work, the other holder scans skip locked holders and this one does not; the kick is deferred to the outermost unlock, so the 1 s cap is not guaranteed and the 2 s escape can fire. Guard it like the other scans, or state the locked-holder case in the risks.Test gaps: removing
t->vc_starved = 0invc_takeor on the escape path still leaves the fixture green, and thestarved grantsline is not asserted. A reset/stat case would close that.Minor: the table's
this PR @ 53a80e735is not in the PR history (head0e5fcecd, fixf2f24114); could you point it at a reachable commit? And because the bug is CPU contention, a Steam Deck/4-core load-and-play run with a frame-time check would exercise the priority inversion; T-9R2R is Windows/GPU only.I merged this with #327 locally: the resolved host compiles and the mask-0 and starvation fixtures pass; the starved + mask-0 + TLS-cell case is the one still untested.
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.