Skip to main content

moq_video/encode/
rate.rs

1//! Rate control: turning a congestion-control bandwidth estimate into the
2//! bitrate the encoder should actually produce at.
3//!
4//! [`Control`] is the one place this policy lives, so every sender backs off the
5//! same way. It's a pure function of the estimate: feed it every value from a
6//! [`bandwidth::Consumer`](moq_net::bandwidth::Consumer) and hand what it
7//! returns to [`Encoder::set_bitrate`](super::Encoder::set_bitrate).
8
9use std::time::Instant;
10
11/// How a bandwidth estimate maps onto the bitrate a sender should produce at.
12///
13/// Build one with [`Policy::new`] and override what you need. The defaults are
14/// tuned for a live contribution encoder on a cellular uplink: give back
15/// bandwidth immediately when the pipe closes, take it back slowly when it
16/// opens, and don't twitch at every jitter in the estimate.
17///
18/// `#[non_exhaustive]`: construct via [`Policy::new`] and set fields, so new
19/// knobs stay additive.
20#[derive(Clone, Debug)]
21#[non_exhaustive]
22pub struct Policy {
23	/// Fraction of the estimate to target, reserving room for the other tracks
24	/// sharing this connection (audio) and for transport overhead. Defaults to
25	/// 0.9. Must be greater than 0; values above 1.0 target more than the link
26	/// is estimated to carry and are clamped away.
27	pub headroom: f64,
28
29	/// Upper bound in bits per second, normally the bitrate the caller asked
30	/// for. The estimate can only ever take the target *down* from here: an
31	/// optimistic estimate is not a reason to send more than was configured.
32	pub max: u64,
33
34	/// Lower bound in bits per second. Below some rate the picture isn't worth
35	/// sending, so the target holds here and the transport's priority queue
36	/// sheds the excess instead. Defaults to a tenth of `max`.
37	pub min: u64,
38
39	/// Ignore moves smaller than this fraction of the current target, so a
40	/// jittering estimate doesn't reconfigure the encoder every 100ms.
41	/// Defaults to 0.05 (5%).
42	pub hysteresis: f64,
43
44	/// How fast the target may climb back, as a fraction of the current target
45	/// per second. Defaults to 0.25 (25%/s, so ~3s from the floor back to a 2x
46	/// higher rate). Drops ignore this and apply at once: overshooting a closing
47	/// uplink costs a stalled picture, while undershooting an opening one costs
48	/// only a few seconds of lower quality.
49	pub ramp: f64,
50}
51
52impl Policy {
53	/// A policy targeting at most `max` bits per second, with the documented
54	/// defaults for every other knob.
55	pub fn new(max: u64) -> Self {
56		Self {
57			headroom: 0.9,
58			max,
59			// A tenth of the ceiling: low enough to ride out a bad uplink, high
60			// enough that what we do send is still worth decoding.
61			min: max / 10,
62			hysteresis: 0.05,
63			ramp: 0.25,
64		}
65	}
66}
67
68/// Maps bandwidth estimates onto a target bitrate, per a [`Policy`].
69///
70/// Feed it every estimate from a
71/// [`bandwidth::Consumer`](moq_net::bandwidth::Consumer); it returns a new
72/// target only when one is worth applying, so a caller can hand the result
73/// straight to an encoder without rate-limiting it further:
74///
75/// ```
76/// # use moq_video::encode::rate::{Control, Policy};
77/// # use std::time::Instant;
78/// let mut control = Control::new(Policy::new(4_000_000));
79/// // A 2 Mbps estimate takes the 4 Mbps target down to 2 Mbps * 0.9 headroom.
80/// assert_eq!(control.update(Some(2_000_000), Instant::now()), Some(1_800_000));
81/// ```
82///
83/// The time source is a parameter rather than an [`Instant::now`] call so the
84/// policy stays pure and testable. Pass the time the estimate was observed.
85#[derive(Clone, Debug)]
86pub struct Control {
87	policy: Policy,
88	target: u64,
89	/// When the target last moved, anchoring the [`Policy::ramp`] limit. `None`
90	/// until the first change, when there's nothing to ramp from.
91	applied: Option<Instant>,
92}
93
94impl Control {
95	/// Start at [`Policy::max`], the optimistic case: until an estimate says
96	/// otherwise, send what the caller configured.
97	pub fn new(policy: Policy) -> Self {
98		Self {
99			target: policy.max.max(policy.min),
100			policy,
101			applied: None,
102		}
103	}
104
105	/// The current target in bits per second.
106	pub fn target(&self) -> u64 {
107		self.target
108	}
109
110	/// Feed a new estimate, returning the new target when it moved enough to be
111	/// worth applying and `None` when it didn't.
112	///
113	/// A `None` estimate (no congestion controller, or disconnected) holds the
114	/// current target rather than resetting to [`Policy::max`]: losing the
115	/// estimate is not evidence the uplink got better.
116	pub fn update(&mut self, estimate: Option<u64>, now: Instant) -> Option<u64> {
117		let estimate = estimate?;
118
119		// Normalize here rather than trusting the fields: `min > max` would make
120		// the clamp below panic, and a non-finite headroom would poison the cast.
121		let min = self.policy.min.min(self.policy.max);
122		let headroom = if self.policy.headroom.is_finite() {
123			self.policy.headroom.clamp(0.0, 1.0)
124		} else {
125			0.0
126		};
127
128		let desired = ((estimate as f64 * headroom) as u64).clamp(min, self.policy.max);
129
130		let next = if desired <= self.target {
131			// Attack: the pipe is closing, give the bandwidth back now.
132			desired
133		} else {
134			// Decay: climb back at no more than `ramp` per second since the last
135			// change. Before the first change there's nothing to ramp from.
136			match self.applied {
137				Some(applied) => {
138					let elapsed = now.saturating_duration_since(applied).as_secs_f64();
139					let ramp = self.policy.ramp.max(0.0);
140					let grown = self.target as f64 * (1.0 + ramp * elapsed);
141					(grown as u64).min(desired).clamp(min, self.policy.max)
142				}
143				None => desired,
144			}
145		};
146
147		// Hysteresis is checked against the *applied* target and deliberately
148		// does not touch `applied` when it suppresses a move. The ramp allowance
149		// therefore keeps growing while small raises are suppressed, so a raise
150		// lands once it clears the threshold instead of being starved forever by
151		// a per-tick allowance smaller than the threshold.
152		let hysteresis = self.policy.hysteresis.max(0.0);
153		if (next.abs_diff(self.target) as f64) < self.target as f64 * hysteresis {
154			return None;
155		}
156
157		self.target = next;
158		self.applied = Some(now);
159		Some(next)
160	}
161}
162
163#[cfg(test)]
164mod tests {
165	use std::time::Duration;
166
167	use super::*;
168
169	/// 4 Mbps ceiling, so the 0.9 headroom and the max/10 floor land on round
170	/// numbers: 400 kbps floor, and an estimate of E targets 0.9 * E.
171	fn control() -> Control {
172		Control::new(Policy::new(4_000_000))
173	}
174
175	#[test]
176	fn starts_optimistic() {
177		assert_eq!(control().target(), 4_000_000);
178	}
179
180	#[test]
181	fn drop_applies_immediately_with_headroom() {
182		let mut control = control();
183		// A 2 Mbps pipe: target 90% of it at once, no ramp, no waiting.
184		assert_eq!(control.update(Some(2_000_000), Instant::now()), Some(1_800_000));
185		assert_eq!(control.target(), 1_800_000);
186	}
187
188	#[test]
189	fn missing_estimate_holds_the_target() {
190		let mut control = control();
191		let now = Instant::now();
192		control.update(Some(2_000_000), now).unwrap();
193
194		// Losing the estimate (disconnected) is not evidence the uplink is
195		// healthy again, so the target must not jump back to max.
196		assert_eq!(control.update(None, now + Duration::from_secs(10)), None);
197		assert_eq!(control.target(), 1_800_000);
198	}
199
200	#[test]
201	fn estimate_never_raises_above_max() {
202		let mut control = control();
203		// A wildly optimistic estimate is not licence to exceed what was configured.
204		assert_eq!(control.update(Some(100_000_000), Instant::now()), None);
205		assert_eq!(control.target(), 4_000_000);
206	}
207
208	#[test]
209	fn target_never_falls_below_min() {
210		let mut control = control();
211		// A near-dead uplink floors at min (max/10) rather than chasing to zero.
212		assert_eq!(control.update(Some(1), Instant::now()), Some(400_000));
213		assert_eq!(control.target(), 400_000);
214	}
215
216	#[test]
217	fn raise_is_ramp_limited() {
218		let mut control = control();
219		let start = Instant::now();
220		control.update(Some(1_000_000), start).unwrap(); // target 900k
221
222		// The pipe reopens to 4 Mbps. One second later the default 25%/s ramp
223		// allows only 900k -> 1125k, not the full 3.6 Mbps the estimate wants.
224		let raised = control.update(Some(4_000_000), start + Duration::from_secs(1)).unwrap();
225		assert_eq!(raised, 1_125_000);
226	}
227
228	#[test]
229	fn raise_eventually_reaches_the_estimate() {
230		let mut control = control();
231		let start = Instant::now();
232		control.update(Some(1_000_000), start).unwrap(); // target 900k
233
234		// Feed a steady healthy estimate every 100ms; the ramp should walk the
235		// target up to the full 90% of it and then stop.
236		for tick in 1..=200 {
237			control.update(Some(4_000_000), start + Duration::from_millis(100 * tick));
238		}
239		assert_eq!(control.target(), 3_600_000);
240	}
241
242	/// Regression: the ramp allowance per tick (25%/s * 100ms = 2.5%) is smaller
243	/// than the hysteresis threshold (5%), so a raise is suppressed on any single
244	/// tick. Suppression must not reset the ramp anchor, or the allowance would be
245	/// recomputed from `now` every tick, never clear the threshold, and the target
246	/// would be starved at the floor forever while the uplink sat idle.
247	#[test]
248	fn suppressed_raises_do_not_starve_the_ramp() {
249		let mut control = control();
250		let start = Instant::now();
251		control.update(Some(1_000_000), start).unwrap(); // target 900k
252
253		// Tick at 100ms: each tick alone is under the 5% threshold.
254		let mut raised = None;
255		for tick in 1..=10 {
256			if let Some(next) = control.update(Some(4_000_000), start + Duration::from_millis(100 * tick)) {
257				raised = Some((tick, next));
258				break;
259			}
260		}
261
262		let (tick, next) = raised.expect("a raise must eventually clear hysteresis");
263		// 5% of 900k needs 0.05/0.25 = 0.2s of ramp, i.e. the tick at 200ms.
264		assert_eq!(tick, 2);
265		assert_eq!(next, 945_000);
266	}
267
268	#[test]
269	fn small_moves_are_suppressed() {
270		let mut control = control();
271		let now = Instant::now();
272		control.update(Some(2_000_000), now).unwrap(); // target 1.8M
273
274		// 2% under the current target: inside the 5% deadband, so no reconfigure.
275		assert_eq!(control.update(Some(1_960_000), now + Duration::from_secs(1)), None);
276		assert_eq!(control.target(), 1_800_000);
277
278		// 20% under: outside the deadband, so it applies.
279		assert_eq!(
280			control.update(Some(1_600_000), now + Duration::from_secs(2)),
281			Some(1_440_000)
282		);
283	}
284
285	/// `min > max` is a caller error, but it must clamp rather than panic: the
286	/// bound is fed straight to `clamp`, which panics on an inverted range.
287	#[test]
288	fn inverted_bounds_do_not_panic() {
289		let mut policy = Policy::new(1_000_000);
290		policy.min = 5_000_000;
291		let mut control = Control::new(policy);
292		control.update(Some(2_000_000), Instant::now());
293		assert!(control.target() <= 5_000_000);
294	}
295
296	/// A non-finite headroom would make the `as u64` cast produce garbage rather
297	/// than a rate, so it's normalized away.
298	#[test]
299	fn non_finite_headroom_does_not_poison_the_target() {
300		let mut policy = Policy::new(4_000_000);
301		policy.headroom = f64::NAN;
302		let mut control = Control::new(policy);
303		control.update(Some(2_000_000), Instant::now());
304		assert_eq!(control.target(), 400_000); // floored, not NaN-cast to 0
305	}
306}