Repository navigation
perf(event): avoid locking manual-reset state reads and ready waits - #356
Conversation
There was a problem hiding this comment.
We don't set a similar document for other benchmarks.
Typically, I'd prefer to make the benchmark suite self-described, or you may put necessary comments inline.
|
Removed the separate doc and added the useful notes as comments in the benchmarks. Thanks for the suggestion |
orthur2
left a comment
There was a problem hiding this comment.
Thanks. The atomic fast path looks sound to me. I left a question on the waker slow path.
| // Clone only a changed waker, outside the lock, then recheck commitment: | ||
| // set/reset may run while preparing the replacement. | ||
| drop(waiters); | ||
| prepared_waker = Some(cx.waker().clone()); |
There was a problem hiding this comment.
Moving the clone outside the lock only helps a waker whose clone calls back into this event. Executor wakers don’t do that (their clone is typically a refcount increment), but with the retry every waker change now takes the lock twice, about 6.0 → 9.8 ns locally(M5 macOS), and has to recheck set/reset after relocking.
Could we keep the atomic fast path and clone under the lock again via register_waker?
| /// | ||
| /// # Synchronization | ||
| /// | ||
| /// An unset-to-set transition synchronizes with the waits it releases, with waits first polled |
There was a problem hiding this comment.
Small docs note: the new publication test relies on is_set() synchronizing with set, but this section only promises that for waits and try_wait. Could we either mention is_set here or drop that branch from the test?
|
@orthur2 Thanks for benchmarking this. I agree that the extra lock acquisition is a meaningful regression for waker replacement, especially since typical executor waker clones are cheap. One concern is that #252 explicitly asks us to keep waker clone callbacks outside internal locks. The new The original implementation did clone under the lock, though, so I understand the trade-off. Would you prefer restoring that behavior and prioritizing the common executor case, or keeping the stronger reentrancy guarantee from #252? I'll also update the synchronization docs to mention |
|
You’re right that #252 lists that requirement. I’d still restore the clone under the lock, as main does today. Waking and dropping the replaced waker would stay outside the lock, so only the clone moves. I had an agent go through the waker implementations in tokio, async-task, futures, std’s Wake, glommio and embassy. In all of them clone only bumps a refcount or copies a pointer, except tokio’s tracing instrumentation under tokio_unstable. AutoResetEvent and the other primitives also clone under their locks, so the guarantee would only hold for this one type. Meanwhile the retry makes every waker change take the lock twice. The fast-path gains don’t depend on it, so this can be decided separately. |
@tisonkun since you wrote #252, could you confirm whether that requirement still holds? |
orthur2
left a comment
There was a problem hiding this comment.
Thanks, looks good to me now.
I reran the event benchmarks against main on an Apple M5 MacBook Pro (10-core CPU, macOS ), with --sample-size 65536 so the sub-nanosecond results stay above the timer resolution. Ranges are Divan medians across two runs.
| Benchmark | main | This PR |
|---|---|---|
replace_pending_waker |
5.9–6.0 ns | 6.0 ns |
is_set, 1 thread |
3.6–3.8 ns | 0.36–0.38 ns |
is_set, 8 threads |
131–134 ns | 0.56–0.61 ns |
wait_already_set, 1 thread |
4.9–5.1 ns | 3.4 ns |
wait_already_set, 8 threads |
182–184 ns | 4.1–4.9 ns |
tisonkun
left a comment
There was a problem hiding this comment.
Thank you!
I may consider the integration tests can be simplified a bit. But I don't look into that. If you have tokens left you may investigate it.
Summary
Let
ManualResetEvent::is_set,try_wait, and already-set waits complete without takingthe waiter mutex. Keep the public API and existing waiter storage, with tests covering
registration races, rapid set/reset, cancellation, reentrant callbacks, and memory
publication.
Local benchmarks show single-thread state reads improving from 13.09 to 1.04 ns and
already-set waits from 15.03 to 5.83 ns. Replacing a pending waker costs about 28% more
because cloning outside the lock requires another lock acquisition. The benchmark report
includes methodology and tradeoffs.
Design Notes
State transitions and waiter registration remain serialized by the same mutex. Atomic
reads use Acquire and set publishes with Release. Released waiters retain their commitment
after reset.
Validation: all 597 tests passed, along with Clippy, formatting, documentation, event-
feature checks, and benchmark runs.
Related to #252; references #315. Waiter-storage replacement remains a separate
experiment.