Skip to content

perf(mpsc): separate bounded waiting and unbounded reclamation - #290

Merged
tisonkun merged 10 commits into
apache:mainfrom
tisonkun:codex/mpsc-storage-and-backpressure
Sep 8, 2026
Merged

perf(mpsc): separate bounded waiting and unbounded reclamation#290
tisonkun merged 10 commits into
apache:mainfrom
tisonkun:codex/mpsc-storage-and-backpressure

Conversation

@tisonkun

@tisonkun tisonkun commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

Keep both custom MPSC implementations while making their module and ownership boundaries explicit. Follow-up to #247. Public channel APIs and cancellation semantics are preserved.

Channel Implementation in PR #290 at f2ce322 Module responsibilities
Bounded Custom ring, cancellable send retry queue, and a local receiver wait protocol bounded/mod.rs: endpoints and lifecycle; ring.rs: storage, publication, and receiver waiting; waiters.rs: sender retries.
Unbounded Mutex-protected inbox with receiver-owned batches and 32 KiB storage segments unbounded/mod.rs: endpoints, liveness, and wake registration; buffer.rs: segmentation, batch handoff, and reclamation.

Both channels use a module directory with mod.rs. The bounded path no longer uses internal::AtomicWaker: this PR's changes to that helper have been reverted, preserving its main-branch implementation and tests. With no production callers remaining, the module is compiled only for tests.

All PR now measurements below identify f2ce32294cf94c0d98bff61a11abdaa90113b0c5; PR before identifies 95ef9eb, which used the custom ring with a hinted generic AtomicWaker. main identifies 27c3067, also a custom ring, with semaphore-based send retries. None of these three columns uses a std channel backend.

Measurements: Apple M4 Max, macOS 26.6.2, Rust 1.99 nightly, Tokio 1.53.1, async-channel 2.5.0, Flume 0.12.0. The same benchmark harness was used for all implementations. Results are medians of five round medians, with implementation order shuffled each round. Each round uses 35 samples except capacity-one OS-thread cases (10), inline concurrent bursts (50), and ready paths (100 samples of 1,000 operations). Lower is better. Throughput tables use ms per complete sample; ready-path tables use ns per send/receive pair. These are workload comparisons, not latency percentiles or a claim of universal superiority; no x86 performance measurements were taken.

Bounded

Design

The ring alone accounts for capacity, including reserved but unpublished slots. A send future owns its unsent value and joins SendWaiters only when it must wait. A notification grants a retry, not a capacity permit; cancelling a notified sender passes the retry to the next waiter. The receiver is the only consumer.

Receiver waiting uses one cache-padded atomic flag and Mutex<Option<Waker>> inside the ring. After publishing a slot, a producer checks the flag and atomically claims a notification before taking the waker lock. Registration installs the waker, arms the flag, and then the receiver retries the queue. Paired sequentially consistent fences prevent registration and publication from both being missed. Taking the waker clears the flag under its lock, so a delayed notification may wake a newer registration but cannot silently erase it. Clone, drop, and wake callbacks run outside the lock. This protocol has no unsafe waker storage; unsafe code remains confined to slot ownership and consumption in the ring.

The receiver registration path does not need a retry loop: only the receiver can install a waker; producers can only take it. Queue positions, the receiver wait flag, and the waker mutex do not share a cache line.

Persistent OS threads

16,384 usize messages per sample, concurrent receiving, reused producer threads and channel. C = capacity, P = producer count. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
C1 / P1 66.17 65.83 65.55 66.67 74.29 38.85
C1 / P8 69.71 71.4 71.02 70.06 123 84.66
C64 / P1 1.275 1.223 1.197 1.234 1.664 1.297
C64 / P2 1.392 1.241 1.382 1.856 1.948 1.984
C64 / P4 1.647 1.661 1.138 2.914 3.319 3.904
C64 / P8 7.7 3.458 3.547 29.96 23.9 23.5
C4096 / P1 0.3017 0.09045 0.09329 1.004 0.5681 0.7291
C4096 / P2 0.6728 0.09233 0.09324 1.637 0.9686 1.019
C4096 / P4 0.9029 0.193 0.1884 2.981 1.513 1.772
C4096 / P8 2.333 1.44 1.322 18.34 1.808 7.743

Tokio tasks

