Skip to content

perf(sync): defer shared reader wake-state allocation - #1067

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

perf(sync): defer shared reader wake-state allocation#1067
Coldwings merged 1 commit into
mainfrom
perf/shared-reader-lazy-wake-v2

Conversation

@Coldwings

@Coldwings Coldwings commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Description

Defer the independent wake-state allocation used by no-token
shared_mutex::lock_shared() until suspension entry has confirmed that the
reader still needs the writer/saturation slow path.

Ready admission and the suspension-entry lock-free retry now allocate nothing.
Token-aware readers retain their eager cancellation state and behavior. The
slow path allocates before taking the waiter-queue mutex, so allocation failure
cannot publish or grant a waiter.

This implements the workload-weighted contract accepted after the earlier
prototype was closed: the common reader-ready path receives the optimization,
while the uncommon contended path is allowed a small absolute cost. The issue
records both the superseded percentage-based gate and the final prospective
starvation-focused policy; this PR does not relabel the earlier result as a
pass.

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 #1040

Changes Made

Core Changes

  • Keep no-token wake state empty across the ready check and the second
    lock-free admission attempt.
  • Allocate the independent wake lease exactly once before slow-path queue
    locking and preserve the existing dequeue/grant lifetime rules.
  • Preserve the unlocked CAS retry from fix(sync): prevent shared reader admission lost wakeup #1063, writer preference, cancellation
    rollback, and the documented reader-count representation boundary.
  • Compile the token and no-token suspension paths separately without changing
    token cancellation semantics.
  • Add deterministic allocation, exception, writer-ordering, grant-destruction,
    and packed-state race regressions plus a same-source persistent-coroutine
    benchmark.
  • Run the benchmark's timeout-bounded --smoke mode in Release CI without a
    timing threshold.

API Changes

Before: the no-token lock_shared() awaiter's await_suspend() was
noexcept, because wake state was allocated eagerly while constructing the
awaiter.

After: ready readers do not allocate. A no-token reader that still reaches
the writer/saturation slow path allocates in await_suspend() and may propagate
std::bad_alloc before any queue or packed-state mutation. Consequently, that
low-level await_suspend() is no longer noexcept.

The lock_shared() call shape, awaiter layout, successful result, ownership,
writer-preference behavior, and token-aware API are unchanged.

Migration Guide

Most coroutine call sites require no change. Code that directly depends on the
no-token awaiter's await_suspend() being noexcept must remove that
assumption or handle allocation failure. Applications should continue to obey
the documented 62-bit outstanding-reader precondition.

Testing

Unit Tests

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

Integration Tests

  • Tested with the benchmark smoke workflow
  • Tested writer progress under mixed and pressure workloads

Sanitizer Testing

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

Test Results

  • Debug/Werror exact regression: 82 assertions / 1 case.
  • Debug/Werror [sync][shared_mutex]: 695 assertions / 18 cases.
  • Full Debug/Werror elio_tests: 13,666 assertions / 844 cases.
  • ASAN exact regression and [sync][shared_mutex]: 82/1 and 695/18.
  • TSAN exact regression and [sync][shared_mutex]: 82/1 and 695/18.
  • Benchmark smoke passed all five rows with exact writer progress; invalid CLI
    exited with status 2.

The first PR review correctly found that the original benchmark drove several
awaiters directly instead of using long-lived coroutine frames. Those earlier
numbers are not used below. The corrected Release/Werror evidence used the
exact same final persistent-coroutine benchmark source and flags for baseline
and candidate, changing only the Elio include root. Core, reader-only, and
mixed suites used 30 balanced interleaved pairs; pressure used a fresh 60
pairs. Analysis used a 200,000-resample order-stratified paired-log bootstrap:

  • Exact ready allocation: 100,000 baseline allocations versus zero candidate
    allocations.
  • Ready path: 25.7107 ns to 16.2918 ns (C/B=0.63366, 95% CI
    [0.62532, 0.64292]).
  • Forced handoff: +0.5493 ns, 95% CI [-2.8036, +3.8947], within the
    preregistered +3 ns point / +4 ns upper bounds.
  • Reader-only persistent-coroutine 1/2/4-worker ratios: 0.63381,
    0.79064, 0.88708; all confidence intervals passed.
  • Mixed 4-worker 90/10 aggregate 0.99091; writer p95/p99 0.99403 /
    0.99610.
  • Mixed 4-worker 50/50 aggregate 0.99220; writer p95/p99 0.99893 /
    1.00399.
  • Corrected pressure4 aggregate 0.96736; writer p95 0.99063 CI
    [0.98494, 0.99642], p99 0.99587 CI [0.97381, 1.01800].
  • Every pressure process completed exactly 50,000 writer operations with no
    timeout or zero-progress sample. The observed maximum remains a diagnostic:
    ratio 0.95847, CI [0.85501, 1.07436].

The process-level CPU mask supplied the allowed set 1-6 rather than individual
thread-role pinning; those six distinct physical cores covered the four readers,
driver, and scheduler without oversubscription.

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
  • I have added a benchmark for the performance-sensitive behavior
  • I have updated API documentation

