Skip to content

fix: lift the 32-range cap on ACK frames - #3674

Open
mvanhorn wants to merge 10 commits into
mozilla:mainfrom
mvanhorn:fix/3641-neqo-ack-32-range-limit
Open

mvanhorn wants to merge 10 commits into
mozilla:mainfrom
mvanhorn:fix/3641-neqo-ack-32-range-limit

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

ACK frames are no longer capped at 32 ranges. write_frame in neqo-transport/src/tracking.rs previously truncated to MAX_RANGES = 32 unconditionally; it now sizes the range count against the actual space available in the packet, so loss patterns producing many small holes get acknowledged in one frame instead of dropping older ranges, as requested in #3641.

Why this matters

larseggert filed #3641 after observing the hard cap: with more than 32 ranges, the peer never learns about older holes and retransmits data the receiver already has. The frame builder already negotiates space byte by byte; the cap was a leftover safety bound. Sizing now accounts for the range-count varint growing to two bytes at 64+ ranges (review round caught that), so ACK_FRAME_RESERVE still guarantees room for a following useful frame in tight packets.

Testing

Added tracking tests covering: many-range ACK in a roomy packet (all ranges encoded), tight-packet truncation (reserve preserved, frame still parses), and the 64+ range varint boundary. cargo fmt --check, cargo clippy --all-targets -- -D warnings, and the full neqo-transport suite (772 tests) pass locally.

AI was used for assistance.

Fixes #3641

@codecov

codecov Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.25581% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.97%. Comparing base (9f9c801) to head (402d83f).
⚠️ Report is 48 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3674      +/-   ##
==========================================
+ Coverage   96.84%   96.97%   +0.12%     
==========================================
  Files         118      118              
  Lines       39989    41921    +1932     
  Branches    39989    41921    +1932     
==========================================
+ Hits        38728    40653    +1925     
- Misses       1237     1244       +7     
  Partials       24       24              
Flag Coverage Δ
freebsd 94.58% <97.96%> (+16.03%) ⬆️
linux 97.18% <98.25%> (+0.11%) ⬆️
macos 95.36% <97.96%> (+0.20%) ⬆️
windows 95.44% <97.96%> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
neqo-common 99.28% <96.92%> (+0.02%) ⬆️
neqo-http3 95.35% <ø> (+0.02%) ⬆️
neqo-qpack 97.10% <ø> (+0.13%) ⬆️
neqo-transport 97.94% <98.56%> (+0.11%) ⬆️
neqo-udp 95.37% <ø> (ø)
mtu 89.13% <ø> (+0.15%) ⬆️

@codspeed

codspeed Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 2.66%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 9 improved benchmarks
❌ 3 regressed benchmarks
✅ 87 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Simulation decode 1000 STREAM frames 179.6 µs 226.4 µs -20.69%
❌ Simulation decode 100 STREAM frames 19.2 µs 24 µs -19.94%
❌ Simulation decode 10 STREAM frames 3.3 µs 3.9 µs -14.74%
⚡ WallTime walltime/pacing-false/same-seed 2.1 ms 2 ms +4.26%
⚡ Simulation simulated/pacing-false/varying-seeds 75.7 ms 72.7 ms +4.09%
⚡ WallTime walltime/pacing-true/same-seed 2.1 ms 2 ms +3.67%
⚡ WallTime walltime/pacing-false/varying-seeds 2.1 ms 2 ms +3.53%
⚡ WallTime walltime/pacing-true/varying-seeds 2.1 ms 2 ms +3.47%
⚡ WallTime walltime/1000-streams/each-1-bytes 3.8 ms 3.7 ms +3.27%
⚡ WallTime walltime/1000-streams/each-1000-bytes 12.6 ms 12.3 ms +2.68%
⚡ WallTime walltime/10-streams/each-1048576-bytes 29.2 ms 28.5 ms +2.43%
⚡ WallTime neqo-s2n 50.3 ms 49.3 ms +2.13%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing mvanhorn:fix/3641-neqo-ack-32-range-limit (402d83f) with main (f819183)

Open in CodSpeed

@larseggert larseggert 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.

I confirmed this fixes the "dropped ranges" occurrences I saw over an emulated path (400ms RTT, 1Mbit/s). Performance wasn't improved however.

Can we extract some repeated code from the various new tests into helpers to be more DRY?

Deduplicate the tracker setup, ACK frame write, token range assertion, and frame decode logic repeated across the new ACK-range tests into shared helpers, as requested in review.
@mvanhorn