16,384 usize messages per sample, reused tasks and channel. The receiver runs on the thread calling block_on; W0 is a current-thread runtime, W4 has four worker threads. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
C64 / P1 / W0 0.2458 0.1889 0.1822 0.3862 0.5758 0.3754
C64 / P4 / W0 0.2597 0.2015 0.1975 0.4022 0.6002 0.4092
C64 / P1 / W4 2.181 2.175 2.156 2.096 2.208 2.091
C64 / P4 / W4 2.114 2.16 2.148 2.2 2.692 2.391
C64 / P8 / W4 2.019 2.113 2.09 2.304 3.066 3.093
C4096 / P1 / W0 0.2139 0.1547 0.1403 0.3692 0.5367 0.3356
C4096 / P4 / W0 0.2148 0.1527 0.1442 0.3745 0.5343 0.3285
C4096 / P1 / W4 0.3154 0.0932 0.3042 1.188 0.855 0.6254
C4096 / P4 / W4 0.9098 0.3161 0.3117 2.736 1.601 1.274
C4096 / P8 / W4 1.386 0.3654 0.3342 2.844 1.613 1.364

Ready paths

Capacity 64, one thread, no actual async suspension. ns per pair.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
Try send + try receive 7.566 3.892 4.046 10.71 28.13 11.07
Ready async send + receive 9.542 6.983 5.921 18.7 30.16 17.79

Alternatives and tradeoffs

A local mutex makes receiver-waker ownership and callback reentrancy easier to audit than the modified generic AtomicWaker, but does not win every workload. In particular, inspect C64/P8 OS threads and C4096/P1/W4 tasks against PR before; the latter loses a substantial part of the earlier PR's advantage. Compare those same rows with main and the ecosystem columns before treating this as a general channel regression.

Three five-round local ablations informed the design. These are separate experiments, not extra implementations shipped by this PR:

Experiment Case Control, ms Candidate, ms Decision
Wait bit in consumer head vs separate flag C4096/P1 OS threads 0.7378 0.08433 Reject the head bit: per-send head reads couple producers to the consumer's frequently modified cache line.
Flag check alone vs atomic notification claim C64/P8 OS threads 4.111 3.872 Claim one notification before locking to reduce redundant mutex contention.
Claimed flag sharing storage vs cache-padded flag C64/P8 OS threads 3.831 3.162 Separate the stable flag from the waker mutex cache line.

STD-A (std sync_channel + SendWaiters + hinted AtomicWaker) and STD-H (std sync_channel + SendWaiters + mutex/flag notification) were earlier local candidates. They are not included in this PR. For historical context, C64/P8 OS threads measured 3.886/9.064 ms for STD-A/STD-H; C4096/P8 OS threads measured 3.562/2.252 ms; C64/P8/W4 tasks measured 1.288/1.811 ms. Those earlier campaign results are not paired with the new tables. They show why neither backend nor notification strategy should be selected from one case alone. The retained ring preserves explicit publication/capacity behavior and the stronger buffered-value cleanup described below.

Unbounded

Design

The inbox mutex serializes enqueueing, receiver liveness, and wake registration. The receiver consumes a private batch between refills. Buffer owns the writable segment, sealed segments, and one spare; it has no knowledge of wakers or futures. Segments target at most 32 KiB of inline storage, and consumed segments are released incrementally. At most two empty payload buffers are retained, independently of peak occupancy; small channels grow lazily and oversized inline allocations are released after their last value. Message and waker callbacks run outside the inbox lock.

This cleanup preserves the previous PR's storage/reclamation policy. Allocation-boundary tests now exercise Buffer directly; public FIFO, cancellation, lifecycle, and concurrent delivery remain covered at the channel boundary.

Persistent OS threads

16,384 usize messages per sample, concurrent receiving and reused workers/channel. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
P1 0.1206 0.1227 0.1271 0.6552 0.4939 0.561
P2 0.2334 0.2267 0.2282 1.111 0.7556 0.5014
P4 0.4384 0.412 0.4004 2.223 1.553 0.7072
P8 0.3743 0.3672 0.3297 3.59 1.881 0.576

Tokio tasks

16,384 usize messages per sample, reused tasks/channel, with the same W0/W4 convention as bounded. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
P1 / W0 0.1447 0.1431 0.1385 0.2077 0.5135 0.2469
P4 / W0 0.1449 0.1436 0.1382 0.2057 0.515 0.2504
P1 / W4 0.1361 0.1461 0.144 0.619 0.5765 0.3439
P4 / W4 0.4465 0.4302 0.4106 1.842 1.639 0.6294
P8 / W4 0.4464 0.4087 0.4178 2.232 1.64 0.609

Repeated bursts and storage reuse

