1use serde::{Deserialize, Serialize};
8use silicera::hardware::HardwareInfo;
9use silicera::measure::{MeasurementConfig, MeasurementEngine, MeasurementSummary};
10use silicera::Result;
11
12use crate::bench::ConcurrencyBench;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ThreadCountPoint {
17 pub threads: usize,
19 pub baseline: MeasurementSummary,
21 pub candidate: MeasurementSummary,
23 pub faster: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ThreadCountReport {
30 pub host_brand: String,
32 pub fingerprint: Option<String>,
34 pub logical_cpus: usize,
36 pub iters_total: u64,
38 pub points: Vec<ThreadCountPoint>,
40 pub best_candidate_threads: usize,
42 pub best_baseline_threads: usize,
44 pub conclusion: String,
46 pub note: String,
48}
49
50pub fn default_thread_sweep(logical_cpus: usize) -> Vec<usize> {
52 let max = logical_cpus.max(1);
53 let mut v = vec![1usize, 2, 4, 8, 16];
54 v.retain(|&t| t <= max);
55 if !v.contains(&max) && max > 1 {
56 v.push(max);
57 }
58 if v.is_empty() {
59 v.push(1);
60 }
61 v.sort_unstable();
62 v.dedup();
63 v
64}
65
66pub fn measure_thread_count_sweep(
72 info: &HardwareInfo,
73 mcfg: &MeasurementConfig,
74 total_iters: u64,
75 thread_counts: Option<&[usize]>,
76) -> Result<ThreadCountReport> {
77 let logical = info
78 .topology
79 .thread_count()
80 .max(info.environment.logical_cpus)
81 .max(1);
82 let counts: Vec<usize> = thread_counts
83 .map(|s| s.to_vec())
84 .unwrap_or_else(|| default_thread_sweep(logical));
85 let eng = MeasurementEngine::new(mcfg.clone());
86 let mut points = Vec::new();
87
88 for &threads in &counts {
89 let iters = (total_iters / threads as u64).max(1);
90 let bench = ConcurrencyBench {
91 iters,
92 threads,
93 };
94 let baseline = eng.measure(|| {
95 let _ = bench.run_baseline();
96 })?;
97 let candidate = eng.measure(|| {
98 let _ = bench.run_candidate();
99 })?;
100 let faster = if candidate.median_ns < baseline.median_ns {
101 "candidate"
102 } else {
103 "baseline"
104 };
105 points.push(ThreadCountPoint {
106 threads,
107 baseline,
108 candidate,
109 faster: faster.into(),
110 });
111 }
112
113 let best_candidate_threads = points
114 .iter()
115 .min_by(|a, b| {
116 a.candidate
117 .median_ns
118 .partial_cmp(&b.candidate.median_ns)
119 .unwrap_or(std::cmp::Ordering::Less)
120 })
121 .map(|p| p.threads)
122 .unwrap_or(1);
123 let best_baseline_threads = points
124 .iter()
125 .min_by(|a, b| {
126 a.baseline
127 .median_ns
128 .partial_cmp(&b.baseline.median_ns)
129 .unwrap_or(std::cmp::Ordering::Less)
130 })
131 .map(|p| p.threads)
132 .unwrap_or(1);
133
134 let more_not_always = points.len() >= 2
135 && points.last().map(|p| p.candidate.median_ns).unwrap_or(0.0)
136 > points
137 .iter()
138 .map(|p| p.candidate.median_ns)
139 .fold(f64::INFINITY, f64::min)
140 + f64::EPSILON;
141
142 let conclusion = if more_not_always {
143 format!(
144 "More threads is NOT always faster on this host. Best candidate median at \
145 threads={best_candidate_threads}; best baseline at threads={best_baseline_threads}."
146 )
147 } else {
148 format!(
149 "Among measured counts, best candidate median at threads={best_candidate_threads}; \
150 best baseline at threads={best_baseline_threads}. Do not extrapolate beyond measured points."
151 )
152 };
153
154 Ok(ThreadCountReport {
155 host_brand: info.brand.clone(),
156 fingerprint: info.fingerprint.as_ref().map(|f| f.value.clone()),
157 logical_cpus: logical,
158 iters_total: total_iters,
159 points,
160 best_candidate_threads,
161 best_baseline_threads,
162 conclusion,
163 note: "Silicera varies only the thread count argument to ConcurrencyBench; \
164 total iteration budget is split across threads (constant work). \
165 No SetThreadAffinityMask / sched_setaffinity / OS scheduler changes."
166 .into(),
167 })
168}