use std::hint::black_box;
use std::time::{Duration, Instant};
const CACHE_LINE_BYTES: usize = 64;
const BATCH_STEPS: u64 = 1 << 14;
const SLOTS_PER_LINE: usize = CACHE_LINE_BYTES / std::mem::size_of::<u32>();
#[derive(Debug, Clone, Copy)]
pub struct ProbeConfig {
pub duration: Duration,
pub working_set_bytes: usize,
pub threads: usize,
pub seed: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProbeResult {
pub accesses: u64,
pub elapsed: Duration,
pub threads: usize,
pub working_set_bytes: usize,
}
impl ProbeResult {
#[must_use]
pub fn million_accesses_per_sec(&self) -> f64 {
let secs = self.elapsed.as_secs_f64();
if secs <= 0.0 {
return 0.0;
}
#[allow(clippy::cast_precision_loss)] let accesses = self.accesses as f64;
accesses / secs / 1e6
}
#[must_use]
pub fn ns_per_access(&self) -> f64 {
if self.accesses == 0 {
return 0.0;
}
#[allow(clippy::cast_precision_loss)]
let accesses = self.accesses as f64;
#[allow(clippy::cast_precision_loss)]
let threads = self.threads as f64;
self.elapsed.as_secs_f64() * 1e9 * threads / accesses
}
}
struct XorShift64(u64);
impl XorShift64 {
fn new(seed: u64) -> Self {
Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
}
fn build_chain(len: usize, seed: u64) -> Vec<u32> {
let mut chain: Vec<u32> =
(0..len).map(|i| u32::try_from(i).expect("chain length is bounded by u32::MAX")).collect();
let mut rng = XorShift64::new(seed);
for i in (1..len).rev() {
let j = usize::try_from(rng.next_u64() % (i as u64)).expect("j < i fits usize");
chain.swap(i, j);
}
chain
}
#[must_use]
pub fn slots_for(working_set_bytes: usize) -> usize {
let lines = working_set_bytes / CACHE_LINE_BYTES;
(lines * SLOTS_PER_LINE).max(2)
}
fn chase(chain: &[u32], deadline: Instant) -> u64 {
let mut index = 0usize;
let mut accesses = 0u64;
while Instant::now() < deadline {
for _ in 0..BATCH_STEPS {
index = chain[index] as usize;
}
black_box(index);
accesses += BATCH_STEPS;
}
accesses
}
#[must_use]
pub fn run(config: &ProbeConfig) -> ProbeResult {
let threads = config.threads.max(1);
let slots = slots_for(config.working_set_bytes);
let chains: Vec<Vec<u32>> =
(0..threads).map(|t| build_chain(slots, config.seed ^ ((t as u64) << 32))).collect();
let start = Instant::now();
let deadline = start + config.duration;
let accesses: u64 = std::thread::scope(|scope| {
let handles: Vec<_> =
chains.iter().map(|chain| scope.spawn(move || chase(chain, deadline))).collect();
handles.into_iter().map(|h| h.join().expect("a chase thread panicked")).sum()
});
ProbeResult {
accesses,
elapsed: start.elapsed(),
threads,
working_set_bytes: slots * std::mem::size_of::<u32>(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny(threads: usize) -> ProbeConfig {
ProbeConfig {
duration: Duration::from_millis(20),
working_set_bytes: 1 << 20,
threads,
seed: 42,
}
}
#[test]
fn chain_is_a_single_full_length_cycle() {
let len = 4096;
let chain = build_chain(len, 7);
let mut seen = vec![false; len];
let mut index = 0usize;
for _ in 0..len {
assert!(!seen[index], "revisited slot {index} before covering the chain");
seen[index] = true;
index = chain[index] as usize;
}
assert_eq!(index, 0, "chain must close back to its start");
assert!(seen.iter().all(|&s| s), "every slot must be visited exactly once");
}
#[test]
fn chain_is_a_permutation() {
let len = 1024;
let mut sorted = build_chain(len, 99);
sorted.sort_unstable();
let expected: Vec<u32> = (0..u32::try_from(len).unwrap()).collect();
assert_eq!(sorted, expected);
}
#[test]
fn chain_never_maps_a_slot_to_itself() {
let chain = build_chain(2048, 5);
for (i, &next) in chain.iter().enumerate() {
assert_ne!(next as usize, i, "slot {i} is a self-loop");
}
}
#[test]
fn same_seed_builds_the_same_chain() {
assert_eq!(build_chain(512, 1234), build_chain(512, 1234));
assert_ne!(build_chain(512, 1234), build_chain(512, 5678));
}
#[test]
fn slots_cover_the_whole_working_set() {
for mb in [1usize, 8, 64] {
let bytes = mb << 20;
assert_eq!(
slots_for(bytes) * std::mem::size_of::<u32>(),
bytes,
"{mb} MB working set does not produce a {mb} MB chain"
);
}
assert!(slots_for(0) >= 2);
assert!(slots_for(1) >= 2);
}
#[test]
fn the_reported_working_set_is_the_memory_actually_walked() {
let result = run(&tiny(1));
assert_eq!(result.working_set_bytes, slots_for(1 << 20) * std::mem::size_of::<u32>());
assert_eq!(result.working_set_bytes, 1 << 20);
}
#[test]
fn probe_reports_positive_throughput() {
let result = run(&tiny(1));
assert!(result.accesses > 0, "probe made no accesses");
assert!(result.million_accesses_per_sec() > 0.0);
assert!(result.ns_per_access() > 0.0);
}
#[test]
fn accesses_scale_with_thread_count() {
let one = run(&tiny(1)).accesses;
let four = run(&tiny(4)).accesses;
assert!(four > one, "4 threads made {four} accesses vs 1 thread {one}");
}
#[test]
fn zero_threads_is_treated_as_one() {
let result = run(&tiny(0));
assert_eq!(result.threads, 1);
assert!(result.accesses > 0);
}
#[test]
fn empty_result_metrics_do_not_divide_by_zero() {
let empty =
ProbeResult { accesses: 0, elapsed: Duration::ZERO, threads: 1, working_set_bytes: 0 };
assert!((empty.million_accesses_per_sec() - 0.0).abs() < f64::EPSILON);
assert!((empty.ns_per_access() - 0.0).abs() < f64::EPSILON);
}
}