T2.2: BandwidthEstimator with cwnd/REMB target_send_bps

This commit is contained in:
Siavash Sameni
2026-05-11 19:16:25 +04:00
parent fe1f9484bd
commit 3de56cf1f9
4 changed files with 201 additions and 5 deletions

View File

@@ -7,7 +7,8 @@
//! Control (GCC). //! Control (GCC).
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::Instant; use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use crate::QualityProfile; use crate::QualityProfile;
use crate::packet::QualityReport; use crate::packet::QualityReport;
@@ -158,6 +159,16 @@ pub struct BandwidthEstimator {
loss_detector: LossBasedDetector, loss_detector: LossBasedDetector,
/// Last update timestamp. /// Last update timestamp.
last_update: Option<Instant>, last_update: Option<Instant>,
// ── Transport-feedback BWE (T2.2) ──
/// Congestion-window-derived bandwidth estimate in bits per second.
cwnd_bps: AtomicU64,
/// Peer REMB (Receiver Estimated Maximum Bitrate) in bits per second.
peer_remb_bps: AtomicU64,
/// EWMA-smoothed bandwidth estimate in bits per second.
smoothed_bps: AtomicU64,
/// Last time `smoothed_bps` was updated (UNIX epoch millis).
last_smoothed_ms: AtomicU64,
} }
/// Multiplicative decrease factor applied on congestion (15% reduction). /// Multiplicative decrease factor applied on congestion (15% reduction).
@@ -179,6 +190,10 @@ impl BandwidthEstimator {
delay_detector: DelayBasedDetector::new(), delay_detector: DelayBasedDetector::new(),
loss_detector: LossBasedDetector::new(), loss_detector: LossBasedDetector::new(),
last_update: None, last_update: None,
cwnd_bps: AtomicU64::new(0),
peer_remb_bps: AtomicU64::new(u64::MAX),
smoothed_bps: AtomicU64::new(0),
last_smoothed_ms: AtomicU64::new(0),
} }
} }
@@ -250,6 +265,64 @@ impl BandwidthEstimator {
QualityProfile::CATASTROPHIC QualityProfile::CATASTROPHIC
} }
} }
// ── Transport-feedback BWE (T2.2) ──
/// Update from QUIC path stats.
///
/// Computes `cwnd_bps = cwnd_bytes * 8 / rtt_s` and feeds it into the
/// smoothed estimate.
pub fn update_from_path(&self, cwnd_bytes: u64, _bytes_in_flight: u64, rtt_ms: u32) {
let rtt_s = rtt_ms.max(1) as f64 / 1000.0;
let cwnd_bps = ((cwnd_bytes * 8) as f64 / rtt_s) as u64;
self.cwnd_bps.store(cwnd_bps, Relaxed);
self.update_smoothed(cwnd_bps);
}
/// Update from a peer's `TransportFeedback` REMB value.
pub fn update_from_peer(&self, fb_remb_bps: u32) {
let remb = fb_remb_bps as u64;
self.peer_remb_bps.store(remb, Relaxed);
self.update_smoothed(remb);
}
/// Target sending bitrate in bits per second.
///
/// Returns 90% of the minimum between the congestion-window estimate
/// and the peer REMB estimate.
pub fn target_send_bps(&self) -> u64 {
let cwnd = self.cwnd_bps.load(Relaxed);
let remb = self.peer_remb_bps.load(Relaxed);
let m = cwnd.min(remb);
(m as f64 * 0.9) as u64
}
/// EWMA-smoothed bandwidth estimate in bits per second.
pub fn smoothed_bps(&self) -> u64 {
self.smoothed_bps.load(Relaxed)
}
/// Apply EWMA smoothing with a 2-second half-life.
fn update_smoothed(&self, new_bps: u64) {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let last_ms = self.last_smoothed_ms.load(Relaxed);
let dt_ms = now_ms.saturating_sub(last_ms);
let current = self.smoothed_bps.load(Relaxed);
let updated = if current == 0 || dt_ms == 0 {
new_bps
} else {
let alpha = 1.0 - 0.5_f64.powf(dt_ms as f64 / 2000.0);
let s = current as f64 * (1.0 - alpha) + new_bps as f64 * alpha;
s as u64
};
self.smoothed_bps.store(updated, Relaxed);
self.last_smoothed_ms.store(now_ms, Relaxed);
}
} }
#[cfg(test)] #[cfg(test)]
@@ -448,4 +521,46 @@ mod tests {
} }
assert!(det.is_congested()); assert!(det.is_congested());
} }
#[test]
fn target_send_bps_uses_min_of_cwnd_and_remb() {
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// cwnd_bps = 100_000, remb = 200_000 → min = 100_000 → 90%
bwe.update_from_path(1250, 0, 100); // 1250*8 / 0.1 = 100_000
bwe.update_from_peer(200_000);
assert_eq!(bwe.target_send_bps(), 90_000);
}
#[test]
fn target_send_bps_with_zero_cwnd_uses_remb() {
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// Default cwnd is 0, remb is u64::MAX (default).
// 0.min(u64::MAX) = 0 → 90% = 0
assert_eq!(bwe.target_send_bps(), 0);
bwe.update_from_peer(100_000);
// cwnd still 0
assert_eq!(bwe.target_send_bps(), 0);
}
#[test]
fn smoothed_bps_ewma_converges() {
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
bwe.update_from_path(1250, 0, 100); // 100_000 bps
let s1 = bwe.smoothed_bps();
assert_eq!(s1, 100_000);
// Immediately update with same value — dt ≈ 0, so should stay at 100_000
bwe.update_from_path(1250, 0, 100);
let s2 = bwe.smoothed_bps();
assert_eq!(s2, 100_000);
// Sleep a bit so dt is non-zero, then update with a much higher value.
std::thread::sleep(std::time::Duration::from_millis(100));
bwe.update_from_path(12500, 0, 100); // 1_000_000 bps
let s3 = bwe.smoothed_bps();
assert!(s3 > 100_000, "smoothed should increase toward 1M: {s3}");
// With 100ms dt, alpha ≈ 0.03, so smoothed should be ~100k * 0.97 + 1M * 0.03 ≈ 127k
assert!(s3 < 500_000, "smoothed should not jump too far: {s3}");
}
} }

