Skip to main content

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}