One thread sends all 65,536 messages, then receives them, reusing the channel across samples. The backlog case keeps half a burst queued. These cases include movement/allocation/reclamation and do not measure asynchronous wakeup scheduling. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
usize / empty after drain 0.5057 0.5028 0.499 0.5534 1.819 0.7452
usize / half-burst backlog retained 0.5216 0.5063 0.5033 0.5617 1.843 0.7552
Inline 64 B 0.7644 0.7584 0.7587 0.9707 2.081 0.9731
Inline 1 KiB 9.957 4.456 4.378 6.332 7.294 4.521
Boxed 1 KiB, including allocation/drop 3.445 3.559 3.466 3.299 4.482 3.945

Concurrent inline messages and receiver wakeups

16,384 inline 1 KiB messages per sample, four Tokio workers, 1/4/8 producers, and aggregate bursts of 64/1,024 messages. Each burst first polls an empty receiver to Pending, then releases producers. Per-producer sequence checks verify delivery/order. Results include producer coordination, scheduling, movement, and reclamation; they are not isolated wake latency. ms.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
P1 / burst 64 2.938 3.822 3.734 3.82 4.177 4.321
P4 / burst 64 3.757 3.967 3.791 3.812 4.521 4.659
P8 / burst 64 3.765 4.044 4.24 3.929 4.712 4.794
P1 / burst 1024 1.264 1.61 1.412 1.565 1.569 2.277
P4 / burst 1024 3.524 3.46 3.724 2.141 2.61 3.772
P8 / burst 1024 3.64 3.81 3.967 2.15 2.652 3.973

Ready paths

One thread, no actual async suspension. ns per pair.

Case main 27c3067 PR before 95ef9eb PR now f2ce322 Tokio async-channel Flume
Send + try receive 9.917 10.44 10.37 7.717 28.83 10.68
Send + ready async receive 10.29 10.55 10.54 8.547 30.06 13.99

Tradeoffs

The batched design remains competitive for small messages and many repeated bursts. It does not eliminate the earlier large-inline-message contention: at P4/P8 with burst 1,024, compare the Tokio and async-channel columns, and at P1/burst 64 compare main. These limitations remain visible rather than being hidden by the single-thread burst result.

The earlier 20-pair inline-burst comparison with Flume found only about 0.26% geometric-mean difference, with its 95% interval crossing zero; treat that result as parity. Flume's warmed VecDeque retained the 64 MiB burst allocation, whereas this PR's segmented policy kept roughly 64 KiB of empty payload buffers. That retention comparison comes from the earlier allocation campaign; the current Buffer boundary tests verify the same incremental-reclamation and bounded-retention policy.

An earlier STD-A unbounded candidate measured approximately 1.612/1.614 ms for P4/P8 with burst 1,024, showing a real alternative for that workload. It was weaker in the earlier small-message multiproducer tests and is not selected here.

Tests and fixes

The integration entry file starts with FIFO, capacity, value ownership, receive cancellation, and disconnection contracts. Separate suites cover sender backpressure/cancellation, reentrant callbacks and panic cleanup, and concurrent/executor operation. Tests that assert wakeup behavior now count actual notifications before repolling, rather than relying on noop wakers. Callback-clone fixtures share one RawWaker implementation. Duplicate trivial executor tests were consolidated; real executor and selection coverage remains.

The release comparison was checked against v0.7.1 and v0.7.2-rc.1 (both used the std-backed bounded implementation):

  • Released bugs: receiver wakers could retain sender/task cycles; dropping a buffered value could panic before blocked senders were notified of disconnection; cloning a sender waker under the semaphore lock could deadlock if the callback received from the channel.
  • Stronger behavior: after one bounded payload destructor panics, remaining buffered values are still released. This is listed as an improvement, separate from the missed-disconnection-notification bug.
  • Development-only corrections: custom-ring publication handling, including capacity-one unpublished reservations, is tested but not presented as a separately shipped bug in the changelog.

Validation on f2ce322: cargo x test, cargo x check, and cargo x lint pass. cargo x miri passes with seed 0, including the unchanged generic AtomicWaker tests. The MPSC unit and integration suites also pass with seeds 1, 2, and 3: 10 storage tests and 21 integration tests per seed; only the three OS-backed Tokio tests are skipped by Miri and pass natively. Benchmark binaries and source trees were fingerprinted, and all recorded runs explicitly enabled Divan benchmarking with --bench.

@tisonkun

tisonkun commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Merging for benchmark cases. I have a new idea for mpsc implementation.

@tisonkun
tisonkun merged commit 64d7a94 into apache:main Sep 8, 2026
10 checks passed
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.

1 participant