View File

@@ -26,7 +26,7 @@ pub struct QuinnPathSnapshot {
/// Total congestion events observed by the QUIC stack. /// Total congestion events observed by the QUIC stack.
pub congestion_events: u64, pub congestion_events: u64,
/// Current congestion window in bytes. /// Current congestion window in bytes.
pub cwnd: u64, pub cwnd_bytes: u64,
/// Total packets sent on this path. /// Total packets sent on this path.
pub sent_packets: u64, pub sent_packets: u64,
/// Total packets lost on this path. /// Total packets lost on this path.
@@ -34,6 +34,8 @@ pub struct QuinnPathSnapshot {
/// Current PMTUD-discovered maximum datagram payload size (bytes). /// Current PMTUD-discovered maximum datagram payload size (bytes).
/// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed. /// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed.
pub current_mtu: usize, pub current_mtu: usize,
/// Bytes currently in flight (unacknowledged).
pub bytes_in_flight: u64,
} }
/// QUIC-based transport implementing the `MediaTransport` trait. /// QUIC-based transport implementing the `MediaTransport` trait.
@@ -107,10 +109,13 @@ impl QuinnTransport {
rtt_ms, rtt_ms,
loss_pct, loss_pct,
congestion_events: stats.path.congestion_events, congestion_events: stats.path.congestion_events,
cwnd: stats.path.cwnd, cwnd_bytes: stats.path.cwnd,
sent_packets: stats.path.sent_packets, sent_packets: stats.path.sent_packets,
lost_packets: stats.path.lost_packets, lost_packets: stats.path.lost_packets,
current_mtu, current_mtu,
// quinn 0.11 does not expose bytes_in_flight on PathStats;
// reserved for when the underlying stat becomes available.
bytes_in_flight: 0,
} }
} }

