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}