Testing

  • I have added tests that prove the optimization and strong exception boundary
  • New and existing unit tests pass locally with my changes
  • I have tested with ASAN and TSAN

Compatibility

  • I have documented the exception-specification behavior change
  • I have considered the impact on existing users
  • I have updated CHANGELOG.md

Performance

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

Screenshots / Diagrams

Not applicable.

Additional Notes

The representation-limit fallback remains subject to the caller precondition
documented by #1062. This PR does not change the correctness fix from #1063.
Independent current-head and corrected-method reviews found no state-machine,
API-documentation, test, or remaining benchmark-method blocker. The initial
review's long-lived-frame and CI-smoke findings are addressed in the updated
head. GitHub CI is pending.

Reviewer Guidance

Areas requiring special attention:

  • Allocation must occur before the waiter-queue mutex and before any
    publication, so std::bad_alloc preserves the strong guarantee.
  • A failed locked CAS may retry only after releasing the internal mutex; a
    writer flag observed on either attempt must preserve writer preference.
  • Published/granted readers must always own a non-null independent wake lease.
  • The low-level no-token await_suspend() exception-specification change must
    match the API reference and changelog.

Thank you for contributing to Elio! 🎉

Copilot AI balanced review requested due to automatic review settings August 14, 2026 09:07

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

Optimizes no-token shared-reader acquisition by deferring wake-state allocation until contention requires suspension.

Changes:

  • Adds lazy wake-state allocation while preserving cancellation and writer preference.
  • Adds regression tests and a shared-mutex benchmark.
  • Documents the exception-specification and performance changes.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
include/elio/sync/shared_mutex.hpp Implements deferred allocation.
tests/unit/test_sync_cancellation.cpp Adds allocation and race regressions.
examples/shared_mutex_reader_benchmark.cpp Adds performance workloads.
examples/CMakeLists.txt Builds the new benchmark.
wiki/API-Reference.md Documents allocation and exception behavior.
wiki/Performance-Tuning.md Documents benchmark methodology.
CHANGELOG.md Records the behavioral change.
Suppressed comments (2)

examples/shared_mutex_reader_benchmark.cpp:97

  • This forced-handoff row schedules std::noop_coroutine() and then calls await_resume() manually, so it never measures wakeup and resumption of a parked reader's long-lived coroutine frame as required by #1040. Please perform each handoff through an actual reader task and wait for that task to resume before recording completion.
        auto waiter = mutex.lock_shared();
        if (waiter.await_ready() ||
            !waiter.await_suspend(std::noop_coroutine())) {
            std::abort();
        }
        mutex.unlock();
        waiter.await_resume();

examples/shared_mutex_reader_benchmark.cpp:119

  • The concurrent-reader rows also invoke the awaiter methods directly on std::threads rather than running long-lived coroutine reader loops. As a result, the documented 1/2/4/8-reader benchmark does not exercise the coroutine path required by #1040. Schedule one persistent reader coroutine per worker and use co_await within each timed loop.
                auto waiter = mutex.lock_shared();
                if (!waiter.await_ready()) std::abort();
                waiter.await_resume();

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

Comment thread examples/shared_mutex_reader_benchmark.cpp Outdated
Comment thread examples/CMakeLists.txt
@Coldwings
Coldwings force-pushed the perf/shared-reader-lazy-wake-v2 branch from 98fa0e2 to 57ebab3 Compare August 14, 2026 09:51
@Coldwings

Copy link
Copy Markdown
Owner Author

@copilot please re-review the updated head only. The two prior findings have been addressed; please review without making code changes.

Copilot AI commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review the updated head only. The two prior findings have been addressed; please review without making code changes.

Both prior findings are addressed in the updated head.

Benchmark coroutine framespersistent_reader, persistent_forced_reader, persistent_forced_driver, mixed_worker, and pressure_reader are all long-lived coroutines scheduled via scheduler.go_joinable_to(...) that drive repeated co_await mutex.lock_shared() inside the frame. The forced-handoff driver synchronises with the reader only after await_suspend has returned and waiter publication is complete, which removes the race that the earlier version had.

CI smoke coverage.github/workflows/ci.yml now runs timeout --kill-after=5s 30s ./build/examples/shared_mutex_reader_benchmark --smoke as a Release-matrix step beside the existing benchmark smokes, with no timing threshold.

The deferred-allocation logic is correct: await_suspend_mode_impl<false> allocates wake_state_ at most once, immediately before the internal mutex is taken and before any queue or packed-state mutation, so std::bad_alloc preserves the strong guarantee. The if constexpr (!Cancellable) branches in the locked and unlocked CAS retries correctly short-circuit without consulting claim_wake_state. The lock_shared_awaitable::await_suspend is no longer noexcept, consistent with the API change documented in the PR description and wiki.

No issues found in the updated head.

@Coldwings
Coldwings merged commit 7cd8c07 into main Aug 14, 2026
11 checks passed
@Coldwings
Coldwings deleted the perf/shared-reader-lazy-wake-v2 branch August 14, 2026 10:15
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 reader locks

3 participants