Skip to main content

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}