Skip to content

Reduce first-waiter overhead in Semaphore permit handoff #400

Description

@tisonkun

On a fresh zero-permit semaphore, registering a pending request and handing it one permit took 232 ms versus 64 ms for Tokio over three million handoffs. The benchmark verifies that the waiter becomes ready and forgets the permit to return the semaphore to zero.

Apple M4 Max, macOS arm64, rustc 1.101.0-nightly. Each cell gives rank, elapsed time relative to Asyncband and the whole-batch median over 10 interleaved runs. Lower is faster. Supplemental measurements

Scenario Fixed rounds Asyncband Tokio async-lock
Hand one permit to a waiting caller 3,000,000 #2, 1.00× (232 ms) #1, 0.28× (64 ms) —
Try and return one available permit 30,000,000 #3, 1.00× (271 ms) #1, 0.67× (181 ms) #2, 0.84× (228 ms)

Object lifetime explains much of the first row on this host. With the same three million handoffs, a fresh semaphore each round took 233 ms for Asyncband and 65 ms for Tokio. Reusing one across rounds gave 86 and 63 ms. Allocation counting found two allocations per fresh Asyncband handoff and zero for Tokio. After warm-up both reused instances had zero allocations per handoff. Asyncband's first waiter goes through the queue lock and arena. Tokio stores its waiter node inside the acquisition future. Tokio's pinned implementation

First contribution

Reproduce both lifetimes with the same zero-permit protocol, then isolate the queue's first allocation. A change should improve first registration and keep the reused case competitive. Check multi-permit ordering, cancellation after a partial or full grant, and absence of missed wakeups. PR #394 addresses acquisition-future size and is related work. PR #394

To run the supplemental peer_checks command below, save and run peer-checks.rb from the linked comment in a disposable checkout of 6102089. The benchmark and library sources match 36ba6f2. Generator and instructions

cargo x bench --bench primitives -- semaphore::acquire::handoff_permit --repeat 10 --raw
cargo x bench --bench peer_checks -- semaphore/ --repeat 10 --raw

Performance tracking issue #396

