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 rendering rate: the samples between its first and its last progress report
19//! that advanced the count, over the time between those two reports, so connection
20//! setup, the final frame's encode and upload and the merge are all excluded; a chunk
21//! too short to have two such reports reports its whole first-progress-to-done rate
22//! instead), otherwise `done / wall time` of the whole chunk ([`marginal_rate`]).
23//!
24//! # Staggered lanes
25//!
26//! Two lanes backed by the same worker would otherwise start together with equal
27//! chunks, finish together and upload their frames together, leaving the worker's GPU
28//! idle during the shared upload. A model built with [`RateModel::staggered`] carries a
29//! one-shot scale ([`RateModel::take_first_chunk_scale`]) that the pool applies to the
30//! lane's first calibrated chunk only, so that lane starts half a chunk out of phase:
31//! one lane renders while the other uploads, for the rest of the run.
32
33use std::time::Duration;
34
35#[cfg(test)]
36mod tests;
37
38/// Weight of a new measurement in the moving average: 0.3 new, 0.7 old, the same
39/// blend the hybrid CPU/GPU split uses.
40pub const DEFAULT_SMOOTHING: f64 = 0.3;
41
42/// The first-chunk factor of a [`RateModel::staggered`] lane: half a chunk, so two lanes
43/// of one worker end up half a chunk apart.
44const STAGGER_SCALE: f64 = 0.5;
45
46/// The worker's hidden per-request cap: a chunk sent to a worker must never be larger,
47/// or the worker rejects the whole request.
48///
49/// The one definition: `indicatrix-worker`'s request validation
50/// (`MAX_SAMPLES_PER_REQUEST`) and the desktop's live remote lane
51/// (`LIVE_CHUNK_MAX_SAMPLES`) re-export it under their own names.
52pub const DEFAULT_MAX_CHUNK_SAMPLES: u32 = 65_536;
53
54/// Throughput in samples per second: `delta_samples` traced over `elapsed`. `None`
55/// when nothing was traced or the span is zero, so a degenerate measurement never
56/// reaches a [`RateModel`].
57#[must_use]
58pub fn marginal_rate(delta_samples: u32, elapsed: Duration) -> Option<f64> {
59 if delta_samples == 0 {
60 return None;
61 }
62 let secs = elapsed.as_secs_f64();
63 if secs <= 0.0 {
64 return None;
65 }
66 Some(f64::from(delta_samples) / secs)
67}
68
69/// How chunks are sized for a [`crate::LanePool`] run.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ChunkPolicy {
72 /// Target wall-clock duration of one chunk. Long enough that per-request overhead
73 /// (handshake, upload, the final transfer) is negligible against it; short enough
74 /// that a slow lane never holds the tail of the image hostage while every other
75 /// lane idles.
76 pub target: Duration,
77 /// Floor for a rate-sized chunk: a zero, tiny or non-finite rate must not collapse
78 /// the chunk to something overhead dominates.
79 pub min_samples: u32,
80 /// Ceiling for any chunk ([`DEFAULT_MAX_CHUNK_SAMPLES`] for remote workers).
81 pub max_samples: u32,
82 /// Size of an uncalibrated lane's first chunk (see the module doc).
83 pub calibration_samples: u32,
84}
85
86impl ChunkPolicy {
87 /// The still export and tilt video: about 22 s per chunk, a 32-sample floor
88 /// (below that a remote round trip costs more than it saves), an 8-sample
89 /// calibration probe -- the desktop export's existing numbers.
90 pub const EXPORT: Self = Self {
91 target: Duration::from_secs(22),
92 min_samples: 32,
93 max_samples: DEFAULT_MAX_CHUNK_SAMPLES,
94 calibration_samples: 8,
95 };
96
97 /// Interactive (live-view) requests: about 1.5 s per chunk so progress arrives
98 /// often, single-sample floor and calibration.
99 pub const INTERACTIVE: Self = Self {
100 target: Duration::from_millis(1500),
101 min_samples: 1,
102 max_samples: DEFAULT_MAX_CHUNK_SAMPLES,
103 calibration_samples: 1,
104 };
105
106 /// Every chunk (calibration included) is exactly `samples` long, whatever the rate.
107 /// The chunk partition of a run is then independent of timing (see
108 /// [`crate::Merger`]'s determinism notes). `0` is treated as `1`.
109 #[must_use]
110 pub const fn fixed(samples: u32) -> Self {
111 let samples = if samples == 0 { 1 } else { samples };
112 Self {
113 target: Duration::ZERO,
114 min_samples: samples,
115 max_samples: samples,
116 calibration_samples: samples,
117 }
118 }
119
120 /// The chunk size for a lane measured at `rate` samples per second: `rate *
121 /// target`, rounded, clamped to `[min_samples, max_samples]`; `min_samples` for a
122 /// non-finite or non-positive rate. Never `0`.
123 #[must_use]
124 pub fn samples_for_rate(&self, rate: f64) -> u32 {
125 self.clamp_samples(rate * self.target.as_secs_f64())
126 }
127
128 /// `samples` multiplied by `scale`, rounded and clamped to `[min_samples,
129 /// max_samples]` exactly like [`Self::samples_for_rate`]; `min_samples` for a
130 /// non-finite product. Never `0`.
131 #[must_use]
132 pub(crate) fn scaled_samples(&self, samples: u32, scale: f64) -> u32 {
133 self.clamp_samples(f64::from(samples) * scale)
134 }
135
136 /// `wanted` rounded to a whole chunk size inside `(min, max)` (see [`Self::bounds`]);
137 /// `min` for a non-finite or too-small value, `max` for a too-large one.
138 fn clamp_samples(&self, wanted: f64) -> u32 {
139 let (min, max) = self.bounds();
140 if !wanted.is_finite() || wanted < f64::from(min) {
141 return min;
142 }
143 if wanted >= f64::from(max) {
144 return max;
145 }
146 (wanted.round() as u32).clamp(min, max)
147 }
148
149 /// `(min, max)` with both at least `1` and `min <= max`.
150 fn bounds(&self) -> (u32, u32) {
151 let max = self.max_samples.max(1);
152 (self.min_samples.clamp(1, max), max)
153 }
154
155 /// The first chunk of an uncalibrated lane, clamped to `[1, max_samples]`.
156 #[must_use]
157 pub fn first_chunk_samples(&self) -> u32 {
158 self.calibration_samples.clamp(1, self.bounds().1)
159 }
160
161 /// Tail-aware, share-aware chunk size for a lane measured at `rate`, given the
162 /// run's whole outstanding sample count `remaining` (every lane, not just this one)
163 /// and `sum_rates`, the sum of every lane's current rate (guess or estimate):
164 ///
165 /// ```text
166 /// want = min(rate * target_secs, ceil(remaining * rate / sum_rates))
167 /// ```
168 ///
169 /// clamped to `[min_samples, max_samples]` exactly like [`Self::samples_for_rate`].
170 /// The first term is the plain target-duration chunk; the second is this lane's
171 /// proportional share of what is left. Taking the smaller keeps ordinary chunk
172 /// sizing unchanged while samples are plentiful (the share term is then far
173 /// larger than the target term) and only shrinks the LAST few chunks of a run, so a
174 /// slow lane can no longer claim a disproportionate slice of a small tail and force
175 /// a fast lane sitting idle in [`crate::pool::epoch::Epoch::claim`] to wait out that
176 /// whole oversized chunk.
177 ///
178 /// Falls back to plain [`Self::samples_for_rate`] when `remaining` is `0` or
179 /// `sum_rates` is non-finite/non-positive (a single lane's `sum_rates` is its own
180 /// `rate`, so this only ever changes anything once `remaining` gets small relative
181 /// to that lane's own target-duration chunk -- exactly the run's tail).
182 #[must_use]
183 pub fn tail_aware_samples(&self, rate: f64, remaining: u32, sum_rates: f64) -> u32 {
184 let by_target = self.samples_for_rate(rate);
185 if remaining == 0 || !sum_rates.is_finite() || sum_rates <= 0.0 {
186 return by_target;
187 }
188 let share = (f64::from(remaining) * rate / sum_rates).ceil();
189 if !share.is_finite() {
190 return by_target;
191 }
192 let (min, max) = self.bounds();
193 let share_samples = if share <= 0.0 {
194 min
195 } else if share >= f64::from(max) {
196 max
197 } else {
198 (share as u32).clamp(min, max)
199 };
200 by_target.min(share_samples).max(min)
201 }
202}
203
204/// One lane's throughput estimate in samples per second. See the module doc.
205#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct RateModel {
207 /// Used until the first measurement.
208 guess: f64,
209 /// `None` until calibrated.
210 estimate: Option<f64>,
211 /// Weight of a new measurement, in `(0, 1]`.
212 smoothing: f64,
213 /// Measurements folded in so far.
214 observations: u32,
215 /// A one-shot factor for the lane's first calibrated chunk (see the module doc's
216 /// "Staggered lanes"); `None` once taken, and for every model not built by
217 /// [`Self::staggered`].
218 first_chunk_scale: Option<f64>,
219}
220
221impl RateModel {
222 /// An uncalibrated model starting from `initial_guess` samples per second. The
223 /// guess only matters for reporting ([`Self::rate`]); an uncalibrated lane's first
224 /// chunk is [`ChunkPolicy::first_chunk_samples`] long regardless.
225 #[must_use]
226 pub const fn new(initial_guess: f64) -> Self {
227 Self {
228 guess: initial_guess,
229 estimate: None,
230 smoothing: DEFAULT_SMOOTHING,
231 observations: 0,
232 first_chunk_scale: None,
233 }
234 }
235
236 /// A model already calibrated at `rate` (carried over from an earlier epoch of the
237 /// same lane, e.g. the previous tilt-video frame), so no calibration chunk is spent.
238 /// A non-finite or non-positive `rate` yields an uncalibrated model instead.
239 #[must_use]
240 pub fn calibrated(rate: f64) -> Self {
241 let mut model = Self::new(rate);
242 if valid_rate(rate) {
243 model.estimate = Some(rate);
244 }
245 model
246 }
247
248 /// [`Self::calibrated`] at `rate`, whose first chunk is only half the size its rate
249 /// asks for ([`Self::take_first_chunk_scale`] yields `Some(0.5)` once), so this lane
250 /// runs half a chunk out of phase with another lane of the same worker that starts
251 /// from [`Self::calibrated`]. An unusable `rate` yields an uncalibrated model with no
252 /// scale: there is no calibrated first chunk to halve.
253 #[must_use]
254 pub fn staggered(rate: f64) -> Self {
255 let mut model = Self::calibrated(rate);
256 if model.is_calibrated() {
257 model.first_chunk_scale = Some(STAGGER_SCALE);
258 }
259 model
260 }
261
262 /// Returns the factor to apply to this lane's first calibrated chunk and clears it,
263 /// so every later call yields `None`. Always `None` for a model not built by
264 /// [`Self::staggered`].
265 pub const fn take_first_chunk_scale(&mut self) -> Option<f64> {
266 self.first_chunk_scale.take()
267 }
268
269 /// Replaces the moving-average weight of a new measurement; clamped to
270 /// `[0.01, 1.0]` (`1.0` means "always trust the latest chunk").
271 #[must_use]
272 pub const fn with_smoothing(mut self, weight_of_new: f64) -> Self {
273 self.smoothing = if weight_of_new.is_finite() {
274 weight_of_new.clamp(0.01, 1.0)
275 } else {
276 DEFAULT_SMOOTHING
277 };
278 self
279 }
280
281 /// Whether at least one real measurement has replaced the guess.
282 #[must_use]
283 pub const fn is_calibrated(&self) -> bool {
284 self.estimate.is_some()
285 }
286
287 /// The current estimate, or the initial guess while uncalibrated.
288 #[must_use]
289 pub fn rate(&self) -> f64 {
290 self.estimate.unwrap_or(self.guess)
291 }
292
293 /// The calibrated estimate only (`None` while uncalibrated) -- what a caller
294 /// carries into the next epoch with [`Self::calibrated`].
295 #[must_use]
296 pub const fn estimate(&self) -> Option<f64> {
297 self.estimate
298 }
299
300 /// Measurements folded in so far.
301 #[must_use]
302 pub const fn observations(&self) -> u32 {
303 self.observations
304 }
305
306 /// Folds in one finished chunk: `done` samples in `elapsed` wall time, with the
307 /// lane's own `reported` marginal rate preferred when it is valid. Returns the
308 /// measurement used, or `None` (model unchanged) when there was nothing to measure.
309 pub fn observe(&mut self, done: u32, elapsed: Duration, reported: Option<f64>) -> Option<f64> {
310 let measured = reported
311 .filter(|&rate| valid_rate(rate))
312 .or_else(|| marginal_rate(done, elapsed))
313 .filter(|&rate| valid_rate(rate))?;
314 self.estimate = Some(
315 self.estimate
316 .map_or(measured, |old| self.smoothing.mul_add(measured - old, old)),
317 );
318 self.observations += 1;
319 Some(measured)
320 }
321
322 /// The size of this lane's next chunk under `policy`: the calibration chunk while
323 /// uncalibrated, otherwise [`ChunkPolicy::samples_for_rate`] of the estimate.
324 #[must_use]
325 pub fn chunk_samples(&self, policy: &ChunkPolicy) -> u32 {
326 self.estimate.map_or_else(
327 || policy.first_chunk_samples(),
328 |rate| policy.samples_for_rate(rate),
329 )
330 }
331}
332
333/// A usable throughput: finite and strictly positive.
334fn valid_rate(rate: f64) -> bool {
335 rate.is_finite() && rate > 0.0
336}