Skip to content

Performance improvements on semaphore acquires - #321

Open
sarsko wants to merge 2 commits into
mainfrom
perf-bsem
Open

Performance improvements on semaphore acquires#321
sarsko wants to merge 2 commits into
mainfrom
perf-bsem

Conversation

@sarsko

@sarsko sarsko commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Been looking a bit at performance, trying to get the hot paths down. Here's a commit that makes BatchSemaphore acquires faster always, and one commit that makes them faster when there is no contention (by not creating a Waiter)


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


perf(semaphore): allocate the waiter only when an acquire blocks
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.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

jorajeev
jorajeev previously approved these changes Aug 18, 2026
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.
@sarsko

sarsko commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased now

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