Activity

  1. rokadepiyush49-rgb commented on Nov 6, 2026

    @rokadepiyush49-rgb

    Could you assign this to me? I'd run semaphore::acquire::handoff_permit on fresh and reused semaphores on an Apple M4 (10 cores, macOS 27.0.1), then repeat on Linux, counting allocations for each.

    My guess is that the two allocations are the first lock of waiters on macOS and the first Arena slot. In a small standalone probe, a fresh Mutex allocated 64 bytes on first lock. That one should disappear on Linux, which would show how much of the gap is the queue.

    If the queue does matter, an inline first slot would keep const construction but make every semaphore larger, while a node in the future would undo some of #394. I'd want your view on that before writing anything. I haven't run the benchmarks yet. I used Claude to help me read the code and write the probe.

  2. tisonkun commented on Nov 6, 2026

    @tisonkun
    MemberAuthor

    Thanks for your interest! We don't assign issues to individual contributors. Anyone is welcome to submit a PR directly for review.

  3. nanami-0713 commented on Nov 9, 2026

    @nanami-0713
    Contributor

    Reproduced both lifetimes on Apple M5 (10 cores), macOS 27.0.1, rustc 1.99.1 stable, in a disposable checkout of 6102089 (PR #383 head, content-identical to the issue's 36ba6f2), with the issue's peer-checks.rb generator running the semaphore scenarios with --repeat 10.

    Scenario Rounds Asyncband Tokio async-lock
    semaphore/pending-handoff — a fresh semaphore per round 3,000,000 170 ms (147..480) 71 ms (63..163) —
    semaphore/try-one-and-return — one semaphore per repetition 30,000,000 243 ms (220..460) 193 ms (178..310) 261 ms (222..587)
    semaphore/reused-handoff — the handoff round below, one semaphore per repetition 3,000,000 75 ms (69..117) 67 ms (60..93) —

    Medians over 10 interleaved runs; ranges are min..max. The first two rows are the semaphore/ scenarios of the generated peer_checks bench. The third row is a local adapter on top of the repository's ecosystem bench (patch below): the same pending-handoff round — acquire(1) polled to pending, release(1), polled to ready, forget() — with the semaphore constructed once per repetition instead of once per round, through the same interleaved runner against the same library sources, executed back-to-back with the peer bench in one session.

    Allocation counting (a GlobalAlloc counter, 1,000,000 rounds; per-round counts are exact in every cell): a fresh Asyncband handoff performs two allocations per round against one for Tokio, and after warm-up both reused semaphores allocate nothing per handoff. Backtraces from the probe identify both sites (each recurs on every round):

    • 64 B — the first lock of the waiters mutex heap-initializes the OS mutex: on this pthread-backed platform std::sync::Mutex boxes the OS mutex lazily (std::sys::sync::once_box::OnceBox in the trace; asyncband's internal::mutex::Mutex wraps std::sync::Mutex directly). Tokio's batch semaphore locks a std-backed mutex on the same path and pays the same boxing — this is its one fresh-round allocation.
    • 192 B — Arena::insert grows the waiter slot vector from empty on the first registration, 4 × 48-byte slots (Arena<Node<WaitNode>>::insert reaching RawVec::grow_one, both under acquired_or_enqueue). This one is Asyncband-specific.

    So the fresh-lifetime overhead is the queue's first-registration cost paid every round: the mutex's lazy boxing, shared with Tokio on this platform, plus the arena's first growth, which is Asyncband's extra allocation. Both disappear on a reused semaphore — the same handoff runs at 2.4x Tokio's time on a fresh semaphore and 1.12x on a reused one (the try/return row, which never queues a waiter, is 1.26x on a reused semaphore).

    Adapter patch — benchmarks/benches/ecosystem/semaphore.rs plus the mod/register lines in main.rs
    diff --git a/benchmarks/benches/ecosystem/main.rs b/benchmarks/benches/ecosystem/main.rs
    index 561eedc..e9a7548 100644
    --- a/benchmarks/benches/ecosystem/main.rs
    +++ b/benchmarks/benches/ecosystem/main.rs
    @@ -20,6 +20,7 @@ use benchmarks::harness::Runner;
     mod broadcast;
     mod mpmc;
     mod mpsc;
    +mod semaphore;
     mod spmc;
     mod waitgroup;
     mod watch;
    @@ -29,6 +30,7 @@ fn main() {
         broadcast::register(&mut runner);
         mpmc::register(&mut runner);
         mpsc::register(&mut runner);
    +    semaphore::register(&mut runner);
         spmc::register(&mut runner);
         waitgroup::register(&mut runner);
         watch::register(&mut runner);
    diff --git a/benchmarks/benches/ecosystem/semaphore.rs b/benchmarks/benches/ecosystem/semaphore.rs
    new file mode 100644
    index 0000000..b3c83c4
    --- /dev/null
    +++ b/benchmarks/benches/ecosystem/semaphore.rs
    @@ -0,0 +1,75 @@
    +// Licensed to the Apache Software Foundation (ASF) under one
    +// or more contributor license agreements.  See the NOTICE file
    +// distributed with this work for additional information
    +// regarding copyright ownership.  The ASF licenses this file
    +// to you under the Apache License, Version 2.0 (the
    +// "License"); you may not use this file except in compliance
    +// with the License.  You may obtain a copy of the License at
    +//
    +//   http://www.apache.org/licenses/LICENSE-2.0
    +//
    +// Unless required by applicable law or agreed to in writing,
    +// software distributed under the License is distributed on an
    +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    +// KIND, either express or implied.  See the License for the
    +// specific language governing permissions and limitations
    +// under the License.
    +
    +use benchmarks::harness::Bencher;
    +use benchmarks::harness::Runner;
    +use benchmarks::support::bench_context;
    +use benchmarks::support::poll_pending;
    +use benchmarks::support::poll_pinned_ready;
    +use std::hint::black_box;
    +use std::pin::pin;
    +
    +mod asyncband_reused {
    +    use super::*;
    +
    +    pub(super) fn run(bencher: Bencher) {
    +        let semaphore = asyncband::semaphore::Semaphore::new(0);
    +        let mut context = bench_context();
    +
    +        bencher.bench_local(|| {
    +            let mut acquire = pin!(semaphore.acquire(black_box(1)));
    +            poll_pending(acquire.as_mut(), &mut context);
    +
    +            semaphore.release(black_box(1));
    +            let permit = poll_pinned_ready(acquire.as_mut(), &mut context);
    +            permit.forget();
    +
    +            black_box(semaphore.available_permits())
    +        });
    +    }
    +}
    +
    +mod tokio_reused {
    +    use super::*;
    +
    +    pub(super) fn run(bencher: Bencher) {
    +        let semaphore = tokio::sync::Semaphore::new(0);
    +        let mut context = bench_context();
    +
    +        bencher.bench_local(|| {
    +            let mut acquire = pin!(semaphore.acquire_many(black_box(1)));
    +            poll_pending(acquire.as_mut(), &mut context);
    +
    +            semaphore.add_permits(black_box(1));
    +            let permit = poll_pinned_ready(acquire.as_mut(), &mut context).unwrap();
    +            permit.forget();
    +
    +            black_box(semaphore.available_permits())
    +        });
    +    }
    +}
    +
    +pub fn register(runner: &mut Runner) {
    +    runner.compare(
    +        "semaphore/reused-handoff",
    +        3_000_000,
    +        &[
    +            ("Asyncband", &|b| asyncband_reused::run(b)),
    +            ("Tokio", &|b| tokio_reused::run(b)),
    +        ],
    +    );
    +}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions