Skip to content

perf(sync): defer semaphore wake-state allocation - #1044

Merged
Coldwings merged 1 commit into
mainfrom
perf/semaphore-ready-wake-state
Aug 13, 2026
Merged

perf(sync): defer semaphore wake-state allocation#1044
Coldwings merged 1 commit into
mainfrom
perf/semaphore-ready-wake-state

Conversation

@Coldwings

Copy link
Copy Markdown
Owner

Description

Make the no-token sync::semaphore::acquire() ready path allocation-free while preserving permit accounting, FIFO publication for queued waiters, cancellation arbitration, and dequeue-to-schedule lifetime.

A no-token waiter now creates shared wake state only after await_ready() observes no permit. Construction occurs before taking the semaphore queue lock, and the existing locked count recheck decides whether to consume a concurrently released permit or publish the waiter. Token-aware acquires retain eager state and their noexcept cancellation-versus-grant 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 #1039
Related to #1040

Changes Made

Core Changes

  • Keep immediately available no-token permits allocation-free.
  • Directly construct slow-path std::shared_ptr<wake_state> state in semaphore-private manual storage while retaining the 56-byte waiter layout.
  • Allocate before the queue lock, then preserve the existing locked count recheck, FIFO publication, release handoff, and popped-permit recovery.
  • Keep token-aware waits eagerly allocated and on the existing noexcept cancellation path.
  • Preserve public low-level waiter helper compatibility through a base dispatcher, a direct no-token helper, and a derived noexcept token helper.
  • Add allocation/failure/race/public-helper tests, permit-conservation regressions, three benchmark layers, changelog, and wiki guidance.

API Changes (if applicable)

Normal co_await semaphore.acquire() source and permit semantics are unchanged. The low-level no-token suspend exception boundary changes.

Before:

auto acquire = semaphore.acquire();  // Wake-state allocation may throw here.
static_assert(noexcept(
    acquire.await_suspend(std::noop_coroutine())));

After:

auto acquire = semaphore.acquire();  // Ready construction is allocation-free.
static_assert(!noexcept(
    acquire.await_suspend(std::noop_coroutine())));
// Entering await_suspend() may propagate std::bad_alloc.

Allocation happens before locking the queue, consuming a permit, or publishing a waiter, so std::bad_alloc leaves permit and queue state unchanged. A permit released between await_ready() and await_suspend() may be consumed by the locked recheck after one unused allocation. Token-aware suspension remains noexcept.

Migration Guide (if breaking change)

No migration is required for normal co_await semaphore.acquire() use. Low-level integrations that require the exact no-token await_suspend member to be noexcept must allow or handle std::bad_alloc. Token-aware code retains the existing non-throwing suspend surface.

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

Focused Normal: 300 assertions / 7 semaphore test cases passed
Full Normal:    12,400 assertions / 817 test cases passed
Full ASAN:      12,401 assertions / 817 test cases passed; no diagnostics
Full TSAN:      12,397 assertions; 816 passed / 1 expected skip; no diagnostics

Deterministic coverage verifies ready=0 allocations, parked=1 allocation/publication, release-before-suspend accounting, a legal pre-publication permit steal, strong bad_alloc safety with empty and released permits, eager token state, public true/false helper compatibility, cancellation/grant behavior, and popped permit recovery.

Pinned Release testing used long-lived frames, fixed CPU affinity, interleaved baseline/candidate order, and same-source binaries:

30-pair gate:
  Ready acquire/release: -38.34%, 95% CI [-39.25%, -37.35%]
  Park/unlink diagnostic: -8.25%, 95% CI [-9.84%, -6.55%]
  Forced handoff: -1.23%, 95% CI [-3.21%, +0.65%]
  Release/no-waiter control: -0.36%, 95% CI [-2.01%, +1.19%]
  Conservative adverse-gate break-even ready share: 21.52%

Final current-base 10-pair direction check:
  Ready acquire/release: 38.826 -> 24.435 ns, -37.06%
  Park/unlink diagnostic: 46.429 -> 43.183 ns, -6.99%
  Forced handoff: 75.581 -> 74.417 ns, -1.54%

The park/unlink benchmark isolates waiter creation/publication/removal from release() wake-vector allocation. The forced handoff benchmark includes that existing common cost. No timing threshold is enforced in CI.

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 benchmark coverage
  • I have updated API documentation

Testing

  • I have added tests that prove the optimization is effective
  • New and existing unit tests pass locally
  • I have tested with ASAN and TSAN

Compatibility

  • Normal co_await use remains compatible; the low-level exception change is documented
  • I have considered the impact on existing users
  • I have updated CHANGELOG.md

Performance (if applicable)

  • I have considered ready, parked, release-control, and forced-handoff performance
  • I have added benchmarks for performance-critical changes

Screenshots / Diagrams

Not applicable.

Additional Notes

The private manual storage defers construction of the existing shared wake-state lease; it does not replace it with frame-local state. A parked waiter still has an independent lifetime across dequeue and scheduling.

The implementation deliberately does not add a lock/unlock precheck before allocation and does not allocate while holding the semaphore mutex. Already-published FIFO ordering remains unchanged; an unpublished waiter has no FIFO entitlement over a later ready acquire.

Reviewer Guidance

Areas requiring special attention:

  • Exactly-once construction/destruction of the manually stored shared pointer.
  • Allocation failure before any permit or queue mutation.
  • The release/ready/suspend publication boundary and permit conservation.
  • Token cancel-versus-grant and popped permit recovery.
  • Public base/derived waiter helper dispatch and noexcept compatibility.

Questions for reviewers:

  • Can any race lose or duplicate a permit?
  • Is every queued waiter guaranteed to have engaged shared state?
  • Does the helper split preserve both the ready hot path and low-level compatibility?

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

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 non-cancellable semaphore acquisition by deferring wake-state allocation until suspension while preserving permit and cancellation semantics.

Changes:

  • Adds lazy wake-state construction and exception-safe suspension.
  • Expands allocation, race, and permit-conservation tests.
  • Adds semaphore benchmarks and documentation.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
include/elio/sync/semaphore.hpp Implements deferred wake-state allocation.
tests/unit/test_sync_cancellation.cpp Adds semaphore regression coverage.
examples/microbench.cpp Adds semaphore performance benchmarks.
wiki/Performance-Tuning.md Documents performance behavior.
wiki/API-Reference.md Documents allocation and exception semantics.
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.

@Coldwings
Coldwings merged commit 0d446c5 into main Aug 13, 2026
11 checks passed
@Coldwings
Coldwings deleted the perf/semaphore-ready-wake-state branch August 13, 2026 07:40
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 semaphore acquires

2 participants