mvanhorn commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing it over the emulated path, that's great to hear. Done in 05b05c5: the repeated tracker setup, ACK frame write, token range assertion, and frame decode logic are now shared helpers, which removed about 60 duplicated lines from the new tests. cargo test -p neqo-transport --lib tracking (31 tests) and clippy both pass locally.

@martinthomson martinthomson 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.

In looking at this changeset, I don't think that it approaches the problem from the right angle. When things are an uncertain length and we have a cap, we should only need to rewind the encoding by a single item at the point that the frame overruns the limit. The O(n^2) linear search for the right size is not the right way to build this.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

@martinthomson thanks for looking. One correction and then I think we agree on the direction.

The sizing loop is not an O(n^2) search. ack_len_for_range_count is closed-form arithmetic (constant frame overhead, plus 16 bytes per extra range, plus the varint width of the extra-range count), so each iteration is O(1). The initial estimate is 1 + avail/16, which only ever overshoots by the growth of the extra-range varint, so the loop decrements once or twice at most.

The real weakness is the one your approach fixes: 16 bytes per range is a worst case, so on typical ranges the frame is packed well under the limit and we send fewer ranges than would fit.

The wrinkle with rewinding a single item is that the frame writes the extra-range count as a varint before any of the ranges, and its width depends on the final count. Dropping the last range can shrink that field, so the rewind is not purely local to the tail. It is handleable, either by reserving the varint at its estimated width or by re-encoding the header after the count settles.

If you are happy with the latter, I will rework it to encode ranges until the frame overruns the limit, rewind the last one, and fix up the count, which drops the pre-computation entirely and packs tighter than this version. I will also rebase, the branch is behind main.

@larseggert
larseggert force-pushed the fix/3641-neqo-ack-32-range-limit branch from db961ec to 0eaaf2c Compare September 21, 2026 10:37
@github-actions

Copy link
Copy Markdown
Contributor

Performance profiles for profiler.firefox.com

Benchmarks (14)
  • neqo-bin-main: pr · base
  • neqo-common-decoder: pr · base
  • neqo-http3-streams_simulated: pr · base
  • neqo-http3-streams_walltime: pr · base
  • neqo-transport-frame_decode: pr · base
  • neqo-transport-min_bandwidth: pr · base
  • neqo-transport-pacer: pr · base
  • neqo-transport-packet_codec: pr · base
  • neqo-transport-range_tracker: pr · base
  • neqo-transport-rx_stream_orderer: pr · base
  • neqo-transport-send_streams: pr · base
  • neqo-transport-sent_packets: pr · base
  • neqo-transport-transfer_simulated: pr · base
  • neqo-transport-transfer_walltime: pr · base
Comparisons (5)

Copilot AI left a comment

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.

Copilot review overview

🟢 Approval recommended

The space calculations handle varint boundaries correctly and the targeted tests cover the affected behavior.

Review effort: Balanced
Findings: None

What changed in this PR

Removes the 32-range ACK cap, sizing ACK frames by available packet space while preserving room for another frame.

Changes:

  • Raises tracked ACK ranges to 128.
  • Accounts for variable-length range-count encoding and packet capacity.
  • Adds coverage for large, constrained, and 64+ range ACKs.
File Description
neqo-transport/​src/​tracking.rs Updates ACK range budgeting and adds boundary tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@larseggert

Copy link
Copy Markdown
Collaborator

@codspeedbot fix this regression

@codspeed

codspeed Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

@codspeedbot fix this regression

I looked into this and there is no regression to fix: the reported deltas come from the benchmark CI runner migration, not from this PR.

Evidence

Your head run (0eaaf2c) and the run of main at 9f9c801 — both executed on the new codspeed-macro-x86-ryzen-9950x runners — produce essentially identical numbers for every flagged benchmark:

Benchmark (mode) main @ 9f9c801 PR head @ 0eaaf2c
coalesce_acked_from_zero 1000 ranges (Simulation) 655.386 µs 655.386 µs (identical)
inbound_frame in-order (Simulation) 1.29066 ms 1.28728 ms (−0.3%)
transfer simulated/pacing-true/same-seed (Simulation) 74.698 ms 74.664 ms (−0.05%)
transfer simulated/pacing-true/same-seed (Memory) 2,443,591 B 2,443,591 B (identical)

