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}