dualis_core/ensemble.rs
1//! Many independent samples, run in parallel, with an answer that does not depend on how many
2//! threads did the work.
3//!
4//! The other axis of parallelism in this workspace. `TreeNBody::with_threads` splits *one*
5//! evaluation across cores; this splits *many* evaluations, which is the shape a Monte Carlo
6//! study has and the shape a parameter sweep has. A hundred thousand detector realisations, a
7//! thousand trajectories from perturbed initial conditions, a grid of designs each run to a
8//! steady state — all the same problem: independent work items and a reduction at the end.
9//!
10//! # Why this can be parallel *and* still bit-for-bit
11//!
12//! Two decisions that were already made, meeting.
13//!
14//! [`Rng::for_index`] is stateless and addressed by index, so sample `i` draws the same numbers
15//! whether it ran first, last, or on another core. Nothing is consumed from a shared stream and
16//! there is no order to depend on.
17//!
18//! And results land in a slot chosen by index, never appended. Each worker owns a disjoint run
19//! of the output and writes nothing else, exactly as `TreeNBody` does — so the vector that comes
20//! back is a function of `(seed, count)` alone. Reduce it however you like; a fold over that
21//! vector is in index order by construction.
22//!
23//! The failure this avoids is worth naming, because it is the usual one: a Monte Carlo that
24//! draws from a shared generator gives a different answer on eight cores than on one, and the
25//! difference looks like statistical noise. It is not noise, it is the result depending on the
26//! scheduler, and no amount of averaging removes it.
27//!
28//! ```
29//! use dualis_core::{Ensemble, Rng};
30//!
31//! // A hundred thousand throws of a loaded die, in parallel.
32//! let hits = Ensemble::new(20, 100_000)
33//! .with_threads(8)
34//! .run(|_, mut rng| u64::from(rng.unit() < 0.25));
35//!
36//! let heads: u64 = hits.iter().sum();
37//! // Same seed, same count, same answer — on one thread or on eight.
38//! assert_eq!(heads, Ensemble::new(20, 100_000).run(|_, mut rng| u64::from(rng.unit() < 0.25))
39//! .iter().sum::<u64>());
40//! ```
41
42use crate::Rng;
43
44/// A set of independent samples to run.
45///
46/// Cheap to build and to copy; it holds a seed, a count and a thread count, and does the work in
47/// [`Ensemble::run`].
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct Ensemble {
50 seed: u64,
51 count: u64,
52 threads: usize,
53}
54
55impl Ensemble {
56 /// `count` samples, each drawing from `Rng::for_index(seed, i)`.
57 ///
58 /// Sequential until [`with_threads`](Ensemble::with_threads) says otherwise, which is the
59 /// right default: threads are a performance decision and this crate does not make those for
60 /// a caller who has not asked.
61 pub fn new(seed: u64, count: u64) -> Ensemble {
62 Ensemble {
63 seed,
64 count,
65 threads: 1,
66 }
67 }
68
69 /// How many threads to spread the samples over. 1 is sequential.
70 ///
71 /// **The answer does not change.** That is the whole point, and it is asserted rather than
72 /// asserted-in-prose: a test runs the same ensemble at one, three and sixteen threads and
73 /// compares the results bit for bit. If you find a thread count that changes an answer, the
74 /// sample closure is reading something it does not own.
75 ///
76 /// Clamped to at least one, and never more than there are samples.
77 pub fn with_threads(mut self, threads: usize) -> Ensemble {
78 self.threads = threads.max(1);
79 self
80 }
81
82 /// The seed every sample's generator is derived from.
83 pub fn seed(&self) -> u64 {
84 self.seed
85 }
86
87 /// How many samples.
88 pub fn count(&self) -> u64 {
89 self.count
90 }
91
92 /// Run every sample and collect the results **in index order**.
93 ///
94 /// The closure receives the sample's index and its own generator. Give it everything else it
95 /// needs by capture; it must not mutate shared state, and `Fn` rather than `FnMut` is how
96 /// that is enforced rather than requested.
97 ///
98 /// Index order matters more than it looks. A caller folding the result — a mean, a variance,
99 /// a histogram — folds in that order whatever the thread count was, so the floating-point
100 /// sum is the same sum. Collecting into a shared accumulator instead would make the answer
101 /// depend on which thread finished first, in the last bits, invisibly.
102 pub fn run<T, F>(&self, sample: F) -> Vec<T>
103 where
104 F: Fn(u64, Rng) -> T + Sync,
105 T: Send + Default + Clone,
106 {
107 let n = self.count as usize;
108 let mut out = vec![T::default(); n];
109 let threads = self.threads.min(n.max(1));
110 let seed = self.seed;
111
112 if threads <= 1 {
113 for (i, slot) in out.iter_mut().enumerate() {
114 *slot = sample(i as u64, Rng::for_index(seed, i as u64));
115 }
116 return out;
117 }
118
119 #[cfg(not(target_family = "wasm"))]
120 {
121 let chunk = n.div_ceil(threads);
122 let sample = &sample;
123 // Disjoint slices, no reduction, nothing shared but the closure.
124 std::thread::scope(|scope| {
125 for (c, slice) in out.chunks_mut(chunk).enumerate() {
126 let base = (c * chunk) as u64;
127 scope.spawn(move || {
128 for (k, slot) in slice.iter_mut().enumerate() {
129 let i = base + k as u64;
130 *slot = sample(i, Rng::for_index(seed, i));
131 }
132 });
133 }
134 });
135 out
136 }
137
138 // WebAssembly has no threads to spawn, so it takes the sequential path and gets the same
139 // answer for a less interesting reason. `TreeNBody` resolves this the same way.
140 #[cfg(target_family = "wasm")]
141 {
142 for (i, slot) in out.iter_mut().enumerate() {
143 *slot = sample(i as u64, Rng::for_index(seed, i as u64));
144 }
145 out
146 }
147 }
148
149 /// Run every sample and reduce to a mean and a standard error, folded in index order.
150 ///
151 /// The two numbers a Monte Carlo study is usually for: the estimate, and how much to trust
152 /// it. The standard error is `s/√N` with the sample standard deviation, so it falls as
153 /// `1/√N` — which is the rate this workspace asks a tolerance to be earned against, and the
154 /// reason `samples` is reported beside it rather than left implicit.
155 ///
156 /// Returns `None` for fewer than two samples, because a variance over one is not a small
157 /// number, it is not defined.
158 pub fn estimate<F>(&self, sample: F) -> Option<Estimate>
159 where
160 F: Fn(u64, Rng) -> f64 + Sync,
161 {
162 if self.count < 2 {
163 return None;
164 }
165 // Per-block partials rather than every sample, so a study is bounded by its *block*
166 // count and not by its sample count. A hundred million f64 is 800 MB held for no reason;
167 // this holds one `Partial` per BLOCK samples, which is 24 bytes per 4096.
168 let blocks = self.blocks(
169 |from, to, sample| Partial::of(from, to, self.seed, sample),
170 &sample,
171 );
172 let total: Partial = blocks
173 .iter()
174 .copied()
175 .reduce(Partial::merge)
176 .expect("count >= 2 means at least one block");
177 Some(total.finish())
178 }
179
180 /// Run the samples in fixed-size blocks and return one value per block.
181 ///
182 /// **The block size does not depend on the thread count**, and that is the whole reason for
183 /// it. A reduction split per *thread* combines a different number of partial sums on four
184 /// cores than on sixteen, and floating-point addition is not associative, so the answer
185 /// moves — quietly, in the last bits, looking like nothing. Splitting per fixed block makes
186 /// the association a function of `count` alone.
187 ///
188 /// A caller wanting a reduction this crate does not provide — a histogram, a maximum, a
189 /// quantile — should build it here for the same reason.
190 pub fn blocks<B, M, F>(&self, of_block: M, sample: &F) -> Vec<B>
191 where
192 M: Fn(u64, u64, &F) -> B + Sync,
193 B: Send + Default + Clone,
194 F: Sync,
195 {
196 let n_blocks = self.count.div_ceil(BLOCK) as usize;
197 let mut out = vec![B::default(); n_blocks];
198 let threads = self.threads.min(n_blocks.max(1));
199 let (count, of_block) = (self.count, &of_block);
200
201 let one = |slice: &mut [B], base: usize| {
202 for (k, slot) in slice.iter_mut().enumerate() {
203 let from = (base + k) as u64 * BLOCK;
204 *slot = of_block(from, (from + BLOCK).min(count), sample);
205 }
206 };
207
208 if threads <= 1 {
209 one(&mut out, 0);
210 return out;
211 }
212
213 #[cfg(not(target_family = "wasm"))]
214 {
215 let chunk = n_blocks.div_ceil(threads);
216 std::thread::scope(|scope| {
217 for (c, slice) in out.chunks_mut(chunk).enumerate() {
218 let base = c * chunk;
219 scope.spawn(move || one(slice, base));
220 }
221 });
222 out
223 }
224
225 #[cfg(target_family = "wasm")]
226 {
227 one(&mut out, 0);
228 out
229 }
230 }
231}
232
233/// Samples per reduction block. A power of two, and fixed: see [`Ensemble::blocks`].
234///
235/// 4096 doubles is 32 KB of intermediate per block, which stays in L1 while a block is folded,
236/// and it keeps the number of partials small enough that combining them costs nothing.
237const BLOCK: u64 = 4096;
238
239/// One block's contribution to a mean and a variance.
240///
241/// Carries a count, a mean and the sum of squared deviations rather than raw power sums.
242/// Merging two of these is Chan's parallel update, which is stable where `sum(x²) − n·mean²`
243/// is not: that form subtracts two large nearly-equal numbers and loses every significant digit
244/// exactly when a Monte Carlo has converged and the mean dwarfs the spread.
245#[derive(Clone, Copy, Debug, Default, PartialEq)]
246struct Partial {
247 n: f64,
248 mean: f64,
249 m2: f64,
250}
251
252impl Partial {
253 fn of<F: Fn(u64, Rng) -> f64>(from: u64, to: u64, seed: u64, sample: &F) -> Partial {
254 let mut p = Partial::default();
255 for i in from..to {
256 let x = sample(i, Rng::for_index(seed, i));
257 // Welford, in index order within the block.
258 p.n += 1.0;
259 let delta = x - p.mean;
260 p.mean += delta / p.n;
261 p.m2 += delta * (x - p.mean);
262 }
263 p
264 }
265
266 fn merge(a: Partial, b: Partial) -> Partial {
267 if a.n == 0.0 {
268 return b;
269 }
270 if b.n == 0.0 {
271 return a;
272 }
273 let n = a.n + b.n;
274 let delta = b.mean - a.mean;
275 Partial {
276 n,
277 mean: a.mean + delta * (b.n / n),
278 m2: a.m2 + b.m2 + delta * delta * (a.n * b.n / n),
279 }
280 }
281
282 fn finish(self) -> Estimate {
283 let variance = self.m2 / (self.n - 1.0);
284 Estimate {
285 mean: self.mean,
286 standard_error: (variance / self.n).sqrt(),
287 samples: self.n as u64,
288 }
289 }
290}
291
292/// What a Monte Carlo run came back with.
293#[derive(Clone, Copy, Debug, PartialEq)]
294pub struct Estimate {
295 /// The sample mean.
296 pub mean: f64,
297 /// `s/√N`: how far the mean is likely to be from the truth, not how spread the samples are.
298 pub standard_error: f64,
299 /// How many samples went into it. Quoted because a mean without one is not a measurement.
300 pub samples: u64,
301}
302
303impl Estimate {
304 /// The sample standard deviation — the spread of the samples themselves.
305 ///
306 /// Distinct from [`standard_error`](Estimate::standard_error), and confusing the two is the
307 /// most common way to state a Monte Carlo result wrongly: the spread does not shrink with
308 /// more samples and the error on the mean does.
309 pub fn standard_deviation(&self) -> f64 {
310 self.standard_error * (self.samples as f64).sqrt()
311 }
312
313 /// Whether a value sits within `k` standard errors of the mean.
314 pub fn within(&self, k: f64, value: f64) -> bool {
315 (value - self.mean).abs() <= k * self.standard_error
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 /// **The thread count does not change a single bit.**
324 ///
325 /// The claim the whole type exists to make. A Monte Carlo drawing from a shared generator
326 /// gives a different answer on eight cores than on one, and the difference *looks like*
327 /// statistical noise — so it is never investigated, and no amount of averaging removes it.
328 ///
329 /// Compared on `to_bits()` element by element rather than on a mean, because a mean can
330 /// agree while the samples behind it are permuted.
331 #[test]
332 fn the_answer_does_not_depend_on_how_many_threads_produced_it() {
333 let draw = |i: u64, mut rng: Rng| rng.gaussian() * (1.0 + i as f64 % 3.0);
334 let one = Ensemble::new(4242, 5_000).run(draw);
335
336 for threads in [2usize, 3, 7, 16, 64] {
337 let many = Ensemble::new(4242, 5_000).with_threads(threads).run(draw);
338 assert_eq!(one.len(), many.len());
339 for (i, (a, b)) in one.iter().zip(&many).enumerate() {
340 assert_eq!(
341 a.to_bits(),
342 b.to_bits(),
343 "sample {i} differed at {threads} threads: {a} against {b}"
344 );
345 }
346 }
347
348 // And the samples are not all the same value, which would make the above vacuous.
349 let spread = one.iter().cloned().fold(f64::MIN, f64::max)
350 - one.iter().cloned().fold(f64::MAX, f64::min);
351 assert!(spread > 1.0, "the draws should vary; spread {spread}");
352 }
353
354 /// **A mean converges at `1/√N`, which is the rate that earns a tolerance.**
355 ///
356 /// The estimator is checked against a distribution whose mean is known exactly rather than
357 /// against another run: a uniform draw on `[0, 1)` has mean `1/2`. Quadrupling the samples
358 /// must halve the error, and that *rate* is the claim — a single count would only say the
359 /// estimator is not wildly wrong.
360 #[test]
361 fn the_error_falls_as_one_over_root_n() {
362 let err = |n: u64| {
363 let e = Ensemble::new(7, n)
364 .with_threads(4)
365 .estimate(|_, mut rng| rng.unit())
366 .expect("more than one sample");
367 (e.mean - 0.5).abs()
368 };
369 let (coarse, fine) = (err(4_000), err(64_000));
370 // Sixteen times the samples is four times the accuracy. Bounded loosely on purpose:
371 // this is a random quantity and the band is wide enough that an honest run passes and
372 // a broken estimator — one whose error does not fall at all — does not.
373 let ratio = coarse / fine;
374 assert!(
375 (1.5..12.0).contains(&ratio),
376 "16x the samples gave {ratio:.2}x the accuracy (expected about 4)"
377 );
378
379 // The reported standard error should bracket the truth most of the time. One estimate,
380 // three sigma: this fails about three times in a thousand by chance, and the seed is
381 // fixed, so it either passes forever or reports a real defect.
382 let e = Ensemble::new(11, 40_000)
383 .with_threads(4)
384 .estimate(|_, mut rng| rng.unit())
385 .unwrap();
386 assert!(
387 e.within(3.0, 0.5),
388 "0.5 is {:.2} standard errors from {:.6}",
389 (e.mean - 0.5).abs() / e.standard_error,
390 e.mean
391 );
392 assert_eq!(e.samples, 40_000);
393
394 // Spread and error-on-the-mean are different numbers, and confusing them is the usual
395 // way to misreport a Monte Carlo. A uniform draw has standard deviation 1/√12.
396 assert!(
397 (e.standard_deviation() - (1.0f64 / 12.0).sqrt()).abs() < 0.01,
398 "standard deviation {:.6}",
399 e.standard_deviation()
400 );
401 }
402
403 /// **Ten million samples, held in kilobytes rather than in eighty megabytes.**
404 ///
405 /// The reason `estimate` folds per block instead of collecting. `run` materialises every
406 /// sample and is right when you want them; a study that only wants a mean should not pay
407 /// 8 bytes times the sample count to get one, and at the sizes a Monte Carlo actually
408 /// reaches — 1e8, 1e9 — paying it is not merely wasteful but impossible.
409 ///
410 /// Still thread-independent, which is the harder half: the block size is fixed, so the
411 /// *association* of the additions is a function of the sample count alone and not of how
412 /// many cores turned up.
413 #[test]
414 fn a_run_too_large_to_hold_still_agrees_across_threads() {
415 let draw = |_: u64, mut rng: Rng| rng.unit();
416 let n = 10_000_000;
417
418 let one = Ensemble::new(5, n).estimate(draw).expect("plenty");
419 for threads in [4usize, 16] {
420 let many = Ensemble::new(5, n)
421 .with_threads(threads)
422 .estimate(draw)
423 .expect("plenty");
424 assert_eq!(
425 one.mean.to_bits(),
426 many.mean.to_bits(),
427 "mean moved at {threads} threads: {} against {}",
428 one.mean,
429 many.mean
430 );
431 assert_eq!(one.standard_error.to_bits(), many.standard_error.to_bits());
432 assert_eq!(one.samples, n);
433 }
434
435 // And it is right: a uniform draw has mean 1/2 and standard deviation 1/sqrt(12), and
436 // ten million samples pin the mean to about a ten-thousandth.
437 assert!(one.within(4.0, 0.5), "mean {:.8}", one.mean);
438 assert!(
439 (one.standard_deviation() - (1.0f64 / 12.0).sqrt()).abs() < 1e-3,
440 "spread {:.6}",
441 one.standard_deviation()
442 );
443 }
444
445 /// **The blocked fold is more accurate than a flat sum, not merely cheaper.**
446 ///
447 /// A mean far from zero against a tiny spread is where the naive `sum(x²) − n·mean²` form
448 /// loses every digit, and where a flat left-to-right sum of ten million values loses several
449 /// to accumulation. Welford within a block and Chan's merge between blocks keeps both.
450 ///
451 /// Checked against a case whose answer is exact: `x = 1e9 + (i mod 2)` has mean
452 /// `1e9 + 0.5` and variance exactly `0.25 · n/(n−1)`.
453 #[test]
454 fn the_estimator_survives_a_large_mean_and_a_small_spread() {
455 let n = 1_000_000u64;
456 let e = Ensemble::new(0, n)
457 .with_threads(8)
458 .estimate(|i, _| 1e9 + (i % 2) as f64)
459 .expect("plenty");
460
461 assert!(
462 (e.mean - (1e9 + 0.5)).abs() < 1e-6,
463 "mean {:.6} against 1000000000.5",
464 e.mean
465 );
466 // Population variance 0.25, so the sample variance is 0.25·n/(n−1).
467 let want = (0.25 * n as f64 / (n as f64 - 1.0)).sqrt();
468 assert!(
469 (e.standard_deviation() / want - 1.0).abs() < 1e-9,
470 "spread {:.9} against {want:.9}",
471 e.standard_deviation()
472 );
473 }
474
475 /// Fewer than two samples has no variance, and says so rather than returning zero.
476 #[test]
477 fn one_sample_is_not_an_estimate() {
478 assert!(Ensemble::new(1, 1).estimate(|_, mut r| r.unit()).is_none());
479 assert!(Ensemble::new(1, 0).estimate(|_, mut r| r.unit()).is_none());
480 assert!(Ensemble::new(1, 2).estimate(|_, mut r| r.unit()).is_some());
481 }
482}