Skip to main content

indicatrix_dispatch/sample_cursor/
mod.rs

1//! [`SampleCursor`]: the shared claim point every backend contributing to one image
2//! draws disjoint absolute sample sub-ranges from -- the desktop export's local and
3//! remote lanes, the live viewport's epoch (`LiveEpoch` in `indicatrix-cut`), and every
4//! lane of a [`crate::LanePool`].
5//!
6//! Moved here from `indicatrix-cut`'s `bridge::sample_cursor`, which now re-exports it,
7//! so the coordinator can share the exact same claim semantics.
8//!
9//! # Why a shared claim point, not a one-shot split
10//!
11//! A fixed up-front split (measured once, dispatched once) can leave a fast engine's
12//! assigned slice finished early with nothing further to claim while the render keeps
13//! going, sitting idle for the rest of it. `SampleCursor` replaces that with a shared
14//! claim point every engine pulls fresh work from whenever free, for as long as any
15//! remains.
16//!
17//! # Disjointness by construction
18//!
19//! [`claim`](SampleCursor::claim) and [`claim_local`](SampleCursor::claim_local) hand
20//! out ranges via a single atomic `fetch_add`, so any number of callers on any number
21//! of threads can never receive overlapping indices, by construction, with no reliance
22//! on callers coordinating access any other way. Disjointness matters because both the
23//! CPU tracer and GPU megakernel derive each sample's pixel-jitter/RNG draws from its
24//! *absolute* index: overlapping ranges would redraw identical samples and silently
25//! bias the average toward whatever indices got traced twice -- wrong, but not
26//! obviously wrong.
27//!
28//! # Two retry piles
29//!
30//! A range a lane failed to finish must be traced again, exactly once, by someone.
31//!
32//! - [`return_to_local`](SampleCursor::return_to_local) (the desktop's single-remote
33//!   model) pushes it onto a pile only [`claim_local`](SampleCursor::claim_local) /
34//!   [`claim_local_bounded`](SampleCursor::claim_local_bounded) drain, never back onto
35//!   the shared pool [`claim`](SampleCursor::claim) draws from. Otherwise remote could
36//!   re-claim its own just-failed range on its very next iteration -- an infinite loop
37//!   for a persistent (not transient) failure. Routing it to a local-only pile
38//!   guarantees it is retried at most once more, locally.
39//! - [`requeue`](SampleCursor::requeue) (the [`crate::LanePool`] model, where every
40//!   lane is equal) pushes it onto a pile [`claim_any`](SampleCursor::claim_any) drains
41//!   before fresh work. The pool itself prevents the infinite loop: a failing lane
42//!   backs off and is retired after a bounded number of consecutive failures.
43//!
44//! The two models are never mixed on one cursor in practice; [`claim`] alone never
45//! sees either pile.
46//!
47//! [`claim`]: SampleCursor::claim
48
49use std::{
50    collections::VecDeque,
51    sync::{
52        Mutex, PoisonError,
53        atomic::{AtomicU32, Ordering},
54    },
55};
56
57#[cfg(test)]
58mod tests;
59
60/// See this module's doc comment. Every method takes `&self`, so one instance can be
61/// shared (by reference inside a `thread::scope`, or behind an `Arc`) across every
62/// lane's own thread.
63#[derive(Debug)]
64pub struct SampleCursor {
65    /// Absolute index of the next sample NO engine has claimed yet. Every claim is one
66    /// `fetch_add` against this -- see [`claim`](Self::claim).
67    next: AtomicU32,
68    /// One past the last sample in this budget (exclusive). A claim is never honoured
69    /// past this, however large a range was requested.
70    end: u32,
71    /// Ranges a remote chunk failed to finish, reserved for the local lane alone -- see
72    /// this module's doc comment.
73    local_retry: Mutex<VecDeque<(u32, u32)>>,
74    /// Ranges a pool lane failed to finish, open to every lane via
75    /// [`claim_any`](Self::claim_any).
76    requeued: Mutex<VecDeque<(u32, u32)>>,
77}
78
79impl SampleCursor {
80    /// Builds a cursor over `[start, end)`. For an export, `start` is wherever a prior
81    /// sequential phase (remote/hybrid calibration) left `samples_done` and `end` is
82    /// the export's total `samples_per_pixel`; for a live epoch it is `[0,
83    /// target_samples)`; for a coordinator job it is the request's
84    /// `[first_sample, first_sample + samples)`.
85    #[must_use]
86    pub const fn new(start: u32, end: u32) -> Self {
87        Self {
88            next: AtomicU32::new(start),
89            end,
90            local_retry: Mutex::new(VecDeque::new()),
91            requeued: Mutex::new(VecDeque::new()),
92        }
93    }
94
95    /// One past the last sample of this budget.
96    #[must_use]
97    pub const fn end(&self) -> u32 {
98        self.end
99    }
100
101    /// Claims up to `want` fresh samples for ANY engine, returning `Some((start,
102    /// count))` with `1 <= count <= want`, or `None` if the budget is already
103    /// exhausted. Never returns a range past `end`, however large `want` is.
104    ///
105    /// `want == 0` always returns `None` rather than a zero-length range.
106    ///
107    /// # A CAS that never advances past `end`
108    ///
109    /// `next` is updated with [`AtomicU32::try_update`], whose closure refuses (`None`)
110    /// once `next >= end`, leaving `next` untouched -- unlike a plain `fetch_add`, which
111    /// unconditionally advances `next` by `want` on EVERY call, including a call that
112    /// finds nothing left to claim. An idle lane polling [`claim_any`](Self::claim_any)
113    /// every tick while another lane's chunk is still in flight (see
114    /// `indicatrix_dispatch::pool::epoch::Epoch::claim`) would otherwise keep advancing
115    /// `next` for as long as it waits, with no work ever handed out -- given enough
116    /// idle polls, `next` (a `u32`) could wrap and hand out a range already traced.
117    /// With the CAS, a call that finds `next >= end` costs nothing and changes nothing,
118    /// however many times it repeats.
119    pub fn claim(&self, want: u32) -> Option<(u32, u32)> {
120        if want == 0 {
121            return None;
122        }
123        let end = self.end;
124        let start = self
125            .next
126            .try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
127                (current < end).then(|| current.saturating_add(want).min(end))
128            })
129            .ok()?;
130        Some((start, want.min(end - start)))
131    }
132
133    /// Claims for the LOCAL lane only: a previously remote-failed range first (WHOLE,
134    /// however long it is), falling back to [`claim`](Self::claim) once that retry
135    /// pile is empty. See this module's doc comment for why a retried range never goes
136    /// back to remote. The export's local lane batches whatever it gets; the live
137    /// viewport uses [`claim_local_bounded`](Self::claim_local_bounded) instead.
138    pub fn claim_local(&self, want: u32) -> Option<(u32, u32)> {
139        let retried = self
140            .local_retry
141            .lock()
142            .unwrap_or_else(PoisonError::into_inner)
143            .pop_front();
144        if retried.is_some() {
145            return retried;
146        }
147        self.claim(want)
148    }
149
150    /// Like [`claim_local`](Self::claim_local), but never hands out more than `want`
151    /// samples even from the retry pile: a longer retried range is split, its head
152    /// returned now and its tail pushed back to the FRONT of the pile for the next
153    /// call. The live viewport traces one short frame per claim, so a whole failed
154    /// remote chunk (possibly hundreds of samples) must not land in a single frame.
155    /// `want == 0` returns `None` without touching anything.
156    pub fn claim_local_bounded(&self, want: u32) -> Option<(u32, u32)> {
157        if want == 0 {
158            return None;
159        }
160        pop_bounded(&self.local_retry, want).or_else(|| self.claim(want))
161    }
162
163    /// Claims for a [`crate::LanePool`] lane: up to `want` samples of a requeued range
164    /// first (split like [`claim_local_bounded`](Self::claim_local_bounded)), falling
165    /// back to fresh work from [`claim`](Self::claim). Never touches the local-only
166    /// retry pile. `want == 0` returns `None` without touching anything.
167    pub fn claim_any(&self, want: u32) -> Option<(u32, u32)> {
168        if want == 0 {
169            return None;
170        }
171        pop_bounded(&self.requeued, want).or_else(|| self.claim(want))
172    }
173
174    /// Whether the SHARED pool has nothing left to claim -- `true` once every sample up
175    /// to `end` has been handed out to some engine. Says nothing about the retry
176    /// piles. A cheap, lock-free read for callers deciding whether waiting around could
177    /// still yield work.
178    #[must_use]
179    pub fn shared_pool_exhausted(&self) -> bool {
180        self.next.load(Ordering::Relaxed) >= self.end
181    }
182
183    /// Whether nothing at all is claimable right now: the shared pool is exhausted and
184    /// both retry piles are empty. A range currently being traced may still come back
185    /// through [`requeue`](Self::requeue) or [`return_to_local`](Self::return_to_local)
186    /// if its lane fails.
187    #[must_use]
188    pub fn fully_claimed(&self) -> bool {
189        self.shared_pool_exhausted()
190            && self
191                .requeued
192                .lock()
193                .unwrap_or_else(PoisonError::into_inner)
194                .is_empty()
195            && self
196                .local_retry
197                .lock()
198                .unwrap_or_else(PoisonError::into_inner)
199                .is_empty()
200    }
201
202    /// Returns `[start, start + count)` to the queue for GUARANTEED local processing
203    /// after a remote chunk failed to finish it -- never re-offered to remote. A `count`
204    /// of `0` is a no-op (nothing to retry) rather than an empty queue entry.
205    pub fn return_to_local(&self, start: u32, count: u32) {
206        if count == 0 {
207            return;
208        }
209        self.local_retry
210            .lock()
211            .unwrap_or_else(PoisonError::into_inner)
212            .push_back((start, count));
213    }
214
215    /// Returns `[start, start + count)` to the pile every [`claim_any`](Self::claim_any)
216    /// caller drains first, after a pool lane failed to finish it. A `count` of `0` is
217    /// a no-op.
218    pub fn requeue(&self, start: u32, count: u32) {
219        if count == 0 {
220            return;
221        }
222        self.requeued
223            .lock()
224            .unwrap_or_else(PoisonError::into_inner)
225            .push_back((start, count));
226    }
227}
228
229/// Pops at most `want` samples off the front of `pile`, pushing a longer entry's tail
230/// back to the front for the next caller. `want` must be non-zero.
231fn pop_bounded(pile: &Mutex<VecDeque<(u32, u32)>>, want: u32) -> Option<(u32, u32)> {
232    let mut pile = pile.lock().unwrap_or_else(PoisonError::into_inner);
233    let head = pile.pop_front();
234    let claimed = match head {
235        Some((start, count)) if count > want => {
236            pile.push_front((start + want, count - want));
237            Some((start, want))
238        }
239        other => other,
240    };
241    drop(pile);
242    claimed
243}