Skip to content

Store sent packets in a ring buffer instead of a BTreeMap#2730

Merged
Ralith merged 2 commits into
quinn-rs:mainfrom
ojhermann:perf/sent-packets-ring-buffer
Jul 20, 2026
Merged

Store sent packets in a ring buffer instead of a BTreeMap#2730
Ralith merged 2 commits into
quinn-rs:mainfrom
ojhermann:perf/sent-packets-ring-buffer

Conversation

@ojhermann

@ojhermann ojhermann commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #2720.

Problem

PacketSpace::sent_packets is inserted on every sent packet and removed on ACK/loss. Because packet numbers are monotonic, a BTreeMap spends most of its cost allocating and freeing a tree node per entry.

Heap-profiling a 200 MiB loopback bench bulk transfer with dhat and deep backtraces (see the confirmation in #2720) attributes 35.8% of all allocations — 24,288 of 67,766 blocks — and ~99% of all BTreeMap-node allocations to this one map, while only ~46 entries are live at peak. It's allocation churn, not residency: CPU spent in the allocator, invisible in RSS, and felt most on edge devices at high packet rates.

Change

Replace the BTreeMap with SentPackets, a sparse ring buffer indexed by packet number - offset (a VecDeque<Option<T>> plus a base offset):

  • insert — packet numbers are monotonic, so this is an append: amortized O(1), no per-packet allocation. Skipped packet numbers (PacketNumberFilter) leave vacant slots.
  • remove — O(1); vacant slots at the front are reclaimed as the low end is acknowledged, so the buffer tracks the live in-flight window rather than growing without bound.
  • get / index / range / values / values_mut / into_values — same surface and semantics as before.

The type keeps a BTreeMap-compatible API, so the call sites are unchanged apart from &pnpn in two loops. Crucially, range() yields present entries in ascending packet-number order, so the loss-detection loop's gap detection (prev_packet != Some(pn - 1)) and the ACK range walk behave identically — a removed or skipped packet number is absent either way.

Result

Re-profiling the same transfer after the change:

before after
sent_packets allocations 24,288 blocks (35.8%) 18 blocks (0.04%)
all BTreeMap-node allocations 24,600 137
total allocations 67,766 40,339 (−40%)

The 18 remaining are the amortized VecDeque growth reallocations (a handful of buffer resizes vs ~24k per-packet node allocs). lost_packets was already negligible (~0.5%) and is left as a BTreeMap.

Testing

  • 8 new unit tests on SentPackets: monotonic-with-gaps insert, out-of-order removal, front reclamation, range bounds (including the excluded-start "first entry after x" query used by sent()), drain, mem::take reuse, out-of-range access, and index panic.
  • Full quinn-proto suite green (283 tests), including the sim-IO ACK / loss / persistent-congestion tests that depend on iteration order and gap detection.
  • cargo fmt and clippy clean.

The dhat harness used for the numbers above is a small feature-gated addition to bench; happy to submit that separately if it'd be useful for future profiling.

Possible follow-ups

Out of scope here, but noting them so they're on record:

  • O(1) in-flight lookups. A couple of call sites still scan sent_packets to answer "is anything in flight?" — with the offset-indexed layout these could become O(1) (a live-entry count maintained on insert/remove), removing the last per-poll walks over the window. Happy to do this as a separate PR if wanted.
  • Upstream the dhat harness. As above — the feature-gated bench addition could land on its own for future allocation profiling.
    This change was written with AI assistance; I've reviewed every line and stand behind it.

@ojhermann
ojhermann force-pushed the perf/sent-packets-ring-buffer branch from c0145f7 to b29f633 Compare July 12, 2026 18:50
@ojhermann
ojhermann marked this pull request as ready for review July 12, 2026 18:52
@Ralith

Ralith commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

I have to run before I can give this a thorough review, but I'll note for starters that this is precisely the data structure I had in mind. Thanks!

@ojhermann

Copy link
Copy Markdown
Contributor Author

I have to run before I can give this a thorough review, but I'll note for starters that this is precisely the data structure I had in mind. Thanks!

great :) - no rush on my end; hopefully this helps

@djc djc left a comment

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.

Thanks for working on this?

Have you run the perf tooling to investigate the effect on throughput?

Please avoid posting LLM-generated comments and PR description. See our in-progress CONTRIBUTING with AI policy here:

Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
@djc

djc commented Jul 13, 2026

Copy link
Copy Markdown
Member
  • O(1) in-flight lookups. A couple of call sites still scan sent_packets to answer "is anything in flight?" — with the offset-indexed layout these could become O(1) (a live-entry count maintained on insert/remove), removing the last per-poll walks over the window. Happy to do this as a separate PR if wanted.