The base used in the report comes from an older environment. Comparing main against main — fb2eb16 (last full run on the GitHub-hosted runners, 18 Sep) versus 9f9c801 (on the macro runners) — reproduces exactly the same list: the same 10 regressions and the same 6 improvements, with zero source changes in between. That comparison also flags the hardware/toolchain change explicitly: AMD EPYC 7763 / Intel Xeon (2 cores) → AMD Ryzen 9 9950X (16 cores), plus a different libc build. Simulation mode derives its cache model from the physical CPU, and Memory mode is sensitive to the toolchain, so both shift on identical code (details).

That also explains the odd shape of the report: a change confined to neqo-transport/src/tracking.rs "regressing" RangeTracker and RxStreamOrderer microbenchmarks, and simultaneously "improving" simulated transfer by 50%+.

No code changes made — the branch is untouched at 0eaaf2c. Once a fresh report is generated against a base measured on the macro runners, these entries should disappear.

One observation while checking: the ACK path this PR changes is not currently exercised by the benchmark suite. transfer_simulated loses packets through tail-drop queues, so losses are contiguous bursts producing only a handful of ACK ranges — never enough to hit the old 32-range cap, which is why head and base are bit-identical there. If you want CI coverage for the many-small-holes pattern from #3641, a simulated path with sparse random loss would be needed; happy to add one if you'd like.

Summary
  • Warming up the performance wizard...
  • Check out PR branch — Branch fix/3641-neqo-ack-32-range-limit at 0eaaf2c (HEAD == FETCH_HEAD)
  • Analyze the reported regressions — Diff is confined to neqo-transport/src/tracking.rs; the regressed benches exercise RangeTracker/RxStreamOrderer, which this PR does not touch
  • Compare PR head against main measured in the same environment — PR head (0eaaf2c) vs main (9f9c801), both on the codspeed-macro runners: values identical (e.g. coalesce 1000 ranges 655.386 µs in both, peak memory 2,443,591 B in both)
  • Identify the source of the reported deltas — Main-vs-main comparison (fb2eb16 -> 9f9c801) reproduces the same 10 regressions and 6 improvements with no code change: the report's base was measured before the CI runner migration in ci: Use CodSpeed-hosted runners for perf CI #4001
  • Implement a fix for the regression — No code change made: no regression is attributable to this PR
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks @larseggert. CodSpeedBot looked into it and found no regression attributable to this PR: head 0eaaf2c and main at 9f9c801, measured on the same macro runners, give identical numbers (coalesce_acked_from_zero 1000 ranges at 655.386 us on both, peak memory 2,443,591 B on both). A main-vs-main comparison across the runner migration (fb2eb16 to 9f9c801) reproduces the same 10 regressions and 6 improvements with zero source changes in between, so the report's base was just measured in the old environment.

The remaining red checks are the Vendor into Gecko jobs, which are currently failing on every branch including the merge-queue run for #4016 on main, so those look unrelated too.

One thing CodSpeedBot noticed that is worth recording: the ACK path this PR changes is not exercised by the bench suite. transfer_simulated drops packets through tail-drop queues, so losses arrive as contiguous bursts and never reach the old 32-range cap. If you want CI coverage for the many-small-holes pattern from #3641, that needs a simulated path with sparse random loss. Happy to add one in a follow-up if you think it is worth having.

Anything else you want before this goes in?

@larseggert

Copy link
Copy Markdown
Collaborator

@mvanhorn I think you wanted to rewrite this to implement @martinthomson's go-back-1-on-overrun design?

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Yes, thanks @larseggert. Reworked to @martinthomson's design in 332a686. Ranges are encoded directly into the frame one at a time. When the next range would overrun the limit, that single range is rolled back and encoding stops. The range-count varint is patched afterwards, and the check accounts for it growing. The O(n²) size search is gone, and there's a test for an overrun mid-list dropping exactly one range. cargo test -p neqo-transport passes locally.

@larseggert larseggert 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.

See inline. Also, the tests could be more DRY.

Comment thread neqo-transport/src/tracking.rs Outdated
Comment thread neqo-transport/src/tracking.rs Outdated
Comment thread neqo-transport/src/tracking.rs Outdated

Copilot AI left a comment

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.

Copilot review overview

🟢 Approval recommended

The encoding logic handles packet limits and varint growth with comprehensive boundary tests.

Review effort: Balanced
Findings: None

