mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
## Summary droiddeck-ntsync (the userspace stand-in for the Linux ntsync driver, Wine sync = ntsync) was slower than droiddeck-esync on every cheap operation. On the tablet a `SetEvent` cost 0.94 µs against esync's 0.21 µs, and a wait on a signaled event 0.45 µs against 0.08 µs. Four things made it slow: - **Signal masking:** every operation blocked and restored the signal mask, so each call made 2 `rt_sigprocmask` syscalls, and a blocking wait made 4. The masking itself is needed: if Wine's suspend signal stopped a thread while it held an object lock, a GC thread that suspended it and then touched the same object would deadlock. - **Queueing for objects that were already free:** a wait linked itself onto every object and unlinked again even when the first object could simply be taken. - **Wakes nobody needed:** a futex wake syscall went out for every handoff, even when the waiter was still spinning and would have seen the change anyway. - **One shared counter:** every call took and dropped a reference on a single process-wide device counter, so threads working on unrelated objects bounced one cache line between cores. ### Changes - **Signals are deferred instead of masked:** handlers installed by Wine's `ntdll.so` run behind a small trampoline. If such a signal arrives inside a critical section (a per-thread depth count), it is saved with its `siginfo` and sent again with `rt_tgsigqueueinfo` (falling back to `tgkill`) as soon as the section ends. - `sigaction` queries still return Wine's own handler. - Synchronous signals (SEGV, BUS, ILL, FPE, TRAP, SYS) are never wrapped, as before. - Handlers installed by anything other than `ntdll.so` are left alone. - **Wait-any fast path:** an object that is signaled and has nobody queued on it is taken directly, in index order. A wait whose timeout has already passed returns `ETIMEDOUT` without building a queue. - **Short spin before sleeping:** a waiter spins for up to 4 µs, bounded by time rather than by iteration count, before it sleeps on the futex. - **Wake only sleeping waiters:** the waiter publishes a `sleeping` flag, and the waker only calls `FUTEX_WAKE` when it is set. Both sides use seq_cst ordering, so a wake can't be lost. The queue layout changed, so `NS_VERSION` is now 2. - **Per-thread device reference:** a thread takes one reference on the device the first time it uses it and keeps it until it exits or moves to another device, instead of two atomics on the shared counter per call. - **More than four signals in one section:** a fifth distinct signal is held pending with the thread's signal mask, then released once the section ends. Its handler never runs while an object lock is held. - **Replay:** deferred signals are replayed with every signal blocked, so each one is sent exactly once. A held signal is released at the same signal-nesting level that held it. Handlers installed with `SA_RESETHAND` are not wrapped, so a one-shot handler keeps its kernel behaviour. - **Waits from a handler during another wait's sleep:** they get their own queue and, for a second device, a reference for that call only. The outer wait keeps its queue and can't pick up the inner wait's result. ### ntsync becomes the default - **New installs:** start on ntsync. - **Existing installs:** move to ntsync once, on the first start of this version, whatever sync they were on before (a one-time `ntsyncDefaultV1` marker). Anything picked after that is kept, including esync, fsync and wineserver. - **Wording:** the ntsync switch in Performance no longer says "experimental" or "for testing", in all 33 languages. - **Unchanged:** per-game sync choices on a game's settings page still win over the app-wide choice. Proton builds without ntsync still fall back to droiddeck-esync, then droiddeck-fsync. - **Tests:** `SyncBackendTest` covers a fresh install, each earlier choice moving to ntsync once, and a later choice surviving the next start. All 2,160 unit tests pass. ## Performance (REDMAGIC tablet) Windows microbenchmark (`syncbench.exe`, x86-64 under FEX), µs per operation: | | esync | ntsync before | ntsync after | |---|---|---|---| | SetEvent | 0.57 / 0.70 | 0.51 / 0.65 | 0.38 / 0.35 | | wait on signaled event | 0.13 / 0.13 | 0.44 / 0.43 | 0.27 / 0.20 | | wait, timeout 0 | 0.27 / 0.25 | 0.52 / 0.51 | 0.19 / 0.18 | | semaphore release + wait | 0.72 / 0.68 | 0.66 / 0.63 | 0.12 / 0.16 | | mutex wait + release | 0.77 / 0.73 | 0.66 / 0.64 | 0.12 / 0.12 | | wait on 8 objects | 0.10 / 0.09 | 0.59 / 0.57 | 0.15 / 0.15 | | alertable wait | 0.06 / 0.05 | 0.43 / 0.42 | 0.07 / 0.07 | | event ping-pong, 2 threads | 25.6 / 24.5 | 24.4 / 29.3 | 1.97 / 1.66 | | 4 threads on one semaphore | 9.4 / 4.6 | 20.3 / 19.1 | 7.3 / 7.6 | The backend each run actually used was checked from its file descriptors: - **esync:** wineserver held eventfds. - **ntsync:** wineserver held the `droiddeck-ntsync` device and its timerfd objects. Native arm64 benchmark against the device directly, µs (before / after): - set: 0.31–0.36 → 0.06–0.08 - signaled wait: 0.37–0.45 → 0.04–0.08 - semaphore pair: 0.56–0.58 → 0.06–0.07 - mutex pair: 0.56–0.61 → 0.04 - ping-pong: 15–30 → 1.7–2.5 Removing the shared device counter on its own takes 4 threads working on their own objects from 0.21 to 0.04 µs per operation (x86-64 host). Games, default Steam launch, The Forest and PEAK at the same scene each run, 20 s pacing. Columns are esync, ntsync before and ntsync after; each cell gives two runs. | | esync | ntsync before | ntsync after | |---|---|---|---| | The Forest Low, fps | 64.5 / 61.9 | 70.1 / 74.8 | 72.1 / 72.1 | | The Forest Low, CPU | 489 / 511% | 434 / 419% | 436 / 426% | | The Forest High, fps | 58.3 / 57.8 | 72.0 / 69.8 | 72.5 / 69.9 | | The Forest High, CPU | 506 / 504% | 417 / 429% | 425 / 435% | | PEAK, fps | 50.4 / 48.6 | — | 47.0 | | RDR2, launch to signed in / game process | — | — | 29.2 / 42.0 s and 28.3 / 41.1 s | Two more games, esync against ntsync after, three runs each, default settings: | | esync | ntsync after | |---|---|---| | Among Us, menu fps (59 cap) | 59.0 / 58.9 / 59.3 | 59.0 / 59.0 / 58.7 | | Among Us, launch to main menu | 6.65 / 6.84 / 6.85 s | 6.39 / 6.52 / 6.64 s | | Among Us, CPU | 123 / 132 / 133% | 133 / 133 / 124% | | Mortal Kombat X attract fight, fps | 56.7 / 54.1 / 56.3 | 61.1 / 56.9 / 56.2 | | Mortal Kombat X, 1% low | 20.0 / 30.3 / 30.3 | 30.3 / 30.3 / 30.3 | | Mortal Kombat X, launch to window | 7.75 / 7.79 / 7.75 s | 7.81 / 7.71 / 7.63 s | Mortal Kombat X's CPU moves between 140% and 275% in both modes because the attract mode changes fighters and stage, so it isn't compared. The Forest's 1% low was 41.7 fps in every run. PEAK is GPU-bound and stays in run-to-run noise. Its second ntsync run stopped at the title menu because the rig's menu input didn't register; the game was still running at 87 fps, and the same rig miss happened on esync before. RDR2 signs in and reaches the game on the new code. ## Tests - **`tools/tests/test_ntsync_preload.py` (new):** builds the preload and checks the emulation against the semantics documented for the kernel driver: - semaphore counts and `EOVERFLOW`; - mutex owners, recursion, `EPERM`, kill/abandon with `EOWNERDEAD` on read and on the next wait; - auto and manual events, set/reset/pulse previous state; - wait-any index order, duplicates, the alert object, `EINVAL` limits, timeouts; - wait-all atomicity, duplicates and alert; - wakes across threads and across processes (inherited fds and fds sent with `SCM_RIGHTS`); - 20,000-round ping-pong and 4-thread semaphore stress with infinite timeouts; - two signal cases. In one, a handler that re-enters the same object is fired 2,000 times at a busy thread. In the other, a handler parks the thread while another thread uses the same object, as Wine's suspend does under a GC. - eight real-time signals fired in bursts at a thread busy on one object, each handler touching that object, so more signals arrive inside a section than can be deferred; - a handler that waits on the same device and on a second device while its thread sleeps in another wait; - a one-shot (`SA_RESETHAND`) handler installed and fired 300 times at a busy thread, every one of which must run. - **The signal tests catch a broken implementation:** with signal protection removed, the program hangs and the test fails. Before the last fix, the burst test hung and the outer wait returned the nested wait's result. - **The suite passes:** 8 of 8 runs natively on arm64 under proot on the tablet, and 40 of 40 on the host. - **On the final build:** The Forest Low ran at 77.7 fps, PEAK at 47.5 fps, and Among Us and two Mortal Kombat X runs played through. - **The new paths stay out of the way in games:** a build that logged them saw about 1,400 Wine signals land during ntsync sleeps over two Mortal Kombat X runs and one The Forest run. None of those handlers waited on ntsync and no signal was held back, so games run the same code as before the last fix. The Forest Low ran at 71.6 fps and PEAK at 47.2 fps on the final build. - **Existing preload tests still pass:** fsync, openat2, usr2 (also with `BL_SYNC=1`), tracer, dircache, noexec, binfmt, faultreport and esync packs. ## Credits - [Linux](https://github.com/torvalds/linux) `Documentation/userspace-api/ntsync.rst` (GPL-2.0): the object, wait and error semantics the new test checks. Also added to the README credits.