io_uring: re-enable GSO batching (MAX_GSO_SEGMENTS_PER_SEND=1 makes UDP_SEGMENT a no-op) #17

Closed
opened 2026-07-02 20:43:21 +00:00 by vxfemboy · 2 comments
vxfemboy commented 2026-07-02 20:43:21 +00:00 (Migrated from github.com)

Summary

The UringDriver's GSO egress path is effectively disabled by a one-line constant, which makes it more expensive than a plain send while delivering none of the batching benefit. This is the primary reason the io_uring driver regressed tunnel RTT and was demoted from the default in #16.

The bug

crates/yip-io/src/uring.rs:

const MAX_GSO_SEGMENTS_PER_SEND: usize = 1;   // line ~35

fn max_gso_datagrams_for_segment(segment_size: u16) -> usize {
    let segment_len = usize::from(segment_size);
    if segment_len == 0 { return 1; }
    let mtu_cap = MAX_UDP_PAYLOAD / segment_len;
    mtu_cap.clamp(1, MAX_GSO_SEGMENTS_PER_SEND.min(MAX_GSO_DATAGRAMS))  // = clamp(1, min(1,64)) = 1
}

min(1, 64) = 1, so clamp(_, 1) returns 1 regardless of mtu_cap. queue_udp_batch then does datagrams.chunks(1) — one datagram per "GSO batch". Every multi-datagram egress that should coalesce into a single sendmsg+UDP_SEGMENT cmsg instead issues one SendMsg per datagram, each paying the extra iovec/msghdr/cmsg construction (GsoSendContext::prepare, CMSG_*, plus a Vec::with_capacity copy) — strictly more expensive than the plain Send path it replaces, for zero batching.

This directly matches the bench profile: the pipeline emits ~2 same-size symbols per sealed packet (source + repair), exactly the case GSO exists to coalesce — and instead it issues two full SendMsg+cmsg calls.

Also: the unit test uring_gso_large_batch_chunks_payload_to_udp_limits computes its expected_submissions by calling the same (buggy) max_gso_datagrams_for_segment under test — it's tautological and cannot catch this regression. Fix the test to use an independent expected value.

Why it was capped to 1

Deliberate, per the Phase B task report: MAX_GSO_SEGMENTS_PER_SEND=1 was set to stop arq_recovers_bulk_loss failing — coalesced GSO super-frames over veth+netem drop as a unit, so one drop loses many segments and defeats the loss test. So raising the cap is not a one-liner: it must be re-validated against the ARQ loss test.

Plan

  • Raise MAX_GSO_SEGMENTS_PER_SEND to a real batch size (e.g. 8–64) and confirm max_gso_datagrams_for_segment scales with mtu_cap.
  • Investigate the arq_recovers_bulk_loss interaction. Options: bound GSO batch size on lossy paths, keep GSO for clean-link bulk only, or accept the segmentation behaviour if netns loss no longer regresses. Understand why coalescing hurt the test (veth GSO/GRO offload keeping the super-frame intact through netem).
  • De-tautologize uring_gso_large_batch_chunks_payload_to_udp_limits (independent expected value).
  • Add a test that actually asserts N datagrams coalesce into 1 submission.
  • Re-benchmark run-driver-ab-rtt.sh + throughput A/B. Only if uring beats epoll does it earn the default back (tunnel.rs selection).
  • Per-packet heap Vec alloc+copy on every recv and send (poll.rs uses a reused stack buffer).
  • Provided buffer reprovided after dispatch, not after the copy-out.
  • SEND_SLOTS=256 shared pool silently drops on exhaustion vs poll's 4 MiB SO_SNDBUF.
  • The real latency win likely needs IORING_SETUP_SQPOLL.

Context: review + demotion in #16; measured regression in crates/yip-bench/README.md ("io_uring Phase B — driver A/B").