Move the in-place varint rewrite into neqo-common as Encoder::rewrite_varint.
Reserve the ACK Range Count at the width the pending ranges could need and
write the final count into it, non-minimally if needed, so encoded ranges
never move. ACKs with fewer than 64 pending ranges keep a 1-byte count.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks, all three are in c367ebc:

  • The in-place rewrite now lives in neqo-common as Encoder::rewrite_varint, next to encode_varint, with its own tests.
  • The ACK Range Count is reserved at the width of the most ranges the frame could carry (the ranges pending an ACK), and the final count is written into that width, non-minimally if needed, so nothing is ever copied. Sizing it from the pending ranges rather than MAX_TRACKED_RANGES means ACKs with fewer than 64 pending ranges keep a 1-byte count and are byte-identical to today; only frames that could need 64+ ranges reserve 2 bytes.
  • The tracking tests now share helpers for building and decoding the frame.

The red CI jobs look unrelated: NetBSD's ignore_connection_reset hit "No buffer space available", the macOS job failed uploading the simulation seeds artifact, and Gecko vendoring is failing on other open PRs too.

@larseggert larseggert 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.

Thanks for the recent updates. One more round please.

Comment thread neqo-transport/src/tracking.rs Outdated
Comment thread neqo-transport/src/tracking.rs
Comment thread neqo-common/src/codec.rs
Comment thread neqo-transport/src/tracking.rs Outdated
Comment thread neqo-transport/src/tracking.rs Outdated
With 64 or more pending ranges the count was reserved at two bytes even
when the packet only had room for fewer ranges, and the base-length
check then skipped the frame. Pick the ranges that fit first and encode
the count at its minimal width. Builder::write_len now uses
Encoder::rewrite_varint, and the test helper measures encoder output
instead of restating the length formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XShqeJJr3336iNwLibpXz
Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

mvanhorn commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for another round. 5405168 covers the inline points: the count is now sized from the ranges that actually fit, so the 65-pending case writes a one-byte count instead of bailing; write_len calls Encoder::rewrite_varint; acked uses with_capacity; and the length helper measures real encoder output.

On MAX_TRACKED_RANGES: an ACK-only packet has room for about 123 single-packet ranges, so at 32 we were dropping gaps we could have reported in a single ACK. 128 covers that. On cost: add() checks the newest range first, so in-order packets return immediately, and the walk only happens on reordering. Happy to go back down to a smaller bound if you'd rather keep that walk shorter.

///
/// We don't send ranges that have been acknowledged, but they still need
/// to be tracked so that duplicates can be detected.
fn write_frame<B: Buffer>(

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.

This function is now extremely long and complicated. How can it be factored better?

Comment thread neqo-transport/src/tracking.rs Outdated
}
},
);
let max_extra = pending.clone().count();

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.

OK, a lot of feedback here. This counting of the length is the source of a lot of extra complexity.

Second, this reserves just two bytes for other frames, which seems excessive. A better design would be to cap the size to (maybe) 512 bytes. At 512 bytes, you can guarantee that the range count is a single byte, which allows you to use something like Encoder::encode_vvec_with for encoding ranges without a performance hit.

The inner closure passed to that could do the size limiting by looping until it doesn't have space (you could do this loosely by writing until there is less than 496 bytes remaining to avoid having to count sizes on every range, but any value smaller than 512 will preserve the one byte length guarantee). Compute that limit by looking at the minimum of encoder.remaining() (minus any ECN count size) and a smaller value, like 512 (I'd prefer smaller still, but this would be the maximum). To avoid counting the size of the last range, you could assume that that one is 16 bytes long pretty safely.

Thus, the algorithm would look approximately like:

let Some(space) = builder.remaining().checked_sub(fixed_len()) else {
  // No space for even the fixed part.  Abort.
  return;
};

