Skip to content

perf(sync)!: defer shared writer wake allocation - #1068

Merged
Coldwings merged 1 commit into
mainfrom
perf/shared-writer-lazy-wake-v2
Aug 17, 2026
Merged

perf(sync)!: defer shared writer wake allocation#1068
Coldwings merged 1 commit into
mainfrom
perf/shared-writer-lazy-wake-v2

Conversation

@Coldwings

@Coldwings Coldwings commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Description

Make ready no-token shared_mutex::lock() acquisitions allocation-free while preserving the existing writer-admission and preference state machine.

The writer waiter now creates independently owned wake state only when suspension entry is reached. Allocation happens before the queue mutex, WRITER_WAITING, pending-writer accounting, or queue publication, so std::bad_alloc has the strong guarantee that no writer state changed. Token-aware writers remain eager.

This intentionally removes noexcept from the no-token writer awaiter's low-level await_suspend() member. The ownership result and high-level co_await mutex.lock() usage are unchanged, but code that explicitly depends on that exception specification must allow std::bad_alloc on the contended path.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Performance improvement (optimization that improves speed/memory usage)
  • Documentation (changes to documentation, comments, or examples)
  • Refactoring (code changes that neither fix bugs nor add features)
  • Tests (adding or modifying tests)
  • Build/CI (changes to build system, CI configuration, or dependencies)

Related Issues

Closes #1041
Related to #1040

Changes Made

Core Changes

  • Keep no-token writer wake state empty through the ready acquisition attempt.
  • Allocate exactly once at suspension entry, before any writer preference, accounting, mutex-protected, or queue mutation.
  • Preserve the existing locked writer admission, preference, grant transfer, cancellation, and abandoned-waiter cleanup paths.
  • Keep token-aware writer cancellation state eager.
  • Add deterministic allocation, publication, unlock-race, bad-allocation, and token-path regressions.
  • Extend the persistent-frame shared-mutex benchmark with ready writer, last-reader handoff, and writer-to-writer handoff rows and exact accounting.
  • Build formal timing without runtime test hooks; use a separate instrumented target only for exact allocation accounting.
  • Keep CI timing-free: x64/arm64 Release jobs execute only a timeout-bounded benchmark smoke.

API Changes

Before:

bool lock_awaitable::await_suspend(std::coroutine_handle<>) noexcept;

After:

bool lock_awaitable::await_suspend(std::coroutine_handle<>);

Migration Guide

Ordinary co_await mutex.lock() call sites need no source change. Low-level code that explicitly requires the no-token writer awaiter's await_suspend() to be noexcept must remove that assumption and may handle std::bad_alloc from a contended acquisition. Allocation failure occurs before writer preference, pending-writer accounting, or queue state is changed.

Testing

Unit Tests

  • Added new tests for the changes
  • Updated existing tests if needed
  • All tests pass locally

Integration Tests

  • Tested with existing examples
  • Tested in real-world scenarios (if applicable)

Sanitizer Testing

  • Tested with ASAN (AddressSanitizer)
  • Tested with TSAN (ThreadSanitizer)
  • No new warnings or errors

Test Results

Debug/Werror full elio_tests: 13,713 assertions / 845 cases
Exact writer-allocation case: 51 assertions / 1 case
[sync][shared_mutex]: 746 assertions / 19 cases
ASAN exact + [sync][shared_mutex]: 51/1 + 746/19, pass
TSAN exact + [sync][shared_mutex]: 51/1 + 746/19, pass
Uninstrumented Release benchmark --smoke: pass
Instrumented Release allocation probe: baseline 100,000; candidate 0
Invalid writer-core CLI: exit 2 as required

Controlled local Release measurements used identical final uninstrumented source and flags, fixed distinct physical CPU sets, 30 balanced interleaved process pairs per workload, strict accounting/timeouts, and 200,000 order-stratified bootstrap resamples. Only the Elio include root differed. A separately instrumented build proved the exact allocation delta; its timing was discarded.

  • Ready writer: 36.056 -> 35.037 ns, C/B 0.9717, 95% CI [0.9582, 0.9863], exact wake allocations 1/op -> 0/op.
  • Last-reader handoff: 576.671 -> 575.245 ns, C/B 0.9975, 95% CI [0.9929, 1.0022].
  • Writer-to-writer handoff: 575.121 -> 573.361 ns, C/B 0.9969, 95% CI [0.9896, 1.0047].
  • 90R/10W aggregate latency C/B 1.0066; writer p95/p99 0.9950/0.9927.
  • 50R/50W aggregate latency C/B 1.0099; writer p95/p99 1.0083/1.0053.
  • Continuous-reader latency C/B 0.9838; reader throughput 1.0165; writer p95/p99 1.0002/0.9887.
  • Every pressure sample completed its exact writer count with nonzero reader progress and zero no-progress observations.
  • Raw maxima remain reported diagnostics, not timing gates.

Checklist

Code Quality

  • My code follows the project's code style
  • I have added/updated comments for complex logic
  • I have removed any debug code, TODOs, or commented-out code
  • My changes generate no new warnings