## Summary The `UringDriver`'s GSO egress path is effectively **disabled** by a one-line constant, which makes it *more* expensive than a plain send while delivering none of the batching benefit. This is the primary reason the io_uring driver regressed tunnel RTT and was demoted from the default in #16. ## The bug `crates/yip-io/src/uring.rs`: ```rust const MAX_GSO_SEGMENTS_PER_SEND: usize = 1; // line ~35 fn max_gso_datagrams_for_segment(segment_size: u16) -> usize { let segment_len = usize::from(segment_size); if segment_len == 0 { return 1; } let mtu_cap = MAX_UDP_PAYLOAD / segment_len; mtu_cap.clamp(1, MAX_GSO_SEGMENTS_PER_SEND.min(MAX_GSO_DATAGRAMS)) // = clamp(1, min(1,64)) = 1 } ``` `min(1, 64) = 1`, so `clamp(_, 1)` returns **1 regardless of `mtu_cap`**. `queue_udp_batch` then does `datagrams.chunks(1)` — one datagram per "GSO batch". Every multi-datagram egress that should coalesce into a single `sendmsg`+`UDP_SEGMENT` cmsg instead issues one `SendMsg` **per datagram**, each paying the extra iovec/msghdr/cmsg construction (`GsoSendContext::prepare`, `CMSG_*`, plus a `Vec::with_capacity` copy) — strictly more expensive than the plain `Send` path it replaces, for zero batching. This directly matches the bench profile: the pipeline emits ~2 same-size symbols per sealed packet (source + repair), exactly the case GSO exists to coalesce — and instead it issues two full `SendMsg`+cmsg calls. **Also:** the unit test `uring_gso_large_batch_chunks_payload_to_udp_limits` computes its `expected_submissions` by calling the same (buggy) `max_gso_datagrams_for_segment` under test — it's tautological and cannot catch this regression. Fix the test to use an independent expected value. ## Why it was capped to 1 Deliberate, per the Phase B task report: `MAX_GSO_SEGMENTS_PER_SEND=1` was set to stop `arq_recovers_bulk_loss` failing — coalesced GSO super-frames over veth+netem drop as a unit, so one drop loses many segments and defeats the loss test. **So raising the cap is not a one-liner:** it must be re-validated against the ARQ loss test. ## Plan - [ ] Raise `MAX_GSO_SEGMENTS_PER_SEND` to a real batch size (e.g. 8–64) and confirm `max_gso_datagrams_for_segment` scales with `mtu_cap`. - [ ] Investigate the `arq_recovers_bulk_loss` interaction. Options: bound GSO batch size on lossy paths, keep GSO for clean-link bulk only, or accept the segmentation behaviour if netns loss no longer regresses. Understand *why* coalescing hurt the test (veth GSO/GRO offload keeping the super-frame intact through netem). - [ ] De-tautologize `uring_gso_large_batch_chunks_payload_to_udp_limits` (independent expected value). - [ ] Add a test that actually asserts N datagrams coalesce into 1 submission. - [ ] **Re-benchmark** `run-driver-ab-rtt.sh` + throughput A/B. Only if uring beats epoll does it earn the default back (`tunnel.rs` selection). ## Related (out of scope here, documented in crates/yip-bench/README.md) - Per-packet heap `Vec` alloc+copy on every recv and send (poll.rs uses a reused stack buffer). - Provided buffer reprovided after dispatch, not after the copy-out. - `SEND_SLOTS=256` shared pool silently drops on exhaustion vs poll's 4 MiB `SO_SNDBUF`. - The real latency win likely needs `IORING_SETUP_SQPOLL`. Context: review + demotion in #16; measured regression in `crates/yip-bench/README.md` ("io_uring Phase B — driver A/B").
vxfemboy commented 2026-07-02 20:50:07 +00:00 (Migrated from github.com)

Mechanism confirmed empirically

Bumped MAX_GSO_SEGMENTS_PER_SEND 1 → 8 and ran arq_recovers_bulk_loss under YIP_USE_URING=1:

GSO cap UDP delivered verdict
1 (current) ~99.3% pass
8 95.1%, 95.3% FAIL (min 98%)

Root cause pinned down: GSO is applied only on the TUN→UDP egress path (handle_dispatch_tunqueue_udp_batch(pkts, allow_gso=true)). That batch is exactly one inner packet's symbols = source + its repair symbols, all the same size, so can_coalesce_gso merges them into a single UDP_SEGMENT skb. The driver can't tell source from repair (opaque Vec<u8>). Over veth the super-skb isn't segmented before netem, so one drop takes the source and its repair together → FEC can't recover. That's why the cap was pinned to 1: it silently disables the feature to keep FEC intact.

