Skip to main content

silicera_lab/
threads.rs

1//! Thread-count / core-placement specialization experiments.
2//!
3//! Silicera-owned only: we vary the thread count passed into lab workloads and
4//! measure. We do **not** change global OS scheduling, affinity, or power plans.
5//! More threads is not assumed faster.
6
7use serde::{Deserialize, Serialize};
8use silicera::hardware::HardwareInfo;
9use silicera::measure::{MeasurementConfig, MeasurementEngine, MeasurementSummary};
10use silicera::Result;
11
12use crate::bench::ConcurrencyBench;
13
14/// One measured thread-count point.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ThreadCountPoint {
17    /// Thread count used for this measurement.
18    pub threads: usize,
19    /// Contended-atomic baseline median.
20    pub baseline: MeasurementSummary,
21    /// Per-thread-local candidate median.
22    pub candidate: MeasurementSummary,
23    /// Which variant had lower median (`baseline` or `candidate`).
24    pub faster: String,
25}
26
27/// Report for the thread-count sweep.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ThreadCountReport {
30    /// Host brand.
31    pub host_brand: String,
32    /// Fingerprint.
33    pub fingerprint: Option<String>,
34    /// Logical CPUs reported by topology / OS.
35    pub logical_cpus: usize,
36    /// Iterations across all threads (divided among threads; constant total work).
37    pub iters_total: u64,
38    /// Measured points.
39    pub points: Vec<ThreadCountPoint>,
40    /// Thread count with lowest candidate median (among measured).
41    pub best_candidate_threads: usize,
42    /// Thread count with lowest baseline median.
43    pub best_baseline_threads: usize,
44    /// Honest conclusion.
45    pub conclusion: String,
46    /// Methodology note.
47    pub note: String,
48}
49
50/// Default thread counts to sweep (clamped to host logical CPUs).
51pub 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
66/// Measure concurrency kernels across thread counts (Silicera-owned; no OS affinity).
67///
68/// Total work is held approximately constant: `iters_per_thread * threads ≈
69/// iters_per_thread_at_1` so wall-time comparisons ask whether parallelism helps,
70/// not whether doing N× more work is slower.
71pub 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}