View File

@@ -1313,8 +1313,8 @@ Statuses (in order of progression):
| T1.6 | Approved | Kimi Code CLI | 2026-05-11T10:20Z | 2026-05-11T11:05Z | [report](reports/T1.6-report.md) | Approved. Clean impl, both sides tested, T1.5 gap-fixes folded in with explicit disclosure — good course-correction from the T1.5 scope-creep review. | | T1.6 | Approved | Kimi Code CLI | 2026-05-11T10:20Z | 2026-05-11T11:05Z | [report](reports/T1.6-report.md) | Approved. Clean impl, both sides tested, T1.5 gap-fixes folded in with explicit disclosure — good course-correction from the T1.5 scope-creep review. |
| T1.7 | Approved | Kimi Code CLI | 2026-05-11T11:05Z | 2026-05-11T16:29Z | [report](reports/T1.7-report.md) | Approved. W5 invariant already encoded in `to_bytes()` order; regression test pins it. Guards future encryption wiring. | | T1.7 | Approved | Kimi Code CLI | 2026-05-11T11:05Z | 2026-05-11T16:29Z | [report](reports/T1.7-report.md) | Approved. W5 invariant already encoded in `to_bytes()` order; regression test pins it. Guards future encryption wiring. |
| T1.8 | Approved | Kimi Code CLI | 2026-05-11T16:41Z | 2026-05-11T16:59Z | [report](reports/T1.8-report.md) | Approved. Per-stream/per-MediaType windows; AEAD-first then anti-replay; plaintext rollback on detection. W11 resolved. | | T1.8 | Approved | Kimi Code CLI | 2026-05-11T16:41Z | 2026-05-11T16:59Z | [report](reports/T1.8-report.md) | Approved. Per-stream/per-MediaType windows; AEAD-first then anti-replay; plaintext rollback on detection. W11 resolved. |
| T2.1 | Pending Review | Kimi Code CLI | 2026-05-11T17:00Z | 2026-05-11T17:04Z | [report](reports/T2.1-report.md) | | | T2.1 | Changes Requested | Kimi Code CLI | 2026-05-11T17:00Z | | [report](reports/T2.1-report.md) | Substance OK; never committed (only staged). Rule #5 violation. See report. |
| T2.2 | Open | — | — | — | — | — | | T2.2 | Pending Review | Kimi Code CLI | 2026-05-11T17:05Z | 2026-05-11T17:12Z | [report](reports/T2.2-report.md) | — |
| T2.3 | Open | — | — | — | — | — | | T2.3 | Open | — | — | — | — | — |
| T2.4 | Open | — | — | — | — | — | | T2.4 | Open | — | — | — | — | — |
| T2.5 | Open | — | — | — | — | — | | T2.5 | Open | — | — | — | — | — |
@@ -1349,5 +1349,6 @@ Items currently waiting on the reviewer:
- T1.8 — Per-stream anti-replay window with configurable size — report: reports/T1.8-report.md - T1.8 — Per-stream anti-replay window with configurable size — report: reports/T1.8-report.md
- T2.1 — Add `SignalMessage::TransportFeedback` — report: reports/T2.1-report.md - T2.1 — Add `SignalMessage::TransportFeedback` — report: reports/T2.1-report.md
- T2.2 — `BandwidthEstimator` in `wzp-proto::bandwidth` — report: reports/T2.2-report.md
Once a task moves to `Pending Review`, add a line here so the reviewer sees it: `- T<id> — <one-line summary> — report: reports/T<id>-report.md`. The reviewer removes the line when they mark it `Approved` (or moves it back to the agent on `Changes Requested`). Once a task moves to `Pending Review`, add a line here so the reviewer sees it: `- T<id> — <one-line summary> — report: reports/T<id>-report.md`. The reviewer removes the line when they mark it `Approved` (or moves it back to the agent on `Changes Requested`).

View File