Design conclusion: you cannot safely coalesce symbols within one object. A correct GSO batch must span different objects (independent w.r.t. loss — each object's repair rides a different skb). That means accumulating same-size datagrams across multiple on_tun dispatches and flushing them as GSO batches at end of poll_once, not the current per-packet coalescing. Bigger change than raising a constant.

Important scoping caveat: GSO batching is a throughput lever, not a latency one. The RTT regression that got uring demoted (#16) is a single-flow ping-pong where there is little to batch; it comes from io_uring's per-op cost + per-packet heap copies, not GSO. So fixing GSO will not on its own earn uring the default back — that likely needs IORING_SETUP_SQPOLL and/or killing the per-packet malloc. GSO and RTT are separate workstreams.

## Mechanism confirmed empirically Bumped `MAX_GSO_SEGMENTS_PER_SEND` 1 → 8 and ran `arq_recovers_bulk_loss` under `YIP_USE_URING=1`: | GSO cap | UDP delivered | verdict | |---|---|---| | 1 (current) | ~99.3% | pass | | 8 | **95.1%, 95.3%** | **FAIL** (min 98%) | Root cause pinned down: GSO is applied **only on the TUN→UDP egress path** (`handle_dispatch_tun` → `queue_udp_batch(pkts, allow_gso=true)`). That batch is exactly *one inner packet's symbols = source + its repair symbols*, all the same size, so `can_coalesce_gso` merges them into a single `UDP_SEGMENT` skb. The driver can't tell source from repair (opaque `Vec<u8>`). Over veth the super-skb isn't segmented before `netem`, so one drop takes the source **and its repair together** → FEC can't recover. That's why the cap was pinned to 1: it silently disables the feature to keep FEC intact. **Design conclusion:** you cannot safely coalesce symbols *within one object*. A correct GSO batch must span **different objects** (independent w.r.t. loss — each object's repair rides a different skb). That means accumulating same-size datagrams across multiple `on_tun` dispatches and flushing them as GSO batches at end of `poll_once`, not the current per-packet coalescing. Bigger change than raising a constant. **Important scoping caveat:** GSO batching is a **throughput** lever, not a latency one. The RTT regression that got uring demoted (#16) is a single-flow ping-pong where there is little to batch; it comes from io_uring's per-op cost + per-packet heap copies, not GSO. So fixing GSO will **not** on its own earn uring the default back — that likely needs `IORING_SETUP_SQPOLL` and/or killing the per-packet malloc. GSO and RTT are separate workstreams.
vxfemboy commented 2026-07-02 20:53:05 +00:00 (Migrated from github.com)

Concrete design (why this is a cross-crate change, not a driver tweak)

Deeper than the first analysis: a GSO skb fate-shares every segment in it under netem (confirmed: cap=8 → ~95% delivery, FEC can't recover). Since an object's source and repair symbols are the same size, they are always GSO-coalescing candidates — so cross-object accumulation alone doesn't fix it either: append order [o1_src, o1_rep, o2_src, o2_rep] still puts o1_src+o1_rep in one skb.

For GSO to coexist with FEC, no GSO skb may contain two datagrams from the same object. The driver can't enforce that because egress datagrams are opaque Vec<u8> — it can't tell source from repair or which object a symbol belongs to.

Proposed design (needs a small yip-transport → yip-io API addition):

  1. on_tun returns each egress datagram tagged with a fate group (object id, or simply source-vs-repair + object index). Cheap: the transport already knows this at encode time.
  2. Driver keeps a per-poll_once egress accumulator. It coalesces same-size datagrams into GSO skbs under the invariant at most one datagram per fate group per skb (e.g. skb A = one source symbol from each of N objects; skb B = the matching repair symbols). Flush at end of poll_once.
  3. Result: a dropped skb costs each object at most one symbol, recoverable from its repair in a different skb — FEC preserved, syscalls amortised.
  4. De-tautologize uring_gso_large_batch_chunks_payload_to_udp_limits; add a netns test that asserts N-object coalescing keeps delivery ≥ threshold under loss.

Alternative (simpler, less general): gate GSO to the zero-repair regime only — when the controller has decayed repair to 0 (clean-link Bulk, the existing bypass), there is no repair to fate-share, so coalescing source symbols across packets is safe. Needs only a boolean "no redundancy in flight" signal from the transport, not full fate tags. Loses GSO on lossy links (acceptable — those are FEC-bound, not syscall-bound).

Either way this touches the FEC egress contract, so it wants a short design pass before implementation — not a solo constant change. Holding here rather than shipping a subtly FEC-defeating coalescer.

Reminder: none of this addresses the RTT regression (separate: SQPOLL + per-packet malloc).

## Concrete design (why this is a cross-crate change, not a driver tweak) Deeper than the first analysis: a GSO skb **fate-shares every segment in it** under netem (confirmed: cap=8 → ~95% delivery, FEC can't recover). Since an object's source and repair symbols are the *same size*, they are always GSO-coalescing candidates — so **cross-object accumulation alone doesn't fix it** either: append order `[o1_src, o1_rep, o2_src, o2_rep]` still puts `o1_src`+`o1_rep` in one skb. For GSO to coexist with FEC, **no GSO skb may contain two datagrams from the same object.** The driver can't enforce that because egress datagrams are opaque `Vec<u8>` — it can't tell source from repair or which object a symbol belongs to. **Proposed design (needs a small yip-transport → yip-io API addition):** 1. `on_tun` returns each egress datagram tagged with a **fate group** (object id, or simply source-vs-repair + object index). Cheap: the transport already knows this at encode time. 2. Driver keeps a per-`poll_once` egress accumulator. It coalesces same-size datagrams into GSO skbs under the invariant **at most one datagram per fate group per skb** (e.g. skb A = one source symbol from each of N objects; skb B = the matching repair symbols). Flush at end of `poll_once`. 3. Result: a dropped skb costs each object at most one symbol, recoverable from its repair in a different skb — FEC preserved, syscalls amortised. 4. De-tautologize `uring_gso_large_batch_chunks_payload_to_udp_limits`; add a netns test that asserts N-object coalescing keeps delivery ≥ threshold under loss. **Alternative (simpler, less general):** gate GSO to the **zero-repair regime only** — when the controller has decayed repair to 0 (clean-link Bulk, the existing bypass), there is no repair to fate-share, so coalescing source symbols across packets is safe. Needs only a boolean "no redundancy in flight" signal from the transport, not full fate tags. Loses GSO on lossy links (acceptable — those are FEC-bound, not syscall-bound). Either way this touches the FEC egress contract, so it wants a short design pass before implementation — not a solo constant change. Holding here rather than shipping a subtly FEC-defeating coalescer. **Reminder:** none of this addresses the RTT regression (separate: SQPOLL + per-packet malloc).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
femboy/yip#17
No description provided.