Skip to main content

hermit/
chaos_timeslice.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The maximum timeslice that `hermit run --chaos --chaos-seeded-timeslice`
10//! draws from the scheduler seed.
11//!
12//! Chaos mode ends each timeslice after a random number of retired
13//! conditional branches, exponentially distributed with a mean of the
14//! maximum timeslice and capped at it. Which mean finds a race depends on the
15//! race: one whose window is a few dozen branches long needs slices of about a
16//! hundred branches, while one whose second thread must run far into its own
17//! work before the conflicting access needs slices that let it get there. A
18//! fixed mean suits one of them. Drawing the mean once per scheduler seed
19//! spreads a sweep of seeds across five orders of magnitude, so it covers both
20//! without a guessed value.
21//!
22//! The draw is not uniform over those orders of magnitude. The races measured
23//! needed either the shortest slices or the longest: a 46-branch window in a
24//! multithreaded C++ test binary failed in 9 of 32 seeds at 1000 ns and in
25//! none of 32 at 1e4, 1e5, 1e6 or the default 2e8 ns (1e7 was not measured),
26//! while races whose other thread must first run tens of thousands of branches
27//! need 1000000 ns or more, and none of the races measured needed the decades
28//! in between. So
29//! [`SEEDED_MAX_TIMESLICE_PIECES`] gives two in five seeds 1000 to 2000 ns (100
30//! to 200 branches per slice), two in five 1000000 to 200000000 ns, and keeps
31//! one in five for the decades between them.
32//!
33//! The draw is a pure function of the scheduler seed (and of the one-RCB
34//! floor, which only a large clock multiplier or slowdown factor raises above
35//! the range). It seeds a generator of its own and takes nothing from the
36//! streams Detcore already derives from that seed (each thread's chaos
37//! generator, the post-fork generator, the per-thread slowdown factors), so
38//! those are seeded exactly as before. Its result is an ordinary
39//! `--max-timeslice` value: Detcore receives the configuration of a run given
40//! that value explicitly.
41
42use std::num::NonZeroU64;
43
44use rand::RngExt as _;
45use rand::SeedableRng;
46use rand_pcg::Pcg64Mcg;
47
48/// The smallest drawn maximum, in virtual nanoseconds: 100 retired
49/// conditional branches at the default clock multiplier.
50pub const SEEDED_MAX_TIMESLICE_MIN_NS: u64 = 1_000;
51
52/// The largest drawn maximum, in virtual nanoseconds: the `--max-timeslice`
53/// default. No draw exceeds it, so a replay of a recorded chaos run, which
54/// keeps the default, is never bounded more tightly than the recording was.
55pub const SEEDED_MAX_TIMESLICE_MAX_NS: u64 = 200_000_000;
56
57/// [`SEEDED_MAX_TIMESLICE_MAX_NS`] as a maximum: the default that
58/// `--chaos-seeded-timeslice` stands in for where it draws nothing.
59pub const DEFAULT_MAX_TIMESLICE: NonZeroU64 = NonZeroU64::new(SEEDED_MAX_TIMESLICE_MAX_NS).unwrap();
60
61/// The pieces of the drawn maximum's distribution, in order: each is
62/// `(share, low, high)`, the share of seeds whose maximum falls in
63/// [`low`, `high`] virtual nanoseconds, log-uniformly within it. The pieces
64/// are contiguous, run from [`SEEDED_MAX_TIMESLICE_MIN_NS`] to
65/// [`SEEDED_MAX_TIMESLICE_MAX_NS`], and their shares sum to one.
66pub const SEEDED_MAX_TIMESLICE_PIECES: [(f64, u64, u64); 3] = [
67    (0.4, SEEDED_MAX_TIMESLICE_MIN_NS, 2_000),
68    (0.2, 2_000, 1_000_000),
69    (0.4, 1_000_000, SEEDED_MAX_TIMESLICE_MAX_NS),
70];
71
72/// Keeps this stream apart from the other streams seeded from the scheduler
73/// seed (`post_fork_prng`'s "postfork", the slowdown factors' "slowdown").
74const SEEDED_TIMESLICE_SALT: u64 = 0x7469_6d65_736c_6963; // "timeslic"
75
76/// The maximum timeslice, in virtual nanoseconds, drawn for `sched_seed`: one
77/// uniform number `u` from the seed's own generator, mapped through the
78/// quantile function of [`SEEDED_MAX_TIMESLICE_PIECES`] (the piece whose
79/// cumulative share passes `u`, log-uniformly within it), rounded to a whole
80/// nanosecond, then raised to `minimum_ns` (one RCB at the run's clock
81/// multiplier and slowdown, [`detcore::Config::minimum_max_timeslice_nanos`])
82/// if it falls below it. The mapping is monotone in `u`.
83pub fn seeded_max_timeslice(sched_seed: u64, minimum_ns: u64) -> NonZeroU64 {
84    let mut prng = Pcg64Mcg::seed_from_u64(sched_seed ^ SEEDED_TIMESLICE_SALT);
85    let u: f64 = prng.random();
86    let drawn = quantile(u).round() as u64;
87    let drawn = drawn
88        .clamp(SEEDED_MAX_TIMESLICE_MIN_NS, SEEDED_MAX_TIMESLICE_MAX_NS)
89        .max(minimum_ns);
90    NonZeroU64::new(drawn).expect("the drawn maximum is at least SEEDED_MAX_TIMESLICE_MIN_NS")
91}
92
93/// The maximum, in virtual nanoseconds, at cumulative probability `u` in
94/// [0, 1) of [`SEEDED_MAX_TIMESLICE_PIECES`].
95fn quantile(u: f64) -> f64 {
96    let mut below = 0.0;
97    for (share, low, high) in SEEDED_MAX_TIMESLICE_PIECES {
98        if u < below + share {
99            let t = (u - below) / share;
100            let (low, high) = ((low as f64).ln(), (high as f64).ln());
101            return (low + t * (high - low)).exp();
102        }
103        below += share;
104    }
105    // Rounding can leave the shares' sum a hair below one.
106    SEEDED_MAX_TIMESLICE_MAX_NS as f64
107}
108
109#[cfg(test)]
110mod tests {
111    use clap::Parser;
112
113    use super::*;
114
115    #[test]
116    fn the_largest_draw_is_the_max_timeslice_default() {
117        let default = detcore::Config::parse_from(["hermit"]).max_timeslice;
118        assert_eq!(default, Some(DEFAULT_MAX_TIMESLICE));
119    }
120
121    #[test]
122    fn a_seed_always_draws_the_same_value() {
123        for seed in [0, 1, 7, u64::MAX] {
124            assert_eq!(
125                seeded_max_timeslice(seed, 10),
126                seeded_max_timeslice(seed, 10)
127            );
128        }
129        // Pinned, so a change to the draw (which changes every seed's
130        // schedule) is a visible decision rather than an accident.
131        let pinned: Vec<u64> = (0..4)
132            .map(|seed| seeded_max_timeslice(seed, 10).get())
133            .collect();
134        assert_eq!(pinned, [1_113, 5_103_600, 12_779_429, 219_666]);
135    }
136
137    #[test]
138    fn the_pieces_tile_the_range_and_their_shares_sum_to_one() {
139        let pieces = SEEDED_MAX_TIMESLICE_PIECES;
140        assert_eq!(pieces[0].1, SEEDED_MAX_TIMESLICE_MIN_NS);
141        assert_eq!(pieces[pieces.len() - 1].2, SEEDED_MAX_TIMESLICE_MAX_NS);
142        for pair in pieces.windows(2) {
143            assert_eq!(pair[0].2, pair[1].1, "{pair:?}");
144        }
145        let total: f64 = pieces.iter().map(|(share, _, _)| share).sum();
146        assert!((total - 1.0).abs() < 1e-12, "{total}");
147        // The quantile function is monotone and meets each piece's bounds.
148        let mut last = 0.0;
149        for i in 0..1000 {
150            let v = quantile(f64::from(i) / 1000.0);
151            assert!(v >= last, "quantile decreases at {i}: {last} > {v}");
152            last = v;
153        }
154        assert!((quantile(0.0) - SEEDED_MAX_TIMESLICE_MIN_NS as f64).abs() < 1e-6);
155        assert!((quantile(0.4) - 2_000.0).abs() < 1e-6);
156        assert!((quantile(0.6) - 1_000_000.0).abs() < 1e-3);
157    }
158
159    /// 4096 seeds: every draw is in range, each piece holds its share (within
160    /// 10%) of the draws, and within the last piece each decade holds its
161    /// log-uniform share (within 20%).
162    #[test]
163    fn draws_follow_the_pieces() {
164        const SEEDS: u64 = 4096;
165        let mut pieces = [0u64; SEEDED_MAX_TIMESLICE_PIECES.len()];
166        let mut decades = [0u64; 2];
167        for seed in 0..SEEDS {
168            let drawn = seeded_max_timeslice(seed, 10).get();
169            assert!(
170                (SEEDED_MAX_TIMESLICE_MIN_NS..=SEEDED_MAX_TIMESLICE_MAX_NS).contains(&drawn),
171                "seed {seed} drew {drawn}"
172            );
173            let piece = SEEDED_MAX_TIMESLICE_PIECES
174                .iter()
175                .position(|(_, _, high)| drawn <= *high)
176                .unwrap();
177            pieces[piece] += 1;
178            if (1_000_000..10_000_000).contains(&drawn) {
179                decades[0] += 1;
180            } else if (10_000_000..100_000_000).contains(&drawn) {
181                decades[1] += 1;
182            }
183        }
184        for (count, (share, low, high)) in pieces.iter().zip(SEEDED_MAX_TIMESLICE_PIECES) {
185            let expected = share * SEEDS as f64;
186            let ratio = *count as f64 / expected;
187            assert!(
188                (0.9..1.1).contains(&ratio),
189                "[{low}, {high}] holds {count} of {SEEDS} draws, expected about {expected:.0}"
190            );
191        }
192        let last = SEEDED_MAX_TIMESLICE_PIECES[2];
193        let per_decade =
194            last.0 * SEEDS as f64 / ((last.2 as f64).log10() - (last.1 as f64).log10());
195        for count in decades {
196            let ratio = count as f64 / per_decade;
197            assert!(
198                (0.8..1.2).contains(&ratio),
199                "a decade of the last piece holds {count} draws, expected about {per_decade:.0}"
200            );
201        }
202    }
203
204    #[test]
205    fn a_draw_below_one_rcb_is_raised_to_it() {
206        // A clock multiplier of 1000 makes one RCB 10,000 virtual ns.
207        for seed in 0..256 {
208            assert!(seeded_max_timeslice(seed, 10_000).get() >= 10_000);
209            let unclamped = seeded_max_timeslice(seed, 10).get();
210            if unclamped >= 10_000 {
211                assert_eq!(seeded_max_timeslice(seed, 10_000).get(), unclamped);
212            }
213        }
214    }
215}