diff --git a/issues/no-published-run-compares-toyos-with-linux-on-one-machine.md b/issues/no-published-run-compares-toyos-with-linux-on-one-machine.md index 723a6c4f1c..aca1cff15e 100644 --- a/issues/no-published-run-compares-toyos-with-linux-on-one-machine.md +++ b/issues/no-published-run-compares-toyos-with-linux-on-one-machine.md @@ -47,7 +47,8 @@ test depends on the public internet. The first paired session (`ToyOSOrg/evidence` branch `pr-1065`, `r3/`) put ToyOS's T14 LAN download and 64 B round trip at Linux's and its upload at -a third of it (`issues/netstack-sends-tcp-at-a-third-of-what-the-t14s-wired-link-carries.md`). +a third of it (`issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md`, +where what remains of that is now recorded). The cryptographic rows are not osbench's. AES-GCM, ChaCha20-Poly1305, SHA-256 and the rest were measured under Linux on both benches against ring, diff --git a/issues/netstack-sends-tcp-at-a-third-of-what-the-t14s-wired-link-carries.md b/issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md similarity index 75% rename from issues/netstack-sends-tcp-at-a-third-of-what-the-t14s-wired-link-carries.md rename to issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md index 644a205601..ef047e352f 100644 --- a/issues/netstack-sends-tcp-at-a-third-of-what-the-t14s-wired-link-carries.md +++ b/issues/tcp-opens-a-connections-window-slower-than-linux-in-its-first-300-ms.md @@ -4,12 +4,17 @@ kind: defect opened: 2026-10-24 --- -# netstack sends TCP at a third of what the T14's wired link carries +# [tcp] opens a connection's window slower than Linux in its first 300 ms -On the T14's I219, a ToyOS TCP sender moves 41 MB/s where the same -program under the bench's Linux moves 113 MB/s on the same path and the -same peer. Received TCP is at parity on that path, so the link, the peer -and the access point are not the limit. +On the T14's wired path to the AMD bench's Wi-Fi peer, a ToyOS TCP sender +delivers 2.1–3.3 MB in a new connection's first 100 ms and 24–25 MB by +300 ms, where the bench's Linux delivers 6.4–6.5 MB and 29.7 MB. From then on +both run at line rate. The upload row reads 112 MB/s against Linux's 113 in +the same session (#1102's A/B, at the end of this record). Received TCP is at +parity on that path. + +This record began as the upload at a third of Linux's (41 MB/s against 113). +What follows is that history, the causes found and removed, and the residual. **Measured** on 2026-10-24 with `osbench lan` at `d99ef5a35`. The path is the T14's I219 at 1000 Mb/s, through the access point, to the AMD bench's @@ -188,12 +193,58 @@ What is left, about 1.3 times short of Linux with the ring full about half of each row whatever the descriptor fetch does, is the next lead: what the part waits on for each frame once it holds it. +**The part keeps line rate while it holds frames; the gap is each +connection's first second, read 2026-10-28** ([evidence `d1ea41856/`](https://github.com/ToyOSOrg/evidence/tree/9f309c28654d89c37e8fb0defc9fd2b1c61cc91b/r1/d1ea41856)). +On the full ring the I219 sent 83,600 frames a second, line rate. One session +read what the peer had received after its first byte, a mean over each row's +five connections, at `d1ea41856` (A) and A with a card-held stop counted as +cwnd's (B), two of each, with Linux at both ends: + +| received by | 100 ms | 300 ms | 1 s | 2 s | upload p50 | +|---|---|---|---|---|---| +| Linux (2 runs) | 5.9–6.4 MB | 27.9–29.0 MB | 109–110 MB | 224–225 MB | 113, 113 | +| A (2 runs) | 0.32 MB | 2.0–2.1 MB | 38–41 MB | 149–153 MB | 88, 89 | +| B (2 runs) | 0.32–0.35 MB | 2.1–2.3 MB | 46–47 MB | 162–163 MB | 93, 92 | + +- From 1 s to 2 s ToyOS carried 112–117 MB, Linux 115. Nothing was lost. +- The peer acknowledged about 50 KB, 34 segments, an ACK. Each ACK that grew + cwnd grew it by one SMSS (RFC 5681 §3.1's min(N, SMSS)), so slow start grew + a few percent a round trip instead of doubling. +- Under A, 75–76 % of ACKs grew nothing: the card's room had ended the + opportunity before them with more than a segment of cwnd left. + +Pull request #1102 replaces the initial slow start with HyStart++ +(RFC 9406, which RFC 9438 §4.10 recommends for CUBIC), whose L = 8 for a +sender that does not pace caps the growth an ACK gives at eight segments, and +counts a card-held window as in use when at least half of it is in flight +(RFC 7661 §4.3's validated window, which RFC 9438 §4.6 admits). + +**#1102's A/B, 2026-10-28** ([evidence](https://github.com/ToyOSOrg/evidence/tree/8f70147d28011645de1fd458f3091297287b57b9/r2/ab)). A was the fix +reverted, B the fix; three of each, A B A B A B, with Linux at both ends: + +| | upload p50, MB/s | received by 100 ms | 300 ms | 1 s | 2 s | +|---|---|---|---|---|---| +| Linux (2 runs) | 113, 113 | 6.4–6.5 MB | 29.7 MB | 110–111 MB | 226–227 MB | +| A (3 runs) | 87, 87, 87 | 0.33–0.34 MB | 2.0–2.1 MB | 34–38 MB | 144–149 MB | +| B (3 runs) | 112, 112, 112 | 2.1–3.3 MB | 23.9–25.4 MB | 106–107 MB | 222–223 MB | + +- B was faster in every pair: one-sided p = 1/20. No segment was resent in + any B run, and no loss probe was sent. +- B's rounds ran 111–115 MB/s. Linux's 113 in this session is above them, + inside Linux's 109–114 across this record's sessions. +- What is left is the first 300 ms. In every B row HyStart++ entered + Conservative Slow Start 590–702 times, about 120–140 a connection, and + resumed every time, never exiting. CSS grows by a quarter while it lasts. + That this oscillation is the residual is an inference: no run separates it. + The clock does not separate the two systems. Each upload row's own readings, `mhz` just after it and `mhz_settled` before it, were about 1.6 GHz under ToyOS (settled 1.6, 1.8 or 3.8 GHz). Under Linux they were 1.5 GHz (settled 1.5 or 4.2 GHz), and Linux still moved 113 MB/s. At most, the clock matters through what ToyOS spends per byte sent. -**Exit**: `osbench_lan` on the T14 reads `lan.wired.tcp.upload` within -Linux's run-to-run range on the same path, or the cause is named and owned -by its own issue. +**Exit**: `osbench_lan` on the T14 reads `lan.wired.tcp.upload` at Linux's +in the same session, with the first 300 ms read at the receiver no slower, or +the cause is named and owned by its own issue. The next leads are the CSS +oscillation on this path (RFC 9406 §4.3 lets an implementation tune +`MIN_RTT_THRESH` and `N_RTT_SAMPLE`) and pacing with L = ∞ (§4.3). diff --git a/issues/tcp-samples-rtt-at-a-millisecond.md b/issues/tcp-samples-rtt-at-a-millisecond.md new file mode 100644 index 0000000000..1b260165be --- /dev/null +++ b/issues/tcp-samples-rtt-at-a-millisecond.md @@ -0,0 +1,36 @@ +--- +status: open +kind: defect +opened: 2026-10-28 +--- + +# [tcp] samples the round trip at a millisecond, and without timestamps once a round trip + +With timestamps negotiated, [tcp] takes every RTT sample from the echoed +TSval (`Ts::echo_rtt`, `system/netstack/shard/tcp/src/conn.rs`), and TSval +ticks once a millisecond (`tsval`, `src/lib.rs`). That tick is the finest RFC +7323 §5.4 permits ("1 ms to 1 sec per tick"), so the tick is not what to +change. But a LAN round trip of 1-3 ms reads as 1, 2 or 3 ms. + +- On the T14's wired path, srtt read 4-7 ms at cwnd stops and the base round + trip is about 2 ms. HyStart++'s delay exit (RFC 9406, #1102) compares round + minima against a threshold of at least 4 ms, so a 1 ms quantum is a quarter + of it. +- RFC 6298's timer adds G = 1 ms (`rtt.rs`), so RTO is unaffected below its + 200 ms floor. + +**Without timestamps, HyStart++'s delay exit is inert.** [tcp] then times one +segment per round trip (Karn's single timer, `timing` in `conn.rs`). A round +never reaches RFC 9406's `N_RTT_SAMPLE` of 8, so the initial slow start grows +with L = 8 until a loss. L = 8 is §4.3's value inside HyStart++, whose delay +exit bounds the overshoot, and §4.3 recommends 8 samples a round. ToyOS offers +timestamps in every SYN, so this needs a peer that declines them. + +Nothing has measured an effect. On a 2 ms path a sample can be off by up to +half the round trip. + +**Exit**: samples at the stack's clock resolution, one an ACK, with +timestamps and without. One way is RFC 8985 §6.1's per-segment transmission +time, keyed by sequence and read on the ACK that newly acknowledges a segment +never retransmitted. Timestamps would then only disambiguate retransmissions +and drive PAWS. Host-test the sample against a known delay below 1 ms. diff --git a/system/netstack/shard/tcp/src/cc.rs b/system/netstack/shard/tcp/src/cc.rs index 094029b6a3..2012b79558 100644 --- a/system/netstack/shard/tcp/src/cc.rs +++ b/system/netstack/shard/tcp/src/cc.rs @@ -1,11 +1,12 @@ -//! The congestion window: slow start with appropriate byte counting (RFC 5681 §3.1), CUBIC in -//! avoidance (RFC 9438), and the reductions a loss or a timeout makes. Growth happens only when -//! the window, not the application or the peer, stopped the sender last (RFC 9438 §5.8), and CUBIC's -//! clock skips the time that was so limited (§4.2). The cube is computed in fixed point: no float, -//! no `libm`. +//! The congestion window: HyStart++ in the initial slow start (RFC 9406, as RFC 9438 §4.10 +//! recommends) and RFC 5681 §3.1's slow start after it, CUBIC in avoidance (RFC 9438), and the +//! reductions a loss or a timeout makes. The window grows only while [`Cc::limited`] says it is in +//! use, and CUBIC's clock skips the time it was not (§4.2). The cube is computed in fixed point: no +//! float, no `libm`. use core::time::Duration; +use crate::seq::Seq; use crate::Instant; /// RFC 5681 §3.1: "arbitrarily high", the largest window a scaled peer can offer. @@ -18,6 +19,15 @@ const CUBE_DENOMINATOR: i128 = 5_000_000_000_000_000_000; /// The farthest from `K` the cubic is evaluated, so the cube stays inside `i128`. const CUBE_SPAN_US: i128 = 1 << 34; +/// RFC 9406 §4.3's constants. L is the non-paced value: this sender does not pace. +const L: u32 = 8; +const MIN_RTT_THRESH: Duration = Duration::from_millis(4); +const MAX_RTT_THRESH: Duration = Duration::from_millis(16); +const MIN_RTT_DIVISOR: u32 = 8; +const N_RTT_SAMPLE: u32 = 8; +const CSS_GROWTH_DIVISOR: u32 = 4; +const CSS_ROUNDS: u32 = 5; + /// RFC 6928 §2. pub fn initial_window(smss: u32) -> u32 { smss.saturating_mul(10).min(smss.saturating_mul(2).max(14_600)) @@ -42,6 +52,110 @@ fn cube_root(x: u128) -> u64 { low } +/// An ACK outside loss recovery: `bytes` newly acknowledged, SND.UNA after it and SND.NXT, and +/// the RTT sample it gave, if any. +#[derive(Clone, Copy, Debug)] +pub struct Ack { + pub now: Instant, + pub bytes: u32, + pub una: Seq, + pub nxt: Seq, + pub rtt: Option, + pub srtt: Option, +} + +/// A HyStart++ transition an ACK made. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SlowStart { + /// A round's minimum RTT rose past the threshold: Conservative Slow Start. The check runs on + /// every ACK of a round once it has its samples (RFC 9406 §4.2), so a round can enter more than + /// once; `first` is its first entry. + Conservative { first: bool }, + /// A CSS round's minimum fell below its baseline: the exit was spurious. + Resumed, + /// CSS ran its rounds: ssthresh is cwnd, and avoidance follows. + Ended, +} + +/// RFC 9406 §4.2's round and delay state. `None` is the RFC's infinity. +#[derive(Clone, Copy, Debug, Default)] +struct HyStart { + /// windowEnd: the round ends when it is acknowledged. + window_end: Option, + last_round_min: Option, + current_round_min: Option, + samples: u32, + css: Option, + css_this_round: bool, +} + +/// Conservative Slow Start: the baseline it compares against and the rounds it has begun. +#[derive(Clone, Copy, Debug)] +struct Css { + baseline: Duration, + rounds: u32, +} + +/// RttThresh: how far a round's minimum RTT must rise over the last round's to leave slow start. +fn rtt_thresh(last_round_min: Duration) -> Duration { + last_round_min.checked_div(MIN_RTT_DIVISOR).unwrap_or_default().clamp(MIN_RTT_THRESH, MAX_RTT_THRESH) +} + +impl HyStart { + /// What the ACK grows cwnd by when the window was in use, of `bytes` newly acknowledged. + fn growth(&self, bytes: u32, smss: u32) -> u32 { + let capped = bytes.min(smss.saturating_mul(L)); + if self.css.is_some() { capped / CSS_GROWTH_DIVISOR } else { capped } + } + + /// The ACK's RTT sample and round, after its growth. + fn observe(&mut self, ack: &Ack) -> Option { + if let Some(rtt) = ack.rtt { + self.current_round_min = Some(self.current_round_min.map_or(rtt, |min| min.min(rtt))); + self.samples = self.samples.saturating_add(1); + } + let mut change = None; + if let (true, Some(current)) = (self.samples >= N_RTT_SAMPLE, self.current_round_min) { + match self.css { + None => { + if let Some(last) = self.last_round_min { + if current >= last.saturating_add(rtt_thresh(last)) { + // The round the exit happens in is CSS's first. + self.css = Some(Css { baseline: current, rounds: 1 }); + change = Some(SlowStart::Conservative { first: !core::mem::replace(&mut self.css_this_round, true) }); + } + } + } + Some(css) if current < css.baseline => { + self.css = None; + change = Some(SlowStart::Resumed); + } + Some(_) => {} + } + } + match self.window_end { + Some(end) if ack.una.at_or_after(end) => {} + Some(_) => return change, + None => { + self.window_end = Some(ack.nxt); + return change; + } + } + self.window_end = Some(ack.nxt); + self.last_round_min = self.current_round_min.take(); + self.samples = 0; + self.css_this_round = false; + match self.css.as_mut() { + Some(css) if css.rounds >= CSS_ROUNDS => Some(SlowStart::Ended), + Some(css) => { + css.rounds = css.rounds.saturating_add(1); + change + } + None => change, + } + } +} + #[derive(Clone, Copy, Debug)] struct Epoch { start: Instant, @@ -65,10 +179,9 @@ pub struct Cc { pub cwnd: u32, pub ssthresh: u32, pub smss: u32, - /// cwnd limited the end of the sender's last transmit opportunity: its last segment left the - /// window full, or it stopped with data waiting and the peer's window open. Every ACK until - /// the next opportunity grows the window (RFC 5681 §3.1 counts each), however many one pass - /// brings. + /// The window is in use: cwnd stopped the sender's last transmit opportunity with data + /// waiting, or the caller's credit ended it with at least half of cwnd in flight (`hand_off`). + /// Every ACK until the next opportunity grows the window, however many one pass brings. pub limited: bool, w_max: Option, /// RFC 9438 §4.3's cwnd_prior: cwnd when ssthresh was last set, and before that the initial @@ -77,6 +190,8 @@ pub struct Cc { after_timeout: bool, epoch: Option, last_ack: Option, + /// Runs while ssthresh is still [`SSTHRESH_INITIAL`] (RFC 9406 §4.3). + hystart: HyStart, } impl Cc { @@ -92,18 +207,31 @@ impl Cc { after_timeout: false, epoch: None, last_ack: None, + hystart: HyStart::default(), } } - /// An ACK that newly acknowledges `acked` bytes outside loss recovery. - pub fn on_ack(&mut self, now: Instant, acked: u32, srtt: Option) { + pub fn on_ack(&mut self, ack: &Ack) -> Option { + let Ack { now, bytes: acked, srtt, .. } = *ack; let grow = self.limited; let since_last = self.last_ack.replace(now).map_or(Duration::ZERO, |last| now.since(last)); + if self.ssthresh == SSTHRESH_INITIAL { + if grow { + self.cwnd = self.cwnd.saturating_add(self.hystart.growth(acked, self.smss)); + } + let change = self.hystart.observe(ack); + if change == Some(SlowStart::Ended) { + // RFC 9438 §4.10: avoidance starts with K = 0 and W_max = cwnd_prior = cwnd. + self.ssthresh = self.cwnd; + self.prior = self.cwnd; + } + return change; + } if self.cwnd < self.ssthresh { if grow { self.cwnd = self.cwnd.saturating_add(acked.min(self.smss)).min(self.ssthresh); } - return; + return None; } let smss = self.smss; let cwnd = self.cwnd; @@ -121,7 +249,7 @@ impl Cc { self.after_timeout = false; if !grow { epoch.start = epoch.start.after(since_last).min(now); - return; + return None; } let t = us(now.since(epoch.start)); let w_t = epoch.cubic(smss, t); @@ -140,6 +268,7 @@ impl Cc { cwnd64.saturating_add(whole) }; self.cwnd = u32::try_from(cwnd).unwrap_or(u32::MAX); + None } /// Entering fast or SACK recovery (RFC 9438 §4.6, §4.7). `flight` excludes Limited Transmit. @@ -198,6 +327,10 @@ mod tests { Instant::from_millis(ms) } + fn ack(now: Instant, bytes: u32, srtt: Option) -> Ack { + Ack { now, bytes, una: Seq::new(0), nxt: Seq::new(0), rtt: None, srtt } + } + fn avoidance(cwnd: u32, w_max: u32) -> Cc { let mut cc = Cc::new(1460, 0); cc.cwnd = cwnd; @@ -212,11 +345,11 @@ mod tests { cc.cwnd = 13_140; cc.ssthresh = 14_000; cc.limited = true; - cc.on_ack(at(0), 2920, None); + cc.on_ack(&ack(at(0), 2920, None)); assert_eq!(cc.cwnd, 14_000); assert!(cc.epoch.is_none()); cc.limited = true; - cc.on_ack(at(10), 1460, None); + cc.on_ack(&ack(at(10), 1460, None)); assert!(cc.epoch.is_some(), "the next ACK is avoidance"); } @@ -229,11 +362,11 @@ mod tests { while cc.cwnd < cc.ssthresh { t += 1; cc.limited = true; - cc.on_ack(at(t), 1460, None); + cc.on_ack(&ack(at(t), 1460, None)); } assert_eq!(cc.cwnd, 10_220); cc.limited = true; - cc.on_ack(at(t + 1), 1460, None); + cc.on_ack(&ack(at(t + 1), 1460, None)); let epoch = cc.epoch.unwrap(); assert_eq!((epoch.k_us, epoch.w_max), (0, 10_220)); } @@ -264,7 +397,7 @@ mod tests { fn s_cc_009_cubic_values() { let mut cc = avoidance(102_200, 146_000); cc.prior = 146_000; - cc.on_ack(at(0), 0, None); + cc.on_ack(&ack(at(0), 0, None)); let epoch = cc.epoch.unwrap(); assert!(epoch.k_us.abs_diff(4_217_200) <= 1_000, "K = {} µs", epoch.k_us); for (t, want) in [(0u64, 102_200f64), (1_000_000, 126_554.0), (2_000_000, 139_635.0), (epoch.k_us, 146_000.0), (5_000_000, 146_280.0)] { @@ -280,21 +413,21 @@ mod tests { fn s_cc_011_reno_friendly_region() { let mut cc = Cc::new(1460, 0); let mut t = 0; - let ack = |cc: &mut Cc, t: &mut u64, step: u64, srtt: Option| { + let segment = |cc: &mut Cc, t: &mut u64, step: u64, srtt: Option| { *t += step; cc.limited = true; - cc.on_ack(at(*t), 1460, srtt); + cc.on_ack(&ack(at(*t), 1460, srtt)); }; while cc.cwnd < 21_900 { - ack(&mut cc, &mut t, 1, None); + segment(&mut cc, &mut t, 1, None); } cc.on_timeout(20_858, false); assert_eq!((cc.ssthresh, cc.prior), (14_600, 21_900)); while cc.cwnd < cc.ssthresh { - ack(&mut cc, &mut t, 1, None); + segment(&mut cc, &mut t, 1, None); } for _ in 0..10 { - ack(&mut cc, &mut t, 5, Some(Duration::from_millis(5))); + segment(&mut cc, &mut t, 5, Some(Duration::from_millis(5))); } let epoch = cc.epoch.unwrap(); assert_eq!((epoch.k_us, epoch.w_max), (0, 14_600)); @@ -304,13 +437,13 @@ mod tests { #[test] fn s_cc_013_application_limited_time_is_skipped() { let mut cc = avoidance(102_200, 146_000); - cc.on_ack(at(0), 0, None); + cc.on_ack(&ack(at(0), 0, None)); for ms in (100..=2_000).step_by(100) { - cc.on_ack(at(ms), 1460, None); + cc.on_ack(&ack(at(ms), 1460, None)); } let start = cc.epoch.unwrap().start; cc.limited = true; - cc.on_ack(at(3_000), 1460, None); + cc.on_ack(&ack(at(3_000), 1460, None)); assert_eq!(at(3_000).since(start), Duration::from_secs(1)); } @@ -329,7 +462,7 @@ mod tests { let mut cc = avoidance(cwnd, w_max); let srtt = Duration::from_millis(1 + next(200)); let mut now = 0; - cc.on_ack(at(now), 0, Some(srtt)); + cc.on_ack(&ack(at(now), 0, Some(srtt))); for _ in 0..12 { let round_start = cc.cwnd; let mut acked = 0u32; @@ -341,7 +474,7 @@ mod tests { let t = us(at(now).since(epoch.start)); let target = epoch.cubic(1460, t + us(srtt)).clamp(u64::from(before), u64::from(before) * 3 / 2); cc.limited = next(4) != 0; - cc.on_ack(at(now), n, Some(srtt)); + cc.on_ack(&ack(at(now), n, Some(srtt))); let reno = cc.epoch.unwrap().estimate >> FRACTION; assert!(u64::from(cc.cwnd) <= target.max(reno), "cwnd {} above target {target}", cc.cwnd); acked += n; @@ -350,4 +483,120 @@ mod tests { } } } + + /// A sender keeping eight segments in flight, each ACK one segment with its RTT: after the + /// first ACK, which starts the first round, every eighth ACK ends one. + struct Rounds { + cc: Cc, + una: u32, + t: u64, + flight: u32, + } + + impl Rounds { + fn new() -> Self { + let mut r = Self { cc: Cc::new(1460, 0), una: 0, t: 0, flight: 8 }; + assert_eq!(r.ack(Some(10)), None); + r + } + + fn ack(&mut self, rtt_ms: Option) -> Option { + self.una += 1460; + self.t += 1; + self.cc.limited = true; + let rtt = rtt_ms.map(Duration::from_millis); + self.cc.on_ack(&Ack { now: at(self.t), bytes: 1460, una: Seq::new(self.una), nxt: Seq::new(self.una + self.flight * 1460), rtt, srtt: rtt }) + } + + /// A round of eight ACKs at `rtt_ms`: the transitions it made, by ACK. + fn round(&mut self, rtt_ms: u64) -> Vec<(usize, SlowStart)> { + self.acks(&[rtt_ms; 8]) + } + + fn acks(&mut self, rtts_ms: &[u64]) -> Vec<(usize, SlowStart)> { + rtts_ms.iter().enumerate().filter_map(|(i, &rtt)| self.ack(Some(rtt)).map(|c| (i, c))).collect() + } + } + + #[test] + fn s_cc_019_rtt_thresh_is_an_eighth_clamped_to_4_and_16_ms() { + let ms = Duration::from_millis; + assert_eq!(rtt_thresh(ms(10)), ms(4)); + assert_eq!(rtt_thresh(ms(64)), ms(8)); + assert_eq!(rtt_thresh(ms(200)), ms(16)); + } + + /// RFC 9406 §4.2: the eighth sample of a round whose minimum rose by RttThresh starts CSS, + /// whose growth is a quarter; CSS's fifth round, counting the one it began in, ends slow start + /// with ssthresh at cwnd, and avoidance starts from it (RFC 9438 §4.10). + #[test] + fn s_cc_020_a_rising_round_trip_ends_slow_start_after_css() { + let mut r = Rounds::new(); + assert_eq!(r.round(10), []); + assert_eq!(r.round(13), [], "under 10 ms + 4 ms"); + for _ in 0..7 { + assert_eq!(r.ack(Some(17)), None, "fewer than eight samples"); + } + assert_eq!(r.ack(Some(17)), Some(SlowStart::Conservative { first: true }), "and the round ends: CSS's first"); + let before = r.cc.cwnd; + assert_eq!(r.ack(Some(17)), None); + assert_eq!(r.cc.cwnd - before, 365, "CSS grows by a quarter"); + for _ in 0..7 { + assert_eq!(r.ack(Some(17)), None); + } + for round in 3..5 { + assert_eq!(r.round(17), [], "CSS round {round}"); + } + assert_eq!(r.cc.ssthresh, SSTHRESH_INITIAL); + assert_eq!(r.round(17), [(7, SlowStart::Ended)]); + assert_eq!((r.cc.ssthresh, r.cc.prior), (r.cc.cwnd, r.cc.cwnd)); + let cwnd = r.cc.cwnd; + r.ack(Some(17)); + let epoch = r.cc.epoch.unwrap(); + assert_eq!((epoch.k_us, epoch.w_max), (0, cwnd)); + } + + /// RFC 9406 §4.2: a CSS round whose minimum falls under the baseline resumes slow start. + #[test] + fn s_cc_021_a_round_trip_back_under_the_baseline_resumes_slow_start() { + let mut r = Rounds::new(); + assert_eq!(r.round(10), []); + assert_eq!(r.round(20), [(7, SlowStart::Conservative { first: true })]); + assert_eq!(r.round(19), [(7, SlowStart::Resumed)]); + let before = r.cc.cwnd; + r.ack(Some(19)); + assert_eq!(r.cc.cwnd - before, 1460); + assert_eq!(r.cc.ssthresh, SSTHRESH_INITIAL); + } + + /// RFC 9406 runs only in the initial slow start: after a loss, slow start is RFC 5681 §3.1's, + /// one SMSS per ACK at most. + #[test] + fn s_cc_022_l_applies_only_to_the_initial_slow_start() { + let mut cc = Cc::new(1460, 0); + cc.limited = true; + cc.on_ack(&ack(at(0), 20_000, None)); + assert_eq!(cc.cwnd, 14_600 + 8 * 1460); + cc.on_timeout(cc.cwnd, false); + cc.limited = true; + cc.on_ack(&ack(at(1), 20_000, None)); + assert_eq!(cc.cwnd, 2 * 1460); + } + + /// RFC 9406 §4.2's checks on every ACK: in a round whose minimum keeps falling while it stays + /// past the threshold, each lower sample resumes slow start and the next re-enters CSS from it. + /// One round, four entries, one of them the round's first; a later round's entry is its own first. + #[test] + fn s_cc_023_a_falling_minimum_flaps_within_a_round() { + let mut r = Rounds::new(); + assert_eq!(r.round(2), []); + r.flight = 16; + assert_eq!(r.round(2), [], "the last round before the rise, at 2 ms"); + let entry = |first| SlowStart::Conservative { first }; + let flaps = r.acks(&[9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 7, 7, 6, 6, 6, 6]); + let want = [(7, entry(true)), (8, SlowStart::Resumed), (9, entry(false)), (10, SlowStart::Resumed), (11, entry(false)), (12, SlowStart::Resumed), (13, entry(false))]; + assert_eq!(flaps, want); + assert_eq!(r.acks(&[5; 16]), [(7, SlowStart::Resumed)]); + assert_eq!(r.acks(&[12; 16]), [(7, entry(true))], "the next round's first entry"); + } } diff --git a/system/netstack/shard/tcp/src/conn.rs b/system/netstack/shard/tcp/src/conn.rs index ac53d27707..7bc214618a 100644 --- a/system/netstack/shard/tcp/src/conn.rs +++ b/system/netstack/shard/tcp/src/conn.rs @@ -8,7 +8,7 @@ use core::time::Duration; use toyos_net_wire::tcp::{SackBlock, SynOptions, TcpFlags, TcpOptions, TcpSegment, Timestamps}; -use crate::cc::Cc; +use crate::cc::{self, Cc, SlowStart}; use crate::counters::{Counter, Log}; use crate::ring::{Lent, Ring}; use crate::rtt::{Rtt, RTO_AFTER_HANDSHAKE_LOSS, RTO_MAX}; @@ -744,7 +744,7 @@ impl Sync { self.tx.trim_scoreboard(); self.rtx_next = self.rtx_next.map(|p| p.later(ack)).filter(|p| p.before(self.tx.nxt)); self.urgent = self.urgent.filter(|u| u.at_or_after(ack)); - self.sample(seg, ack, flight, ctx); + let rtt = self.sample(seg, ack, flight, ctx); self.progress_since = Some(now); self.acked_since = Some(now); self.stalled = 0; @@ -770,7 +770,25 @@ impl Sync { self.cc.end_recovery(); } Recovery::Sack { .. } => {} - Recovery::None => self.cc.on_ack(now, bytes, self.rtt.srtt()), + Recovery::None => { + ctx.log.count(if self.cc.limited { Counter::AcksCwndLimited } else { Counter::AcksNotLimited }); + let before = self.cc.cwnd; + match self.cc.on_ack(&cc::Ack { now, bytes, una: ack, nxt: self.tx.nxt, rtt, srtt: self.rtt.srtt() }) { + Some(SlowStart::Conservative { first }) => { + ctx.log.count(Counter::HyStartCss); + if first { + ctx.log.count(Counter::HyStartCssRounds); + } + } + Some(SlowStart::Ended) => { + ctx.log.count(Counter::HyStartExits); + ctx.log.add(Counter::HyStartExitCwndBytes, u64::from(self.cc.cwnd)); + } + Some(SlowStart::Resumed) => ctx.log.count(Counter::HyStartResumed), + None => {} + } + ctx.log.add(Counter::CwndGrownBytes, u64::from(self.cc.cwnd.saturating_sub(before))); + } } self.dupacks = 0; self.lt_budget = 0; @@ -813,28 +831,26 @@ impl Sync { } } - fn sample(&mut self, seg: &In<'_>, ack: Seq, flight: u32, ctx: &mut Ctx<'_>) { + fn sample(&mut self, seg: &In<'_>, ack: Seq, flight: u32, ctx: &mut Ctx<'_>) -> Option { let now = ctx.now; - match self.ts { + let (rtt, weight) = match self.ts { Some(ts) => { - let Some(t) = seg.options.timestamps() else { return }; - match ts.echo_rtt(t.echo, now) { - Some(rtt) => { - let expected = flight.div_ceil(self.smss().saturating_mul(2).max(1)).max(1); - self.rtt.sample(rtt, expected); - self.sampled = true; - } - None => ctx.log.count(Counter::TsEcrInvalid), - } + let t = seg.options.timestamps()?; + let Some(rtt) = ts.echo_rtt(t.echo, now) else { + ctx.log.count(Counter::TsEcrInvalid); + return None; + }; + (rtt, flight.div_ceil(self.smss().saturating_mul(2).max(1)).max(1)) } None => { - if let Some((_, at)) = self.timing.filter(|&(end, _)| ack.at_or_after(end)) { - self.timing = None; - self.rtt.sample(now.since(at), 1); - self.sampled = true; - } + let (_, at) = self.timing.filter(|&(end, _)| ack.at_or_after(end))?; + self.timing = None; + (now.since(at), 1) } - } + }; + self.rtt.sample(rtt, weight); + self.sampled = true; + Some(rtt) } /// A duplicate acknowledgment: RFC 5681 §2's without SACK, RFC 6675 §2's with it. @@ -1278,9 +1294,11 @@ impl Sync { self.sws = None; self.sws_fired = false; // Until the next stop says otherwise: an opportunity that ends on the caller's credit - // ends here, and cwnd limited it only if this left it full. - let in_flight = self.in_flight(); - self.cc.limited = self.cwnd().saturating_sub(in_flight) < self.smss(); + // ends here. Below cwnd, RFC 9438 §5.8 calls the flow application limited; at half of + // cwnd in flight it grows anyway, as RFC 7661 §4.3's validated window, which §4.6 + // admits. cwnd then stays within twice the flight, and a loss sets ssthresh from the + // flight (§4.6). + self.cc.limited = self.in_flight() >= self.cwnd() / 2; } let data_end = start.add(len); if data_end.after(old) { diff --git a/system/netstack/shard/tcp/src/counters.rs b/system/netstack/shard/tcp/src/counters.rs index 538eea7231..247016f73b 100644 --- a/system/netstack/shard/tcp/src/counters.rs +++ b/system/netstack/shard/tcp/src/counters.rs @@ -76,6 +76,14 @@ toyos_net_wire::counters! { CongestionStops = "tcp.congestion-stops"; CongestionStopsCwndBytes = "tcp.congestion-stops.cwnd.bytes"; CongestionStopsSrttMicros = "tcp.congestion-stops.srtt.us"; + AcksCwndLimited = "tcp.acks.cwnd-limited"; + AcksNotLimited = "tcp.acks.not-limited"; + CwndGrownBytes = "tcp.cwnd.grown.bytes"; + HyStartCss = "tcp.hystart.css"; + HyStartCssRounds = "tcp.hystart.css.rounds"; + HyStartResumed = "tcp.hystart.resumed"; + HyStartExits = "tcp.hystart.exits"; + HyStartExitCwndBytes = "tcp.hystart.exit.cwnd.bytes"; EventOverflow = "tcp.event-overflow"; } diff --git a/system/netstack/shard/tcp/tests/congestion.rs b/system/netstack/shard/tcp/tests/congestion.rs index 30b4feb37f..c381f4def6 100644 --- a/system/netstack/shard/tcp/tests/congestion.rs +++ b/system/netstack/shard/tcp/tests/congestion.rs @@ -42,13 +42,14 @@ fn s_cc_004_one_segment_after_two_syn_retransmissions() { expect(&h.send(3010, 30_000), &["LEN=1460"]); } +/// RFC 9406 §4.2: an ACK of two segments grows the initial slow start by both. #[test] fn s_cc_005_slow_start() { let mut h = fixture_e(); h.send(0, 30_000); let outs = h.input(10, seg(5001).ack(3921)); - assert_eq!(h.info().cwnd, 16_060); - expect(&outs, &["SEQ=15601", "SEQ=17061", "SEQ=18521"]); + assert_eq!(h.info().cwnd, 17_520); + expect(&outs, &["SEQ=15601", "SEQ=17061", "SEQ=18521", "SEQ=19981"]); } #[test] @@ -105,17 +106,32 @@ fn s_cc_016_acks_in_one_pass_each_grow_slow_start() { assert_eq!((one_at_a_time, h.info().cwnd), (29_200, 29_200)); } -/// RFC 9438 §5.8, RFC 7661: a transmit opportunity that ends on the caller's credit with cwnd room -/// left is not cwnd's stop, and the ACKs after it grow nothing. +/// RFC 7661 §4.3, which RFC 9438 §4.6 admits: an opportunity the caller's credit ends with half +/// of cwnd in flight leaves the window in use, and the ACK after it grows cwnd; a segment under +/// half, it is application limited (§5.8), and grows nothing. #[test] -fn s_cc_017_a_sender_the_card_holds_grows_no_window() { +fn s_cc_017_a_sender_the_card_holds_grows_a_window_half_in_flight() { + for (credit, grown) in [(9, 1460), (8, 0)] { + let mut h = fixture_e(); + assert_eq!(h.send(0, 60_000).len(), 10, "a stop at the initial window"); + h.credit = Some(credit); + assert_eq!(h.deliver(seg(5001).ack(15_601)).len(), credit); + let cwnd = h.info().cwnd; + assert_eq!(cwnd, 26_280, "the ACK after cwnd's stop grows it"); + h.credit = Some(0); + h.deliver(seg(5001).ack(17_061)); + assert_eq!(h.info().cwnd - cwnd, grown, "{credit} of the half's 9 segments in flight"); + } +} + +/// RFC 9406 §4.2 with §4.3's L = 8 for a sender that does not pace: an ACK of the whole flight +/// grows the initial slow start by eight segments, where RFC 5681 §3.1 grows it by one. +#[test] +fn s_cc_018_a_stretch_ack_grows_the_initial_slow_start_by_eight_segments() { let mut h = fixture_e(); - assert_eq!(h.send(0, 60_000).len(), 10, "a stop at the initial window"); - h.credit = Some(1); - h.input(10, seg(5001).ack(3921)); - assert_eq!(h.info().cwnd, 16_060, "the ACK after cwnd's stop grows it"); - // Three segments' room, one segment's credit: the opportunity ended on the card. - h.credit = Some(1); - assert_eq!(h.input(11, seg(5001).ack(5381)).len(), 1); - assert_eq!(h.info().cwnd, 16_060, "a sender the card held grew its window"); + assert_eq!(h.send(0, 60_000).len(), 10); + assert_eq!(h.input(10, seg(5001).ack(15_601)).len(), 18); + assert_eq!(h.info().cwnd, 26_280); + h.input(20, seg(5001).ack(41_881)); + assert_eq!(h.info().cwnd, 37_960); } diff --git a/system/netstack/shard/tcp/tests/net.rs b/system/netstack/shard/tcp/tests/net.rs index 61e5ff4a52..eab404b0a3 100644 --- a/system/netstack/shard/tcp/tests/net.rs +++ b/system/netstack/shard/tcp/tests/net.rs @@ -427,10 +427,10 @@ fn syn_without_timestamps(from: usize, o: &O) -> Option> { /// The receive buffer grows to twice what its reader takes per round trip, by the receiver's own /// estimate of the round trip, with timestamps and without. The path's round trip quadruples once /// the handshake is done, so a downloader's SRTT, which only its own data samples, still says -/// 20 ms. The reader takes a burst every millisecond twice per 20 ms: it is never offered more -/// than twice its rate over an estimate at most half again the path's 80 ms, plus a burst and a -/// segment, and once the window has grown it is never kept waiting, which an estimate under the -/// path's would. +/// 20 ms. The writer starts after that, so its slow start sees one path and not a queue. The +/// reader takes a burst every millisecond twice per 20 ms: it is never offered more than twice its +/// rate over an estimate at most half again the path's 80 ms, plus a burst and a segment, and once +/// the window has grown it is never kept waiting, which an estimate under the path's would. #[test] fn the_receive_window_grows_by_what_is_read_per_round_trip() { const BURST: u64 = 40_000; @@ -444,8 +444,10 @@ fn the_receive_window_grows_by_what_is_read_per_round_trip() { net.rewrite = Some(Box::new(syn_without_timestamps)); } net.connections(1, 80, [64 * MIB, 0]); + net.apps[0].write_limit = Some(0); assert!(net.run(100, |n| n.apps.len() == 2)); net.delay = ns(40); + net.apps[0].write_limit = None; let reader = net.apps.iter().position(|a| a.node == 1).unwrap(); let handshake = net.nodes[1].tcp.info(net.apps[reader].id).unwrap().srtt; assert!(handshake < Some(std::time::Duration::from_millis(30))); diff --git a/system/netstack/shard/tcp/tests/send_limit.rs b/system/netstack/shard/tcp/tests/send_limit.rs index 3c1ef37963..dcd4ce4923 100644 --- a/system/netstack/shard/tcp/tests/send_limit.rs +++ b/system/netstack/shard/tcp/tests/send_limit.rs @@ -94,3 +94,16 @@ fn a_wait_that_ends_in_a_timeout_is_congestions() { assert_eq!(h.send(t + 11, 1460).len(), 1); assert_eq!(held(&h), [11 * MS, 0, fired * MS]); } + +/// Each ACK outside recovery is counted by whether cwnd ended the opportunity before it, with +/// what it grew cwnd by. +#[test] +fn each_ack_is_counted_with_what_it_acknowledged_and_grew() { + let mut h = fixture_e(); + assert_eq!(h.send(0, 16_060).len(), 10, "cwnd stops the sender with a segment waiting"); + assert_eq!(h.input(10, seg(5001).ack(3921)).len(), 1, "the user's data ends the next"); + h.input(20, seg(5001).ack(5381)); + assert_eq!(h.count(Counter::AcksCwndLimited), 1); + assert_eq!(h.count(Counter::AcksNotLimited), 1); + assert_eq!(h.count(Counter::CwndGrownBytes), 2920); +} diff --git a/tests/guest/osbench/lan/src/lib.rs b/tests/guest/osbench/lan/src/lib.rs index 666b7dad00..6bdf229ca2 100644 --- a/tests/guest/osbench/lan/src/lib.rs +++ b/tests/guest/osbench/lan/src/lib.rs @@ -8,7 +8,7 @@ //! //! Each TCP connection to the peer's [`PORT`] opens with one [`Request`]: //! echo fixed-size messages, sink everything to end-of-file and answer the -//! count, or source for a span and close. +//! count and its [`Ramp`], or source for a span and close. use std::io::{self, ErrorKind, Read, Write}; use std::net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream, UdpSocket}; @@ -23,8 +23,8 @@ pub const PORT: u16 = 47_321; /// What a bench broadcasts, and what the peer's answer starts with: the rest /// is its link's description, at most [`MAX_SAID`] bytes of printable ASCII. -pub const HELLO: &[u8] = b"osbench-lan/2 hello"; -pub const ANSWER: &[u8] = b"osbench-lan/2 peer "; +pub const HELLO: &[u8] = b"osbench-lan/3 hello"; +pub const ANSWER: &[u8] = b"osbench-lan/3 peer "; pub const MAX_SAID: usize = 160; /// How long either end waits on one read or write before it gives the @@ -43,7 +43,8 @@ pub const MAX_SOURCE_MS: u32 = 30_000; pub enum Request { /// Echo every `size`-byte message until end-of-file. Echo { size: u32 }, - /// Read to end-of-file, then answer the count as eight little-endian bytes. + /// Read to end-of-file, then answer the count and then the [`Ramp`], each + /// as eight little-endian bytes. Sink, /// Send for `millis`, then close. Source { millis: u32 }, @@ -145,11 +146,71 @@ pub fn discover(to: SocketAddr, patience: Duration) -> io::Result> Ok(None) } -/// A transfer's bytes and the bench's nanoseconds for them. +/// A transfer's bytes and the bench's nanoseconds for them, and for an +/// upload what the peer had received by each of [`RAMP_MARKS_MS`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Transfer { pub bytes: u64, pub ns: u64, + pub ramp: Option, +} + +/// The milliseconds after a sink's first byte at which it reads its count: how +/// fast the sender's slow start opened, read the same way for every sender. +pub const RAMP_MARKS_MS: [u64; 4] = [100, 300, 1000, 2000]; + +/// Bytes a sink had received by each of [`RAMP_MARKS_MS`] after its first byte. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Ramp(pub [u64; RAMP_MARKS_MS.len()]); + +impl Ramp { + const BYTES: usize = RAMP_MARKS_MS.len() * 8; + + fn encode(self) -> [u8; Self::BYTES] { + let mut out = [0; Self::BYTES]; + for (at, bytes) in out.as_chunks_mut::<8>().0.iter_mut().zip(self.0) { + *at = bytes.to_le_bytes(); + } + out + } + + fn decode(bytes: &[u8; Self::BYTES]) -> Self { + let mut ramp = Self::default(); + for (to, at) in ramp.0.iter_mut().zip(bytes.as_chunks::<8>().0) { + *to = u64::from_le_bytes(*at); + } + ramp + } +} + +/// A sink's count as it reads: the count at each mark is what arrived before +/// the read that passed it, and a mark the transfer ended before is the whole. +struct RampCount { + first: Option, + ramp: Ramp, + marked: usize, +} + +impl RampCount { + fn new() -> Self { + Self { first: None, ramp: Ramp::default(), marked: 0 } + } + + fn read(&mut self, count: u64) { + let first = *self.first.get_or_insert_with(Instant::now); + let since = first.elapsed(); + while self.marked < RAMP_MARKS_MS.len() && since >= Duration::from_millis(RAMP_MARKS_MS[self.marked]) { + self.ramp.0[self.marked] = count; + self.marked += 1; + } + } + + fn end(mut self, count: u64) -> Ramp { + for at in &mut self.ramp.0[self.marked..] { + *at = count; + } + self.ramp + } } fn ns_since(t: Instant) -> u64 { @@ -205,11 +266,13 @@ impl Peer { let mut count = [0u8; 8]; stream.read_exact(&mut count)?; let ns = ns_since(t); + let mut ramp = [0u8; Ramp::BYTES]; + stream.read_exact(&mut ramp)?; let counted = u64::from_le_bytes(count); if counted != sent { return Err(io::Error::other(format!("the peer counted {counted} of the {sent} bytes sent"))); } - Ok(Transfer { bytes: sent, ns }) + Ok(Transfer { bytes: sent, ns, ramp: Some(Ramp::decode(&ramp)) }) } /// What the peer sends for `span` after its first read, timed from that @@ -229,7 +292,7 @@ impl Peer { n => bytes += n as u64, } } - Ok(Transfer { bytes, ns: ns_since(t) }) + Ok(Transfer { bytes, ns: ns_since(t), ramp: None }) } } @@ -313,13 +376,18 @@ fn session(mut stream: TcpStream, said: &dyn Fn(&str)) -> io::Result<()> { Request::Sink => { let mut buf = vec![0u8; CHUNK]; let mut count = 0u64; + let mut ramp = RampCount::new(); loop { match stream.read(&mut buf)? { 0 => break, - n => count += n as u64, + n => { + ramp.read(count); + count += n as u64; + } } } - stream.write_all(&count.to_le_bytes()) + stream.write_all(&count.to_le_bytes())?; + stream.write_all(&ramp.end(count).encode()) } Request::Source { millis } => { let chunk = vec![0x3cu8; CHUNK]; @@ -389,18 +457,20 @@ mod tests { let up = peer.upload(Duration::from_millis(50), &mut |_| looked += 1).unwrap(); assert!(up.bytes >= CHUNK as u64, "{up:?}"); assert_eq!(looked, 1, "the upload shows its connection once"); + let ramp = up.ramp.expect("an upload carries its ramp").0; + assert!(ramp.windows(2).all(|pair| pair[0] <= pair[1]) && ramp[3] == up.bytes, "{ramp:?}"); let down = peer.download(Duration::from_millis(50)).unwrap(); assert!(down.bytes >= CHUNK as u64, "{down:?}"); } #[test] fn an_answer_carries_the_peers_link_and_nothing_else_is_one() { - assert_eq!(answered(b"osbench-lan/2 peer wired driver=e1000e speed_mbps=1000"), Some("wired driver=e1000e speed_mbps=1000")); - assert_eq!(answered(b"osbench-lan/2 peer "), Some("")); + assert_eq!(answered(b"osbench-lan/3 peer wired driver=e1000e speed_mbps=1000"), Some("wired driver=e1000e speed_mbps=1000")); + assert_eq!(answered(b"osbench-lan/3 peer "), Some("")); assert_eq!(answered(b"osbench-lan/1 peer"), None); - assert_eq!(answered(b"osbench-lan/2 hello"), None); - assert_eq!(answered(b"osbench-lan/2 peer wired\n"), None); - assert_eq!(answered("osbench-lan/2 peer ä".as_bytes()), None); + assert_eq!(answered(b"osbench-lan/3 hello"), None); + assert_eq!(answered(b"osbench-lan/3 peer wired\n"), None); + assert_eq!(answered("osbench-lan/3 peer ä".as_bytes()), None); let mut long = ANSWER.to_vec(); long.extend([b'x'; MAX_SAID]); assert!(answered(&long).is_some()); diff --git a/tests/guest/osbench/src/lan.rs b/tests/guest/osbench/src/lan.rs index 38e8366247..43bf80bb59 100644 --- a/tests/guest/osbench/src/lan.rs +++ b/tests/guest/osbench/src/lan.rs @@ -77,6 +77,7 @@ fn measure(peer: &Peer, [connect, rtt, upload, download]: &[String; 4]) { let download_of: Transfer = |peer, span, _| peer.download(span); for (bench, run) in [(upload, Peer::upload as Transfer), (download, download_of)] { let mut stack = Vec::new(); + let mut ramps = Vec::new(); let mut rounds = || -> Result, String> { run(peer, SPAN, &mut |_| {}).map_err(|e| e.to_string())?; crate::clock::settle(); @@ -85,6 +86,7 @@ fn measure(peer: &Peer, [connect, rtt, upload, download]: &[String; 4]) { let mbps = (0..ROUNDS) .map(|_| { let moved = run(peer, SPAN, &mut |stream| log.look(stream)).map_err(|e| e.to_string())?; + ramps.extend(moved.ramp); Ok(moved.bytes * 1000 / moved.ns) }) .collect::, String>>()?; @@ -96,6 +98,7 @@ fn measure(peer: &Peer, [connect, rtt, upload, download]: &[String; 4]) { Ok(mut mbps) => { let mut extra = vec![("span_ms".to_string(), SPAN.as_millis().to_string())]; extra.extend(stack); + extra.extend(ramp_means(&ramps)); let extra: Vec<(&str, String)> = extra.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); result(bench, "MB/s", &mut mbps, &extra) } @@ -104,6 +107,22 @@ fn measure(peer: &Peer, [connect, rtt, upload, download]: &[String; 4]) { } } +/// The bytes the peer had received by each of its ramp marks, as a mean over the rounds: how +/// fast each round's new connection opened, read at the receiver for every sender alike. +fn ramp_means(ramps: &[osbench_lan::Ramp]) -> Vec<(String, String)> { + if ramps.is_empty() { + return Vec::new(); + } + osbench_lan::RAMP_MARKS_MS + .iter() + .enumerate() + .map(|(at, ms)| { + let sum: u64 = ramps.iter().map(|ramp| ramp.0[at]).sum(); + (format!("peer_received_by_{ms}ms_bytes_mean"), (sum / ramps.len() as u64).to_string()) + }) + .collect() +} + /// What the stack's counters moved by over a row's timed rounds, each as a field named /// `stack_`; a reading that failed is one field saying why. fn stack_moved(before: Result, String>, after: Result, String>) -> Vec<(String, String)> { diff --git a/tests/guest/osbench/src/sys/toyos.rs b/tests/guest/osbench/src/sys/toyos.rs index fb2e012384..a34dd4d1b5 100644 --- a/tests/guest/osbench/src/sys/toyos.rs +++ b/tests/guest/osbench/src/sys/toyos.rs @@ -397,11 +397,10 @@ pub fn persistent_dir() -> Option<(String, String, String)> { Some(("/home".into(), served_by.into(), "nvme".into())) } -/// What a transfer row reports of the stack beneath it, as netstack's `inspect` answers it: -/// [tcp]'s bytes, losses and the time its senders waited on each send limit, and the card's -/// full-ring waits and missed frames. Those a card does not report are left out. +/// What a transfer row reports of the stack beneath it, as netstack's `inspect` answers it: [tcp]'s +/// and the card's counters. Those a card does not report are left out. pub fn net_counters() -> Result, String> { - const READ: [&str; 21] = [ + const READ: [&str; 30] = [ "net.tcp.sent.bytes", "net.tcp.retransmit.bytes", "net.tcp.rto", @@ -416,6 +415,14 @@ pub fn net_counters() -> Result, String> { "net.tcp.congestion-stops", "net.tcp.congestion-stops.cwnd.bytes", "net.tcp.congestion-stops.srtt.us", + "net.tcp.acks.cwnd-limited", + "net.tcp.acks.not-limited", + "net.tcp.cwnd.grown.bytes", + "net.tcp.hystart.css", + "net.tcp.hystart.css.rounds", + "net.tcp.hystart.resumed", + "net.tcp.hystart.exits", + "net.tcp.hystart.exit.cwnd.bytes", "net.transmit.full", "net.transmit.wake_armed", "net.transmit.wake_taken", @@ -423,6 +430,7 @@ pub fn net_counters() -> Result, String> { "net.transmit.unreported", "net.errors.missed", "net.wire.sent", + "net.wire.received", ]; let snap = inspect::ask(toyos_inspect::NET)?; Ok(READ