Documentation

  • I have updated documentation (wiki, README, code comments)
  • I have added examples for new features (if applicable)
  • I have updated API documentation (if applicable)

Testing

  • I have added tests that prove my fix is effective or my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested with ASAN and TSAN

Compatibility

  • My changes are backward compatible (or I've documented breaking changes)
  • I have considered the impact on existing users
  • I have updated CHANGELOG.md (if applicable)

Performance

  • I have considered the performance impact
  • I have added benchmarks for performance-critical changes

Screenshots / Diagrams

Not applicable.

Additional Notes

The original W0 experiment and the first current-main timing generation remain visible in #1041. The original raw-maximum gate was prospectively superseded, while the first current-main timing was invalidated when review found that test-only allocation instrumentation biased the baseline. The final table above comes from a fresh uninstrumented generation; no earlier samples were pooled or reused.

GitHub-hosted CI is used only for correctness, compatibility, sanitizers, and timeout-bounded smoke. It has no timing threshold. In controlled local evidence, sub-3% differences are treated as approximately neutral unless exact mechanism evidence exists. Acceptance here rests on the exact 1-to-0 ready allocation change, the simpler reader/writer-symmetric contract already established by #1040, unchanged writer preference/progress, strong allocation-failure safety, and the absence of a material controlled regression.

Reviewer Guidance

Areas requiring special attention:

  • Allocation failure must occur before WRITER_WAITING, pending-writer accounting, or waiter publication.
  • The existing locked writer admission and preference state machine must remain unchanged after allocation.
  • Token-aware eager cancellation and grant/abandonment cleanup must retain their existing exactly-once semantics.
  • Formal timing must stay free of ELIO_RUNTIME_TEST_HOOKS; only the separate allocation probe may enable them.
  • The benchmark must use persistent coroutine frames and CI must not enforce performance timing thresholds.

Questions for reviewers:

  • Does any no-token path dereference an empty wake state before suspension allocates it?
  • Is the documented await_suspend() exception-specification change complete and accurate?
  • Is the timing/probe target separation sufficient to keep exact mechanism accounting out of latency evidence?

Copilot AI balanced review requested due to automatic review settings August 17, 2026 02:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Defers wake-state allocation for ready no-token shared-mutex writers while preserving writer admission and cancellation behavior.

Changes:

  • Lazily allocates writer wake state on suspension.
  • Adds allocation, failure, race, and token-path tests.
  • Extends benchmarks and documentation for writer performance.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
include/elio/sync/shared_mutex.hpp Implements deferred writer allocation.
tests/unit/test_sync_cancellation.cpp Adds writer allocation regressions.
examples/shared_mutex_reader_benchmark.cpp Adds writer benchmark workloads.
examples/CMakeLists.txt Enables benchmark test hooks.
wiki/Performance-Tuning.md Documents measurement policy.
wiki/API-Reference.md Documents allocation and exception behavior.
wiki/API-Contracts.md Updates shared-mutex contract.
CHANGELOG.md Records the breaking performance change.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread examples/CMakeLists.txt Outdated
@Coldwings
Coldwings force-pushed the perf/shared-writer-lazy-wake-v2 branch from f272093 to 5664738 Compare August 17, 2026 03:03
@Coldwings

Copy link
Copy Markdown
Owner Author

@copilot please re-review only; do not make direct code modifications.

Copilot AI commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review only; do not make direct code modifications.

Re-reviewed the current head. All three questions from the PR are answered cleanly:

1. No no-token path dereferences an empty wake_state_ before suspension allocates it.

Every wake_state_->* call in lock_waiter is either (a) inside a if (cancellable_) guard, (b) inside the else if (!wake_state_) block that performs the allocation first, or (c) on the queue-park path (set_handle/unblock_after_publish, lines 585/594) which is only reachable after the allocation at line 522 has already succeeded. The allocation-failure path throws before touching WRITER_WAITING, pending_writers_, or the waiter list — the strong-guarantee claim holds.

2. The await_suspend() exception-specification change is complete and accurate.

lock_awaitable::await_suspend (line 692) no longer carries noexcept, matching the PR description. The lock() doc comment (line 742) explicitly names std::bad_alloc and the pre-state guarantee. Token-aware (lock(token)) and reader paths are unaffected.

3. The timing/probe target separation is sufficient.

shared_mutex_reader_benchmark carries no ELIO_RUNTIME_TEST_HOOKS definition; shared_mutex_writer_allocation_probe does (lines 63–64 of examples/CMakeLists.txt). The benchmark source guards every allocation counter and hook path under #ifdef ELIO_RUNTIME_TEST_HOOKS, so the uninstrumented timing binary runs neither the counter fetch_add nor the pause logic. The original reviewer concern about biased baseline timing is fully addressed.

@Coldwings
Coldwings merged commit 8b9c04e into main Aug 17, 2026
11 checks passed
@Coldwings
Coldwings deleted the perf/shared-writer-lazy-wake-v2 branch August 17, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Performance] Avoid wake-state allocation for ready shared-mutex writer locks

3 participants