Skip to content

perf(semaphore): register the first waiter without allocating - #419

Open
nanami-0713 wants to merge 2 commits into
apache:mainfrom
nanami-0713:perf/arena-inline-first-slot
Open

nanami-0713 wants to merge 2 commits into
apache:mainfrom
nanami-0713:perf/arena-inline-first-slot

Conversation

@nanami-0713

Copy link
Copy Markdown
Contributor

Summary

The waiter arena keeps its first slot inline, so the first registration on any queue-backed primitive no longer allocates. The slot vector — with today's growth behavior — is only created by the second concurrent waiter. On the fresh-semaphore permit handoff from #400 this removes one of the two allocations per handoff and cuts the median time by about a quarter. Fixes #400.

Design notes

  • Slot 0 lives in the inline first field until the vector first becomes non-empty; the spill moves it into slots[0], so every slot ID stays stable across the spill and the vacant list only ever spans the vector.
  • take_all drains both storages and restarts at slot 0, preserving the existing ID-restart semantics (the existing arena test is unchanged).
  • WaitList and every consumer — semaphore, condvar, both reset events, broadcast MPMC — are untouched, so multi-permit ordering, cancellation after a partial or full grant, and wakeup delivery keep their current behavior, covered by the unchanged queue logic and the existing suites.
  • Trade-off: every arena now carries one inline Option<T> — a semaphore's wait list grows by 56 bytes on 64-bit targets. Uncontended primitives pay this in size in exchange for an allocation-free first waiter.

Measurements

Apple M5 (10 cores), macOS 27.0.1, rustc 1.99.1 stable; this branch versus main at cf7ec96, run in one session.

cargo x bench --bench primitives -- semaphore::acquire::handoff_permit --repeat 10:

median min..max
main 176 ms 163..203
this branch 131 ms 125..165

Allocation counting (GlobalAlloc probe, 1,000,000 rounds): a fresh handoff drops from two allocations per round (64 B + 192 B) to one (64 B), and a reused semaphore's warm-up registration drops from two allocations to one. The remaining 64 B is std::sync::Mutex's lazy pthread boxing under acquired_or_enqueue, which Tokio's batch semaphore pays on the same path on this platform; futex-backed targets box nothing.

Verification

cargo x test passes, including new arena tests for the inline slot, spill ID stability, and the take_all restart.

@tisonkun
tisonkun requested review from orthur2 and tisonkun and a balanced review from Copilot November 9, 2026 07:06

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.

🟡 Changes recommended

The new tests lack regression assertions for allocation-free first registration, lone-waiter reuse, and spill behavior that preserves slot 0, so they would also pass with the previous vector-only implementation.

1 open issue
What changed in this PR

This PR reduces first-waiter allocation overhead in queue-backed synchronization primitives by storing the arena’s first waiter inline, addressing #400.

Changes:

  • Keep the first arena slot inline and spill to vector storage when a second concurrent waiter registers.
  • Update arena access, removal, and draining logic, with tests for slot reuse and ID stability.
  • Document the performance improvement in the changelog.
File Description
CHANGELOG.md Records reduced first-waiter allocation overhead for affected primitives.
asyncband/​src/​internal/​arena.rs Adds inline storage, spill handling, and arena regression tests.

Review effort: Balanced

Comment on lines +270 to +280
assert_eq!(id, SlotId::from_index(0));
assert_eq!(arena.get(id), Some(&"only"));
assert_eq!(arena.len(), 1);

assert_eq!(arena.remove(id), "only");
assert!(arena.is_empty());
assert_eq!(arena.get(id), None);

// Slot 0 is handed out again while the vector is still empty.
let again = arena.insert("again");
assert_eq!(again, id);
@nanami-0713

Copy link
Copy Markdown
Contributor Author

The initial tests were invariant tests — they pin ID stability, reuse, and take-all-restart semantics, and they intentionally pass on the previous vector-only implementation too, since those invariants hold there. What was missing was a test that distinguishes the change itself. 5fa4ec1 adds both layers:

  • lone_waiter_uses_the_inline_slot now also asserts vector_len() == 0 after a lone registration, which fails on the previous implementation (it pushes into the vector immediately).
  • first_registration_allocates_nothing_beyond_the_mutex_boxing counts this thread's allocations across register, cancel, and remove, filtering only the 64-byte std::sync::Mutex pthread boxing (zero on futex-backed targets), and asserts zero — the previous implementation's 192-byte arena growth is caught by this filter on every platform.

This branch has not been deployed

No deployments
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.

Reduce first-waiter overhead in Semaphore permit handoff

2 participants