Skip to main content

indicatrix_dispatch/merge/
mod.rs

1//! [`Merger`]: the one merged radiance buffer and exact sample count of an image epoch.
2//!
3//! # Determinism of the float summation
4//!
5//! Chunks finish in whatever order the lanes happen to finish them. Adding each chunk
6//! into the merged buffer on arrival would make the float rounding of the result
7//! depend on thread timing. Instead the merge is a left fold **in ascending
8//! `first_sample` order**, starting from zero:
9//!
10//! ```text
11//! merged = ((0 + chunk[a]) + chunk[b]) + chunk[c] + ...   with a < b < c < ...
12//! ```
13//!
14//! A chunk that arrives ahead of the fold's frontier (the next unmerged sample index)
15//! is parked in a pending map keyed by its `first_sample`; whenever the chunk starting
16//! exactly at the frontier arrives, it and every parked chunk contiguous with it are
17//! folded in, in order. Because the samples of one epoch come from one
18//! [`crate::SampleCursor`], the finished chunks tile the range exactly, so the frontier
19//! always reaches the end and nothing stays parked in a complete run.
20//!
21//! **What this guarantees:** for a given partition of the range into chunks (and
22//! given each chunk's own sum), the merged buffer is bit-identical no matter which lane
23//! traced which chunk or in which order they finished.
24//!
25//! **What it does not:** the partition itself. With a timed [`crate::ChunkPolicy`],
26//! chunk sizes come from measured rates, so two runs may cut the range differently and
27//! round differently; the results are then statistically equivalent, not
28//! bit-identical. [`crate::ChunkPolicy::fixed`] (and no lane failures) makes the
29//! partition timing-independent too. Different backends are never bit-identical to
30//! each other anyway (GPU `fma` fusion), so a chunk's own sum is only reproducible on
31//! the same lane.
32//!
33//! # Memory
34//!
35//! A parked chunk holds a full-frame buffer. Parking only happens while an earlier
36//! chunk is still in flight; with chunks sized to similar durations that is at most a
37//! few chunks per lane.
38
39use glam::Vec3;
40use std::{
41    collections::BTreeMap,
42    fmt,
43    sync::{Mutex, MutexGuard, PoisonError},
44};
45
46#[cfg(test)]
47mod tests;
48
49/// Why [`Merger::add`] refused a chunk. A refused chunk contributes nothing, so the
50/// count always matches the buffer.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum MergeError {
53    /// The chunk's buffer is not `width * height` long.
54    WrongLength {
55        /// The merger's pixel count.
56        expected: usize,
57        /// The chunk's buffer length.
58        got: usize,
59    },
60    /// The chunk overlaps samples already merged or parked: a disjointness violation.
61    Overlap {
62        /// The refused chunk's first sample.
63        first_sample: u32,
64        /// The refused chunk's traced sample count.
65        done: u32,
66    },
67}
68
69impl fmt::Display for MergeError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::WrongLength { expected, got } => write!(
73                f,
74                "chunk buffer holds {got} pixels, the image has {expected}"
75            ),
76            Self::Overlap { first_sample, done } => write!(
77                f,
78                "chunk [{first_sample}, +{done}) overlaps samples already merged"
79            ),
80        }
81    }
82}
83
84impl std::error::Error for MergeError {}
85
86/// A chunk that arrived ahead of the fold's frontier.
87#[derive(Debug)]
88struct Parked {
89    done: u32,
90    sum: Vec<Vec3>,
91}
92
93#[derive(Debug)]
94struct MergeState {
95    /// Every chunk below `frontier`, folded in order. Allocated on the first fold.
96    merged: Vec<Vec3>,
97    merged_count: u32,
98    /// The next sample index the fold expects.
99    frontier: u32,
100    /// Chunks at or past `frontier`, keyed by `first_sample`.
101    parked: BTreeMap<u32, Parked>,
102    parked_count: u32,
103}
104
105/// See the module doc. Shared by reference between the pool's lane threads (which
106/// [`add`](Self::add)) and any observer thread (which reads
107/// [`snapshot_into`](Self::snapshot_into) / [`total`](Self::total)).
108#[derive(Debug)]
109pub struct Merger {
110    pixels: usize,
111    first_sample: u32,
112    state: Mutex<MergeState>,
113}
114
115impl Merger {
116    /// An empty merge for a `pixels`-pixel image whose sample range starts at
117    /// `first_sample`.
118    #[must_use]
119    pub const fn new(pixels: usize, first_sample: u32) -> Self {
120        Self {
121            pixels,
122            first_sample,
123            state: Mutex::new(MergeState {
124                merged: Vec::new(),
125                merged_count: 0,
126                frontier: first_sample,
127                parked: BTreeMap::new(),
128                parked_count: 0,
129            }),
130        }
131    }
132
133    /// The image's pixel count.
134    #[must_use]
135    pub const fn pixel_count(&self) -> usize {
136        self.pixels
137    }
138
139    /// The first sample index of the range this merger was built for.
140    #[must_use]
141    pub const fn first_sample(&self) -> u32 {
142        self.first_sample
143    }
144
145    fn lock(&self) -> MutexGuard<'_, MergeState> {
146        self.state.lock().unwrap_or_else(PoisonError::into_inner)
147    }
148
149    /// Adds the chunk `[first_sample, first_sample + done)` whose summed radiance is
150    /// `sum`, returning the new exact total count. `done == 0` is a no-op (and `sum`
151    /// may then be empty).
152    ///
153    /// # Errors
154    ///
155    /// [`MergeError`] when `sum` has the wrong length or the chunk overlaps samples
156    /// already added; nothing is merged then.
157    pub fn add(&self, first_sample: u32, done: u32, sum: Vec<Vec3>) -> Result<u32, MergeError> {
158        if done == 0 {
159            return Ok(self.total());
160        }
161        if sum.len() != self.pixels {
162            return Err(MergeError::WrongLength {
163                expected: self.pixels,
164                got: sum.len(),
165            });
166        }
167        let mut state = self.lock();
168        if overlaps(&state, first_sample, done) {
169            return Err(MergeError::Overlap { first_sample, done });
170        }
171        state.parked.insert(first_sample, Parked { done, sum });
172        state.parked_count += done;
173        advance_frontier(&mut state, self.pixels);
174        let total = state.merged_count + state.parked_count;
175        drop(state);
176        Ok(total)
177    }
178
179    /// The exact number of samples added so far (folded plus parked).
180    #[must_use]
181    pub fn total(&self) -> u32 {
182        let state = self.lock();
183        state.merged_count + state.parked_count
184    }
185
186    /// Adds everything merged so far into `dst` (folded buffer first, then parked
187    /// chunks in `first_sample` order) and returns exactly the sample count that
188    /// represents. `dst` must be `pixel_count` long; any other length adds nothing and
189    /// returns `0`, so the returned count always matches what was added.
190    pub fn snapshot_into(&self, dst: &mut [Vec3]) -> u32 {
191        if dst.len() != self.pixels {
192            return 0;
193        }
194        let state = self.lock();
195        add_assign(dst, &state.merged);
196        for parked in state.parked.values() {
197            add_assign(dst, &parked.sum);
198        }
199        let count = state.merged_count + state.parked_count;
200        drop(state);
201        count
202    }
203
204    /// Consumes the merger: the merged buffer (`pixel_count` long, zeros if nothing
205    /// was added) and its exact sample count. Parked chunks left behind by an
206    /// incomplete run (cancel, lanes lost) are folded in `first_sample` order, so this
207    /// is deterministic too.
208    #[must_use]
209    pub fn into_parts(self) -> (Vec<Vec3>, u32) {
210        let pixels = self.pixels;
211        let mut state = self
212            .state
213            .into_inner()
214            .unwrap_or_else(PoisonError::into_inner);
215        let parked = std::mem::take(&mut state.parked);
216        for chunk in parked.into_values() {
217            fold(&mut state.merged, pixels, &chunk.sum);
218            state.merged_count += chunk.done;
219        }
220        if state.merged.is_empty() {
221            state.merged = vec![Vec3::ZERO; pixels];
222        }
223        (state.merged, state.merged_count)
224    }
225}
226
227/// Whether `[first, first + done)` intersects the folded prefix or any parked chunk.
228fn overlaps(state: &MergeState, first: u32, done: u32) -> bool {
229    let end = first + done;
230    if first < state.frontier {
231        return true;
232    }
233    let before = state.parked.range(..=first).next_back();
234    if before.is_some_and(|(&start, chunk)| start + chunk.done > first) {
235        return true;
236    }
237    state
238        .parked
239        .range(first..)
240        .next()
241        .is_some_and(|(&start, _)| start < end)
242}
243
244/// Folds every parked chunk that starts exactly at the frontier, in order.
245fn advance_frontier(state: &mut MergeState, pixels: usize) {
246    while let Some(chunk) = state.parked.remove(&state.frontier) {
247        fold(&mut state.merged, pixels, &chunk.sum);
248        state.frontier += chunk.done;
249        state.merged_count += chunk.done;
250        state.parked_count -= chunk.done;
251    }
252}
253
254/// `merged += sum`, allocating `merged` as zeros on first use so every fold is the
255/// same `0 + a + b + ...` sequence.
256fn fold(merged: &mut Vec<Vec3>, pixels: usize, sum: &[Vec3]) {
257    if merged.is_empty() {
258        *merged = vec![Vec3::ZERO; pixels];
259    }
260    add_assign(merged, sum);
261}
262
263fn add_assign(dst: &mut [Vec3], src: &[Vec3]) {
264    for (d, s) in dst.iter_mut().zip(src) {
265        *d += *s;
266    }
267}