subetha_cxc/bbr.rs
1//! BBR congestion control for the RLC transport's send path.
2//!
3//! A from-the-spec implementation of BBR (Bottleneck Bandwidth and
4//! Round-trip propagation time), Cardwell et al., following:
5//!
6//! - `draft-cardwell-iccrg-bbr-congestion-control` (the state machine,
7//! the pacing-rate / cwnd formulas, the full-pipe detection), and
8//! - `draft-cheng-iccrg-delivery-rate-estimation` (the per-packet
9//! rate-sample bookkeeping).
10//!
11//! This is the BBRv1 model: the bottleneck is characterised by two
12//! quantities the sender can measure, `BtlBw` (the windowed-MAX of the
13//! delivery rate) and `RTprop` (the windowed-MIN of the round-trip
14//! time), and the sender PACES at `pacing_gain * BtlBw` while bounding
15//! in-flight to `cwnd_gain * BDP` (BDP = BtlBw * RTprop). That keeps the
16//! bottleneck queue near-empty: throughput at the bottleneck rate with
17//! minimal standing queue, which is the whole point, low latency under
18//! bufferbloat where a loss-based controller fills the buffer.
19//!
20//! # Why the rate sampler matters (the part a naive version gets wrong)
21//!
22//! The delivery rate must NOT be `acked_bytes / ack_arrival_interval`:
23//! when a frontier hole fills, the receiver delivers a backlog at once,
24//! the ACKs arrive compressed, and that ratio spikes to many times the
25//! true link rate. The spec's fix (followed here) snapshots, PER PACKET
26//! at send time, the connection's `delivered` count and the time it was
27//! last updated; on ACK the rate is `delivered_delta /
28//! max(send_elapsed, ack_elapsed)`. Taking the MAX of the send-side and
29//! ack-side elapsed intervals makes the estimate robust to both ACK
30//! compression (ack_elapsed too small) and send bursts (send_elapsed too
31//! small). This is exactly what an earlier hand-rolled
32//! windowed-max-of-raw-ACK-delta got wrong.
33
34use std::collections::VecDeque;
35use std::time::{Duration, Instant};
36
37/// Startup pacing/cwnd gain `2/ln(2) ~= 2.885`: the smallest gain that
38/// doubles the sending rate each round trip, an exponential search for
39/// the bottleneck bandwidth.
40const STARTUP_GAIN: f64 = 2.0 / std::f64::consts::LN_2;
41/// Drain pacing gain `ln(2)/2 ~= 0.35` (the inverse of the startup gain):
42/// drains the queue the startup overshoot built, in about one round.
43const DRAIN_PACING_GAIN: f64 = std::f64::consts::LN_2 / 2.0;
44/// Steady-state in-flight headroom: cwnd = 2 * BDP tolerates delayed/
45/// aggregated ACKs without starving the pipe.
46const CWND_GAIN: f64 = 2.0;
47/// ProbeBW pacing-gain cycle, one phase per RTprop: probe UP at 1.25x for
48/// one round to look for more bandwidth, drain at 0.75x the next round,
49/// then cruise at 1.0x for six rounds.
50const PROBE_BW_GAINS: [f64; 8] = [1.25, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
51/// Startup is done once BtlBw fails to grow by >=25% for this many rounds
52/// (the pipe is full, further probing only builds queue).
53const FULL_BW_THRESH: f64 = 1.25;
54const FULL_BW_COUNT: u32 = 3;
55/// RTprop windowed-min length: a min RTT older than this is stale (the
56/// path may have changed), so ProbeRTT re-measures it.
57const MIN_RTT_FILTER_LEN: Duration = Duration::from_secs(10);
58/// BtlBw windowed-max length, in round trips.
59const BW_FILTER_ROUNDS: u64 = 10;
60/// ProbeRTT: every `PROBE_RTT_INTERVAL`, hold cwnd at `PROBE_RTT_CWND`
61/// for `PROBE_RTT_DURATION` to drain the queue and read a clean RTprop.
62const PROBE_RTT_INTERVAL: Duration = Duration::from_secs(10);
63const PROBE_RTT_DURATION: Duration = Duration::from_millis(200);
64const PROBE_RTT_CWND_PKTS: u64 = 4;
65/// Floor on cwnd so the pipe never fully empties.
66const MIN_PIPE_CWND_PKTS: u64 = 4;
67/// 1% pacing discount, so the sender never quite outpaces the bottleneck.
68const PACING_MARGIN: f64 = 0.01;
69
70/// Per-packet rate-sample snapshot, stored by the caller alongside each
71/// in-flight packet and handed back when that packet is delivered.
72#[derive(Clone, Copy, Debug)]
73pub struct PacketSample {
74 /// `C.delivered` at the moment this packet was sent.
75 delivered: u64,
76 /// `C.delivered_time` at the moment this packet was sent.
77 delivered_time: Instant,
78 /// `C.first_sent_time` at the moment this packet was sent.
79 first_sent_time: Instant,
80 /// When this packet was sent.
81 sent_time: Instant,
82 /// Whether the connection was application-limited when this was sent.
83 is_app_limited: bool,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87enum State {
88 Startup,
89 Drain,
90 ProbeBw,
91 ProbeRtt,
92}
93
94/// BBR sender-side state.
95pub struct Bbr {
96 // --- delivery-rate-estimation connection state ---
97 /// Total payload bytes the receiver has delivered over the connection.
98 delivered: u64,
99 /// Wall-clock time `delivered` was last updated.
100 delivered_time: Instant,
101 /// Send time of the packet most recently marked delivered.
102 first_sent_time: Instant,
103 /// Payload bytes in flight (sent, not yet delivered), drives the
104 /// app-limited detection and the cwnd gate.
105 inflight: u64,
106
107 // --- estimates ---
108 /// BtlBw: windowed-max of the delivery rate (bytes/s). The deque holds
109 /// `(round, rate)`; the front is the current max.
110 bw_filter: VecDeque<(u64, f64)>,
111 btlbw_bps: f64,
112 round_count: u64,
113 /// The `delivered` count at which the current round ends (round trip).
114 next_round_delivered: u64,
115 /// RTprop: windowed-min RTT and when it was stamped.
116 min_rtt: Duration,
117 min_rtt_stamp: Instant,
118
119 // --- state machine ---
120 state: State,
121 pacing_gain: f64,
122 cwnd_gain: f64,
123 /// Startup full-pipe detection.
124 full_bw: f64,
125 full_bw_count: u32,
126 filled_pipe: bool,
127 /// ProbeBW gain-cycle phase + when it last advanced.
128 cycle_index: usize,
129 cycle_stamp: Instant,
130 /// ProbeRTT scheduling.
131 probe_rtt_done_stamp: Option<Instant>,
132 last_probe_rtt: Instant,
133
134 /// Per-packet payload size (fixed in this transport) for cwnd-in-packets.
135 packet_bytes: u64,
136}
137
138impl Bbr {
139 pub fn new(now: Instant, packet_bytes: u64) -> Self {
140 let mut bbr = Self {
141 delivered: 0,
142 delivered_time: now,
143 first_sent_time: now,
144 inflight: 0,
145 bw_filter: VecDeque::new(),
146 btlbw_bps: 0.0,
147 round_count: 0,
148 next_round_delivered: 0,
149 min_rtt: Duration::from_secs(10),
150 min_rtt_stamp: now,
151 state: State::Startup,
152 pacing_gain: STARTUP_GAIN,
153 cwnd_gain: STARTUP_GAIN,
154 full_bw: 0.0,
155 full_bw_count: 0,
156 filled_pipe: false,
157 cycle_index: 0,
158 cycle_stamp: now,
159 probe_rtt_done_stamp: None,
160 last_probe_rtt: now,
161 packet_bytes: packet_bytes.max(1),
162 };
163 bbr.enter_startup();
164 bbr
165 }
166
167 fn enter_startup(&mut self) {
168 self.state = State::Startup;
169 self.pacing_gain = STARTUP_GAIN;
170 self.cwnd_gain = STARTUP_GAIN;
171 }
172
173 fn enter_drain(&mut self) {
174 self.state = State::Drain;
175 self.pacing_gain = DRAIN_PACING_GAIN;
176 self.cwnd_gain = STARTUP_GAIN;
177 }
178
179 fn enter_probe_bw(&mut self, now: Instant) {
180 self.state = State::ProbeBw;
181 self.cwnd_gain = CWND_GAIN;
182 self.cycle_index = 0;
183 self.pacing_gain = PROBE_BW_GAINS[0];
184 self.cycle_stamp = now;
185 }
186
187 /// Snapshot the rate-sample state for a packet about to be sent. The
188 /// caller stores the returned [`PacketSample`] with the packet and
189 /// returns it on delivery via [`on_ack`](Self::on_ack).
190 pub fn on_send(&mut self, now: Instant, app_limited: bool) -> PacketSample {
191 if self.inflight == 0 {
192 // Restart from idle: reset the rate-sample interval origin.
193 self.first_sent_time = now;
194 self.delivered_time = now;
195 }
196 self.inflight += self.packet_bytes;
197 PacketSample {
198 delivered: self.delivered,
199 delivered_time: self.delivered_time,
200 first_sent_time: self.first_sent_time,
201 sent_time: now,
202 is_app_limited: app_limited,
203 }
204 }
205
206 /// Process an ACK delivering `samples` (the snapshots of every packet
207 /// newly known-received on this ACK, both the cumulative-ACK advance
208 /// and any newly SACKed ids). The RTT is taken from the most recently
209 /// sent acked packet, per the spec; picking that packet by its `delivered`
210 /// snapshot also makes the rate sample robust to retransmits (a resent
211 /// symbol keeps its old, low `delivered`, so it is never picked).
212 pub fn on_ack(&mut self, now: Instant, samples: &[PacketSample]) {
213 if samples.is_empty() {
214 return;
215 }
216 let n = samples.len() as u64;
217 let delivered_bytes = n * self.packet_bytes;
218 self.delivered += delivered_bytes;
219 self.delivered_time = now;
220 self.inflight = self.inflight.saturating_sub(delivered_bytes);
221
222 // The "most recently sent" delivered packet: max prior-delivered.
223 let p = samples
224 .iter()
225 .max_by_key(|s| s.delivered)
226 .copied()
227 .expect("non-empty");
228 self.first_sent_time = p.sent_time;
229
230 // RTprop windowed-min: RTT of the most recently sent acked packet.
231 let rtt = now.saturating_duration_since(p.sent_time);
232 if rtt > Duration::ZERO {
233 let expired = now.saturating_duration_since(self.min_rtt_stamp) > MIN_RTT_FILTER_LEN;
234 if rtt <= self.min_rtt || expired {
235 self.min_rtt = rtt;
236 self.min_rtt_stamp = now;
237 }
238 }
239
240 // Round-trip accounting (BBRUpdateRound): a round ends when we ACK a
241 // packet that was SENT at or after the `delivered` mark captured at the
242 // previous round start - i.e. roughly one RTT, one BDP of delivery. The
243 // mark is the acked packet's OWN `delivered` snapshot, NOT the running
244 // cumulative; using the cumulative ticked a round per ACK batch, making
245 // the BtlBw window far shorter than its intended 10 round trips so the
246 // peak expired and the estimate decayed.
247 let round_start = p.delivered >= self.next_round_delivered;
248 if round_start {
249 self.next_round_delivered = self.delivered;
250 self.round_count += 1;
251 }
252
253 // Delivery-rate sample = delivered / max(send_elapsed, ack_elapsed),
254 // gated on a reliable interval (>= min RTT). An app-limited sample
255 // understates the bandwidth, so it only RAISES the max filter.
256 let send_elapsed = p.sent_time.saturating_duration_since(p.first_sent_time);
257 let ack_elapsed = now.saturating_duration_since(p.delivered_time);
258 let interval = send_elapsed.max(ack_elapsed);
259 let rs_delivered = self.delivered - p.delivered;
260 if interval >= self.min_rtt && interval > Duration::ZERO {
261 let rate = rs_delivered as f64 / interval.as_secs_f64();
262 if !p.is_app_limited || rate >= self.btlbw_bps {
263 self.update_btlbw(rate);
264 }
265 }
266
267 if !self.filled_pipe {
268 self.check_full_pipe(round_start, p.is_app_limited);
269 }
270 self.update_state(now);
271 }
272
273 fn update_btlbw(&mut self, rate: f64) {
274 // Windowed max over BW_FILTER_ROUNDS rounds: drop samples older
275 // than the window, then the running max is the front-most peak.
276 let floor = self.round_count.saturating_sub(BW_FILTER_ROUNDS);
277 while let Some(&(r, _)) = self.bw_filter.front() {
278 if r < floor {
279 self.bw_filter.pop_front();
280 } else {
281 break;
282 }
283 }
284 // Maintain a monotonically-decreasing deque of candidates (the
285 // classic sliding-window-maximum structure).
286 while let Some(&(_, v)) = self.bw_filter.back() {
287 if v <= rate {
288 self.bw_filter.pop_back();
289 } else {
290 break;
291 }
292 }
293 self.bw_filter.push_back((self.round_count, rate));
294 self.btlbw_bps = self.bw_filter.front().map(|&(_, v)| v).unwrap_or(rate);
295 }
296
297 fn check_full_pipe(&mut self, round_start: bool, is_app_limited: bool) {
298 if self.filled_pipe || !round_start || is_app_limited {
299 return;
300 }
301 if self.btlbw_bps >= self.full_bw * FULL_BW_THRESH {
302 self.full_bw = self.btlbw_bps;
303 self.full_bw_count = 0;
304 return;
305 }
306 self.full_bw_count += 1;
307 if self.full_bw_count >= FULL_BW_COUNT {
308 self.filled_pipe = true;
309 }
310 }
311
312 fn update_state(&mut self, now: Instant) {
313 // ProbeRTT is due periodically regardless of state.
314 if self.state != State::ProbeRtt
315 && now.saturating_duration_since(self.last_probe_rtt) > PROBE_RTT_INTERVAL
316 {
317 self.state = State::ProbeRtt;
318 self.pacing_gain = 1.0;
319 self.cwnd_gain = 1.0;
320 self.probe_rtt_done_stamp = None;
321 return;
322 }
323 match self.state {
324 State::Startup => {
325 if self.filled_pipe {
326 self.enter_drain();
327 }
328 }
329 State::Drain => {
330 // Once in-flight has drained to about a BDP, cruise.
331 if self.inflight <= self.bdp_bytes() {
332 self.enter_probe_bw(now);
333 }
334 }
335 State::ProbeBw => {
336 // Advance the gain cycle one phase per RTprop.
337 if now.saturating_duration_since(self.cycle_stamp) >= self.min_rtt {
338 self.cycle_index = (self.cycle_index + 1) % PROBE_BW_GAINS.len();
339 self.pacing_gain = PROBE_BW_GAINS[self.cycle_index];
340 self.cycle_stamp = now;
341 }
342 }
343 State::ProbeRtt => {
344 // Hold the reduced cwnd for PROBE_RTT_DURATION once in-flight
345 // has fallen to the floor, then resume.
346 if self.probe_rtt_done_stamp.is_none()
347 && self.inflight <= PROBE_RTT_CWND_PKTS * self.packet_bytes
348 {
349 self.probe_rtt_done_stamp = Some(now + PROBE_RTT_DURATION);
350 }
351 if let Some(done) = self.probe_rtt_done_stamp
352 && now >= done
353 {
354 self.last_probe_rtt = now;
355 self.min_rtt_stamp = now;
356 if self.filled_pipe {
357 self.enter_probe_bw(now);
358 } else {
359 self.enter_startup();
360 }
361 }
362 }
363 }
364 }
365
366 fn bdp_bytes(&self) -> u64 {
367 (self.btlbw_bps * self.min_rtt.as_secs_f64()) as u64
368 }
369
370 /// Target pacing rate in bytes/second: `pacing_gain * BtlBw`, minus the
371 /// 1% margin. Zero until the first reliable bandwidth sample lands
372 /// (the caller should then fall back to its own flow control).
373 pub fn pacing_rate_bps(&self) -> f64 {
374 self.pacing_gain * self.btlbw_bps * (1.0 - PACING_MARGIN)
375 }
376
377 /// Target congestion window in bytes: `cwnd_gain * BDP`, floored at
378 /// `MIN_PIPE_CWND` and reduced to `PROBE_RTT_CWND` during ProbeRTT.
379 pub fn cwnd_bytes(&self) -> u64 {
380 if self.state == State::ProbeRtt {
381 return PROBE_RTT_CWND_PKTS * self.packet_bytes;
382 }
383 let target = (self.cwnd_gain * self.bdp_bytes() as f64) as u64;
384 target.max(MIN_PIPE_CWND_PKTS * self.packet_bytes)
385 }
386
387 /// Whether a usable bandwidth estimate exists yet.
388 pub fn has_estimate(&self) -> bool {
389 self.btlbw_bps > 0.0
390 }
391
392 /// BtlBw estimate (bytes/s), telemetry.
393 pub fn btlbw_bps(&self) -> f64 {
394 self.btlbw_bps
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 /// Drive `bbr` through a realistic pipelined link: `link_bps` bottleneck,
403 /// `rtt` propagation, a `cwnd_pkts` sliding window. Packets enter the
404 /// bottleneck serialised at the link rate (so delivery is link-paced) and
405 /// are acked one `rtt` after delivery. Events are processed in time order,
406 /// exactly as on a real connection (send and ack interleave). `compress`
407 /// optionally batches all acks in a round into one instant (ACK
408 /// compression) to test the sampler's robustness.
409 fn simulate(link_bps: f64, rtt: Duration, pkt: u64, n: u64, compress: bool) -> Bbr {
410 let t0 = Instant::now();
411 let mut bbr = Bbr::new(t0, pkt);
412 bbr.min_rtt = rtt;
413 let serialize = Duration::from_secs_f64(pkt as f64 / link_bps); // bottleneck time/pkt
414 // Event-ordered sim: maintain the next free bottleneck time and a
415 // queue of (deliver_time, sample). Keep ~cwnd packets in flight.
416 let cwnd = ((link_bps * rtt.as_secs_f64() / pkt as f64) as u64 + 2).max(4);
417 let mut next_bottleneck = t0;
418 let mut inflight: std::collections::VecDeque<(Instant, PacketSample)> =
419 std::collections::VecDeque::new();
420 let mut sent = 0u64;
421 let mut now = t0;
422 while sent < n || !inflight.is_empty() {
423 // Send while the window has room and packets remain.
424 while sent < n && (inflight.len() as u64) < cwnd {
425 let s = bbr.on_send(now, false);
426 // Bottleneck serialises: this packet is delivered when the link
427 // is free, plus the propagation delay.
428 let deliver = next_bottleneck.max(now) + serialize;
429 next_bottleneck = deliver;
430 inflight.push_back((deliver + rtt, s)); // ack one rtt after delivery
431 sent += 1;
432 }
433 // Advance to the next ack.
434 let Some(&(ack_t, _)) = inflight.front() else { break };
435 now = ack_t;
436 if compress {
437 // Deliver every packet whose ack is due now in one batch.
438 let mut batch = Vec::new();
439 while let Some(&(t, s)) = inflight.front() {
440 if t <= now {
441 batch.push(s);
442 inflight.pop_front();
443 } else {
444 break;
445 }
446 }
447 bbr.on_ack(now, &batch);
448 } else {
449 let (_, s) = inflight.pop_front().unwrap();
450 bbr.on_ack(now, std::slice::from_ref(&s));
451 }
452 }
453 bbr
454 }
455
456 /// The sampler recovers the true link rate from a clean pipelined stream.
457 #[test]
458 fn rate_sampler_recovers_link_rate() {
459 // 10 Mbit/s = 1.25 MB/s, RTT 50 ms, 1000-byte packets.
460 let mbit = simulate(1.25e6, Duration::from_millis(50), 1000, 2000, false).btlbw_bps()
461 * 8.0
462 / 1e6;
463 assert!((8.0..=12.5).contains(&mbit), "btlbw {mbit:.1} mbit/s off true 10");
464 }
465
466 /// ACK compression (a whole round acked in one instant) must NOT inflate
467 /// btlbw above the true link rate: the send_elapsed term bounds it.
468 #[test]
469 fn compressed_ack_burst_does_not_inflate() {
470 let mbit = simulate(1.25e6, Duration::from_millis(50), 1000, 2000, true).btlbw_bps()
471 * 8.0
472 / 1e6;
473 assert!(mbit < 15.0, "compressed ACKs inflated btlbw to {mbit:.1} mbit/s (true 10)");
474 }
475
476 /// A full run fills the pipe and leaves Startup for ProbeBW.
477 #[test]
478 fn startup_fills_pipe_then_leaves() {
479 let bbr = simulate(1.25e6, Duration::from_millis(20), 1000, 4000, false);
480 assert!(bbr.filled_pipe, "never detected a full pipe");
481 assert!(
482 matches!(bbr.state, State::ProbeBw | State::ProbeRtt),
483 "did not reach steady state: {:?}",
484 bbr.state
485 );
486 }
487
488 #[test]
489 fn pacing_rate_is_gain_times_btlbw() {
490 let t0 = Instant::now();
491 let mut bbr = Bbr::new(t0, 1000);
492 bbr.btlbw_bps = 1_000_000.0; // 1 MB/s
493 bbr.pacing_gain = 1.25;
494 let r = bbr.pacing_rate_bps();
495 assert!((r - 1_250_000.0 * 0.99).abs() < 1.0, "pacing rate {r}");
496 }
497}