Skip to main content

indicatrix_dispatch/pool/
mod.rs

1//! [`LanePool`]: N lanes rendering one image epoch against one [`SampleCursor`].
2//!
3//! # One run
4//!
5//! [`LanePool::run`] (or [`LanePool::run_into`]) spawns one scoped thread per lane.
6//! Each lane loops:
7//!
8//! 1. size a chunk from its own [`RateModel`] under the pool's [`ChunkPolicy`]
9//!    (a calibration chunk while uncalibrated);
10//! 2. claim it from the epoch's cursor ([`SampleCursor::claim_any`]: a failed lane's
11//!    returned remainder first, fresh samples after);
12//! 3. trace it with [`WorkerLane::render_chunk`];
13//! 4. merge the valid prefix into the [`Merger`] (chunk-start order, see its
14//!    determinism notes) and fold the measurement into its rate model;
15//! 5. on a short chunk: requeue the untraced tail for any lane, count a consecutive
16//!    failure, back off, and retire after [`PoolConfig::retire_after_failures`].
17//!
18//! A lane with nothing to claim waits while any other lane still has a chunk in flight
19//! (that chunk could fail and come back); claims and in-flight bookkeeping share one
20//! lock, so no lane can conclude "all done" between another lane's claim and its
21//! bookkeeping. The run ends when every lane has exited: all samples traced exactly
22//! once ([`PoolStatus::Complete`]), cancelled, or every lane retired with samples
23//! left ([`PoolStatus::LanesExhausted`]).
24//!
25//! A lane that panics inside `render_chunk` is treated as a failed chunk with nothing
26//! traced; the panic does not take the pool down.
27//!
28//! # Lanes joining and leaving a running epoch
29//!
30//! [`LanePool::run_into_fed`] also polls a [`LaneFeed`] on a thread of its own and starts
31//! every lane it returns at once, against the same cursor and merger. A lane whose
32//! resource is gone for good ([`WorkerLane::lost`]) is removed ([`PoolEvent::LaneRemoved`])
33//! as soon as its chunk is settled, its untraced tail already requeued, unless it is the
34//! last lane running: the last one keeps going through the failure schedule, and only an
35//! empty roster with samples left ends the run as [`PoolStatus::LanesExhausted`]. A lane
36//! offered after the last lane has left is dropped unused.
37
38mod epoch;
39mod feed;
40mod lane_loop;
41#[cfg(test)]
42mod tests;
43
44use crate::{CancelToken, ChunkPolicy, Merger, RateModel, SampleCursor, SampleRange, WorkerLane};
45use epoch::Epoch;
46pub use feed::LaneFeed;
47use glam::Vec3;
48use indicatrix_net::SceneState;
49use std::{
50    fmt,
51    sync::{Arc, Mutex, PoisonError, RwLock},
52    thread,
53    time::Duration,
54};
55
56/// Failure handling and chunk sizing for a [`LanePool`].
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct PoolConfig {
59    /// How chunks are sized from each lane's rate.
60    pub policy: ChunkPolicy,
61    /// Consecutive failed chunks a lane may have before it starts pausing between
62    /// attempts (`1`: pause after every failure). A single dropped connection is common
63    /// enough that retrying at once is often right.
64    pub pause_after_failures: u32,
65    /// Consecutive failed chunks after which a lane is retired for the rest of the
66    /// epoch. `u32::MAX` never retires.
67    pub retire_after_failures: u32,
68    /// The first pause; each further consecutive failure doubles it.
69    pub backoff_initial: Duration,
70    /// The longest pause.
71    pub backoff_max: Duration,
72}
73
74impl PoolConfig {
75    /// Still export / tilt video: 22 s chunks, pause from the 2nd consecutive failure
76    /// (15 s doubling to 120 s, the desktop export's schedule), retire after 5.
77    pub const EXPORT: Self = Self {
78        policy: ChunkPolicy::EXPORT,
79        pause_after_failures: 2,
80        retire_after_failures: 5,
81        backoff_initial: Duration::from_secs(15),
82        backoff_max: Duration::from_secs(120),
83    };
84
85    /// Interactive requests: 1.5 s chunks, short pauses (250 ms to 2 s), retire after 3.
86    pub const INTERACTIVE: Self = Self {
87        policy: ChunkPolicy::INTERACTIVE,
88        pause_after_failures: 1,
89        retire_after_failures: 3,
90        backoff_initial: Duration::from_millis(250),
91        backoff_max: Duration::from_secs(2),
92    };
93
94    /// The pause after `consecutive_failures` failed chunks in a row: zero below
95    /// [`Self::pause_after_failures`], then `backoff_initial` doubling per further
96    /// failure, capped at `backoff_max`.
97    #[must_use]
98    pub fn backoff(&self, consecutive_failures: u32) -> Duration {
99        if consecutive_failures < self.pause_after_failures.max(1) {
100            return Duration::ZERO;
101        }
102        let doublings = consecutive_failures - self.pause_after_failures.max(1);
103        self.backoff_initial
104            .saturating_mul(1u32 << doublings.min(16))
105            .min(self.backoff_max)
106    }
107}
108
109/// Something that happened during a [`LanePool`] run. `lane` is the index
110/// [`LanePool::add_lane`] returned; [`LanePool::lane_name`] names it.
111///
112/// Events are delivered on the lane threads, concurrently, in no global order.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum PoolEvent {
115    /// The lane's thread started claiming work.
116    LaneStarted {
117        /// The lane.
118        lane: usize,
119    },
120    /// A chunk's traced prefix was merged: progress.
121    ChunkMerged {
122        /// The lane.
123        lane: usize,
124        /// The chunk as claimed.
125        range: SampleRange,
126        /// How many of its samples were traced and merged (a prefix).
127        done: u32,
128        /// The exact merged total across all lanes after this chunk.
129        total_done: u32,
130        /// The run's target sample count.
131        target: u32,
132    },
133    /// A finished chunk was refused because the merger's parked-bytes budget is full and
134    /// is being retried once the frontier moves. Sent for a lane's first deferral at a
135    /// frontier and then every 50th, so a stalled frontier is visible. After 1200 in a row
136    /// at one frontier the lane fails the chunk instead ([`PoolEvent::LaneFailed`]).
137    ChunkDeferred {
138        /// The lane.
139        lane: usize,
140        /// The chunk as claimed.
141        range: SampleRange,
142        /// The merge frontier the chunk is waiting on.
143        frontier: u32,
144        /// Samples merged or parked so far across all lanes.
145        total_done: u32,
146        /// Consecutive deferrals of this lane at this frontier, this one included.
147        deferrals: u32,
148    },
149    /// A chunk ended short; its untraced tail went back to the cursor.
150    LaneFailed {
151        /// The lane.
152        lane: usize,
153        /// Why (the lane's own error text, or a pool-side reason).
154        error: String,
155        /// The tail returned to the cursor for any lane.
156        returned: SampleRange,
157        /// Failed chunks in a row, this one included.
158        consecutive_failures: u32,
159        /// The pause the lane now takes; `None` when it is being retired instead.
160        pause: Option<Duration>,
161    },
162    /// The lane failed too often in a row and takes no more work this run.
163    LaneRetired {
164        /// The lane.
165        lane: usize,
166        /// Failed chunks in a row.
167        consecutive_failures: u32,
168    },
169    /// The lane's resource is gone for good ([`WorkerLane::lost`]) and other lanes carry
170    /// on: it left the epoch without a backoff or retirement. Whatever chunk it had
171    /// in flight is already back on the cursor.
172    LaneRemoved {
173        /// The lane.
174        lane: usize,
175    },
176    /// The lane stopped normally: nothing left to claim, or cancelled.
177    LaneFinished {
178        /// The lane.
179        lane: usize,
180        /// Chunks it merged at least one sample from.
181        chunks: u32,
182        /// Samples it contributed.
183        samples: u32,
184    },
185}
186
187/// How a run ended.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum PoolStatus {
190    /// Every sample of the range was traced exactly once.
191    Complete,
192    /// The cancel token was raised; `missing` samples were not traced.
193    Cancelled {
194        /// Samples of the range not merged.
195        missing: u32,
196    },
197    /// Every lane retired (or there were none) with `missing` samples untraced. A
198    /// coordinator reports this to the viewer as a lost job.
199    LanesExhausted {
200        /// Samples of the range not merged.
201        missing: u32,
202    },
203}
204
205/// The result of [`LanePool::run`].
206#[derive(Debug, Clone, PartialEq)]
207pub struct PoolOutcome {
208    /// Merged per-pixel radiance sum, `width * height` long.
209    pub sum: Vec<Vec3>,
210    /// Exact number of samples in `sum`.
211    pub count: u32,
212    /// How the run ended.
213    pub status: PoolStatus,
214}
215
216/// [`LanePool::run_into`] was handed a [`Merger`] built for a different image or range.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct MergerMismatch {
219    /// `scene.width * scene.height`.
220    pub expected_pixels: usize,
221    /// The range's first sample.
222    pub expected_first_sample: u32,
223    /// The merger's pixel count.
224    pub merger_pixels: usize,
225    /// The merger's first sample.
226    pub merger_first_sample: u32,
227    /// Samples the merger already held (a fresh merger holds none).
228    pub merger_samples: u32,
229}
230
231impl fmt::Display for MergerMismatch {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(
234            f,
235            "merger built for {} px from sample {} holding {} samples, run needs a fresh \
236             one for {} px from sample {}",
237            self.merger_pixels,
238            self.merger_first_sample,
239            self.merger_samples,
240            self.expected_pixels,
241            self.expected_first_sample
242        )
243    }
244}
245
246impl std::error::Error for MergerMismatch {}
247
248/// One registered lane and its rate model, which persists across runs.
249struct LaneSlot {
250    lane: Arc<dyn WorkerLane>,
251    rate: Mutex<RateModel>,
252}
253
254/// See the module doc. Lanes and their rate models persist across runs, so a caller
255/// rendering many images of one scene (a tilt video, successive coordinator jobs)
256/// calibrates each lane once.
257pub struct LanePool {
258    config: PoolConfig,
259    /// Grows while a fed run ([`Self::run_into_fed`]) adds lanes; a lane's position is
260    /// its index.
261    lanes: RwLock<Vec<Arc<LaneSlot>>>,
262}
263
264impl LanePool {
265    /// An empty pool.
266    #[must_use]
267    pub const fn new(config: PoolConfig) -> Self {
268        Self {
269            config,
270            lanes: RwLock::new(Vec::new()),
271        }
272    }
273
274    fn lanes_mut(&mut self) -> &mut Vec<Arc<LaneSlot>> {
275        self.lanes.get_mut().unwrap_or_else(PoisonError::into_inner)
276    }
277
278    fn slot(&self, lane: usize) -> Option<Arc<LaneSlot>> {
279        self.lanes
280            .read()
281            .unwrap_or_else(PoisonError::into_inner)
282            .get(lane)
283            .cloned()
284    }
285
286    /// Registers `lane` starting from `rate`, returning its index.
287    pub fn add_lane(&mut self, lane: Arc<dyn WorkerLane>, rate: RateModel) -> usize {
288        let lanes = self.lanes_mut();
289        lanes.push(Arc::new(LaneSlot {
290            lane,
291            rate: Mutex::new(rate),
292        }));
293        lanes.len() - 1
294    }
295
296    /// The pool's configuration.
297    #[must_use]
298    pub const fn config(&self) -> &PoolConfig {
299        &self.config
300    }
301
302    /// How many lanes are registered (those a fed run added included).
303    #[must_use]
304    pub fn lane_count(&self) -> usize {
305        self.lanes
306            .read()
307            .unwrap_or_else(PoisonError::into_inner)
308            .len()
309    }
310
311    /// Lane `lane`'s [`WorkerLane::name`].
312    #[must_use]
313    pub fn lane_name(&self, lane: usize) -> Option<String> {
314        self.slot(lane).map(|slot| slot.lane.name().to_owned())
315    }
316
317    /// Lane `lane`'s current rate model.
318    #[must_use]
319    pub fn rate(&self, lane: usize) -> Option<RateModel> {
320        self.slot(lane)
321            .map(|slot| *slot.rate.lock().unwrap_or_else(PoisonError::into_inner))
322    }
323
324    /// Renders `range` of `scene` with every lane and returns the merged result.
325    /// Blocks until the run ends (see [`PoolStatus`]); `events` is called from the lane
326    /// threads as things happen.
327    pub fn run(
328        &self,
329        scene: &SceneState,
330        range: SampleRange,
331        cancel: &CancelToken,
332        events: &(dyn Fn(PoolEvent) + Sync),
333    ) -> PoolOutcome {
334        let merger = Merger::new(pixel_count(scene), range.first_sample);
335        let status = self.run_unchecked(scene, range, &merger, cancel, events, None);
336        let (sum, count) = merger.into_parts();
337        PoolOutcome { sum, count, status }
338    }
339
340    /// Like [`Self::run`], merging into a caller-owned `merger` so another thread can
341    /// read progressive snapshots ([`Merger::snapshot_into`]) while the run is going.
342    /// `merger` must be fresh, built with `Merger::new(width * height,
343    /// range.first_sample)`.
344    ///
345    /// # Errors
346    ///
347    /// [`MergerMismatch`] when `merger` was built for another size or range; nothing
348    /// is rendered then.
349    pub fn run_into(
350        &self,
351        scene: &SceneState,
352        range: SampleRange,
353        merger: &Merger,
354        cancel: &CancelToken,
355        events: &(dyn Fn(PoolEvent) + Sync),
356    ) -> Result<PoolStatus, MergerMismatch> {
357        self.run_into_fed(scene, range, merger, cancel, events, None)
358    }
359
360    /// Like [`Self::run_into`], and additionally polls `feed` while the run is going:
361    /// every lane it returns joins the epoch at once (see [`LaneFeed`]) and ends with it.
362    /// Lanes added this way are registered in the pool afterwards, so [`Self::rate`] and
363    /// [`Self::lane_name`] answer for them too. Without lanes of its own the run still
364    /// ends as soon as no lane is running, however long `feed` could still deliver.
365    ///
366    /// # Errors
367    ///
368    /// [`MergerMismatch`] as for [`Self::run_into`].
369    pub fn run_into_fed(
370        &self,
371        scene: &SceneState,
372        range: SampleRange,
373        merger: &Merger,
374        cancel: &CancelToken,
375        events: &(dyn Fn(PoolEvent) + Sync),
376        feed: Option<&dyn LaneFeed>,
377    ) -> Result<PoolStatus, MergerMismatch> {
378        let expected_pixels = pixel_count(scene);
379        let merger_samples = merger.total();
380        if merger.pixel_count() != expected_pixels
381            || merger.first_sample() != range.first_sample
382            || merger_samples != 0
383        {
384            return Err(MergerMismatch {
385                expected_pixels,
386                expected_first_sample: range.first_sample,
387                merger_pixels: merger.pixel_count(),
388                merger_first_sample: merger.first_sample(),
389                merger_samples,
390            });
391        }
392        Ok(self.run_unchecked(scene, range, merger, cancel, events, feed))
393    }
394
395    fn run_unchecked(
396        &self,
397        scene: &SceneState,
398        range: SampleRange,
399        merger: &Merger,
400        cancel: &CancelToken,
401        events: &(dyn Fn(PoolEvent) + Sync),
402        feed: Option<&dyn LaneFeed>,
403    ) -> PoolStatus {
404        let started = self.lane_count();
405        let epoch = Epoch {
406            scene,
407            cursor: SampleCursor::new(range.first_sample, range.end()),
408            merger,
409            cancel,
410            events,
411            config: &self.config,
412            target: range.samples,
413            pixels: merger.pixel_count(),
414            sched: epoch::Sched::new(),
415            lanes: &self.lanes,
416            roster: epoch::Roster::new(started),
417        };
418        if !range.is_empty() {
419            let epoch = &epoch;
420            thread::scope(|scope| {
421                for index in 0..started {
422                    if let Some(slot) = self.slot(index) {
423                        feed::spawn_lane(scope, epoch, index, slot);
424                    }
425                }
426                if let Some(feed) = feed {
427                    scope.spawn(move || feed::feed_lanes(scope, epoch, feed));
428                }
429            });
430        }
431        let missing = range.samples.saturating_sub(merger.total());
432        if missing == 0 {
433            PoolStatus::Complete
434        } else if cancel.is_cancelled() {
435            PoolStatus::Cancelled { missing }
436        } else {
437            PoolStatus::LanesExhausted { missing }
438        }
439    }
440}
441
442/// `width * height` of `scene`.
443const fn pixel_count(scene: &SceneState) -> usize {
444    scene.width as usize * scene.height as usize
445}