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 #[expect(
147 clippy::cast_possible_truncation,
148 clippy::cast_sign_loss,
149 reason = "wanted is finite and within [min, max) here, so the rounded value is a small non-negative integer"
150 )]
151 let rounded = wanted.round() as u32;
152 rounded.clamp(min, max)
153 }
154
155 /// `(min, max)` with both at least `1` and `min <= max`.
156 fn bounds(&self) -> (u32, u32) {
157 let max = self.max_samples.max(1);
158 (self.min_samples.clamp(1, max), max)
159 }
160
161 /// The first chunk of an uncalibrated lane, clamped to `[1, max_samples]`.
162 #[must_use]
163 pub fn first_chunk_samples(&self) -> u32 {
164 self.calibration_samples.clamp(1, self.bounds().1)
165 }
166
167 /// Tail-aware, share-aware chunk size for a lane measured at `rate`, given the
168 /// run's whole outstanding sample count `remaining` (every lane, not just this one)
169 /// and `sum_rates`, the sum of every lane's current rate (guess or estimate):
170 ///
171 /// ```text
172 /// want = min(rate * target_secs, ceil(remaining * rate / sum_rates))
173 /// ```
174 ///
175 /// clamped to `[min_samples, max_samples]` exactly like [`Self::samples_for_rate`].
176 /// The first term is the plain target-duration chunk; the second is this lane's
177 /// proportional share of what is left. Taking the smaller keeps ordinary chunk
178 /// sizing unchanged while samples are plentiful (the share term is then far
179 /// larger than the target term) and only shrinks the LAST few chunks of a run, so a
180 /// slow lane can no longer claim a disproportionate slice of a small tail and force
181 /// a fast lane sitting idle in [`crate::pool::epoch::Epoch::claim`] to wait out that
182 /// whole oversized chunk.
183 ///
184 /// Falls back to plain [`Self::samples_for_rate`] when `remaining` is `0` or
185 /// `sum_rates` is non-finite/non-positive (a single lane's `sum_rates` is its own
186 /// `rate`, so this only ever changes anything once `remaining` gets small relative
187 /// to that lane's own target-duration chunk -- exactly the run's tail).
188 #[must_use]
189 pub fn tail_aware_samples(&self, rate: f64, remaining: u32, sum_rates: f64) -> u32 {
190 let by_target = self.samples_for_rate(rate);
191 if remaining == 0 || !sum_rates.is_finite() || sum_rates <= 0.0 {
192 return by_target;
193 }
194 let share = (f64::from(remaining) * rate / sum_rates).ceil();
195 if !share.is_finite() {
196 return by_target;
197 }
198 let (min, max) = self.bounds();
199 let share_samples = if share <= 0.0 {
200 min
201 } else if share >= f64::from(max) {
202 max
203 } else {
204 #[expect(
205 clippy::cast_possible_truncation,
206 clippy::cast_sign_loss,
207 reason = "share is in (0, max) here, so the truncated value is a small non-negative integer"
208 )]
209 let truncated = share as u32;
210 truncated.clamp(min, max)
211 };
212 by_target.min(share_samples).max(min)
213 }
214}
215
216/// One lane's throughput estimate in samples per second. See the module doc.
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct RateModel {
219 /// Used until the first measurement.
220 guess: f64,
221 /// `None` until calibrated.
222 estimate: Option<f64>,
223 /// Weight of a new measurement, in `(0, 1]`.
224 smoothing: f64,
225 /// Measurements folded in so far.
226 observations: u32,
227 /// A one-shot factor for the lane's first calibrated chunk (see the module doc's
228 /// "Staggered lanes"); `None` once taken, and for every model not built by
229 /// [`Self::staggered`].
230 first_chunk_scale: Option<f64>,
231}
232
233impl RateModel {
234 /// An uncalibrated model starting from `initial_guess` samples per second. The
235 /// guess only matters for reporting ([`Self::rate`]); an uncalibrated lane's first
236 /// chunk is [`ChunkPolicy::first_chunk_samples`] long regardless.
237 #[must_use]
238 pub const fn new(initial_guess: f64) -> Self {
239 Self {
240 guess: initial_guess,
241 estimate: None,
242 smoothing: DEFAULT_SMOOTHING,
243 observations: 0,
244 first_chunk_scale: None,
245 }
246 }
247
248 /// A model already calibrated at `rate` (carried over from an earlier epoch of the
249 /// same lane, e.g. the previous tilt-video frame), so no calibration chunk is spent.
250 /// A non-finite or non-positive `rate` yields an uncalibrated model instead.
251 #[must_use]
252 pub fn calibrated(rate: f64) -> Self {
253 let mut model = Self::new(rate);
254 if valid_rate(rate) {
255 model.estimate = Some(rate);
256 }
257 model
258 }
259
260 /// [`Self::calibrated`] at `rate`, whose first chunk is only half the size its rate
261 /// asks for ([`Self::take_first_chunk_scale`] yields `Some(0.5)` once), so this lane
262 /// runs half a chunk out of phase with another lane of the same worker that starts
263 /// from [`Self::calibrated`]. An unusable `rate` yields an uncalibrated model with no
264 /// scale: there is no calibrated first chunk to halve.
265 #[must_use]
266 pub fn staggered(rate: f64) -> Self {
267 let mut model = Self::calibrated(rate);
268 if model.is_calibrated() {
269 model.first_chunk_scale = Some(STAGGER_SCALE);
270 }
271 model
272 }
273
274 /// Returns the factor to apply to this lane's first calibrated chunk and clears it,
275 /// so every later call yields `None`. Always `None` for a model not built by
276 /// [`Self::staggered`].
277 pub const fn take_first_chunk_scale(&mut self) -> Option<f64> {
278 self.first_chunk_scale.take()
279 }
280
281 /// Replaces the moving-average weight of a new measurement; clamped to
282 /// `[0.01, 1.0]` (`1.0` means "always trust the latest chunk").
283 #[must_use]
284 pub const fn with_smoothing(mut self, weight_of_new: f64) -> Self {
285 self.smoothing = if weight_of_new.is_finite() {
286 weight_of_new.clamp(0.01, 1.0)
287 } else {
288 DEFAULT_SMOOTHING
289 };
290 self
291 }
292
293 /// Whether at least one real measurement has replaced the guess.
294 #[must_use]
295 pub const fn is_calibrated(&self) -> bool {
296 self.estimate.is_some()
297 }
298
299 /// The current estimate, or the initial guess while uncalibrated.
300 #[must_use]
301 pub fn rate(&self) -> f64 {
302 self.estimate.unwrap_or(self.guess)
303 }
304
305 /// The calibrated estimate only (`None` while uncalibrated) -- what a caller
306 /// carries into the next epoch with [`Self::calibrated`].
307 #[must_use]
308 pub const fn estimate(&self) -> Option<f64> {
309 self.estimate
310 }
311
312 /// Measurements folded in so far.
313 #[must_use]
314 pub const fn observations(&self) -> u32 {
315 self.observations
316 }
317
318 /// Folds in one finished chunk: `done` samples in `elapsed` wall time, with the
319 /// lane's own `reported` marginal rate preferred when it is valid. Returns the
320 /// measurement used, or `None` (model unchanged) when there was nothing to measure.
321 pub fn observe(&mut self, done: u32, elapsed: Duration, reported: Option<f64>) -> Option<f64> {
322 let measured = reported
323 .filter(|&rate| valid_rate(rate))
324 .or_else(|| marginal_rate(done, elapsed))
325 .filter(|&rate| valid_rate(rate))?;
326 self.estimate = Some(
327 self.estimate
328 .map_or(measured, |old| self.smoothing.mul_add(measured - old, old)),
329 );
330 self.observations += 1;
331 Some(measured)
332 }
333
334 /// The size of this lane's next chunk under `policy`: the calibration chunk while
335 /// uncalibrated, otherwise [`ChunkPolicy::samples_for_rate`] of the estimate.
336 #[must_use]
337 pub fn chunk_samples(&self, policy: &ChunkPolicy) -> u32 {
338 self.estimate.map_or_else(
339 || policy.first_chunk_samples(),
340 |rate| policy.samples_for_rate(rate),
341 )
342 }
343}
344
345/// A usable throughput: finite and strictly positive.
346fn valid_rate(rate: f64) -> bool {
347 rate.is_finite() && rate > 0.0
348}