Skip to content

Performance improvements on semaphore acquires - #1

Open
clemenslosbichler-cloud wants to merge 2 commits into
cg-321-basefrom
cg-321-head
Open

Performance improvements on semaphore acquires#1
clemenslosbichler-cloud wants to merge 2 commits into
cg-321-basefrom
cg-321-head

Conversation

@clemenslosbichler-cloud

Copy link
Copy Markdown
Owner

Replay of awslabs#321 for ContextGoblin review. Not for merge.

sarsko added 2 commits August 19, 2026 10:38
`Acquire::poll` ran this in release builds on every poll:

    assert_eq!(is_queued, self.waiter.waker.lock().unwrap().is_some());

That takes a `std::sync::Mutex` to check an internal invariant, on the
uncontended fast path, for every `Mutex::lock`, `RwLock::read`/`write`,
`Semaphore::acquire`, and every channel send and recv in the program under test.
Make it a `debug_assert_eq!`.

Two smaller cleanups alongside it: the `will_succeed` computation took two
separate `RefCell` borrows of the semaphore state (`is_closed`, then
`available_permits`) where one suffices and gives a single consistent snapshot,
and `release()` computed `ExecutionState::me()` eagerly for a `trace!` argument,
paying an `ExecutionState::with` on every release even with tracing disabled.

Measured on an M1 Pro, release, single task, no contention, against an identical
61ns per-scheduling-decision control:

    operation            steps/op    before     after
    mutex lock/unlock           2     234ns     167ns   -29%
    rwlock write                2     240ns     163ns   -32%
    semaphore acquire           2     272ns     163ns   -40%
    mpsc send+recv              4     507ns     353ns   -30%

Attribution: restoring only the `assert_eq!` while keeping the two cleanups
returns the numbers to baseline, so the assertion accounts for essentially all of
it. An isolated `std::sync::Mutex` lock/unlock measures 10.4ns, so the remainder
is presumably the barrier interacting with the surrounding `SeqCst` atomics.

Note this only helps release builds, since `debug_assert` stays active under a
default `cargo test`.
`BatchSemaphore::acquire` did `Arc::new(Waiter::new(num_permits))`
unconditionally, so every `Mutex::lock`, `RwLock::read`/`write`,
`Semaphore::acquire`, and every channel send and recv did a heap allocation.

The `Arc` exists because a blocked acquire is shared between two owners: the
`Acquire` future on the blocking task's stack, and the semaphore's
`waiters: VecDeque<Arc<Waiter>>`, which the releasing task reaches into to flip
`is_queued`/`has_permits`, read the clock, and take the waker. An acquire that
gets its permits immediately is never enqueued and never observed by another
task, so it needs none of that sharing.

`Acquire` now holds `num_permits`, `task_id`, `clock` and a plain `has_permits`
bool inline, with `waiter: Option<Arc<Waiter>>` allocated in the one branch that
enqueues. `has_permits()`, `is_queued()` and `grant_permits()` read the inline
copies while no waiter exists and the shared ones once it does, since from then
on the releasing task owns those writes.

`task_id` and `clock` are still snapshotted in `Acquire::new`, exactly as before,
and moved into the `Waiter` at enqueue. This is load bearing: `waiter.clock` feeds
the happens-before edge recorded in `unblock_waiters_from_front`, and a
`thread::switch()` sits between construction and blocking, so reading the clock at
enqueue time instead would silently change the happens-before graph that vector
clocks use to detect races.

A `Waiter` cannot instead live on the `Task` and be reused, because a task can
have several acquires outstanding at once: `future::batch_semaphore` already has a
test where one task drives two `lock()` futures for the same semaphore through
`FuturesUnordered`, and `upgrade()` polls an `Acquire` then releases while it is
still alive. Queue identity also matters, since `remove_waiter` locates entries by
`Arc::ptr_eq`.

Measured on an M1 Pro, release, single task, no contention, control 52-54ns:

    operation            steps/op    before     after   primitive work
    mutex lock/unlock           2     167ns     111ns    45ns ->  7ns
    rwlock write                2     163ns     113ns    42ns -> 10ns
    semaphore acquire           2     163ns     112ns    41ns ->  8ns
    mpsc send+recv              4     353ns     250ns   110ns -> 43ns

"primitive work" is cost above what the same number of bare scheduling decisions
takes. This beat the 33ns the allocation alone accounts for, because the fast path
also stopped touching `SeqCst` atomics: `has_permits` and `is_queued` are plain
bool reads when no waiter exists.
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.

2 participants