I would like this in the same PR, as a separate commit.

@ojhermann

Copy link
Copy Markdown
Contributor Author

Have you run the perf tooling to investigate the effect on throughput?

Yes, and with positive results.

I ran quinn-perf over loopback, comparing the branch tip against its parent commit. Both binaries built --release from the same toolchain; before/after runs interleaved to cancel drift; 2 GiB transfer, 12 s/run, warmup interval dropped.

Median loopback throughput on an 8-core machine:

  • download (server→client): 3.80 → 4.12 Gbps (~+8%); slowest ring-buffer run beat the fastest baseline run
  • upload (client→server): 3.81 → 3.96 Gbps (~+5%); noisier than download but consistently favoured the change

In agreement with the profiling: removing per-packet allocator churn, so it shows up as CPU headroom / throughput rather than RSS.

@ojhermann

Copy link
Copy Markdown
Contributor Author

@djc I've pushed some changes related to your comments, each as a single commit.

I'm going to run the throughput tests again to confirm the performance gains remain; I'll update after that's done.

@djc

djc commented Jul 13, 2026

Copy link
Copy Markdown
Member

We'll want all of your intermediate commits to be squashed -- we rebase on merge so we want the PR history to be clean, with review feedback addressed in the originating commit.

@ojhermann

Copy link
Copy Markdown
Contributor Author

Re-ran the loopback throughput (same method as before) after the review changes, comparing the branch head against the pre-PR state (BTreeMap):

  • download: 3.87 → 4.18 Gbps (~+8%), fully separated
  • upload: 4.07 → 4.29 Gbps (~+5%), noisier but mean/median both favour the change

So the refactors and the O(1) has_in_flight haven't regressed throughput; the improvement over the BTreeMap baseline still holds.

@djc

djc commented Jul 13, 2026

Copy link
Copy Markdown
Member

We'll want all of your intermediate commits to be squashed -- we rebase on merge so we want the PR history to be clean, with review feedback addressed in the originating commit.

(To be clear, still good to keep the O(1) changes in a separate commit.)

@ojhermann
ojhermann force-pushed the perf/sent-packets-ring-buffer branch 2 times, most recently from a0242a7 to c941b77 Compare July 13, 2026 15:05
@ojhermann

Copy link
Copy Markdown
Contributor Author

Two commits:

  • the original work
  • the O(1) work

Also rebased onto the current upstream base.

@ojhermann
ojhermann force-pushed the perf/sent-packets-ring-buffer branch from c941b77 to 9d90509 Compare July 13, 2026 15:13

@djc djc left a comment

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.

LGTM, thanks!

Comment thread quinn-proto/src/connection/sent_packets.rs Outdated
`PacketSpace::sent_packets` is inserted on every sent packet and removed
on ACK/loss. Because packet numbers are monotonic, a `BTreeMap` spends
most of its cost allocating and freeing a tree node per entry: heap
profiling a loopback bulk transfer attributes ~36% of all allocations
(and ~99% of BTreeMap-node allocations) to this one map, even though only
a few dozen entries are live at once — it is allocation churn, not
residency.

Replace it with `SentPackets`, a sparse ring buffer indexed by
`packet number - offset`. Insert and lookup are O(1) with no per-packet
allocation; skipped packet numbers leave vacant slots, and vacant slots
at the front are reclaimed as the low end is acknowledged. Iteration
order and range/gap semantics match the previous `BTreeMap`, so ACK and
loss detection are unchanged.

Fixes quinn-rs#2720.
`PacketSpace::has_in_flight` scanned `sent_packets` on every PTO
computation. Track the number of in-flight (`size != 0`) entries on insert
and remove instead, so the query is a single comparison. This was the last
per-poll scan of the sent-packet window, so `values` is no longer needed.
@ojhermann
ojhermann force-pushed the perf/sent-packets-ring-buffer branch from 9d90509 to ea5a7a4 Compare July 14, 2026 11:49
@ojhermann

Copy link
Copy Markdown
Contributor Author

LGTM, thanks!

cool :)

I've resolved the nit, so once CI is green again, it should be OK for a merge.

@djc

djc commented Jul 14, 2026

Copy link
Copy Markdown
Member

I've resolved the nit, so once CI is green again, it should be OK for a merge.

Will wait for a review from @Ralith first.

@Ralith Ralith left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is great, thank you!

@Ralith
Ralith added this pull request to the merge queue Jul 20, 2026
Merged via the queue into quinn-rs:main with commit 553fbd4 Jul 20, 2026
19 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.

BTreeMap for ACK takes too much CPU time for memery allocate

3 participants