Repository navigation
perf(semaphore): register the first waiter without allocating - #419
nanami-0713 wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
🟡 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
| 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); |
|
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:
|
|
Thanks @nanami-0713 for isolating the After reviewing both inline approaches (#419 and #415) against the full set of
See #400 for the full summary. Closing this PR accordingly—thanks again for the investigation and clear write-up! |

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
firstfield until the vector first becomes non-empty; the spill moves it intoslots[0], so every slot ID stays stable across the spill and the vacant list only ever spans the vector.take_alldrains both storages and restarts at slot 0, preserving the existing ID-restart semantics (the existing arena test is unchanged).WaitListand 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.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:Allocation counting (
GlobalAllocprobe, 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 isstd::sync::Mutex's lazy pthread boxing underacquired_or_enqueue, which Tokio's batch semaphore pays on the same path on this platform; futex-backed targets box nothing.Verification
cargo x testpasses, including new arena tests for the inline slot, spill ID stability, and thetake_allrestart.