feat: N=1 cooperative fiber scheduler replacing per-thread host threads#137
feat: N=1 cooperative fiber scheduler replacing per-thread host threads#137smmathews wants to merge 17 commits into
Conversation
dcd18be to
b01382c
Compare
|
leaving this in draft while I give it a more thorough review, but overall I think it should be a more robust solution than #134 |
|
still leaving in draft, I don't like how vita is broken and I can't test it. working on it. |
|
Updated and out of draft. Since the last revision:
Happy to re-merge over #140 if that lands first. |
9fe1a30 to
0208321
Compare
d75884d to
93be65c
Compare
|
Re-merged after #140/#141 landed — branch is again a single commit (93be65c) on current main, conflict-free, all CI green. Notes on the resolution:
Local validation: 354/354 (incl. the new dispatchGuestBranch tests) on ucontext and pthread backends, TSan and ASan/LSan clean apart from the documented intentional MMIO-polling reports. One observation from the merge, independent of this PR: #140 made |
3ca6eb3 to
fbe062e
Compare
|
I have this running well with my dq8 recomp project, and I'm feeling confident in the direction, but at net 12k lines of code, I don't trust my LLMs judgement on it. Gonna spend some time better understanding it myself. |
Downstream findings from integrating this scheduler (SDBZ Recomp fork)Tracking a black-screen boot blocker in our fork (SDBZ Recomp) and traced it far enough that it may be relevant to this PR's fiber scheduler, though the link is not yet proven — flagging now so it's visible while we finish verifying. Confirmed (static + log evidence):
Not yet confirmed — this is the part that needs your eyes or more testing on our end:
Next step on our side: breakpoint at the yield back-edge + live read of the guest-token waiter state to confirm/refute non-resumption directly, rather than relying on log-absence inference. Will follow up here with results — posting now in case the Update: pinned the exact back-edge, and the Re-ran with our existing per-callsite bisect instrumentation (logs
// fn_110E60_0x110e60.cpp, ~line 681
if (branch_taken_0x111138) {
ctx->pc = 0x110F68u;
if (runtime->shouldPreemptGuestExecution()) {
return;
}
goto label_110f68;
}So this is confirmed to be the exact mechanism, not just an inference from log absence: Still open on our end: haven't yet read |
|
2ef26f2 to
be319b4
Compare
Replace the per-guest-thread std::thread model (g_hostThreads) with an N=1
cooperative fiber scheduler: exactly one host executor thread runs all guest
EE threads as fibers, highest priority first, FIFO within a priority, with
cooperative yield points sampled every 128 back-edges. This matches the real
EE's single-core execution model that games' kernel-object usage assumes
(semaphores as ownership tokens, priority scheduling, RotateThreadReadyQueue
rotation) and removes the cross-thread races and lock-order deadlocks of the
previous model.
Blocking syscalls park through a publish -> arm_park -> block_current
protocol whose wake_pending handshake cannot lose wakeups; all wait sites
re-check their predicates in Mesa-style loops. Host workers (IRQ, vsync,
alarm, RPC) either wake fibers through gated entry points (with
{generation,tid} tokens rejecting stale wakeups after tid reuse) or borrow
the guest token via AsyncGuestScope. Host threads carrying a guest tid get
full ThreadInfo wait bookkeeping (wait lists, ReleaseWaitThread targeting)
but never park on the fiber scheduler; they poll with bounded backoff.
Four fiber backends: ucontext (POSIX default), Win32 Fibers (_WIN32),
SceFiber (PLATFORM_VITA; compiles against a real VitaSDK, untested on
hardware, no guard page - documented), and pthread (PS2X_FIBER_PTHREAD=ON,
exists so ThreadSanitizer can instrument the scheduler). PS2X_SANITIZE wires
TSan/ASan through every target from the top-level CMakeLists.
Preserves upstream's guest-visible semantics (ran-j#136 sid-on-success semaphore
returns throughout, including older tests).
Bugs found and fixed while hardening: lost ReleaseWaitThread during Mesa
retries; alarm-worker use-after-free vs CancelAlarm; non-fiber WaitEventFlag
and WaitForNextVSyncTick returning without waiting; INTC/DMAC enable-mask
races; a terminate or resume wake dropped in the publish->park window; a
tid-recycling ABA in join_fiber; main-thread guest identity (tid 1)
restored to upstream semantics. Dogfooding against a real commercial title found
more: executor/worker token-handoff starvation, fixed across three layers
(the executor's defer-to-parked-worker predicate, yield_point's fiber-side
handoff, and a dispatchLoop back-edge hook so cross-function spins reach
both); sceSifRpcLoop's stub returning instead of parking, which
monopolized the executor; async callback stacks carved from top-of-RAM
inside the guest's own main stack, relocated to kernel-reserved memory
(0x80000-0x100000); requestStop joining workers from guest context
(deadlock - now signal-only); DMAC dispatch borrowing the guest token from
guest context (fatal abort - now dispatches inline); per-OS-thread
thread_local state shared across fibers under N=1 - the dispatch-history
ring, the syscall-override reentrancy guard, and the RPC-invoke/inline-
DMAC/MPEG-callback scratch stacks - all now fiber-owned or per-invocation;
pthread-backend fiber threads not marked as guest context, self-deadlocking
the is_guest_thread gates; and join_fiber flooring the joiner one priority
level below the target (t->priority + 1) instead of at the target's own
level, so an equal-priority sibling of the target kept the joiner off the
run-queue head and TerminateThread never returned (DQ8: main thread
terminating a same-priority worker while a sibling stayed runnable). EE
priority is "lower number = higher"; flooring at the target's own level lets
the joiner tie the siblings and cycle back via FIFO rotation to observe the
target finish and return.
Tests: passing across ucontext and pthread backends (deterministic across
repeated runs), TSan-clean (zero warnings) with the three documented
intentional hardware-modeled vsync/GS-CSR reports suppressed via
ps2xTest/tsan.supp, ASan/LSan clean. Scheduler suites cover the
wakeup-window protocol, suspend/resume gating, tid reuse, terminate windows,
priority rotation and join floors (including a SchedulerJoinStarvation
regression: a higher-priority joiner terminating a target with an
equal-priority runnable sibling, asserting TerminateThread returns within a
bounded number of rounds), shutdown ordering, and borrowed workers, with
regression tests that fail against the unfixed code. The workload dogfood
regression suite (ps2_scheduler_workload_regression_tests.cpp) adds targeted
coverage for the bugs above: token handoff, RPC-loop park, recovery
isolation, guest-context stop, kernel stack pool, DMAC guest dispatch,
stack isolation, and override isolation.
be319b4 to
5891a9b
Compare
…S/current, drop SceFiber tls_pending_self
…d prologue, self-exit mark helper
…tackPool allocator
… lock in block_current borrowed-worker path
…en-drop, fix log-once backoff warning - Extract withGuestTokenDropped() as the single place that knows the async_guest_end/begin bracketing for a non-fiber wait pause; both NonFiberBackoff::step's sleep and nonFiberBlockBackoff's yield now route through it instead of duplicating the drop/reacquire branch. - Fix a real bug in NonFiberBackoff::step: the cap-reached warning was meant to fire once, but spins froze at kMaxSpins so `spins == kMaxSpins` stayed true and it re-fired every iteration after the cap. Now increments-and-compares so it fires exactly once. Also drop the now-redundant spins < kMaxSpins guard around the delay ramp since delay already self-clamps at the 1ms cap. - Fold arm_park + block_current + the conditional step into NonFiberBackoff::wait(onFiber), and make step() private so no caller can feed it a fiber block result. Route WaitSema, WaitEventFlag, SleepThread, and WaitForNextVSyncTick's two block_current sites through it, preserving each site's exact lock handling and the arm-before-block ordering.
…iberToken, context-carrying stop callback
…elpers, markDeletedAndWake
…he workload regression suite
…ers in the expansion suite
# Conflicts: # ps2xRuntime/CMakeLists.txt # ps2xRuntime/include/ps2_runtime.h # ps2xRuntime/include/ps2_syscalls.h # ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp # ps2xRuntime/src/lib/Kernel/Syscalls/Common.h # ps2xRuntime/src/lib/ps2_runtime.cpp
…efactor - elf_parser.h: add missing <cstdint> include; elfio's headers rely on transitive uint16_t/uint32_t visibility that newer libstdc++ no longer provides implicitly, which broke every elfio symbol in elf_parser.cpp. - ps2_syscalls.h / ps2_runtime.cpp: drop the joinAllGuestHostThreads()/ detachAllGuestHostThreads() declaration and destructor call that the merge resolution reintroduced from origin/main. This branch already replaced the g_hostThreads std::thread-per-guest-thread model with the fiber scheduler, so the underlying joinAllHostThreads()/ detachAllHostThreads() helpers no longer exist; the destructor comment already correctly notes that scheduler_shutdown() in run() owns fiber pool teardown now.
…field-parity test The sceGsSyncV interlaced field-parity test reconstructed the tick each sceGsSyncV call consumed by calling GetCurrentVSyncTick() separately after the call returned. The vsync worker free-runs on a wall-clock timer, so the global tick counter can advance between sceGsSyncV returning and that re-sample, and the slop's parity is not stable across the two calls. On a loaded windows-msvc CI runner the test thread was descheduled between return and re-sample with differing parity, breaking the field0 ^ (delta & 1) cross-check even though sceGsSyncV's field logic was correct. Fast, unloaded linux-clang/linux-gcc runners never hit the window, so it passed there. Record the exact consumed tick inside sceGsSyncV (atomically with, and from the same value as, the field it computes) and expose it via lastGsSyncVConsumedTick() for tests to read, instead of re-sampling the racy free-running counter. This removes the non-determinism at its source rather than loosening a tolerance; the XOR-by-parity cross-check still guards the field-derivation logic against real regressions.
Thanks for the detailed trace. I don't think the mechanism you've flagged is a flaw in the preemption protocol, the bare return at the back-edge is only half of it. Every recompiled JAL/JALR call site (upstream #73, 8584c06, predates this PR) emits a caller-side sentinel: it captures __entryPc, and after the call does if (ctx->pc != fallthroughPc) return;. So when CMemory_Init preempts with ctx->pc parked at 0x110f68, MemSysInit's sentinel sees the mismatch and returns too, your own trace confirms this (both MemSysInit and GameInit exit with pc=0x110f68). The unwind reaching dispatchLoop is by design; no register state is lost since everything's in ctx->r[]/RDRAM. The reason it doesn't resume is downstream: dispatchLoop resolves ctx->pc=0x110f68 via lookupFunction and re-enters fn_110e60 at its case 0x110f68: label, which only works if 0x110f68 is registered in the address table. I hit this exact symptom booting DQ8 on this scheduler: preemption correctly unwound to an internal label of a manually-spliced function, but only the entry point was registered, not the internal resume labels, so lookupFunction missed, the recover-pc fallback fired, and boot corrupted. Fix was registration completeness, not a protocol change. I'd check whether 0x110f68 (and the other internal labels of fn_110e60, plus any manually-spliced/packed-entry functions in your tree) are in your lookupFunction table at the resume point, that's likely your smstt/smrdy WaitSema stall too, same root cause, different call site, as you suspected. |
…n group rotate_ready_queue() only rotated nodes already in g_run_queue, but the currently-running fiber is popped off g_run_queue by the executor before it resumes -- so the caller, which real EE hardware treats as the head of its own priority ring, was invisible to the rotation and was never moved. Combined with maybe_yield()/yield_point() preempting only for a strictly higher priority, the one syscall whose entire purpose is round-robining equal-priority threads could not do so: a guest thread looping on RotateThreadReadyQueue(myOwnPriority) -- the standard PS2 idle/yield-thread idiom -- monopolised the N=1 cooperative executor forever and starved every other Ready fiber at that priority. When the caller's priority matches the requested priority, treat it as the ring head: enqueue it at the tail of its own priority group via enqueue_locked() and yield, gated on another fiber at that priority or better actually being runnable so a lone thread does not burn a pointless context switch. Rotating a group the caller is not in keeps the previous head-to-tail behaviour unchanged, now in an else branch.
Adds four SchedulerProtocol cases that drive RotateThreadReadyQueue from inside a
running fiber (tls_current_fiber == self). The first three call it with the
caller's own priority, exercising the new caller-is-head path rather than the
existing [A,B,C]->[B,C,A] test's group-you-are-not-in fallback; the fourth
rotates a foreign priority group and pins that the caller-is-head yield stays
gated on the caller actually being at the rotated priority:
- yield-to-peer: A rotates its own priority group with an equal-priority peer
B Ready; asserts B logs first and A last, so the caller yields its ring head.
Reintroducing the rotate livelock makes A log first and fails the case.
- lone-caller no-yield: A is alone at its priority (empty run queue) with a
host worker parked; asserts A logs before the worker, pinning the
peerAtPriorityOrBetterRunnable gate's first conjunct (g_run_queue != nullptr)
-- a caller with nobody else runnable must not requeue+yield.
- lower-priority-peer no-yield: A is alone at its priority but a
strictly-lower-priority fiber B is Ready; asserts A logs first, then the
parked worker, then B, pinning the gate's second conjunct
(g_run_queue->priority <= priority) -- the caller must not yield to a
worse-priority fiber.
- foreign-priority rotate: A (prio 10) rotates a foreign prio-20 group [C,D]
while a host worker is parked; asserts A logs first (no yield), then the
worker, then D and C -- the target group rotated head-to-tail while the caller
kept running. Pins the entry guard's priority-equality conjunct
(self->priority == priority): only a caller at its own priority takes the
yield path, so a foreign-priority caller must not requeue+yield. Dropping that
conjunct lets A yield and fails the case.
A fifth case drives RotateThreadReadyQueue(ownPriority) from inside an
inline-dispatched DMAC handler with an equal-priority peer queued and pins the
inline-handler case-1 path crash/deadlock-safe and deterministic: the peer
runs first, then the handler resumes on its still-live scratch stack and
returns. It is the only case exercising the is_guest_thread() inline-dispatch
path, and reverting the caller-is-head rotation fails it just as it fails the
thread-context yield case.
…duler # Conflicts: # .gitignore # CMakeLists.txt # ps2xRuntime/include/ps2_runtime.h # ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.cpp # ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp # ps2xRuntime/src/lib/ps2_runtime.cpp # ps2xTest/src/ps2_runtime_expansion_tests.cpp
feat: N=1 cooperative fiber scheduler replacing per-thread host threads
What this replaces and why
Upstream runs each guest EE thread on its own host
std::thread(g_hostThreadsinHelpers/State.h, spawned fromThread.cpp/ps2_runtime.cpp). That model allows two guest threads to execute truly concurrently, which the real EE (a single core) never does — games' kernel-object usage (semaphores as pure ownership tokens, priority-based cooperative scheduling,RotateThreadReadyQueueround-robin) assumes exactly one running thread. Under the std::thread model this shows up as (DQ8 examples observed while debugging): audio-thread starvation, missed wakeups betweenWaitSema/SignalSemapairs, and lock-order deadlocks between the run token and guest mutexes (earlier attempt: #134).This PR replaces that model with an N=1 cooperative fiber scheduler:
RotateThreadReadyQueuerotates the equal-priority group).ps2_fiber.{h,cpp}) with per-fiberR5900Contextstate; blocking syscalls park the fiber via an arm/publish/park protocol that cannot lose wakeups (see below).AsyncGuestScope) so handler code is serialized against fibers.yield_point()every 128 back-edges (terminate/suspend/priority checks); blocking syscalls yield cooperatively.Wakeup protocol (the core correctness argument)
A blocking syscall does: publish to the object wait-list under the object mutex →
arm_park()(marks Blocked under the scheduler mutex) → release object mutex →block_current(). A waker that fires in the publish/arm window finds the fiber still running and recordswake_pendinginstead of enqueueing (wake_locked);block_current()consumes it and returnsWokenInWindowwithout parking; all wait sites re-check their predicate in a Mesa-style loop. Wakeups gate onsuspendCount(a suspended waiter staysTHS_WAITSUSPENDuntilResumeThread). Stale wakeups after tid reuse are rejected by{generation, tid}tokens (enqueue_external_wakeup_validated). Host threads that call blocking syscalls (no fiber) use bounded-backoff Mesa loops on the same predicates.Backends
PROT_NONEguard page_WIN32FIBER_FLAG_FLOAT_SWITCHPLATFORM_VITAsceKernelAllocMemBlock(no guard page — Vita has no per-subrange protection API; documented)_sceFiberInitializeImpl,<psp2/fiber.h>); untested on hardware-DPS2X_FIBER_PTHREAD=ONpthread_attr_setstackswapcontext); full suite green-DPS2X_SANITIZE=thread|address(top-level) wires sanitizers through every target; TSan requires the pthread backend (enforced at configure time).Testing
updateGsCsrFieldForVSync(untouched by this PR; fixed separately in fix(runtime): make GS CSR atomic to fix vsync-worker/guest data race #145).ReleaseWaitThreadduring Mesa retries, alarm-worker use-after-free vsCancelAlarm, non-fiberWaitEventFlag/WaitForNextVSyncTickreturning without waiting, INTC/DMAC enable-mask races, a terminate/suspend wake dropped in the publish→park window, and a tid-recycling ABA injoin_fiber.Guest-visible semantics notes (for review against ps2sdk)
GetThreadIdfrom the primary host thread returns 1 (upstream reserved tid 1 for main; the runtime claims it at construction).SetEventFlag/SignalSemaarriving whilesuspendCount > 0leaves the threadTHS_WAITSUSPEND; it completes the wait afterResumeThread. (Divergence from literal EE kernel timing, where the wake transitions WAITSUSPEND→SUSPEND immediately; the end state after resume is identical. Called out in the adapted kernel test.)Exitingstate so a guest exit handler can make blocking syscalls without the scheduler freeing the live trampoline frame.Vita status (honest labeling)
The SceFiber backend is compile-verified against the real VitaSDK toolchain (reproducible container harness; zero warnings) but has not run on hardware — I don't have a Vita to test with. The previous "vita is broken" state is resolved at the compile level:
<psp2/fiber.h>,_sceFiberInitializeImpl(vita-headers exposes nosceFiberInitializewrapper), andsceKernelAllocMemBlockstacks (VitaSDK has no<sys/mman.h>). If someone with hardware can boot-test, I'll support fixes.Fixed since last update: RotateThreadReadyQueue could not yield to an equal-priority thread
Summary
rotate_ready_queue() only rotated nodes already sitting in g_run_queue. The
caller -- the fiber that just entered RotateThreadReadyQueue -- has already been
popped off g_run_queue by the executor before it resumed, so the caller was
invisible to the rotation and was never moved. Combined with
maybe_yield()/yield_point() preempting only for a strictly higher priority, the
one syscall whose entire purpose is round-robining equal-priority threads could
not do so: a guest thread looping on RotateThreadReadyQueue(myOwnPriority) --
the standard PS2 idle/yield-thread idiom -- monopolised the N=1 cooperative
executor forever, starving every other Ready fiber at that priority.
Changes
ps2xRuntime/src/lib/ps2_scheduler.cpp -- rotate_ready_queue(int priority): when
the caller's own priority equals
priority, treat it as the ring head (as onreal EE hardware) -- enqueue it at the tail of its own priority group via the
existing enqueue_locked() and yield, gated on another fiber at that priority or
better actually being runnable so a lone thread does not burn a pointless
context switch. Rotating a group the caller is not in keeps the previous
head-to-tail behaviour, now in an else branch, unchanged.
ps2xRuntime/include/ps2_scheduler.h -- the doc comment above rotate_ready_queue
is a short summary that points to the authoritative case analysis at the
definition in ps2_scheduler.cpp. In brief, the primitive has three cases: (1) a
fiber caller at its own priority moves to its group's tail and yields, but only
when another fiber at that priority or better is runnable; (2) a fiber caller
rotating a different priority moves that group's head to the tail and does not
yield in rotate itself, though the wrapper's trailing maybe_yield() can still
preempt it for a strictly-higher-priority fiber; (3) a caller with no current
fiber (tls_current_fiber == nullptr) -- a host-borrowed worker under
AsyncGuestScope -- rotates the requested group without yielding. The yield turns
solely on tls_current_fiber, not on interrupt-vs-thread context, since
maybe_yield() is likewise a no-op when tls_current_fiber == nullptr. The cpp
preamble carries the full derivation; the three properties are pinned by the
tests below.
Fidelity note: an interrupt/DMAC handler can run inline on a fiber (see
dispatchDmacHandlersForCause), and iRotateThreadReadyQueue is a direct alias of
the base syscall, so an in-handler caller at its own priority with a runnable
peer takes case 1 and yields mid-handler. This is a deliberate EE-fidelity
deviation, called out rather than folded under "safe": real EE i-prefixed
handlers defer any reschedule to interrupt exit and never context-switch
mid-handler, whereas this path reschedules to an equal-priority peer before the
handler returns. It is not left as an untested assertion. A dedicated
SchedulerProtocol test drives an inline-DMAC-dispatched handler that calls
RotateThreadReadyQueue at its own priority with a queued equal-priority peer and
pins that the mid-handler switch is crash- and deadlock-safe and deterministic:
the peer runs to completion, then the handler resumes and returns on its
still-live per-invocation scratch stack -- the same fresh-scratch-stack
mechanism inline dispatch already reserves so a handler body can yield at a
back-edge. The deviation is a bounded extension of that back-edge-yield
behaviour, now exercised by test rather than flagged for sign-off.
maybe_yield()/yield_point() are untouched: making those yield on equal priority
too would turn every SignalSema and every other maybe_yield() call site into an
implicit round-robin point, changing global scheduling for every title. The EE
contract requires "yield to equals" only for RotateThreadReadyQueue.
Verification
Five new SchedulerProtocol tests drive RotateThreadReadyQueue from inside a
running fiber. The first three call it at the caller's own priority. One asserts
the caller yields to an equal-priority peer before resuming (fails if the
livelock is reintroduced). One asserts the caller returns without yielding when
it is alone at its priority. One keeps the caller alone at its priority but
places a strictly-lower-priority fiber in the ready queue and asserts the caller
still does not yield -- pinning that the peerAtPriorityOrBetterRunnable
short-circuit turns on a peer at the caller's priority or better, not merely on
the queue being non-empty, so the caller never yields to a worse-priority fiber
and a lone thread never burns a pointless context switch.
A fourth test drives RotateThreadReadyQueue at a priority the calling fiber does
not hold: it asserts the target group rotates head-to-tail while the caller runs
on and logs before yielding, pinning that only a caller at its own priority
takes the yield path -- weakening the case-1 entry guard so any fiber caller
yields passes the three self-priority tests but fails this one.
A fifth test drives RotateThreadReadyQueue(ownPriority) from inside an
inline-dispatched DMAC handler with an equal-priority peer queued. It asserts the
caller yields mid-handler to that peer, the peer completes, and the handler then
resumes and returns cleanly -- pinning that the inline-handler case-1 path is
crash/deadlock-safe and deterministic. It is the only test that exercises the
is_guest_thread() inline-dispatch scratch-stack path, and it fails (logging the
caller before the peer) if the caller-is-head rotation is reverted.
This was motivated by an observed wedge in a retail title's idle loop that
spins on RotateThreadReadyQueue at its own priority; the correctness argument
rests on the tests above and the EE round-robin semantics they encode, not on
that observation.
Android fiber backend
Bionic does not implement the POSIX ucontext functions —
getcontext/makecontext/swapcontextare absent from its exported symbols on every ABI — so the default ucontext fiber backend cannot link on Android. Android builds therefore select the pthread+semaphore backend (PS2X_FIBER_PTHREAD, the same implementation used for the TSan configuration): each guest thread is a real thread with semaphore handoff, and the N=1 guarantee is unchanged — only one thread executes guest code at any time. The cost is a futex pair per fiber switch instead of a user-space context switch.If profiling later shows fiber-switch overhead matters on Android, the planned follow-up is linking libucontext (ISC-licensed; aarch64 and x86_64 are tier-1 targets) for Android only, built with unprefixed weak symbols so the existing ucontext backend compiles unchanged, and flipping the platform gate — keeping the pthread backend as the TSan configuration and the universal escape hatch (
-DPS2X_FIBER_PTHREADstill forces it on any platform). One note for that follow-up: libucontext's fast path deliberately skips signal-mask save/restore, which matches the fiber semantics used here — the Win32 and Vita backends do not switch signal masks either.