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