Skip to content

perf(mpmc): back bounded queues with a ring buffer - #382

Open
ariesdevil wants to merge 2 commits into
apache:mainfrom
ariesdevil:dev_ariesdevil
Open

ariesdevil wants to merge 2 commits into
apache:mainfrom
ariesdevil:dev_ariesdevil

Conversation

@ariesdevil

@ariesdevil ariesdevil commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Partial related: #298

Summary

Back the bounded MPMC queue (added in #265) with a preallocated ring of stamped slots, adapted from crossbeam-channel's array flavor, instead of a VecDeque behind one mutex. Sends, receives, and reservations no longer take a lock unless a sender or receiver has to wait. Pending send and reserve operations still receive capacity in wait-queue order, permits still hold capacity without a slot, and cancellation and disconnection behave as before. Storage for the full capacity is now allocated when a queue is created, and capacity is limited to 2,097,151.

The unbounded queue keeps its mutex and drops the bounded-only code. CachePadded is copied from crossbeam-utils, and LICENSE, licenserc.toml, and .asf.yaml record the incorporated crossbeam portions. WakerBatch::wake_all skips its overflow storage when nothing spilled, which makes waking a blocked sender 3% to 10% faster.

The benchmarks gain an AsyncbandMpmcReserve adapter that sends each value through reserve().await and a permit, a crossbeam-channel adapter, fixed-work scenarios for 1P/8C, capacity 1, and a spinning batch that isolates queue contention from wakeups, and reservation micro-benchmarks.

Design Notes

  • While no capacity is reserved and no sender is queued, the slots are the capacity, as in crossbeam. Permits and grants hold capacity without a slot. Their count, and a bit marking queued senders, sit in tail between the slot index and the lap, so every exchange on tail compares the reservation state it was computed from. The other values a sender reads, head and the slot stamps, only advance, so reading them early can only understate free capacity.
  • An exchange prepared from an earlier tail can succeed only after the lap count wraps. Positions are 64 bits, so that takes 2^54 sends at capacity 64, and limiting capacity to 2,097,151 keeps it at 2^40 sends or more. A 32-bit cursor would leave as few as two lap bits, so a stalled send could overwrite a queued value after 65,532 sends. Targets without 64-bit atomics, such as ARMv5TE and 32-bit PowerPC and MIPS, therefore keep 64-bit positions by protecting each cursor and stamp with a lock: slower, but the same algorithm. Miri exercises that path by interpreting powerpc-unknown-linux-gnu.
  • A permit's send claims its slot and returns its reservation in one exchange. reserve and try_reserve take no lock while capacity is free and no sender is queued, and neither does dropping an unused permit unless senders are queued. send joins the wait queue directly once try_send fails, because another attempt would only add contention. Granting the last queued sender clears the queued bit in the same update. A grant removes the waiter's node and sets a flag in its pinned future, so the woken sender claims its permit without the lock.
  • While senders are queued, a send that uses a permit takes over granting the capacity that receivers free until it finishes. Without this, a single receiver that grants and wakes every queued sender itself can fall behind them, keeping them queued so that every send waits; on Linux, blocking 8P/1C then ran at half speed in some rounds.
  • Receivers leave the wait queue before taking a value, so a notification cannot be assigned to a receiver that has already taken another value.
  • Four earlier designs were measured and dropped. Taking capacity from the internal semaphore, and separate sender and receiver capacity counters, made every message read or write a counter that the other side also writes, which dominated perf profiles on x86. Keeping the count in its own word behind a version in tail was incorrect: 2^24 reservations wrap the version, after which a stalled send can claim capacity that a permit holds. A regression test covers that interleaving. Taking the wait-queue lock in every reserve was dropped as well: removing it made multi-producer reservations on a multi-thread Tokio runtime 29% to 40% faster on Linux, but blocking 1P/1C and 8P/1C reservations 59% and 20% slower, because the lock kept a lone producer from outrunning its consumer and waiting for grants.

Benchmarks

Fixed-work comparisons from the harness in #383, with Asyncband at c37b1a4 (main) as the reference library. The new scenarios use round counts that put the reference near 300 ms on macOS, as the existing ones do. Each binary ran --repeat 10 in four alternating passes (main, this PR, this PR, main), so every cell pools 20 runs. Cells show the median and the minimum-to-maximum range in milliseconds; negative changes are faster. The primitives rows omit the primitives:: prefix. Built with rustc 1.99.0.

macOS, Apple M5 Pro:

Scenario Rounds main ms This PR ms Change
mpmc/bounded/tasks-2p2c-cap64-4w 270 282 (276..296) 253 (228..261) −10%
↳ AsyncbandMpmcReserve 338 (326..366) 225 (213..227) −33%
mpmc/bounded/threads-2p2c-cap64 120 280 (275..293) 82 (79..126) −71%
↳ AsyncbandMpmcReserve 320 (316..343) 148 (144..167) −54%
mpmc/bounded/threads-1p8c-cap64 27 314 (245..340) 130 (114..151) −59%
mpmc/bounded/threads-2p2c-cap1 4 334 (324..405) 400 (376..422) +20%
mpmc/bounded/spin-2p2c-cap64 100 314 (306..325) 70 (64..73) −78%
mpmc/unbounded/tasks-2p2c-4w 330 288 (279..312) 266 (211..291) −8%
mpmc/unbounded/threads-2p2c 280 240 (235..260) 251 (246..263) +4%
mpmc::bounded::try_send_then_try_recv 30M 279 (278..290) 160 (157..194) −43%
mpmc::bounded::send_then_recv 24M 306 (301..386) 196 (195..199) −36%
mpmc::bounded::try_reserve_then_send 22M 306 (300..320) 150 (147..182) −51%
mpmc::bounded::reserve_then_send 20M 308 (306..325) 168 (168..183) −45%
mpmc::bounded::wake_blocked_sender 9M 257 (255..261) 282 (279..304) +10%
mpmc::unbounded::send_then_try_recv 30M 271 (269..290) 303 (297..345) +12%
mpmc::unbounded::send_then_recv 24M 257 (255..288) 236 (233..287) −8%
mpmc::unbounded::wake_pending_receiver 9M 238 (236..272) 218 (217..241) −8%
mpmc::unbounded::repoll_pending_receiver 40M 256 (252..281) 233 (231..256) −9%

Linux, Intel Xeon 6982P-C, 16 cores, KVM guest, Ubuntu 26.04, kernel 7.0:

Scenario Rounds main ms This PR ms Change
mpmc/bounded/tasks-2p2c-cap64-4w 270 1806 (1420..2047) 592 (550..641) −67%
↳ AsyncbandMpmcReserve 2076 (1913..2335) 686 (645..737) −67%
mpmc/bounded/threads-2p2c-cap64 120 1044 (871..1091) 296 (276..315) −72%
↳ AsyncbandMpmcReserve 1341 (1260..1352) 431 (415..450) −68%
mpmc/bounded/threads-1p8c-cap64 27 478 (467..487) 456 (396..508) −5%
mpmc/bounded/threads-2p2c-cap1 4 330 (326..337) 340 (335..352) +3%
mpmc/bounded/spin-2p2c-cap64 100 730 (709..755) 122 (118..129) −83%
mpmc/unbounded/tasks-2p2c-4w 330 1579 (1443..1817) 1573 (1407..1710) 0%
mpmc/unbounded/threads-2p2c 280 1663 (1636..1875) 1576 (1524..1600) −5%
mpmc::bounded::try_send_then_try_recv 30M 887 (887..889) 454 (454..455) −49%
mpmc::bounded::send_then_recv 24M 833 or 1022 482 (478..485) −42% or −53%
mpmc::bounded::try_reserve_then_send 22M 935 (934..937) 749 (748..753) −20%
mpmc::bounded::reserve_then_send 20M 1037 (1034..1040) 833 (832..837) −20%
mpmc::bounded::wake_blocked_sender 9M 817 (812..820) 936 (932..943) +15%
mpmc::unbounded::send_then_try_recv 30M 855 (855..855) 863 (862..868) +1%
mpmc::unbounded::send_then_recv 24M 714 (708..720) 694 (693..698) −3%
mpmc::unbounded::wake_pending_receiver 9M 715 (714..715) 719 (714..720) +1%
mpmc::unbounded::repoll_pending_receiver 40M 606 (605..613) 583 (582..586) −4%

Each Linux send_then_recv process on main settles at either 833 or 1022 ms, so both modes are shown. The macOS-only mpmc::unbounded::send_then_try_recv change is not reproduced on Linux, and the unbounded queue's send and receive paths only lost code.

Peers in this PR's binaries, as elapsed time relative to Asyncband's send (macOS / Linux):

Scenario AsyncbandMpmcReserve async-channel flume crossbeam-channel
mpmc/bounded/tasks-2p2c-cap64-4w 0.89x / 1.16x 1.45x / 3.41x 1.23x / 3.10x
mpmc/bounded/threads-2p2c-cap64 1.80x / 1.45x 4.59x / 3.20x 3.44x / 2.34x 1.07x / 0.90x
mpmc/bounded/threads-1p8c-cap64 4.94x / 3.06x 3.95x / 0.99x 0.51x / 0.38x
mpmc/bounded/threads-2p2c-cap1 0.73x / 0.97x 0.27x / 0.27x 0.03x / 0.10x
mpmc/bounded/spin-2p2c-cap64 3.76x / 4.76x 4.45x / 6.04x 0.96x / 1.98x
mpmc/unbounded/tasks-2p2c-4w 2.47x / 1.78x 1.13x / 1.06x
mpmc/unbounded/threads-2p2c 1.77x / 1.84x 0.95x / 0.98x

The remaining regressions are on paths where every operation waits: capacity 1 and a blocked sender's handoff. Peers ran the same code in both binaries. Between them, peer medians moved by at most 4.3% on macOS; on Linux they stayed within 4% except crossbeam-channel, which moved 5.5% in threads-2p2c-cap64 and 23% in spin-2p2c-cap64, so treat Linux spinning comparisons as noisy.

@ariesdevil ariesdevil changed the title perf(mpmc): make bounded queues lock-free perf(mpmc): back bounded queues with a ring buffer Oct 20, 2026
@ariesdevil
ariesdevil force-pushed the dev_ariesdevil branch 4 times, most recently from 94d174b to 4ea81c4 Compare October 21, 2026 08:39
Comment thread licenserc.toml Resolved
Comment thread .asf.yaml Resolved
Comment thread asyncband/src/lib.rs Resolved
Comment thread asyncband/src/lib.rs Resolved
@tisonkun

tisonkun commented Nov 3, 2026

Copy link
Copy Markdown
Member

@ariesdevil You may update the branch to resolve the conflict with the main branch, and I will also adopt the new benchmark best practice that you can leverage. Once this pull request is clean, I will do another review.

Replace the bounded queue's VecDeque behind one mutex with a preallocated ring of stamped slots
adapted from crossbeam-channel's array flavor. Sends, receives, and reservations take no lock unless
a sender or receiver has to wait, while wait-queue order, exact capacity, permits, cancellation, and
disconnection behave as before. Storage for the full capacity is allocated when the queue is
created, and capacity is limited to 2,097,151.

The capacity that permits and grants hold, and a bit marking queued senders, live in the tail
cursor beside the position, so every exchange on it compares the reservation state it was computed
from. While senders are queued, a send with a permit grants the capacity that receivers free until
it finishes, so a single receiver does not have to wake every queued sender itself.

Positions are 64 bits on every target, so a stalled exchange can succeed only after at least 2^40
sends. Targets without 64-bit atomics, such as ARMv5TE and 32-bit PowerPC and MIPS, keep each cursor
and stamp as a u64 behind a lock, which is slower but runs the same algorithm.

The unbounded queue keeps its mutex and drops the bounded-only code. Copy CachePadded from
crossbeam-utils and record the incorporated crossbeam portions in LICENSE, licenserc.toml, and
.asf.yaml.

The benchmarks gain an AsyncbandMpmcReserve adapter that sends through permits, a crossbeam-channel
adapter, fixed-work scenarios for 1P/8C, capacity 1, and a spinning batch, and reservation
micro-benchmarks.

Co-authored-by: tison <wander4096@gmail.com>
Comment thread CHANGELOG.md Outdated

### Notable changes

* Bounded MPMC queues now panic when created with a capacity greater than 2,097,151.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we now have this limitation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The ring's tail packs the slot index, the disconnect mark, the capacity that permits and grants hold, the queued-senders bit, and a lap count into one 64-bit word, so every exchange on it checks all of that state at once. That is what makes a send that stalled before its exchange fail if reservations changed meanwhile; the versioned counter this replaced could be fooled after 2^24 reservations. The index and the reserved count each need about log2(capacity) bits, leaving the rest for the lap count, and a stale exchange can only succeed after the lap count wraps. Capping capacity at 2^21 − 1 keeps at least 20 lap bits, so a wrap takes at least 2^40 sends. At 2^28 it would take about 2^34 sends, which a fast producer reaches within minutes while another thread is descheduled. Larger buffers can use mpmc::unbounded.

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.

2 participants