Skip to content

perf(waitgroup): streamline completion for participants and observers - #421

Merged
tisonkun merged 9 commits into
apache:mainfrom
nanami-0713:perf/waitgroup-streamline-completion
Nov 14, 2026
Merged

tisonkun merged 9 commits into
apache:mainfrom
nanami-0713:perf/waitgroup-streamline-completion

Conversation

@nanami-0713

@nanami-0713 nanami-0713 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Continues #395. Fixes #397.

  • Share one collective allocation reference in State::refs across all live WaitGroup handles so cloning and dropping worker handles update only the participant count, while each Wait future owns an individual reference.
  • Store the first pending observer's waker inline and allocate the overflow WakerSet lazily when multiple observers register concurrently, backed by WakerSet::{insert, update, remove} and register_waker.
  • Protect waiter registration with crate::internal::rwlock::RwLock, whose uncontended state stays inline on Darwin where std::sync::Mutex allocates a pthread_mutex_t on first lock (tracked globally in Evaluate an inline lock for internal::mutex::Mutex on Apple targets #425).
  • Expand WaitGroup integration tests for concurrent registration, slot reuse, racing completion, and reentrant wake/drop paths, wire waitgroup_test into cargo x miri, add wait-after-work, nested-4-workers, and drain-4-observers ecosystem benchmarks, and simplify the WaitGroup and Barrier module examples.

Benchmarks

Original PR measurement on Apple M5 (10 cores), macOS 27.0.1, rustc 1.99.1 stable, with cargo x bench --bench ecosystem -- waitgroup --repeat 10:

Scenario main 629e073 PR baseline waitgroup 0.1.2
complete-waiter 175 ms 52 ms 61 ms
complete-4-workers 170 ms 68 ms 64 ms
drain-4-observers — 129 ms 502 ms (shared drain)

A #[global_allocator] probe counts three allocations (208 B) per complete-waiter round on main (629e073) — the state allocation, Darwin's pthread_mutex_t boxing on the first lock, and the first Arena<Waker> insert — and one 56 B allocation per round on this PR.

Updated apples-to-apples comparison on HEAD (63f7c76) vs main (8ee2520) on Apple M4 Max (aarch64-apple-darwin), macOS 26.6.2, rustc 1.101.0-nightly (8006091eb 2026-10-17), --repeat 10:

Ecosystem scenario Rounds main 8ee2520 (ms) HEAD 63f7c76 (ms) HEAD / main Peer baseline (HEAD run)
waitgroup/complete-waiter 3,500,000 224 (220..245) 68 (67..91) 0.30x waitgroup 0.1.2: 68 (67..70)
waitgroup/complete-4-workers 2,600,000 225 (219..229) 89 (88..94) 0.40x waitgroup 0.1.2: 87 (86..89)
waitgroup/wait-after-work 5,500,000 245 (243..248) 85 (83..86) 0.35x waitgroup 0.1.2: 90 (89..94)
waitgroup/nested-4-workers 3,000,000 239 (231..242) 89 (88..90) 0.37x waitgroup 0.1.2: 88 (87..92)
waitgroup/drain-4-observers 2,500,000 240 (235..245) 169 (165..171) 0.70x WaitgroupRsShared: 592 (588..597) / TokioTaskTracker: 381 (379..383)
Primitives scenario Rounds main 8ee2520 (ms) HEAD 63f7c76 (ms) HEAD / main
primitives::waitgroup::wait::complete_waiter_batch/1 5,000,000 439 (436..461) 220 (218..245) 0.50x
primitives::waitgroup::wait::complete_waiter_batch/4 1,250,000 189 (184..192) 164 (163..167) 0.87x
primitives::waitgroup::wait::complete_waiter_batch/16 312,500 160 (158..162) 138 (137..141) 0.86x

Validated on 63f7c76 with cargo x lint, cargo test --doc --all-features -p asyncband, Miri on asyncband --lib --all-features and waitgroup_test, and the ecosystem/primitives benchmarks above.

Comment thread asyncband/src/waitgroup/mod.rs Outdated
Comment on lines +153 to +156
#[inline(never)]
fn finish(&mut self) {
self.waiters.wake_all();
self.release_observer();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could a panic in wake_all() prevent release_observer() from running and end up leaking the state? Maybe we should ensure cleanup still happens if a waker panics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walking the path: Waiters::wake_all detaches the slots under the write lock and wakes after unlocking, so a panicking wake callback runs with no internal lock held. If one still panics:

  • The observers after the panicking one never receive their wake. That is inherent to a panic mid-notification and predates this change — the previous implementation also mem::take-ed the waker collection under the lock and called wake_all outside it, so a panic in the same place stranded the remaining observers identically.
  • What this change does alter is reclamation: with count-based ownership the panicking path skips release_observer, so the state allocation is not reclaimed while unwinding, where the previous Arc-based implementation would have freed it.

That difference lives entirely on a path the repository's waker contract excludes: executor waker operations, including waking and dropping, are expected not to panic, and primitives deliberately add no custom panic recovery (AGENTS.md "Waker Contract", decision #257). A drop guard in finish that releases the observer count on unwind would be exactly that recovery machinery, and the contract's review guidance is to not require it absent an explicit local contract.

@Kaileshwar16

Copy link
Copy Markdown
Contributor

i also think earlier PR showed a small performance regression on Linux in the four-observer case. It would be worth rerunning the benchmark to check whether thats still present.

@nanami-0713

Copy link
Copy Markdown
Contributor Author

On the four-observer Linux point: the only Linux four-observer data I can find is the arm64 Linux VM control documented in #395 — a standalone driver repeating the same four-observer lifecycle, with ten-run medians of 159 ms on main vs 164 ms on the revised head 540858b (the content of this PR), about 3% above main.

I reran the comparison on hardware I have (Apple M5, 10 cores, macOS 27.0.1, rustc 1.99.1 stable), back-to-back in one session, --repeat 10. Base is 629e073 with this branch's benchmark commit cherry-picked on top; candidate is this branch.

Scenario Rounds main + bench this PR
drain-4-observers 2,500,000 191 ms (190..192) 129 ms (128..130)
complete-4-workers 2,600,000 174 ms (173..179) 67 ms (67..67)

So on arm64 macOS the revised implementation is clearly ahead on the four-observer drain, matching #395's own M4 Max numbers for the same comparison (294 → 230/221 ms); the small deficit exists only in the Linux VM control. I don't have Linux hardware to rerun that control. The scenario is now in-tree, so a Linux check is one command away for anyone who has the hardware:

cargo x bench --bench ecosystem -- waitgroup/drain-4-observers --repeat 10

Happy to fold in a Linux datapoint if someone can run it.

@Kaileshwar16

Copy link
Copy Markdown
Contributor

@nanami-0713 totaly missed it, thanks for clarifying : )

@Kaileshwar16

Copy link
Copy Markdown
Contributor

Ran the four-observer benchmark on native x86_64 Linux (Intel), using 2.5M rounds and --repeat 10.

  • First run: main 597 ms, PR 576 ms (~3.5% faster)
  • Reverse-order run: main 631 ms, PR 582 ms (~7.8% faster)

Used 629e073 with the benchmark commit cherry-picked for the baseline. The PR was consistently faster in both comparisons on my machine, so I didnt observe a regression on x86_64 Linux.

Hope this helps with the Linux benchmark comparison <3

@nanami-0713

Copy link
Copy Markdown
Contributor Author

Thanks for running this — exactly the missing datapoint.

Native x86_64 Linux showing the PR consistently ahead in both orderings settles the last open question for me: across PR-vs-main four-observer runs, arm64 macOS (M4 Max in #395, M5 in my table above) and now native x86_64 Linux are all ahead, and the only deficit on record remains the arm64 Linux VM control in #395 — which now reads as VM- or arm64-specific rather than a property of the implementation.

Thanks again for taking the time to benchmark it <3

Waiter registration split observer state across a submodule, overloaded `Registration::Set(None)` as the unregistered state, and duplicated single-slot waker replacement logic while leaving `internal::mutex` gated on `waitgroup` without any remaining caller.

Fold waiter storage into `waitgroup::mod` with explicit `Unregistered`, `Inline`, and `Overflow(WakerToken)` states backed by `register_waker` and `WakerSet::{insert, update, remove}`. Restore `WaitGroup` and `Wait` unwind-safety auto-traits via `PhantomData<State>`, remove the invalid unwind-safety test, drop the unused `internal::mutex` feature gate, and align module documentation and the changelog entry with current conventions.
The shared-drain benchmark maintained a separate submodule with custom atomic waker plumbing to assert single wakeups inside a timing loop, and `tasks-4-workers-4w` primarily measured Tokio task spawning rather than `WaitGroup` coordination.

Fold `drain-4-observers` into `benches/ecosystem/waitgroup.rs` on top of the shared `bench_context` helpers and drop the noisy task-spawning scenario so the suite focuses on `WaitGroup` coordination costs.
Add `crate::internal::rwlock::RwLock` alongside `crate::internal::mutex::Mutex` to handle lock-poisoning recovery consistently, and use it for `WaitGroup` waiter registration instead of open-coding `PoisonError::into_inner` in `waitgroup`.
Use `do_work().await` directly in the `WaitGroup` module example without redundant `JoinHandle` awaits or inline-const atomic arrays, and replace the inline-const atomic array in the `Barrier` module example with a single arrival counter.
@tisonkun
tisonkun enabled auto-merge (squash) November 14, 2026 02:11
@tisonkun
tisonkun merged commit c63b266 into apache:main Nov 14, 2026
9 checks passed
@tisonkun

Copy link
Copy Markdown
Member

Thanks for your contribution @nanami-0713!

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.

Bring WaitGroup completion to parity with waitgroup

3 participants