perf(sync): defer mutex wake-state allocation - #1043
Conversation
There was a problem hiding this comment.
Pull request overview
Optimizes non-cancellable mutex acquisition by deferring wake-state allocation until contention requires parking.
Changes:
- Adds lazy wake-state construction while preserving cancellation and handoff behavior.
- Adds allocation-failure tests and mutex benchmarks.
- Documents performance and exception-boundary changes.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
include/elio/sync/mutex.hpp |
Implements deferred wake-state construction. |
include/elio/sync/detail/wake_state.hpp |
Adds allocation-failure test hook. |
tests/unit/test_sync_cancellation.cpp |
Tests allocation and failure paths. |
examples/microbench.cpp |
Benchmarks mutex fast path and handoffs. |
wiki/API-Reference.md |
Documents allocation and exception behavior. |
wiki/Performance-Tuning.md |
Explains mutex performance tradeoffs. |
CHANGELOG.md |
Records the optimization. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
1331221 to
0dbffca
Compare
|
@copilot Please review the updated pull request head only. Verify that the revised performance guidance now distinguishes wake-state allocation from actual FIFO queue publication. Please review; do not make code changes. |
The revised guidance at lines 440–443 of
The text no longer implies every slow-path allocation is queued. The separation is clear and accurate. |
0dbffca to
ea92a47
Compare
|
@copilot Please review the new head only. A read-only audit found that the public low-level |
Reviewed the new head for compatibility, object lifetime, and cancellation semantics. Double-construction risk: not present.
Object lifetime: correct.
Cancellation semantics: unchanged.
|
Description
Make the non-token
sync::mutex::lock()uncontended path allocation-free while preserving shared wake-state lifetime, FIFO ownership transfer, and popped-but-not-resumed recovery whenever a waiter actually parks.Only a waiter that remains contended after the suspension-entry CAS directly constructs shared wake state in mutex-private manual storage, before taking the waiter-queue mutex. Token-aware locks retain eager arbitration state and the existing cancellation-versus-grant protocol.
This is an intentionally asymmetric performance tradeoff. Across two independent pinned Release comparisons, uncontended lock/unlock latency decreased by approximately 45.7% to 46.9%, while the permanently contended local-trampoline forced-handoff benchmark increased by approximately 2.7% to 3.3%. Using adverse paired-confidence-interval endpoints and approximately 26 ns ready / 60 ns handoff baselines, the conservative break-even is approximately 21% ready acquisitions. This favors workloads with a meaningful uncontended fraction; it does not claim every contention pattern becomes faster.
Type of Change
Related Issues
Closes #1038
Related to #1039
Changes Made
Core Changes
std::shared_ptr<wake_state>in mutex-private manual storage while preserving the existing 56-byte waiter layout.noexceptcancellation path.API Changes (if applicable)
Normal
co_await mutex.lock()source and ownership semantics are unchanged. The low-level exception boundary of the non-token awaiter changes.Before:
After:
await_ready()remainsnoexcept. Allocation occurs before takinginternal_mutex_or mutating the waiter queue, sostd::bad_allocleaves lock ownership and queue state unchanged. The token-aware awaiter retains eager construction and anoexceptawait_suspend().Migration Guide (if breaking change)
No migration is required for normal
co_await mutex.lock()use. Low-level integrations that require the exact no-tokenawait_suspendmember to benoexcept, or call it from their ownnoexceptfunction, must allow or handlestd::bad_allocon the contended path. The token-aware overload remains non-throwing at suspension.Testing
Unit Tests
Integration Tests
Sanitizer Testing
Test Results
Deterministic tests verify zero allocations for ready locks and the ready/suspend unlock race; exactly one allocation for a parked lock; eager token arbitration; strong
bad_allocsafety for non-token suspension and token construction; and existing destroy-after-dequeue and popped-handoff behavior.Pinned Release comparisons used long-lived coroutine frames, fixed CPU affinity, and interleaved baseline/candidate order:
The forced-handoff benchmark is deliberately permanently contended and removes worker-scheduling noise through the local trampoline. It exposes the slow-path tradeoff rather than representing every production pattern. No timing threshold is enforced in CI.
Checklist
Code Quality
Documentation
Testing
Compatibility
co_awaituse remains compatible; the low-level exception-specification change is documented aboveCHANGELOG.mdPerformance (if applicable)
Screenshots / Diagrams
Not applicable.
Additional Notes
The private manual storage does not embed
wake_statein the coroutine frame. It only defers construction of the existingstd::shared_ptr<wake_state>. Once a waiter parks, unlock and waiter destruction retain independent shared leases across the dequeue-to-schedule window.Empty-
shared_ptrassignment, swap, generic bit-packed storage, and allocation-before-recheck variants were benchmarked and rejected because they produced larger forced-handoff regressions.Reviewer Guidance
Areas requiring special attention:
shared_ptrlifetime: engagement only after successful construction and exactly-once destruction.Questions for reviewers:
shared_ptrexactly once?