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}