// Note: I would factor the closure out.
// It receives a reference to `self.ranges`, so you'd need a `Self::foo` function.
let mut acked = Vec::new(); // we might want to look at pre-allocation, but not right now
builder.encode_frame(frame_type, |b| {
  b.encode_varint(first.largest);
  //... other fixed fields
            b.encode_varint(first.largest);
            b.encode_varint(ack_delay);
   // See below for how this works.
            let range_count_reservation = b.reserve::<1>();
            b.encode_varint(first.len() - 1);

  // Note: I'd factor this block out as well.
  // As above, the reference to `self.ranges` means a `Self::foo` function.
  {
    // Require space for a full-sized gap plus range.
    // ACK frames are sent first in most cases, so it is unlikely that this will be a problem.
    // This is better than counting the size of every range.
    let range_space = min(Self::RANGE_FRAME_SPACE, space).saturating_sub(16);
    let stop_after = b.len() + range_space;

    let mut last = first.smallest;
    for range in pending {
      if b.len() > stop_after { break; }
      b.encode_varint(last - range.largest - 2);
      b.encode_varint(range.len() - 1);
      last = range.smallest;
      acked.push(range.clone());
    }
    // Writing in at a given offset requires new code in `Encoder`.
    b.overwrite(range_count_reservation, [u8::try_from(acked.len())?]);
    // This could be made safer by having a "reserve()" function that returns an opaque 
    // type that holds a reference to the range that was reserved.
    // Clues: `struct Reservation<const N: usize>(usize);`
    // and `pub fn overwrite<const N: usize>(&mut self, rsv: Reservation<N>, data: [u8; N]) { ... }`.

    if let Some((ect0, ect1, ce)) = ecn {
      // write those out
    }
  });

Comment thread neqo-transport/src/tracking.rs Outdated
// the extra ranges that fit. Sizing it from every pending range uses two
// bytes once 64 extras are pending, and that extra byte can push the base
// frame over the limit when the packet has room for fewer ranges.
let limit = builder.limit().saturating_sub(Self::ACK_FRAME_RESERVE);

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.

This needs to use Builder::remaining(). We send ACK frames before any others in most cases, but that's not a guarantee we want to bake in.

Follow the design from review: write the fixed ACK fields, reserve a
one-byte ACK Range Count, then write extra ranges until fewer than 16
bytes of the range budget remain, and overwrite the count at the end.
The range budget is the smaller of 512 bytes and Builder::remaining()
less the fixed fields and ECN counts, so the count always fits in one
byte and no range is measured before it is written. Ranges that do not
fit wait for a later ACK.

The fixed fields and the range loop are factored into
encode_ack_fixed and encode_ack_ranges. neqo-common gains
Encoder::reserve and Encoder::overwrite with a Reservation<N> type.
USEFUL_ACK_LEN goes back to a one-byte count, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFTE4BHbGYzgf8Uhzcppdh
Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

@martinthomson reworked along the lines you sketched in 402d83f: the fixed fields go through encode_ack_fixed, which reserves a one-byte ACK Range Count via a new Encoder::reserve/Encoder::overwrite pair with a Reservation<N> type, and encode_ack_ranges writes ranges until less than 16 bytes of min(512, Builder::remaining() - fixed) are left, then fills in the count. Nothing is measured per range, and USEFUL_ACK_LEN is back to a one-byte count as on main. cargo fmt --check, cargo clippy -p neqo-transport -p neqo-common --all-targets -- -D warnings and cargo test -p neqo-common -p neqo-transport are clean on my machine.

@martinthomson martinthomson 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.

I like the changes (no surprise). At least one more round. I stopped before looking before I got to tests; plenty of small-scale stuff to that point.

Comment thread neqo-common/src/codec.rs
///
/// When `rsv` is not contained in the bytes written to this encoder.
pub fn overwrite<const N: usize>(&mut self, rsv: Reservation<N>, data: [u8; N]) -> &mut Self {
assert!(rsv.0 >= self.start, "reservation is outside the encoder");

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.

Suggested change
assert!(rsv.0 >= self.start, "reservation is outside the encoder");
debug_assert!(rsv.0 >= self.start, "reservation is outside the encoder");

Reservations are produced by this code, so a caller should not be able to produce one that violates these constraints.

Comment thread neqo-common/src/codec.rs
Comment on lines +375 to +376
let len = self.len();
assert!(end <= len, "reservation extends past encoder");

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.

As above, plus an inlining.

Suggested change
let len = self.len();
assert!(end <= len, "reservation extends past encoder");
debug_assert!(end <= self.len(), "reservation extends past encoder");

Comment thread neqo-common/src/codec.rs
Comment on lines +783 to +784
enc.overwrite(one, [0x11]);
enc.overwrite(two, [0x22, 0x33]);

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.

Suggested change
enc.overwrite(one, [0x11]);
enc.overwrite(two, [0x22, 0x33]);
enc.overwrite(two, [0x22, 0x33]);
enc.overwrite(one, [0x11]);

Just to demonstrate that order doesn't matter.

stats.largest_acknowledged = first.largest;
stats.ack += 1;

let acked = {

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.

I'm fairly sure you can drop this let acked = { ... } wrapper.

Suggested change
let acked = {

Comment on lines +534 to +535
acked
};

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.

Continuing the above suggestion...

Suggested change
acked
};

Comment on lines +524 to +525
let mut acked = Vec::new();
builder.encode_frame(frame_type, |encoder| {

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.

Maybe we can change encode_frame() to return the value of the closure, which would avoid the need for the weird mut variable assignment pattern. (Very optional; I can open an issue to track doing that.)

Comment on lines +503 to +505
let ecn_len = ecn.map_or(0, |(ect0, ect1, ce)| {
Encoder::varint_len(ect0) + Encoder::varint_len(ect1) + Encoder::varint_len(ce)
});

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.

I'd inline this variable as well. It's not doing anything on its own.

let mut acked = Vec::new();
builder.encode_frame(frame_type, |encoder| {
let reservation = Self::encode_ack_fixed(encoder, &first, ack_delay);
acked = Self::encode_ack_ranges(ranges, encoder, &first, reservation, space);

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.

I think you can pass first by value here, which will save cloning it one extra time.

Comment on lines +482 to 484
let Some(first) = self.ranges.iter().find(|range| range.ack_needed()).cloned() else {
return;
};

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.

It would require a change to the function signatures, but I think you can create a filtered iterator for interesting ranges before this, as follows:

Suggested change
let Some(first) = self.ranges.iter().find(|range| range.ack_needed()).cloned() else {
return;
};
let mut ack_needed = self.ranges.iter().filter(PacketRange::ack_needed);
let Some(first) = ack_needed.next().map(Clone::clone) else {
return;
};

Then you pass the same iterator to encode_ack_ranges.

That avoids iterating over packets that don't need an ACK twice (once here, once when building the packet).

Comment thread neqo-common/src/codec.rs
Comment on lines +332 to +353
#[expect(
clippy::cast_possible_truncation,
reason = "The fit check guarantees that `value` fits in the selected width."
)]
pub fn rewrite_varint<T: Into<u64>>(&mut self, off: usize, len: usize, value: T) -> &mut Self {
let value = value.into();
assert!(matches!(len, 1 | 2 | 4 | 8), "invalid varint length {len}");
assert!(
Encoder::<Vec<u8>>::varint_len(value) <= len,
"varint value does not fit in {len} bytes"
);
assert!(off + len <= self.len(), "varint extends past encoder");
let dest = &mut self.as_mut()[off..off + len];
match len {
1 => dest[0] = value as u8,
2 => dest.copy_from_slice(&(value as u16 | (1 << 14)).to_be_bytes()),
4 => dest.copy_from_slice(&(value as u32 | (2 << 30)).to_be_bytes()),
8 => dest.copy_from_slice(&(value | (3 << 62)).to_be_bytes()),
_ => unreachable!("len checked above"),
}
self
}

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.

I didn't pick up on this before, but this is an entirely unrelated change. It's not bad, but it is a LOT. I think you should revert this part.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark results

No significant performance differences relative to f819183.

All results
transfer/1-conn/1-100mb-req (aka. Upload)/mtu-1500
       time:   [49.816 ms 49.897 ms 49.977 ms]
       thrpt:  [1.9540 GiB/s 1.9572 GiB/s 1.9603 GiB/s]
transfer/1-conn/1-100mb-resp (aka. Download)/mtu-1500
       time:   [49.326 ms 49.362 ms 49.399 ms]
       thrpt:  [1.9769 GiB/s 1.9784 GiB/s 1.9798 GiB/s]
transfer/1-conn/1-1b-resp (aka. HPS)/mtu-1500
       time:   [2.9106 ms 2.9117 ms 2.9129 ms]
       thrpt:  [343.30   B/s 343.44   B/s 343.57   B/s]
Found 3 outliers among 100 measurements (3.00%)
3 (3.00%) high mild
transfer/1-conn/10_000-parallel-1b-resp (aka. RPS)/mtu-1500
       time:   [68.996 ms 69.271 ms 69.546 ms]
       thrpt:  [143.79 Kelem/s 144.36 Kelem/s 144.94 Kelem/s]
streams-flow-controlled/walltime/1-streams/each-4194304-bytes
       time:   [10.776 ms 10.778 ms 10.781 ms]
       thrpt:  [371.02 MiB/s 371.11 MiB/s 371.20 MiB/s]
streams-flow-controlled/walltime/10-streams/each-1048576-bytes
       time:   [29.541 ms 29.553 ms 29.566 ms]
       thrpt:  [338.23 MiB/s 338.38 MiB/s 338.51 MiB/s]
Found 2 outliers among 100 measurements (2.00%)
2 (2.00%) high mild
streams/walltime/1-streams/each-1000-bytes
       time:   [132.77 µs 133.30 µs 133.98 µs]
       thrpt:  [7.1179 MiB/s 7.1542 MiB/s 7.1827 MiB/s]
Found 2 outliers among 100 measurements (2.00%)
2 (2.00%) high mild
streams/walltime/1000-streams/each-1-bytes
       time:   [3.8081 ms 3.8206 ms 3.8329 ms]
       thrpt:  [254.78 KiB/s 255.60 KiB/s 256.44 KiB/s]
streams/walltime/1000-streams/each-1000-bytes
       time:   [12.320 ms 12.326 ms 12.332 ms]
       thrpt:  [77.333 MiB/s 77.370 MiB/s 77.407 MiB/s]
transfer/walltime/pacing-false/same-seed
       time:   [2.0311 ms 2.0324 ms 2.0339 ms]
       thrpt:  [1.9206 GiB/s 1.9220 GiB/s 1.9232 GiB/s]
Found 1 outliers among 100 measurements (1.00%)
1 (1.00%) high severe
transfer/walltime/pacing-false/varying-seeds
       time:   [1.9936 ms 1.9951 ms 1.9969 ms]
       thrpt:  [1.9562 GiB/s 1.9580 GiB/s 1.9594 GiB/s]
transfer/walltime/pacing-true/same-seed
       time:   [2.0975 ms 2.0998 ms 2.1026 ms]
       thrpt:  [1.8578 GiB/s 1.8603 GiB/s 1.8623 GiB/s]
transfer/walltime/pacing-true/varying-seeds
       time:   [2.0948 ms 2.0960 ms 2.0974 ms]
       thrpt:  [1.8624 GiB/s 1.8637 GiB/s 1.8647 GiB/s]

Instructions per cycle

Criterion reported no significant timing changes.

All benchmarks
Benchmark IPC before IPC after ΔIPC
streams-flow-controlled/walltime/1-streams/each-4194304-bytes 3.04 2.98 -1.9%
transfer/1-conn/1-100mb-resp (aka. Download)/mtu-1500 3.21 3.16 -1.5%
streams-flow-controlled/walltime/10-streams/each-1048576-bytes 3.17 3.15 -0.8%
transfer/walltime/pacing-true/same-seed 2.90 2.88 -0.8%
transfer/1-conn/1-100mb-req (aka. Upload)/mtu-1500 3.16 3.14 -0.7%
transfer/1-conn/10_000-parallel-1b-resp (aka. RPS)/mtu-1500 3.20 3.18 -0.7%
streams/walltime/1000-streams/each-1000-bytes 2.99 3.00 +0.3%
transfer/walltime/pacing-false/same-seed 2.88 2.89 +0.3%
transfer/walltime/pacing-false/varying-seeds 2.89 2.90 +0.3%
transfer/1-conn/1-1b-resp (aka. HPS)/mtu-1500 2.24 2.25 +0.3%
streams/walltime/1-streams/each-1000-bytes 2.55 2.55 +0.2%
transfer/walltime/pacing-true/varying-seeds 2.88 2.88 -0.1%
streams/walltime/1000-streams/each-1-bytes 3.29 3.29 -0.0%
Profiles for profiler.firefox.com (62)

Download data for profiler.firefox.com or download performance comparison data.

@github-actions

Copy link
Copy Markdown
Contributor

Client/server transfer results

Performance differences relative to f819183.

Transfer of 33554432 bytes over loopback, min. 100 runs. All unit-less numbers are in milliseconds.

Client vs. server Mean±σ Min–Max Median±MAD MiB/s±σ ΔMedian
google-neqo-cubic 70.6 ± 0.4 69.5 – 71.4 70.6 ± 0.4 453.3 ± 2.3 💔 +1.0 (+1.5%)
neqo-neqo-cubic-nopacing 19.5 ± 0.2 19.2 – 19.9 19.5 ± 0.1 1644.1 ± 12.9 💚 -0.2 (-1.2%)
neqo-neqo-newreno-nopacing 19.3 ± 0.1 19.0 – 19.8 19.3 ± 0.1 1658.7 ± 12.8 💚 -0.4 (-1.8%)

Table above only shows statistically significant changes. See all results below.

All results

Transfer of 33554432 bytes over loopback, min. 100 runs. All unit-less numbers are in milliseconds.

Client vs. server Mean±σ Min–Max Median±MAD MiB/s±σ ΔMedian
google-google 132.4 ± 0.5 131.2 – 133.7 132.4 ± 0.6 241.6 ± 1.0
google-neqo-cubic 70.6 ± 0.4 69.5 – 71.4 70.6 ± 0.4 453.3 ± 2.3 💔 +1.0 (+1.5%)
neqo-google-cubic ⚠️ 252.2 ± 75.2 175.3 – 536.2 234.8 ± 29.6 126.9 ± 37.8 +8.0 (+3.5%)
neqo-neqo-cubic 19.4 ± 0.2 19.1 – 20.0 19.4 ± 0.1 1648.4 ± 13.0 -0.2 (-0.8%)
neqo-neqo-cubic-nopacing 19.5 ± 0.2 19.2 – 19.9 19.5 ± 0.1 1644.1 ± 12.9 💚 -0.2 (-1.2%)
neqo-neqo-newreno 19.7 ± 0.1 19.4 – 20.2 19.7 ± 0.1 1626.7 ± 12.1 -0.1 (-0.6%)
neqo-neqo-newreno-nopacing 19.3 ± 0.1 19.0 – 19.8 19.3 ± 0.1 1658.7 ± 12.8 💚 -0.4 (-1.8%)
neqo-quiche-cubic 29.7 ± 0.2 29.1 – 30.4 29.7 ± 0.2 1076.6 ± 8.4 -0.1 (-0.2%)
neqo-s2n-cubic 38.0 ± 0.2 37.6 – 38.7 38.0 ± 0.2 842.0 ± 5.1 -0.1 (-0.2%)
quiche-neqo-cubic 31.2 ± 0.4 30.2 – 32.0 31.2 ± 0.3 1026.6 ± 12.3 +0.0 (+0.1%)
quiche-quiche 35.6 ± 0.3 34.9 – 36.2 35.6 ± 0.2 899.0 ± 6.3
s2n-neqo-cubic 118.2 ± 0.5 116.7 – 119.6 118.1 ± 0.4 270.7 ± 1.2 +0.4 (+0.3%)
s2n-s2n ⚠️ 167.6 ± 26.9 136.9 – 261.8 161.1 ± 1.3 191.0 ± 30.6

Download data for profiler.firefox.com or download performance comparison data.

@github-actions

Copy link
Copy Markdown
Contributor

Failed Interop Tests

QUIC Interop Runner, client vs. server, differences relative to main at 9f9c801.

neqo-pr as clientneqo-pr as server
neqo-pr vs. go-x-net: BP BA
neqo-pr vs. haproxy: BP BA
neqo-pr vs. kwik: DC LR 🚀S Z ⚠️M R L1 🚀L2 C1 🚀BA
neqo-pr vs. lsquic: L1 C1
neqo-pr vs. msquic: Z A L1 C1
neqo-pr vs. mvfst: A BA
neqo-pr vs. neqo: run cancelled after 20 min
neqo-pr vs. nginx: 🚀C1 BP BA
neqo-pr vs. ngtcp2: Z 🚀L1 C1 CM
neqo-pr vs. picoquic: Z A
neqo-pr vs. quic-go: A
neqo-pr vs. quiche: BP BA
neqo-pr vs. s2n-quic: CM
neqo-pr vs. tquic: S BP BA
neqo-pr vs. xquic: S R Z A L1 C1
go-x-net vs. neqo-pr: 🚀BA CM
kwik vs. neqo-pr: BP BA CM
msquic vs. neqo-pr: CM
mvfst vs. neqo-pr: Z L1 C1 CM
neqo vs. neqo-pr: Z A
openssl vs. neqo-pr: LR M A CM
quic-go vs. neqo-pr: CM
quic-zig vs. neqo-pr: CM
quiche vs. neqo-pr: CM
quinn vs. neqo-pr: V2 CM
s2n-quic vs. neqo-pr: CM
tquic vs. neqo-pr: CM
xquic vs. neqo-pr: M CM
All results

Succeeded Interop Tests

QUIC Interop Runner, client vs. server

neqo-pr as client

neqo-pr as server

Unsupported Interop Tests

QUIC Interop Runner, client vs. server

neqo-pr as client

neqo-pr as server

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.

ACK frames are unnecessarily limited to 32 ranges

4 participants