Skip to main content

lindhard/
rng.rs

1//! Deterministic per-particle random streams and a parallel driver.
2//!
3//! Every particle history draws from its own stream, keyed on
4//! `(run seed, particle index)`. The generator is ChaCha8 (the `rand_chacha`
5//! crate, MIT OR Apache-2.0): a counter-based design in which the stream
6//! identifier is part of the cipher state, so streams for different indices
7//! are distinct keystreams by construction, not segments of one sequence
8//! spaced apart. Counter-based generators for parallel Monte Carlo are
9//! described in J. K. Salmon, M. A. Moraes, R. O. Dror, D. E. Shaw, "Parallel
10//! random numbers: as easy as 1, 2, 3", Proc. SC'11 (2011); ChaCha is
11//! D. J. Bernstein, "ChaCha, a variant of Salsa20" (2008), here with 8 rounds.
12//!
13//! The parallel driver [`run_particles`] fixes the floating-point summation
14//! order independent of the thread count: particles are split into chunks of a
15//! caller-chosen, thread-independent size, each chunk gets its own tally, and
16//! the chunk tallies are merged in chunk-index order. The determinism test is
17//! `lindhard/tests/determinism.rs`. Chunks are evaluated in consecutive
18//! batches of a few chunks per worker and each batch is merged before the next
19//! starts, so the number of partial tallies held at once is bounded by the
20//! pool size, not by the history count; batching never changes a chunk, a
21//! stream or the merge order.
22
23use rand_chacha::ChaCha8Rng;
24use rand_core::{Rng, SeedableRng};
25use rayon::prelude::*;
26
27/// The generator type returned by [`stream`].
28pub type ParticleRng = ChaCha8Rng;
29
30/// Uniform draw on `[0, 1)` with 53 random bits: the top 53 bits of one
31/// `next_u64()`, scaled by the exact power of two `2^-53`.
32///
33/// Every sampler in the crate maps raw words to uniform variates through this
34/// one function, so the mapping from a stream to physics is fixed in one place
35/// (the thread-count invariant of [`run_particles`] rests on it).
36pub fn uniform<R: Rng + ?Sized>(rng: &mut R) -> f64 {
37    (rng.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
38}
39
40/// Random stream for particle `index` of the run with the given `seed`.
41///
42/// The 64-bit seed forms the ChaCha key (little-endian, zero padded) and the
43/// particle index is the 64-bit ChaCha stream identifier. The result depends
44/// only on `(seed, index)`.
45pub fn stream(seed: u64, index: u64) -> ParticleRng {
46    let mut key = [0u8; 32];
47    key[..8].copy_from_slice(&seed.to_le_bytes());
48    let mut rng = ChaCha8Rng::from_seed(key);
49    rng.set_stream(index);
50    rng
51}
52
53/// Run `n_particles` histories in parallel and return the merged tally.
54///
55/// * `chunk_size` sets the unit of work and of summation order. It must not be
56///   derived from the thread count if bit-identical results across thread
57///   counts are wanted. A value of 0 is treated as 1.
58/// * `new_tally` makes an empty tally; one is created per chunk.
59/// * `history(&mut tally, &mut rng, index)` simulates particle `index` using
60///   the stream `stream(seed, index)`.
61/// * `merge(&mut total, chunk_tally)` folds a chunk tally into the total. It is
62///   called sequentially in increasing chunk order, starting from an empty
63///   tally, so the result is independent of scheduling and thread count.
64///
65/// Chunks run in parallel in consecutive batches of a fixed small multiple of
66/// the worker count; each batch is merged, in chunk order, before the next
67/// begins. At most one batch of chunk tallies (plus the total) is alive at a
68/// time, independent of `n_particles`. The batch length affects scheduling
69/// only, never the result.
70///
71/// Runs on the current rayon pool (use `ThreadPool::install` to choose it).
72/// Particle indices are `0..n_particles`; [`run_particles_range`] continues a
73/// global stream from another first index.
74pub fn run_particles<T, N, H, M>(
75    seed: u64,
76    n_particles: u64,
77    chunk_size: u64,
78    new_tally: N,
79    history: H,
80    merge: M,
81) -> T
82where
83    T: Send,
84    N: Fn() -> T + Sync,
85    H: Fn(&mut T, &mut ParticleRng, u64) + Sync,
86    M: FnMut(&mut T, T),
87{
88    run_particles_range(seed, 0, n_particles, chunk_size, new_tally, history, merge)
89}
90
91/// [`run_particles`] for the global particle indices
92/// `first_index..first_index + n_particles`: particle `first_index + k` uses
93/// the stream `stream(seed, first_index + k)` and is passed that index.
94///
95/// This is how a run split into several parts (the fluence steps of a dynamic
96/// run) continues one global stream instead of replaying indices from zero.
97/// Chunks are counted from `first_index`, so with `first_index = 0` this is
98/// exactly [`run_particles`]. Because every history depends only on
99/// `(seed, index)`, splitting `0..n` into consecutive ranges draws the same
100/// random numbers as one run of `n` particles.
101pub fn run_particles_range<T, N, H, M>(
102    seed: u64,
103    first_index: u64,
104    n_particles: u64,
105    chunk_size: u64,
106    new_tally: N,
107    history: H,
108    mut merge: M,
109) -> T
110where
111    T: Send,
112    N: Fn() -> T + Sync,
113    H: Fn(&mut T, &mut ParticleRng, u64) + Sync,
114    M: FnMut(&mut T, T),
115{
116    let batch_len = rayon::current_num_threads().saturating_mul(BATCH_CHUNKS_PER_WORKER);
117    run_chunk_batches(
118        seed,
119        first_index,
120        n_particles,
121        chunk_size,
122        batch_len as u64,
123        &new_tally,
124        &history,
125        &mut merge,
126    )
127}
128
129/// Chunks per rayon worker in one batch of [`run_particles_range`].
130///
131/// The driver evaluates at most `BATCH_CHUNKS_PER_WORKER` times the worker
132/// count chunk tallies at once, so the number of live partial tallies is
133/// bounded by the pool size rather than by the history count. The value only
134/// shapes scheduling: chunk boundaries, the stream of every history and the
135/// merge order are fixed by `chunk_size`, so results do not depend on it.
136/// A few chunks per worker leave rayon room to balance unequal chunk costs
137/// inside a batch while keeping the idle tail at each batch boundary short.
138const BATCH_CHUNKS_PER_WORKER: usize = 4;
139
140/// Shared body of [`run_particles_range`] with an explicit batch length (in
141/// chunks; 0 is treated as 1).
142///
143/// Consecutive batches of `batch_len` chunk indices are evaluated in parallel;
144/// each batch is collected in chunk-index order and merged into the running
145/// total before the next batch starts. Every chunk is therefore merged in the
146/// same increasing order, into the same initially empty total, as a
147/// collect-all-then-fold driver would, and at most `batch_len` chunk tallies
148/// (plus the total) are alive at any time.
149#[allow(clippy::too_many_arguments)]
150fn run_chunk_batches<T, N, H, M>(
151    seed: u64,
152    first_index: u64,
153    n_particles: u64,
154    chunk_size: u64,
155    batch_len: u64,
156    new_tally: &N,
157    history: &H,
158    merge: &mut M,
159) -> T
160where
161    T: Send,
162    N: Fn() -> T + Sync,
163    H: Fn(&mut T, &mut ParticleRng, u64) + Sync,
164    M: FnMut(&mut T, T),
165{
166    let chunk = chunk_size.max(1);
167    let n_chunks = n_particles.div_ceil(chunk);
168    let batch_len = batch_len.max(1);
169    let mut total = new_tally();
170    let mut batch_start = 0;
171    while batch_start < n_chunks {
172        let batch_end = batch_start.saturating_add(batch_len).min(n_chunks);
173        let partials: Vec<T> = (batch_start..batch_end)
174            .into_par_iter()
175            .map(|c| {
176                let mut tally = new_tally();
177                let start = c * chunk;
178                let end = (start + chunk).min(n_particles);
179                for k in start..end {
180                    let i = first_index + k;
181                    let mut rng = stream(seed, i);
182                    history(&mut tally, &mut rng, i);
183                }
184                tally
185            })
186            .collect(); // indexed collect preserves chunk order
187        for p in partials {
188            merge(&mut total, p);
189        }
190        batch_start = batch_end;
191    }
192    total
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::sync::atomic::{AtomicUsize, Ordering};
199    use std::sync::Arc;
200
201    #[test]
202    fn uniform_is_the_top_53_bits_scaled_by_two_to_the_minus_53() {
203        let (mut a, mut b) = (stream(7, 3), stream(7, 3));
204        for _ in 0..1000 {
205            let u = uniform(&mut a);
206            assert_eq!(u, (b.next_u64() >> 11) as f64 / (1u64 << 53) as f64);
207            assert!((0.0..1.0).contains(&u));
208        }
209    }
210
211    /// Test-only copy of the driver before batching (#393): every chunk tally
212    /// is collected, then all are folded in chunk order into an empty total.
213    fn collect_then_fold<T, N, H, M>(
214        seed: u64,
215        first_index: u64,
216        n_particles: u64,
217        chunk_size: u64,
218        new_tally: N,
219        history: H,
220        mut merge: M,
221    ) -> T
222    where
223        T: Send,
224        N: Fn() -> T + Sync,
225        H: Fn(&mut T, &mut ParticleRng, u64) + Sync,
226        M: FnMut(&mut T, T),
227    {
228        let chunk = chunk_size.max(1);
229        let n_chunks = n_particles.div_ceil(chunk);
230        let partials: Vec<T> = (0..n_chunks)
231            .into_par_iter()
232            .map(|c| {
233                let mut tally = new_tally();
234                let start = c * chunk;
235                let end = (start + chunk).min(n_particles);
236                for k in start..end {
237                    let i = first_index + k;
238                    let mut rng = stream(seed, i);
239                    history(&mut tally, &mut rng, i);
240                }
241                tally
242            })
243            .collect();
244        let mut total = new_tally();
245        for p in partials {
246            merge(&mut total, p);
247        }
248        total
249    }
250
251    /// A tally whose merge is not associative: a floating-point sum whose
252    /// rounding depends on the merge tree, a running hash over the merge
253    /// sequence, and the merged history indices in order.
254    #[derive(Default, Debug, PartialEq)]
255    struct Order {
256        sum: f64,
257        hash: u64,
258        indices: Vec<u64>,
259        merges: u64,
260    }
261
262    fn order_history(t: &mut Order, rng: &mut ParticleRng, i: u64) {
263        let u = uniform(rng);
264        // Spread magnitudes so the rounding of the sum depends on order.
265        t.sum += (u - 0.5) * 10f64.powi((i % 17) as i32 - 8);
266        t.indices.push(i);
267    }
268
269    fn order_merge(a: &mut Order, b: Order) {
270        a.sum += b.sum;
271        a.hash =
272            a.hash.rotate_left(7).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.sum.to_bits() ^ b.hash;
273        a.indices.extend(b.indices);
274        a.merges += 1 + b.merges;
275    }
276
277    #[test]
278    fn batched_driver_matches_collect_then_fold() {
279        let pool = rayon::ThreadPoolBuilder::new()
280            .num_threads(2)
281            .build()
282            .unwrap();
283        pool.install(|| {
284            // (first index, histories, chunk size); covers nonzero first
285            // indices, incomplete final chunks, zero histories and chunk 0.
286            let cases = [
287                (0, 1000, 7),
288                (12_345, 1000, 7),
289                (3, 999, 10),
290                (0, 0, 5),
291                (40, 0, 0),
292                (5, 37, 0),
293                (0, 1, 64),
294                (u64::MAX / 2, 257, 16),
295            ];
296            for (first, n, chunk) in cases {
297                let want = collect_then_fold(
298                    0xBADC0DE,
299                    first,
300                    n,
301                    chunk,
302                    Order::default,
303                    order_history,
304                    order_merge,
305                );
306                assert_eq!(want.indices, (first..first + n).collect::<Vec<_>>());
307                // Batch lengths of one chunk, a few, more than all chunks,
308                // the pool default, and 0 (treated as 1).
309                for batch in [0, 1, 2, 3, 8, 1_000_000] {
310                    let got = run_chunk_batches(
311                        0xBADC0DE,
312                        first,
313                        n,
314                        chunk,
315                        batch,
316                        &Order::default,
317                        &order_history,
318                        &mut order_merge,
319                    );
320                    assert_eq!(
321                        got.sum.to_bits(),
322                        want.sum.to_bits(),
323                        "{first} {n} {chunk} {batch}"
324                    );
325                    assert_eq!(got, want, "{first} {n} {chunk} {batch}");
326                }
327                let got = run_particles_range(
328                    0xBADC0DE,
329                    first,
330                    n,
331                    chunk,
332                    Order::default,
333                    order_history,
334                    order_merge,
335                );
336                assert_eq!(got, want, "{first} {n} {chunk} default batch");
337            }
338        });
339    }
340
341    /// Counts live instances and records the peak.
342    struct Live {
343        live: Arc<AtomicUsize>,
344        count: u64,
345    }
346
347    impl Live {
348        fn new(live: &Arc<AtomicUsize>, peak: &Arc<AtomicUsize>) -> Self {
349            let now = live.fetch_add(1, Ordering::SeqCst) + 1;
350            peak.fetch_max(now, Ordering::SeqCst);
351            Live {
352                live: Arc::clone(live),
353                count: 0,
354            }
355        }
356    }
357
358    impl Drop for Live {
359        fn drop(&mut self) {
360            self.live.fetch_sub(1, Ordering::SeqCst);
361        }
362    }
363
364    /// Peak number of live tallies over one run and the merged history count.
365    fn live_peak(threads: usize, n: u64, chunk: u64, batch: Option<u64>) -> (usize, u64) {
366        let live = Arc::new(AtomicUsize::new(0));
367        let peak = Arc::new(AtomicUsize::new(0));
368        let pool = rayon::ThreadPoolBuilder::new()
369            .num_threads(threads)
370            .build()
371            .unwrap();
372        let new = || Live::new(&live, &peak);
373        let history = |t: &mut Live, _: &mut ParticleRng, _: u64| t.count += 1;
374        let mut merge = |a: &mut Live, b: Live| a.count += b.count;
375        let total = pool.install(|| match batch {
376            Some(b) => run_chunk_batches(1, 0, n, chunk, b, &new, &history, &mut merge),
377            None => run_particles_range(1, 0, n, chunk, new, history, merge),
378        });
379        let count = total.count;
380        drop(total);
381        assert_eq!(live.load(Ordering::SeqCst), 0, "a tally leaked");
382        (peak.load(Ordering::SeqCst), count)
383    }
384
385    #[test]
386    fn live_partial_tallies_stay_within_the_batch_bound() {
387        // Explicit batch of 5 chunks: at most 5 partials plus the total, for
388        // any number of chunks, while the old driver held every chunk.
389        for n_chunks in [1u64, 5, 6, 50, 2_000] {
390            let (peak, count) = live_peak(2, n_chunks * 3, 3, Some(5));
391            assert_eq!(count, n_chunks * 3);
392            assert!(peak <= 5 + 1, "{n_chunks} chunks: peak {peak}");
393        }
394        // Public driver on a 2-worker pool, 2_000 chunks = 250 batches.
395        let bound = 2 * BATCH_CHUNKS_PER_WORKER + 1;
396        for n_chunks in [10u64, 2_000] {
397            let (peak, count) = live_peak(2, n_chunks * 4, 4, None);
398            assert_eq!(count, n_chunks * 4);
399            assert!(peak <= bound, "{n_chunks} chunks: peak {peak} > {bound}");
400        }
401        // The reference holds all of them: the bound is a real change.
402        let live = Arc::new(AtomicUsize::new(0));
403        let peak = Arc::new(AtomicUsize::new(0));
404        let total = collect_then_fold(
405            1,
406            0,
407            2_000,
408            1,
409            || Live::new(&live, &peak),
410            |t, _, _| t.count += 1,
411            |a, b| a.count += b.count,
412        );
413        assert_eq!(total.count, 2_000);
414        assert!(peak.load(Ordering::SeqCst) > 2_000);
415    }
416
417    #[test]
418    fn ranges_continue_the_global_stream() {
419        let collect = |first: u64, n: u64| {
420            run_particles_range(
421                9,
422                first,
423                n,
424                4,
425                Vec::new,
426                |v: &mut Vec<(u64, u64)>, rng, i| v.push((i, rng.next_u64())),
427                |a, b| a.extend(b),
428            )
429        };
430        let whole = collect(0, 10);
431        let mut parts = collect(0, 3);
432        parts.extend(collect(3, 7));
433        assert_eq!(whole, parts);
434        assert_eq!(whole[5], (5, stream(9, 5).next_u64()));
435    }
436
437    #[test]
438    fn stream_is_reproducible_and_distinct() {
439        let x = stream(1, 5).next_u64();
440        assert_eq!(x, stream(1, 5).next_u64());
441        assert_ne!(x, stream(1, 6).next_u64());
442        assert_ne!(x, stream(2, 5).next_u64());
443    }
444}