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}