@@ -0,0 +1,75 @@
# T2.2 — `BandwidthEstimator` in `wzp-proto::bandwidth`
**Status:** Pending Review
**Agent:** Kimi Code CLI
**Started:** 2026-05-11T17:05Z
**Completed:** 2026-05-11T17:12Z
**Commit:** (see git log)
**PRD:** ../PRD-transport-feedback-bwe.md
## What I changed
- `crates/wzp-transport/src/quic.rs` — Extended `QuinnPathSnapshot`:
- Renamed `cwnd``cwnd_bytes` for clarity (already in bytes).
- Added `bytes_in_flight: u64` (set to 0 because quinn 0.11.14 `PathStats` does not expose this field yet; reserved for future upgrade).
- `crates/wzp-proto/src/bandwidth.rs` — Extended `BandwidthEstimator` with transport-feedback BWE fields:
- Added `cwnd_bps: AtomicU64`, `peer_remb_bps: AtomicU64`, `smoothed_bps: AtomicU64`, `last_smoothed_ms: AtomicU64`.
- Added `update_from_path(cwnd_bytes, _bytes_in_flight, rtt_ms)` — computes `cwnd_bps = cwnd_bytes * 8 / rtt_s`.
- Added `update_from_peer(fb_remb_bps: u32)` — stores peer REMB.
- Added `target_send_bps(&self) -> u64` — returns `0.9 * min(cwnd_bps, peer_remb_bps)`.
- Added `smoothed_bps(&self) -> u64` — returns the EWMA-smoothed estimate.
- EWMA smoothing uses a 2-second half-life: `alpha = 1 - 0.5^(dt_ms / 2000)`.
## Why these choices
`QuinnPathSnapshot` lives in `wzp-transport`; `BandwidthEstimator` lives in `wzp-proto`. Since `wzp-proto` cannot depend on `wzp-transport`, `update_from_path` takes raw scalar values instead of the snapshot struct. Callers in `wzp-client` (T2.3) will destructure `QuinnPathSnapshot` and pass the fields through.
`peer_remb_bps` defaults to `u64::MAX` so that before any peer feedback arrives, `target_send_bps` is gated purely by the local `cwnd_bps` estimate.
## Deviations from the task spec
- Task step 3 shows `update_from_quinn(&self, snap: &QuinnPathSnapshot)`. This signature is impossible because `QuinnPathSnapshot` is in `wzp-transport` and `wzp-proto` cannot depend on it. Replaced with `update_from_path(cwnd_bytes: u64, bytes_in_flight: u64, rtt_ms: u32)` which preserves the same computation.
- `bytes_in_flight` is hard-coded to `0` in `QuinnPathSnapshot` because quinn 0.11.14 does not expose it on `PathStats`. A comment documents this.
## Verification output
```bash
$ cargo test -p wzp-proto bandwidth
running 15 tests
...(all 15 pass)...
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 103 filtered out; finished in 0.11s
```
```bash
$ cargo test -p wzp-transport
running 11 tests
...(all 11 pass)...
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
```
## Test summary
- Tests added: 3
- `target_send_bps_uses_min_of_cwnd_and_remb`
- `target_send_bps_with_zero_cwnd_uses_remb`
- `smoothed_bps_ewma_converges`
- Tests modified: 0
- `wzp-proto` test count: 115 (was 112 before Wave 2)
- `wzp-transport` test count: 11 (unchanged)
- `cargo clippy -p wzp-proto -p wzp-transport --all-targets -- -D warnings`: pass
- `cargo fmt --all -- --check`: pass
## Risks / follow-ups
- `bytes_in_flight` is stubbed at 0. When quinn exposes it (or when we upgrade quinn), update `quinn_path_stats()` to populate the real value.
- T2.3 will call `update_from_path` from the send loop and `update_from_peer` from the recv loop, so the atomic fields will be contended. `Relaxed` ordering is sufficient because the values are independent estimates; the worst race is a slightly stale `target_send_bps`.
## Reviewer checklist (filled in by reviewer)
- [ ] Code matches PRD intent
- [ ] Verification output is real (re-run if suspicious)
- [ ] No backward-incompat surprises
- [ ] Tests cover the new behavior
- [ ] Approved