Skip to main content

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}