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//! # Invalid values
34//!
35//! A chunk sum arrives from a lane (possibly a remote, untrusted one). A pixel with a
36//! non-finite or negative component is replaced by zero before the chunk is parked or
37//! folded -- dropped but still counted, the tracer's own rule -- so one bad value can
38//! never poison the epoch's buffer. [`Merger::dropped_pixels`] is the running total, for
39//! the caller to log once per request.
40//!
41//! # Memory
42//!
43//! A parked chunk holds a full-frame buffer. Parking only happens while an earlier
44//! chunk is still in flight; with chunks sized to similar durations that is at most a
45//! few chunks per lane -- UNLESS the chunk at the frontier is stalled (a wedged tracer
46//! that still heartbeats), in which case every other lane keeps completing
47//! chunks that pile up in `parked` with nothing to bound them. [`Self::with_parked_budget`]
48//! charges each one against an external budget and refuses (rather than parks) once
49//! it is exhausted, so a stalled frontier fails its own chunk's lane instead of
50//! growing memory without limit.
51
52use glam::Vec3;
53use indicatrix_net::radiance::shuffle::is_valid_sample;
54use std::{
55 collections::BTreeMap,
56 fmt,
57 sync::{
58 Arc, Mutex, MutexGuard, PoisonError,
59 atomic::{AtomicU64, Ordering},
60 },
61};
62
63#[cfg(test)]
64mod tests;
65
66/// An external byte budget a [`Merger`] charges its parked (full-frame) buffers against.
67///
68/// Refuses to park a chunk past the budget rather than letting them grow unbounded (see
69/// the module doc's "Memory" section: a stalled lane's straggling frontier
70/// otherwise parks one full-frame buffer per completed-but-unmergeable chunk, with
71/// nothing capping how many pile up). See [`Merger::with_parked_budget`].
72pub trait ParkedBudget: fmt::Debug + Send + Sync {
73 /// Reserves `bytes` for one newly parked chunk; `false` refuses (nothing charged).
74 fn reserve(&self, bytes: u64) -> bool;
75
76 /// Releases `bytes` reserved by an earlier [`Self::reserve`] once its chunk folds
77 /// into the merged prefix (or the [`Merger`] is consumed by
78 /// [`Merger::into_parts`] or dropped while the chunk was still parked).
79 fn release(&self, bytes: u64);
80}
81
82/// Why [`Merger::add`] refused a chunk. A refused chunk contributes nothing, so the
83/// count always matches the buffer.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum MergeError {
86 /// The chunk's buffer is not `width * height` long.
87 WrongLength {
88 /// The merger's pixel count.
89 expected: usize,
90 /// The chunk's buffer length.
91 got: usize,
92 },
93 /// The chunk overlaps samples already merged or parked: a disjointness violation.
94 Overlap {
95 /// The refused chunk's first sample.
96 first_sample: u32,
97 /// The refused chunk's traced sample count.
98 done: u32,
99 },
100 /// Parking this chunk would exceed the [`ParkedBudget`] passed to
101 /// [`Merger::with_parked_budget`]; refused, not parked, so the count and buffer
102 /// stay exactly what they were before this call.
103 ParkedBudgetExceeded {
104 /// The bytes this one chunk's buffer would have cost.
105 bytes: u64,
106 },
107}
108
109impl fmt::Display for MergeError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 Self::WrongLength { expected, got } => write!(
113 f,
114 "chunk buffer holds {got} pixels, the image has {expected}"
115 ),
116 Self::Overlap { first_sample, done } => write!(
117 f,
118 "chunk [{first_sample}, +{done}) overlaps samples already merged"
119 ),
120 Self::ParkedBudgetExceeded { bytes } => write!(
121 f,
122 "parking this {bytes}-byte chunk would exceed the merger's parked-bytes budget"
123 ),
124 }
125 }
126}
127
128impl std::error::Error for MergeError {}
129
130/// A chunk that arrived ahead of the fold's frontier.
131#[derive(Debug)]
132struct Parked {
133 done: u32,
134 sum: Vec<Vec3>,
135}
136
137#[derive(Debug)]
138struct MergeState {
139 /// Every chunk below `frontier`, folded in order. Allocated on the first fold.
140 merged: Vec<Vec3>,
141 merged_count: u32,
142 /// The next sample index the fold expects.
143 frontier: u32,
144 /// Chunks at or past `frontier`, keyed by `first_sample`.
145 parked: BTreeMap<u32, Parked>,
146 parked_count: u32,
147}
148
149/// See the module doc.
150///
151/// Shared by reference between the pool's lane threads (which
152/// [`add`](Self::add)) and any observer thread (which reads
153/// [`snapshot_into`](Self::snapshot_into) / [`total`](Self::total)).
154#[derive(Debug)]
155pub struct Merger {
156 pixels: usize,
157 first_sample: u32,
158 state: Mutex<MergeState>,
159 /// External byte budget every newly parked chunk is charged against, if any -- see
160 /// [`Self::with_parked_budget`].
161 budget: Option<Arc<dyn ParkedBudget>>,
162 /// Pixels zeroed so far for a non-finite or negative component (module doc).
163 dropped_pixels: AtomicU64,
164}
165
166impl Merger {
167 /// An empty merge for a `pixels`-pixel image whose sample range starts at
168 /// `first_sample`.
169 #[must_use]
170 pub const fn new(pixels: usize, first_sample: u32) -> Self {
171 Self {
172 pixels,
173 first_sample,
174 state: Mutex::new(MergeState {
175 merged: Vec::new(),
176 merged_count: 0,
177 frontier: first_sample,
178 parked: BTreeMap::new(),
179 parked_count: 0,
180 }),
181 budget: None,
182 dropped_pixels: AtomicU64::new(0),
183 }
184 }
185
186 /// Charges every chunk this merger parks (see the module doc's "Memory" section)
187 /// against `budget`, refusing (as [`MergeError::ParkedBudgetExceeded`]) instead of
188 /// parking once it says no; released again as each chunk folds.
189 #[must_use]
190 pub fn with_parked_budget(mut self, budget: Arc<dyn ParkedBudget>) -> Self {
191 self.budget = Some(budget);
192 self
193 }
194
195 /// The image's pixel count.
196 #[must_use]
197 pub const fn pixel_count(&self) -> usize {
198 self.pixels
199 }
200
201 /// The first sample index of the range this merger was built for.
202 #[must_use]
203 pub const fn first_sample(&self) -> u32 {
204 self.first_sample
205 }
206
207 /// The next sample index the fold expects: every sample below it is already folded
208 /// into the merged prefix. Advances as the chunk at the frontier arrives.
209 #[must_use]
210 pub fn frontier(&self) -> u32 {
211 self.lock().frontier
212 }
213
214 /// How many pixels of added chunks were zeroed for a non-finite or negative
215 /// component (module doc, "Invalid values").
216 #[must_use]
217 pub fn dropped_pixels(&self) -> u64 {
218 self.dropped_pixels.load(Ordering::Relaxed)
219 }
220
221 fn lock(&self) -> MutexGuard<'_, MergeState> {
222 self.state.lock().unwrap_or_else(PoisonError::into_inner)
223 }
224
225 /// Bytes one parked (full-frame) chunk buffer costs.
226 const fn chunk_bytes(&self) -> u64 {
227 self.pixels as u64 * std::mem::size_of::<Vec3>() as u64
228 }
229
230 /// Adds the chunk `[first_sample, first_sample + done)` whose summed radiance is
231 /// `sum`, returning the new exact total count. `done == 0` is a no-op (and `sum`
232 /// may then be empty).
233 ///
234 /// # Errors
235 ///
236 /// [`MergeError`] when `sum` has the wrong length, the chunk overlaps samples
237 /// already added, or parking it would exceed [`Self::with_parked_budget`]'s budget;
238 /// nothing is merged then.
239 pub fn add(&self, first_sample: u32, done: u32, mut sum: Vec<Vec3>) -> Result<u32, MergeError> {
240 if done == 0 {
241 return Ok(self.total());
242 }
243 if sum.len() != self.pixels {
244 return Err(MergeError::WrongLength {
245 expected: self.pixels,
246 got: sum.len(),
247 });
248 }
249 let dropped = zero_invalid_pixels(&mut sum);
250 let mut state = self.lock();
251 if overlaps(&state, first_sample, done) {
252 return Err(MergeError::Overlap { first_sample, done });
253 }
254 // Reserved bytes always equal `parked.len() * chunk_bytes` as an invariant
255 // maintained across calls: `before` is what it was reserved for coming in,
256 // `after` is what it should be reserved for now that this call's insert and
257 // fold have run. A chunk that arrives exactly at the frontier and immediately
258 // folds away (relieving a backlog) never needs budget of its own -- only a
259 // chunk that stays parked when this call returns does.
260 let before = state.parked.len();
261 state.parked.insert(first_sample, Parked { done, sum });
262 state.parked_count += done;
263 advance_frontier(&mut state, self.pixels);
264 let after = state.parked.len();
265 if let Some(budget) = &self.budget {
266 let chunk_bytes = self.chunk_bytes();
267 match after.cmp(&before) {
268 // `advance_frontier` only ever removes, so a net increase can only be
269 // this call's own new entry, alone, staying parked.
270 std::cmp::Ordering::Greater if !budget.reserve(chunk_bytes) => {
271 state.parked.remove(&first_sample);
272 state.parked_count -= done;
273 return Err(MergeError::ParkedBudgetExceeded { bytes: chunk_bytes });
274 }
275 std::cmp::Ordering::Less => {
276 budget.release(chunk_bytes * (before - after) as u64);
277 }
278 std::cmp::Ordering::Greater | std::cmp::Ordering::Equal => {}
279 }
280 }
281 let total = state.merged_count + state.parked_count;
282 drop(state);
283 if dropped > 0 {
284 self.dropped_pixels.fetch_add(dropped, Ordering::Relaxed);
285 }
286 Ok(total)
287 }
288
289 /// The exact number of samples added so far (folded plus parked).
290 #[must_use]
291 pub fn total(&self) -> u32 {
292 let state = self.lock();
293 state.merged_count + state.parked_count
294 }
295
296 /// Adds everything merged so far into `dst` (folded buffer first, then parked
297 /// chunks in `first_sample` order) and returns exactly the sample count that
298 /// represents. `dst` must be `pixel_count` long; any other length adds nothing and
299 /// returns `0`, so the returned count always matches what was added.
300 pub fn snapshot_into(&self, dst: &mut [Vec3]) -> u32 {
301 if dst.len() != self.pixels {
302 return 0;
303 }
304 let state = self.lock();
305 add_assign(dst, &state.merged);
306 for parked in state.parked.values() {
307 add_assign(dst, &parked.sum);
308 }
309 let count = state.merged_count + state.parked_count;
310 drop(state);
311 count
312 }
313
314 /// Consumes the merger: the merged buffer (`pixel_count` long, zeros if nothing
315 /// was added) and its exact sample count. Parked chunks left behind by an
316 /// incomplete run (cancel, lanes lost) are folded in `first_sample` order, so this
317 /// is deterministic too.
318 #[must_use]
319 pub fn into_parts(mut self) -> (Vec<Vec3>, u32) {
320 let pixels = self.pixels;
321 let parked = self.take_parked();
322 let state = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
323 for chunk in parked.into_values() {
324 fold(&mut state.merged, pixels, &chunk.sum);
325 state.merged_count += chunk.done;
326 }
327 if state.merged.is_empty() {
328 state.merged = vec![Vec3::ZERO; pixels];
329 }
330 (std::mem::take(&mut state.merged), state.merged_count)
331 }
332
333 /// Empties the parked map and releases its budget charge, so the charge is returned
334 /// exactly once however the merger ends ([`Self::into_parts`] or [`Drop`]).
335 fn take_parked(&mut self) -> BTreeMap<u32, Parked> {
336 let chunk_bytes = self.chunk_bytes();
337 let state = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
338 let parked = std::mem::take(&mut state.parked);
339 state.parked_count = 0;
340 if let Some(budget) = &self.budget
341 && !parked.is_empty()
342 {
343 budget.release(chunk_bytes * parked.len() as u64);
344 }
345 parked
346 }
347}
348
349impl Drop for Merger {
350 /// Returns whatever is still charged for parked chunks, so a merger dropped without
351 /// [`Merger::into_parts`] (a cancelled or exhausted run) does not leak its parked
352 /// bytes into a shared budget.
353 fn drop(&mut self) {
354 drop(self.take_parked());
355 }
356}
357
358/// Zeroes every pixel that fails [`is_valid_sample`] and returns how many there were.
359fn zero_invalid_pixels(sum: &mut [Vec3]) -> u64 {
360 let mut dropped = 0u64;
361 for pixel in sum {
362 if !is_valid_sample(*pixel) {
363 *pixel = Vec3::ZERO;
364 dropped += 1;
365 }
366 }
367 dropped
368}
369
370/// Whether `[first, first + done)` intersects the folded prefix or any parked chunk.
371fn overlaps(state: &MergeState, first: u32, done: u32) -> bool {
372 let end = first + done;
373 if first < state.frontier {
374 return true;
375 }
376 let before = state.parked.range(..=first).next_back();
377 if before.is_some_and(|(&start, chunk)| start + chunk.done > first) {
378 return true;
379 }
380 state
381 .parked
382 .range(first..)
383 .next()
384 .is_some_and(|(&start, _)| start < end)
385}
386
387/// Folds every parked chunk that starts exactly at the frontier, in order.
388fn advance_frontier(state: &mut MergeState, pixels: usize) {
389 while let Some(chunk) = state.parked.remove(&state.frontier) {
390 fold(&mut state.merged, pixels, &chunk.sum);
391 state.frontier += chunk.done;
392 state.merged_count += chunk.done;
393 state.parked_count -= chunk.done;
394 }
395}
396
397/// `merged += sum`, allocating `merged` as zeros on first use so every fold is the
398/// same `0 + a + b + ...` sequence.
399fn fold(merged: &mut Vec<Vec3>, pixels: usize, sum: &[Vec3]) {
400 if merged.is_empty() {
401 *merged = vec![Vec3::ZERO; pixels];
402 }
403 add_assign(merged, sum);
404}
405
406fn add_assign(dst: &mut [Vec3], src: &[Vec3]) {
407 for (d, s) in dst.iter_mut().zip(src) {
408 *d += *s;
409 }
410}