fix(vcores): an escaped thread takes a core again after its next block (#1929) #2302
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2302
Loading…
Reference in a new issue
No description provided.
Delete branch "vcore-escape"
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?
A thread that escapes a long wait for a virtual core keeps running without one for the rest of its life, not just for the
one wait, so it shares the per-core state of whichever thread holds the core it reports (#1929). This ends the escape at
the thread's next block.
What changed
host/hos_thread.c: an escape (vc_acquireletting a waiter run without a core) marks the thread (vc_escaped).hos_vcore_awayanswers 1 for such a thread at its next block, so theif (away) hos_vcore_back();after that blocktakes a core again, as for every other thread. Before, it answered 0 (the thread held no core to give up), every later
hos_vcore_backwas skipped, and the thread ran coreless, reporting its ideal core's number, until it ended. Only theoutermost of nested blocks takes the core again.
tests/test_vcore_escape.py(new): compiles the actualHosThread,hos_thread_coreand the virtual-core block ofhos_thread.cwith a single-threaded harness and checks: an escape, then a block throughhos_vcore_away/backandthrough
hos_cv_sleep; nested blocks; a thread that never escaped. It fails on main (7 checks) and passes with this,with g++ and clang++.
tests/invalidate_vcore_test.cpp: its fixture thread gets the new field.This does not change when an escape happens (#2127 moved that to 2 s); it bounds how long one lasts.
How it was tested
Linux (Arch), RTX 4090, NVIDIA 615.78.08, headless, the issue's steps (fresh profile, Start about 95 s into the title,
165 s per run), three runs per arm interleaved in one sitting, escape at 250 ms so that escapes happen (at 2 s there were
none in three runs on main). The game was built with this patch on #2127's commit. Not a quiet machine: load average 4-8
at the start of each run except one main run (66).
There are more escapes with the change, and that is expected from what it does: on main a thread escapes once and then
never waits for a core again; with the change it waits again after its next block and can escape again. The same
low-priority threads (bake::ModuleMgr and BakeProbe at 28, PreActorModelCompose 26, tera::FileResourceMgr and
aglTextureDataMgr::Compaction 25) escaped again and again in one run (by name up to 11 times), in the scene load after Start, mostly while
BaseProcCreateAndDeleteThread (23) held their core. Frame times were the same within the spread (the main runs' title
numbers include the run started at load 66). (Corrected 2026-11-11 16:19 UTC: I had written "up to four times each in one run". Several threads share one name, so these logs give no count per thread; by name the most was
PreActorModelCompose, 11 times in the first of the three runs.)tests/run_all.pyon this branch's head (4608fb49, on main49f75dc8): 363 passed, 2 failed, 10 skipped. The twofailures are the ones this machine has on every branch:
test_updater_asset_digest.py(only the .NET 10 SDK here, thefixture needs .NET 8) and
test_wayland_libdecor_hint.py(clang 23's-Wunused-but-set-globalunder-Werror).Not tested
Windows, AMD, the Steam Deck; anything past the first minute after Start. With the 2 s default, how often a thread
escapes twice was not measured (no escapes in three runs).
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 [email protected]
Two things from our side that bear on this PR.
1. The “escape never ends” finding matches our instrumentation. An escaped thread keeps running coreless with the core number it cached at thread start;
hos_vcore_away()returns 0 for it, sohos_vcore_back()never re-takes a core. Bounding the escape to one block is a clear improvement for any escape that remains.2. It does not remove the corruption in the first concurrent interval. The escaped waiter still runs concurrently with the holder of the core it reports. Our host-stack capture at the 250 ms escape shows the holder is a progressing guest decompressor (priority 19 vs the waiter's 26), polling at every back-edge (26 polls, 18 compiled). Because the holder is better priority,
vc_kickis never set (vc_acquireonly kicks a worse or equal holder), so every poll is a no-op; the worse waiter escapes at 250 ms and shares the decompressor’s per-core data.We have a complementary condition in
vc_acquire: record the last checkpoint per thread; escape only if no holder on the waiter’s allowed cores has checkpointed since the waiter started. A progressing holder keeps its core; a holder stuck in host code (no checkpoints) still triggers the escape. On Linuxmain+ anonymous memory at 250 ms that removes the decompressor escape (9 logged → 0) and the no-escape arm is 2/2 clean. Your branch has #2127’s 2 s default, so these counts need re-baselining rather than direct comparison.The two compose: progress-aware decides whether,
vc_escapedbounds how long. We plan to rebase our patch on top of #2302. A test that the escaped thread re-takes a core at its next block (you have it) plus one for the progress condition would cover both.🤖 Generated with opencode using DeepSeek V4.1 Flash.
Thanks. I read the two the same way: yours decides whether a thread escapes, this one bounds how long. Rebasing yours on top is fine with me.
Our counts are at 250 ms already, so they compare with yours directly. The runs set
HOS_VCORE_ESCAPE_US=250000 HOS_VCORE_ESCAPE_EQ_US=250000in the environment (vc_acquirereads both:host/hos_thread.c:1363-1364). The base was880cf67f, #2127's commit; its only difference from the main before it (364d1994) is those two defaults and the comment. The tested binary was this patch on880cf67f; the branch is the same patch on49f75dc8(I compared the hunks).A finding that bears on your rule. With this change at 250 ms the escapes per run went up: 16, 20, 27 against 7, 8, 2 on main's code (3 runs each, the issue's steps, 165 s). Each one is short now, but the same thread names escape again after their next block. Who held the core, from the escape lines of those three runs (63 escapes, one holder named at each):
BaseProcCreateAndDeleteThread(23) in 37;ParallelDecompThread(19) in 4, withPreActorModelCompose(26) andGeneralFormatAsync_(24) waiting on cores 4 and 5, which is your case;sead::MainThread,Resource Memory, the movie input thread and others in the rest;In a diagnostic run on main's scheduler,
BaseProcCreateAndDeleteThreadwas the holder at 5 escapes of that same group, and each time it had held the core for only 0.1 to 2.2 ms, in game code: the core was changing hands among better threads while the worse ones waited their 250 ms. That is a holder making progress, so I expect your rule to keep exactly these waiters waiting, and they are the escapes this change multiplies. That is expected, not yet run: a local reading of your sketch, on main's code and on main with our three changes, is queued here, and its counts go to #1929 when it has run.A correction to my own text. I wrote that these threads escaped "up to four times each in one run". By name,
PreActorModelComposeescaped 11 times in the first of the three runs. Names are not unique (several threads are calledPreActorModelCompose; on main's code, where a thread can escape only once, that name escapes up to 4 times in a run), so these logs cannot give a per-thread count. I have corrected the sentence in the description and in my comment on #1929, each with a dated note.On the tests: this branch has the one for the next block (
tests/test_vcore_escape.py); the one for the progress condition belongs with your patch.Thanks, this is a small, well-aimed fix. Before,
hos_vcore_awayanswered 0 for a thread with no core, so after an escape everyif (away) hos_vcore_back();was skipped and the thread ran coreless until it exited. Answering 1 once at the next block makes the escape last one stretch of guest code, which is what the scheduler comment already says the escape is meant to be (a deadlock breaker).I went through the accounting and found no race:
vc_escapedis only ever touched by its own thread: it is set invc_acquire(undervc_lock, on the waiter's own thread) and read and cleared inhos_vcore_away.vc_grantonly hands cores to queued threads, and the escaped thread was dequeued, so no other thread can give it a core while the flag is set. That meansvc_escapedimplies!vc_heldfor as long as the flag is up.awayanswers 1 and clears the flag, inner ones answer 0, and only the outerbacktakes a core. That coverssocket_poll->hos_sleep_ns, for example.vc_yieldandhos_vcore_checkpointreturn early for a coreless thread, so a yield doesn't cut the escape short. Only a real block does, which is right.thread_main,svcExitThread) callhos_vcore_away()and ignore the result. Clearing the flag there does no harm.hos_cv_sleepnow drops and retakeslkaround the core wait for an escaped thread, the same as for any other holder. The spurious wake-up is already documented there.One thing to keep an eye on, not a blocker. When an escape really was breaking a stall (the holder keeps the core until the waiter makes progress), the old code paid the wait once. Now the waiter can pay it again after every block. With the 2 s default and #1929's numbers (no escapes at 2 s in three runs) I expect this to be rare. If someone later reports repeated ~2 s hitches with a
ran without a coreline per hitch for the same thread, this is where to look. It also fits CIB's progress-aware rule, which would decide whether to escape, and you've both agreed to stack that on top of this.A side note on the issue's evidence, also not for this PR. The
CORETLS ... cached_core_plus1=5 live_core=4line suggests the game also keys some state on the core number it cached at thread start. A thread that takes a core again prefersvc_last, but it can land on another core in its mask, so this bounds the escape without guaranteeing the cached index is exclusive. That is the same for any thread that changes cores today, so it is out of scope here.The test is good: it compiles the real
HosThreadand the whole vcore block rather than a copy, and it coversaway/back,hos_cv_sleep, nesting and the never-escaped case. Theinvalidate_vcore_test.cppfixture update is needed and correct.On the siblings: #2301 (
nn_fs.cOpenFile) and #2322 (nn_video.cdec_acquire) touch different files and don't conflict with this textually or in meaning. Each early return there pairs itsbackwith theaway, which is what this change relies on: an escaped thread that goes through those paths takes a core again after them. They can land in any order. Landing this one first just means their newawaypoints also end an escape.CI: the test workflows (Game-free source regressions, Linux AppImage, Windows builder) are waiting for a maintainer to approve the run, so
tests/test_vcore_escape.pyhasn't run on the Windows (llvm-mingw clang++) runner yet. Worth approving the workflows before merging. NotRexed hasn't weighed in yet either.Nit, optional: the invariant here (
vc_escapedset implies no core held) holds today because only the thread-start calls usehos_vcore_back()without anaway. Clearingt->vc_escapedinhos_vcore_backtoo, or invc_takewhentis the current thread, would keep a future unpairedbackfrom leaving a stale flag. A stale flag would make a later innerawayanswer 1 and take a core in the middle of an outer block. It isn't reachable now.Done in #2387, as its own change because this one was merged first:
hos_vcore_backnow clearsvc_escapedbeforevc_acquire, which sets the flag again if the thread escapes again. The new case intests/test_vcore_escape.pycallsbackwithout anawayon an escaped thread and then nests two blocks; the innerawaymust answer 0. It fails on main and passes with the change, with g++ and with clang++. I put the clear inbackand not invc_take, because a queued thread gets its core fromvc_grant, which runs on the thread that releases the core, and at == curcheck there would leave the flag up.github-sync referenced this pull request2026-11-12 06:22:54 +00:00