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 chunk ended short; its untraced tail went back to the cursor.
134    LaneFailed {
135        /// The lane.
136        lane: usize,
137        /// Why (the lane's own error text, or a pool-side reason).
138        error: String,
139        /// The tail returned to the cursor for any lane.
140        returned: SampleRange,
141        /// Failed chunks in a row, this one included.
142        consecutive_failures: u32,
143        /// The pause the lane now takes; `None` when it is being retired instead.
144        pause: Option<Duration>,
145    },
146    /// The lane failed too often in a row and takes no more work this run.
147    LaneRetired {
148        /// The lane.
149        lane: usize,
150        /// Failed chunks in a row.
151        consecutive_failures: u32,
152    },
153    /// The lane's resource is gone for good ([`WorkerLane::lost`]) and other lanes carry
154    /// on: it left the epoch without a backoff or retirement. Whatever chunk it had
155    /// in flight is already back on the cursor.
156    LaneRemoved {
157        /// The lane.
158        lane: usize,
159    },
160    /// The lane stopped normally: nothing left to claim, or cancelled.
161    LaneFinished {
162        /// The lane.
163        lane: usize,
164        /// Chunks it merged at least one sample from.
165        chunks: u32,
166        /// Samples it contributed.
167        samples: u32,
168    },
169}
170
171/// How a run ended.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum PoolStatus {
174    /// Every sample of the range was traced exactly once.
175    Complete,
176    /// The cancel token was raised; `missing` samples were not traced.
177    Cancelled {
178        /// Samples of the range not merged.
179        missing: u32,
180    },
181    /// Every lane retired (or there were none) with `missing` samples untraced. A
182    /// coordinator reports this to the viewer as a lost job.
183    LanesExhausted {
184        /// Samples of the range not merged.
185        missing: u32,
186    },
187}
188
189/// The result of [`LanePool::run`].
190#[derive(Debug, Clone, PartialEq)]
191pub struct PoolOutcome {
192    /// Merged per-pixel radiance sum, `width * height` long.
193    pub sum: Vec<Vec3>,
194    /// Exact number of samples in `sum`.
195    pub count: u32,
196    /// How the run ended.
197    pub status: PoolStatus,
198}
199
200/// [`LanePool::run_into`] was handed a [`Merger`] built for a different image or range.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct MergerMismatch {
203    /// `scene.width * scene.height`.
204    pub expected_pixels: usize,
205    /// The range's first sample.
206    pub expected_first_sample: u32,
207    /// The merger's pixel count.
208    pub merger_pixels: usize,
209    /// The merger's first sample.
210    pub merger_first_sample: u32,
211    /// Samples the merger already held (a fresh merger holds none).
212    pub merger_samples: u32,
213}
214
215impl fmt::Display for MergerMismatch {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(
218            f,
219            "merger built for {} px from sample {} holding {} samples, run needs a fresh \
220             one for {} px from sample {}",
221            self.merger_pixels,
222            self.merger_first_sample,
223            self.merger_samples,
224            self.expected_pixels,
225            self.expected_first_sample
226        )
227    }
228}
229
230impl std::error::Error for MergerMismatch {}
231
232/// One registered lane and its rate model, which persists across runs.
233struct LaneSlot {
234    lane: Arc<dyn WorkerLane>,
235    rate: Mutex<RateModel>,
236}
237
238/// See the module doc. Lanes and their rate models persist across runs, so a caller
239/// rendering many images of one scene (a tilt video, successive coordinator jobs)
240/// calibrates each lane once.
241pub struct LanePool {
242    config: PoolConfig,
243    /// Grows while a fed run ([`Self::run_into_fed`]) adds lanes; a lane's position is
244    /// its index.
245    lanes: RwLock<Vec<Arc<LaneSlot>>>,
246}
247
248impl LanePool {
249    /// An empty pool.
250    #[must_use]
251    pub const fn new(config: PoolConfig) -> Self {
252        Self {
253            config,
254            lanes: RwLock::new(Vec::new()),
255        }
256    }
257
258    fn lanes_mut(&mut self) -> &mut Vec<Arc<LaneSlot>> {
259        self.lanes.get_mut().unwrap_or_else(PoisonError::into_inner)
260    }
261
262    fn slot(&self, lane: usize) -> Option<Arc<LaneSlot>> {
263        self.lanes
264            .read()
265            .unwrap_or_else(PoisonError::into_inner)
266            .get(lane)
267            .cloned()
268    }
269
270    /// Registers `lane` starting from `rate`, returning its index.
271    pub fn add_lane(&mut self, lane: Arc<dyn WorkerLane>, rate: RateModel) -> usize {
272        let lanes = self.lanes_mut();
273        lanes.push(Arc::new(LaneSlot {
274            lane,
275            rate: Mutex::new(rate),
276        }));
277        lanes.len() - 1
278    }
279
280    /// The pool's configuration.
281    #[must_use]
282    pub const fn config(&self) -> &PoolConfig {
283        &self.config
284    }
285
286    /// How many lanes are registered (those a fed run added included).
287    #[must_use]
288    pub fn lane_count(&self) -> usize {
289        self.lanes
290            .read()
291            .unwrap_or_else(PoisonError::into_inner)
292            .len()
293    }
294
295    /// Lane `lane`'s [`WorkerLane::name`].
296    #[must_use]
297    pub fn lane_name(&self, lane: usize) -> Option<String> {
298        self.slot(lane).map(|slot| slot.lane.name().to_owned())
299    }
300
301    /// Lane `lane`'s current rate model.
302    #[must_use]
303    pub fn rate(&self, lane: usize) -> Option<RateModel> {
304        self.slot(lane)
305            .map(|slot| *slot.rate.lock().unwrap_or_else(PoisonError::into_inner))
306    }
307
308    /// Renders `range` of `scene` with every lane and returns the merged result.
309    /// Blocks until the run ends (see [`PoolStatus`]); `events` is called from the lane
310    /// threads as things happen.
311    pub fn run(
312        &self,
313        scene: &SceneState,
314        range: SampleRange,
315        cancel: &CancelToken,
316        events: &(dyn Fn(PoolEvent) + Sync),
317    ) -> PoolOutcome {
318        let merger = Merger::new(pixel_count(scene), range.first_sample);
319        let status = self.run_unchecked(scene, range, &merger, cancel, events, None);
320        let (sum, count) = merger.into_parts();
321        PoolOutcome { sum, count, status }
322    }
323
324    /// Like [`Self::run`], merging into a caller-owned `merger` so another thread can
325    /// read progressive snapshots ([`Merger::snapshot_into`]) while the run is going.
326    /// `merger` must be fresh, built with `Merger::new(width * height,
327    /// range.first_sample)`.
328    ///
329    /// # Errors
330    ///
331    /// [`MergerMismatch`] when `merger` was built for another size or range; nothing
332    /// is rendered then.
333    pub fn run_into(
334        &self,
335        scene: &SceneState,
336        range: SampleRange,
337        merger: &Merger,
338        cancel: &CancelToken,
339        events: &(dyn Fn(PoolEvent) + Sync),
340    ) -> Result<PoolStatus, MergerMismatch> {
341        self.run_into_fed(scene, range, merger, cancel, events, None)
342    }
343
344    /// Like [`Self::run_into`], and additionally polls `feed` while the run is going:
345    /// every lane it returns joins the epoch at once (see [`LaneFeed`]) and ends with it.
346    /// Lanes added this way are registered in the pool afterwards, so [`Self::rate`] and
347    /// [`Self::lane_name`] answer for them too. Without lanes of its own the run still
348    /// ends as soon as no lane is running, however long `feed` could still deliver.
349    ///
350    /// # Errors
351    ///
352    /// [`MergerMismatch`] as for [`Self::run_into`].
353    pub fn run_into_fed(
354        &self,
355        scene: &SceneState,
356        range: SampleRange,
357        merger: &Merger,
358        cancel: &CancelToken,
359        events: &(dyn Fn(PoolEvent) + Sync),
360        feed: Option<&dyn LaneFeed>,
361    ) -> Result<PoolStatus, MergerMismatch> {
362        let expected_pixels = pixel_count(scene);
363        let merger_samples = merger.total();
364        if merger.pixel_count() != expected_pixels
365            || merger.first_sample() != range.first_sample
366            || merger_samples != 0
367        {
368            return Err(MergerMismatch {
369                expected_pixels,
370                expected_first_sample: range.first_sample,
371                merger_pixels: merger.pixel_count(),
372                merger_first_sample: merger.first_sample(),
373                merger_samples,
374            });
375        }
376        Ok(self.run_unchecked(scene, range, merger, cancel, events, feed))
377    }
378
379    fn run_unchecked(
380        &self,
381        scene: &SceneState,
382        range: SampleRange,
383        merger: &Merger,
384        cancel: &CancelToken,
385        events: &(dyn Fn(PoolEvent) + Sync),
386        feed: Option<&dyn LaneFeed>,
387    ) -> PoolStatus {
388        let started = self.lane_count();
389        let epoch = Epoch {
390            scene,
391            cursor: SampleCursor::new(range.first_sample, range.end()),
392            merger,
393            cancel,
394            events,
395            config: &self.config,
396            target: range.samples,
397            pixels: merger.pixel_count(),
398            sched: epoch::Sched::new(),
399            lanes: &self.lanes,
400            roster: epoch::Roster::new(started),
401        };
402        if !range.is_empty() {
403            let epoch = &epoch;
404            thread::scope(|scope| {
405                for index in 0..started {
406                    if let Some(slot) = self.slot(index) {
407                        feed::spawn_lane(scope, epoch, index, slot);
408                    }
409                }
410                if let Some(feed) = feed {
411                    scope.spawn(move || feed::feed_lanes(scope, epoch, feed));
412                }
413            });
414        }
415        let missing = range.samples.saturating_sub(merger.total());
416        if missing == 0 {
417            PoolStatus::Complete
418        } else if cancel.is_cancelled() {
419            PoolStatus::Cancelled { missing }
420        } else {
421            PoolStatus::LanesExhausted { missing }
422        }
423    }
424}
425
426/// `width * height` of `scene`.
427const fn pixel_count(scene: &SceneState) -> usize {
428    scene.width as usize * scene.height as usize
429}