indicatrix_dispatch/rate/mod.rs
1//! [`RateModel`]: one lane's throughput estimate, and [`ChunkPolicy`]: how a chunk is
2//! sized from it.
3//!
4//! Generalised from the desktop export's remote lane (`remote_chunk_samples`,
5//! `remote_marginal_rate`, `calibrate_remote_rate`, the per-video rate carry) and the
6//! hybrid split's 0.7-old/0.3-new moving average:
7//!
8//! 1. **Initial guess.** A lane that has never been measured starts from a guess and
9//! is handed a small calibration chunk ([`ChunkPolicy::calibration_samples`]) rather
10//! than a chunk sized from the guess, so a wildly wrong guess costs one short chunk.
11//! 2. **First-chunk calibration.** The first valid measurement REPLACES the guess
12//! outright; a guess is not evidence worth averaging with.
13//! 3. **Blending.** Every later measurement is folded in with an exponential moving
14//! average ([`DEFAULT_SMOOTHING`] weight on the new sample), so thermal drift and load
15//! changes are followed without one noisy chunk swinging the next chunk's size.
16//!
17//! A measurement is the lane's own reported marginal rate when it has one (a remote
18//! worker's steady-state rate, excluding connection setup and upload), otherwise
19//! `done / wall time` of the whole chunk ([`marginal_rate`]).
20
21use std::time::Duration;
22
23#[cfg(test)]
24mod tests;
25
26/// Weight of a new measurement in the moving average: 0.3 new, 0.7 old, the same
27/// blend the hybrid CPU/GPU split uses.
28pub const DEFAULT_SMOOTHING: f64 = 0.3;
29
30/// The worker's hidden per-request cap: a chunk sent to a worker must never be larger,
31/// or the worker rejects the whole request.
32///
33/// The one definition: `indicatrix-worker`'s request validation
34/// (`MAX_SAMPLES_PER_REQUEST`) and the desktop's live remote lane
35/// (`LIVE_CHUNK_MAX_SAMPLES`) re-export it under their own names.
36pub const DEFAULT_MAX_CHUNK_SAMPLES: u32 = 65_536;
37
38/// Throughput in samples per second: `delta_samples` traced over `elapsed`. `None`
39/// when nothing was traced or the span is zero, so a degenerate measurement never
40/// reaches a [`RateModel`].
41#[must_use]
42pub fn marginal_rate(delta_samples: u32, elapsed: Duration) -> Option<f64> {
43 if delta_samples == 0 {
44 return None;
45 }
46 let secs = elapsed.as_secs_f64();
47 if secs <= 0.0 {
48 return None;
49 }
50 Some(f64::from(delta_samples) / secs)
51}
52
53/// How chunks are sized for a [`crate::LanePool`] run.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct ChunkPolicy {
56 /// Target wall-clock duration of one chunk. Long enough that per-request overhead
57 /// (handshake, upload, the final transfer) is negligible against it; short enough
58 /// that a slow lane never holds the tail of the image hostage while every other
59 /// lane idles.
60 pub target: Duration,
61 /// Floor for a rate-sized chunk: a zero, tiny or non-finite rate must not collapse
62 /// the chunk to something overhead dominates.
63 pub min_samples: u32,
64 /// Ceiling for any chunk ([`DEFAULT_MAX_CHUNK_SAMPLES`] for remote workers).
65 pub max_samples: u32,
66 /// Size of an uncalibrated lane's first chunk (see the module doc).
67 pub calibration_samples: u32,
68}
69
70impl ChunkPolicy {
71 /// The still export and tilt video: about 22 s per chunk, a 32-sample floor
72 /// (below that a remote round trip costs more than it saves), an 8-sample
73 /// calibration probe -- the desktop export's existing numbers.
74 pub const EXPORT: Self = Self {
75 target: Duration::from_secs(22),
76 min_samples: 32,
77 max_samples: DEFAULT_MAX_CHUNK_SAMPLES,
78 calibration_samples: 8,
79 };
80
81 /// Interactive (live-view) requests: about 1.5 s per chunk so progress arrives
82 /// often, single-sample floor and calibration.
83 pub const INTERACTIVE: Self = Self {
84 target: Duration::from_millis(1500),
85 min_samples: 1,
86 max_samples: DEFAULT_MAX_CHUNK_SAMPLES,
87 calibration_samples: 1,
88 };
89
90 /// Every chunk (calibration included) is exactly `samples` long, whatever the rate.
91 /// The chunk partition of a run is then independent of timing (see
92 /// [`crate::Merger`]'s determinism notes). `0` is treated as `1`.
93 #[must_use]
94 pub const fn fixed(samples: u32) -> Self {
95 let samples = if samples == 0 { 1 } else { samples };
96 Self {
97 target: Duration::ZERO,
98 min_samples: samples,
99 max_samples: samples,
100 calibration_samples: samples,
101 }
102 }
103
104 /// The chunk size for a lane measured at `rate` samples per second: `rate *
105 /// target`, rounded, clamped to `[min_samples, max_samples]`; `min_samples` for a
106 /// non-finite or non-positive rate. Never `0`.
107 #[must_use]
108 pub fn samples_for_rate(&self, rate: f64) -> u32 {
109 let (min, max) = self.bounds();
110 let target = rate * self.target.as_secs_f64();
111 if !target.is_finite() || target < f64::from(min) {
112 return min;
113 }
114 if target >= f64::from(max) {
115 return max;
116 }
117 (target.round() as u32).clamp(min, max)
118 }
119
120 /// `(min, max)` with both at least `1` and `min <= max`.
121 fn bounds(&self) -> (u32, u32) {
122 let max = self.max_samples.max(1);
123 (self.min_samples.clamp(1, max), max)
124 }
125
126 /// The first chunk of an uncalibrated lane, clamped to `[1, max_samples]`.
127 #[must_use]
128 pub fn first_chunk_samples(&self) -> u32 {
129 self.calibration_samples.clamp(1, self.bounds().1)
130 }
131
132 /// Tail-aware, share-aware chunk size for a lane measured at `rate`, given the
133 /// run's whole outstanding sample count `remaining` (every lane, not just this one)
134 /// and `sum_rates`, the sum of every lane's current rate (guess or estimate):
135 ///
136 /// ```text
137 /// want = min(rate * target_secs, ceil(remaining * rate / sum_rates))
138 /// ```
139 ///
140 /// clamped to `[min_samples, max_samples]` exactly like [`Self::samples_for_rate`].
141 /// The first term is the plain target-duration chunk; the second is this lane's
142 /// proportional share of what is left. Taking the smaller keeps ordinary chunk
143 /// sizing unchanged while samples are plentiful (the share term is then far
144 /// larger than the target term) and only shrinks the LAST few chunks of a run, so a
145 /// slow lane can no longer claim a disproportionate slice of a small tail and force
146 /// a fast lane sitting idle in [`crate::pool::epoch::Epoch::claim`] to wait out that
147 /// whole oversized chunk.
148 ///
149 /// Falls back to plain [`Self::samples_for_rate`] when `remaining` is `0` or
150 /// `sum_rates` is non-finite/non-positive (a single lane's `sum_rates` is its own
151 /// `rate`, so this only ever changes anything once `remaining` gets small relative
152 /// to that lane's own target-duration chunk -- exactly the run's tail).
153 #[must_use]
154 pub fn tail_aware_samples(&self, rate: f64, remaining: u32, sum_rates: f64) -> u32 {
155 let by_target = self.samples_for_rate(rate);
156 if remaining == 0 || !sum_rates.is_finite() || sum_rates <= 0.0 {
157 return by_target;
158 }
159 let share = (f64::from(remaining) * rate / sum_rates).ceil();
160 if !share.is_finite() {
161 return by_target;
162 }
163 let (min, max) = self.bounds();
164 let share_samples = if share <= 0.0 {
165 min
166 } else if share >= f64::from(max) {
167 max
168 } else {
169 (share as u32).clamp(min, max)
170 };
171 by_target.min(share_samples).max(min)
172 }
173}
174
175/// One lane's throughput estimate in samples per second. See the module doc.
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub struct RateModel {
178 /// Used until the first measurement.
179 guess: f64,
180 /// `None` until calibrated.
181 estimate: Option<f64>,
182 /// Weight of a new measurement, in `(0, 1]`.
183 smoothing: f64,
184 /// Measurements folded in so far.
185 observations: u32,
186}
187
188impl RateModel {
189 /// An uncalibrated model starting from `initial_guess` samples per second. The
190 /// guess only matters for reporting ([`Self::rate`]); an uncalibrated lane's first
191 /// chunk is [`ChunkPolicy::first_chunk_samples`] long regardless.
192 #[must_use]
193 pub const fn new(initial_guess: f64) -> Self {
194 Self {
195 guess: initial_guess,
196 estimate: None,
197 smoothing: DEFAULT_SMOOTHING,
198 observations: 0,
199 }
200 }
201
202 /// A model already calibrated at `rate` (carried over from an earlier epoch of the
203 /// same lane, e.g. the previous tilt-video frame), so no calibration chunk is spent.
204 /// A non-finite or non-positive `rate` yields an uncalibrated model instead.
205 #[must_use]
206 pub fn calibrated(rate: f64) -> Self {
207 let mut model = Self::new(rate);
208 if valid_rate(rate) {
209 model.estimate = Some(rate);
210 }
211 model
212 }
213
214 /// Replaces the moving-average weight of a new measurement; clamped to
215 /// `[0.01, 1.0]` (`1.0` means "always trust the latest chunk").
216 #[must_use]
217 pub const fn with_smoothing(mut self, weight_of_new: f64) -> Self {
218 self.smoothing = if weight_of_new.is_finite() {
219 weight_of_new.clamp(0.01, 1.0)
220 } else {
221 DEFAULT_SMOOTHING
222 };
223 self
224 }
225
226 /// Whether at least one real measurement has replaced the guess.
227 #[must_use]
228 pub const fn is_calibrated(&self) -> bool {
229 self.estimate.is_some()
230 }
231
232 /// The current estimate, or the initial guess while uncalibrated.
233 #[must_use]
234 pub fn rate(&self) -> f64 {
235 self.estimate.unwrap_or(self.guess)
236 }
237
238 /// The calibrated estimate only (`None` while uncalibrated) -- what a caller
239 /// carries into the next epoch with [`Self::calibrated`].
240 #[must_use]
241 pub const fn estimate(&self) -> Option<f64> {
242 self.estimate
243 }
244
245 /// Measurements folded in so far.
246 #[must_use]
247 pub const fn observations(&self) -> u32 {
248 self.observations
249 }
250
251 /// Folds in one finished chunk: `done` samples in `elapsed` wall time, with the
252 /// lane's own `reported` marginal rate preferred when it is valid. Returns the
253 /// measurement used, or `None` (model unchanged) when there was nothing to measure.
254 pub fn observe(&mut self, done: u32, elapsed: Duration, reported: Option<f64>) -> Option<f64> {
255 let measured = reported
256 .filter(|&rate| valid_rate(rate))
257 .or_else(|| marginal_rate(done, elapsed))
258 .filter(|&rate| valid_rate(rate))?;
259 self.estimate = Some(
260 self.estimate
261 .map_or(measured, |old| self.smoothing.mul_add(measured - old, old)),
262 );
263 self.observations += 1;
264 Some(measured)
265 }
266
267 /// The size of this lane's next chunk under `policy`: the calibration chunk while
268 /// uncalibrated, otherwise [`ChunkPolicy::samples_for_rate`] of the estimate.
269 #[must_use]
270 pub fn chunk_samples(&self, policy: &ChunkPolicy) -> u32 {
271 self.estimate.map_or_else(
272 || policy.first_chunk_samples(),
273 |rate| policy.samples_for_rate(rate),
274 )
275 }
276}
277
278/// A usable throughput: finite and strictly positive.
279fn valid_rate(rate: f64) -> bool {
280 rate.is_finite() && rate > 0.0
281}