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}