Skip to main content

silicera_lab/
studies.rs

1//! Research studies: alignment, placement, and spot-check verification.
2//!
3//! Consolidated from former `alignment`, `placement`, and `spot_check` modules.
4//! Public types and functions remain re-exported at the crate root.
5
6use serde::{Deserialize, Serialize};
7use silicera::hardware::HardwareInfo;
8use silicera::hnep::{Confidence, HnepProfile};
9use silicera::measure::{MeasurementConfig, MeasurementEngine, MeasurementSummary};
10use silicera::Result;
11
12use crate::bench::{AlignmentBench, CacheTarget, ConcurrencyBench, FloatBench, IntegerBench, MemOpBench};
13
14// ── Alignment ───────────────────────────────────────────────────────────────
15
16/// One alignment offset sample.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AlignmentSample {
19    /// Byte offset into a larger buffer.
20    pub offset: usize,
21    /// Scalar checksum median ns.
22    pub scalar: MeasurementSummary,
23    /// Chunked checksum median ns.
24    pub chunked: MeasurementSummary,
25    /// Winner name for this offset.
26    pub winner: String,
27    /// Relative improvement of winner vs loser (0 if tie/no meaningful gain).
28    pub improvement: f64,
29}
30
31/// Full alignment report.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AlignmentReport {
34    /// Host brand.
35    pub host_brand: String,
36    /// Fingerprint.
37    pub fingerprint: Option<String>,
38    /// Working-set length.
39    pub len: usize,
40    /// Samples per offset.
41    pub samples: Vec<AlignmentSample>,
42    /// True when winners differ across offsets.
43    pub alignment_matters: bool,
44    /// Recommendation for dispatch complexity.
45    pub recommendation: String,
46}
47
48/// Run alignment experiment for offsets `[0, 1, 7, 15, 63]`.
49pub fn measure_alignment(
50    info: &HardwareInfo,
51    len: usize,
52    cfg: &MeasurementConfig,
53    min_improvement: f64,
54) -> Result<AlignmentReport> {
55    let eng = MeasurementEngine::new(cfg.clone());
56    let offsets = [0usize, 1, 7, 15, 63];
57    let mut samples = Vec::new();
58    for &offset in &offsets {
59        let b = AlignmentBench::new(len, offset);
60        let scalar = eng.measure(|| {
61            let _ = b.run_scalar();
62        })?;
63        let chunked = eng.measure(|| {
64            let _ = b.run_chunked();
65        })?;
66        let (winner, improvement) = if chunked.median_ns < scalar.median_ns * (1.0 - min_improvement)
67        {
68            (
69                "chunked".into(),
70                (scalar.median_ns - chunked.median_ns) / scalar.median_ns,
71            )
72        } else if scalar.median_ns < chunked.median_ns * (1.0 - min_improvement) {
73            (
74                "scalar".into(),
75                (chunked.median_ns - scalar.median_ns) / chunked.median_ns,
76            )
77        } else {
78            ("no_meaningful_difference".into(), 0.0)
79        };
80        samples.push(AlignmentSample {
81            offset,
82            scalar,
83            chunked,
84            winner,
85            improvement,
86        });
87    }
88    let meaningful: Vec<&str> = samples
89        .iter()
90        .filter(|s| s.winner != "no_meaningful_difference")
91        .map(|s| s.winner.as_str())
92        .collect();
93    let alignment_matters = meaningful.len() >= 2
94        && meaningful.windows(2).any(|w| w[0] != w[1])
95        || samples.iter().any(|s| {
96            s.offset != 0
97                && s.winner != "no_meaningful_difference"
98                && samples[0].winner != s.winner
99                && samples[0].winner != "no_meaningful_difference"
100        });
101    // Also treat large absolute timing shifts at misalignment as "matters" for docs.
102    let base = samples[0].scalar.median_ns;
103    let timing_shift = samples.iter().any(|s| {
104        s.offset != 0 && (s.scalar.median_ns - base).abs() / base.max(1.0) > 0.10
105    });
106    let alignment_matters = alignment_matters || timing_shift;
107    let recommendation = if alignment_matters {
108        "Alignment materially affected measurements on this host — consider alignment in dispatch for this workload class.".into()
109    } else {
110        "No meaningful alignment-driven strategy change under these knobs — do not add dispatch complexity.".into()
111    };
112    Ok(AlignmentReport {
113        host_brand: info.brand.clone(),
114        fingerprint: info.fingerprint.as_ref().map(|f| f.value.clone()),
115        len,
116        samples,
117        alignment_matters,
118        recommendation,
119    })
120}
121
122// ── Placement ───────────────────────────────────────────────────────────────
123
124/// Placement strategy under test.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PlacementStrategy {
128    /// OS default — no affinity.
129    OsDefault,
130    /// Prefer first N logical CPUs (compact).
131    Compact,
132    /// Spread across even logical CPUs (sparse).
133    Spread,
134}
135
136impl PlacementStrategy {
137    /// Label.
138    pub fn label(self) -> &'static str {
139        match self {
140            PlacementStrategy::OsDefault => "os_default",
141            PlacementStrategy::Compact => "compact",
142            PlacementStrategy::Spread => "spread",
143        }
144    }
145}
146
147/// One placement measurement.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct PlacementSample {
150    /// Strategy.
151    pub strategy: String,
152    /// Thread count.
153    pub threads: usize,
154    /// Summary.
155    pub summary: MeasurementSummary,
156    /// Whether affinity was applied.
157    pub affinity_applied: bool,
158    /// Notes.
159    pub notes: String,
160}
161
162/// Placement sweep report.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PlacementReport {
165    /// Host brand.
166    pub host_brand: String,
167    /// Fingerprint.
168    pub fingerprint: Option<String>,
169    /// Logical CPU count.
170    pub logical_cpus: usize,
171    /// Samples.
172    pub samples: Vec<PlacementSample>,
173    /// Fastest strategy label.
174    pub fastest: String,
175    /// Conclusion.
176    pub conclusion: String,
177}
178
179/// Run a constant-work parallel checksum under different placements.
180pub fn measure_placement(
181    info: &HardwareInfo,
182    threads: usize,
183    iters_per_thread: u64,
184    cfg: &MeasurementConfig,
185) -> Result<PlacementReport> {
186    let eng = MeasurementEngine::new(cfg.clone());
187    let logical = info.topology.thread_count().max(1);
188    let threads = threads.clamp(1, logical);
189    let strategies = [
190        PlacementStrategy::OsDefault,
191        PlacementStrategy::Compact,
192        PlacementStrategy::Spread,
193    ];
194    let mut samples = Vec::new();
195    for strat in strategies {
196        let (masks, notes) = affinity_masks(strat, threads, logical);
197        let summary = eng.measure(|| {
198            run_placed(threads, iters_per_thread, &masks);
199        })?;
200        samples.push(PlacementSample {
201            strategy: strat.label().into(),
202            threads,
203            summary,
204            affinity_applied: masks.iter().any(|m| m.is_some()),
205            notes,
206        });
207    }
208
209    let mut fastest = "os_default".to_string();
210    let mut best = f64::INFINITY;
211    for s in &samples {
212        if s.summary.median_ns < best {
213            best = s.summary.median_ns;
214            fastest = s.strategy.clone();
215        }
216    }
217
218    let conclusion = format!(
219        "Fastest under these knobs: {fastest}. Placement changes global OS policy: no. \
220         Affinity is Silicera-owned and temporary. Do not assume compact > spread."
221    );
222    Ok(PlacementReport {
223        host_brand: info.brand.clone(),
224        fingerprint: info.fingerprint.as_ref().map(|f| f.value.clone()),
225        logical_cpus: logical,
226        samples,
227        fastest,
228        conclusion,
229    })
230}
231
232fn affinity_masks(
233    strat: PlacementStrategy,
234    threads: usize,
235    logical: usize,
236) -> (Vec<Option<usize>>, String) {
237    match strat {
238        PlacementStrategy::OsDefault => (
239            vec![None; threads],
240            "no affinity; OS schedules freely".into(),
241        ),
242        PlacementStrategy::Compact => {
243            let masks: Vec<Option<usize>> = (0..threads).map(|i| Some(i % logical)).collect();
244            (
245                masks,
246                format!("compact: logical CPUs 0..{}", threads.min(logical)),
247            )
248        }
249        PlacementStrategy::Spread => {
250            let step = (logical / threads.max(1)).max(1);
251            let masks: Vec<Option<usize>> = (0..threads)
252                .map(|i| Some((i * step) % logical))
253                .collect();
254            (
255                masks,
256                format!("spread: step={step} across {logical} logical CPUs"),
257            )
258        }
259    }
260}
261
262fn run_placed(threads: usize, iters: u64, masks: &[Option<usize>]) {
263    let mut handles = Vec::new();
264    for t in 0..threads {
265        let mask = masks.get(t).copied().flatten();
266        handles.push(std::thread::spawn(move || {
267            if let Some(cpu) = mask {
268                set_current_thread_affinity(cpu);
269            }
270            let mut x = t as u64;
271            for i in 0..iters {
272                x = x
273                    .wrapping_mul(1664525)
274                    .wrapping_add(1013904223)
275                    .wrapping_add(i);
276            }
277            std::hint::black_box(x)
278        }));
279    }
280    for h in handles {
281        let _ = h.join();
282    }
283}
284
285#[cfg(windows)]
286mod win_affinity {
287    use std::ffi::c_void;
288
289    type Handle = *mut c_void;
290
291    #[link(name = "kernel32")]
292    extern "system" {
293        fn GetCurrentThread() -> Handle;
294        fn SetThreadAffinityMask(thread: Handle, mask: usize) -> usize;
295    }
296
297    pub fn set(logical_cpu: usize) {
298        if logical_cpu < usize::BITS as usize {
299            let mask = 1usize << logical_cpu;
300            // SAFETY: Silicera-owned thread; single-bit affinity mask.
301            unsafe {
302                let _ = SetThreadAffinityMask(GetCurrentThread(), mask);
303            }
304        }
305    }
306}
307
308fn set_current_thread_affinity(logical_cpu: usize) {
309    #[cfg(windows)]
310    {
311        win_affinity::set(logical_cpu);
312    }
313    #[cfg(not(windows))]
314    {
315        let _ = logical_cpu;
316    }
317}
318
319// ── Spot-check ──────────────────────────────────────────────────────────────
320
321/// One spot-check outcome.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct SpotCheckItem {
324    /// Workload name from profile.
325    pub workload: String,
326    /// Profile winner.
327    pub profile_winner: String,
328    /// Profile confidence.
329    pub profile_confidence: String,
330    /// Fresh measurement note.
331    pub observation: String,
332    /// Whether the spot-check agrees with keeping the profile entry.
333    pub ok: bool,
334}
335
336/// Spot-check report.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct SpotCheckReport {
339    /// Profile path label.
340    pub profile_label: String,
341    /// Host brand.
342    pub host_brand: String,
343    /// Items checked.
344    pub items: Vec<SpotCheckItem>,
345    /// Count OK.
346    pub ok_count: usize,
347    /// Count failed / inconclusive recheck.
348    pub fail_count: usize,
349    /// Summary.
350    pub summary: String,
351}
352
353/// Spot-check up to `limit` non-inconclusive workloads with tiny samples.
354pub fn spot_check_profile(
355    info: &HardwareInfo,
356    profile: &HnepProfile,
357    limit: usize,
358    cfg: &MeasurementConfig,
359) -> Result<SpotCheckReport> {
360    let eng = MeasurementEngine::new(cfg.clone());
361    let mut items = Vec::new();
362    for w in profile
363        .workloads
364        .iter()
365        .filter(|w| w.confidence != Confidence::Inconclusive)
366        .take(limit)
367    {
368        items.push(check_one(info, &eng, &w.name, &w.winner, w.confidence)?);
369    }
370    if let Some(sc) = profile.size_classes.first() {
371        if items.len() < limit {
372            let name = format!("memscan-{}", sc.class.to_ascii_lowercase());
373            items.push(check_one(
374                info,
375                &eng,
376                &name,
377                &sc.winner,
378                sc.confidence,
379            )?);
380        }
381    }
382    let ok_count = items.iter().filter(|i| i.ok).count();
383    let fail_count = items.len() - ok_count;
384    let summary = if items.is_empty() {
385        "No non-inconclusive workloads to spot-check.".into()
386    } else if fail_count == 0 {
387        format!("All {ok_count} spot-checks consistent under tiny sample.")
388    } else {
389        format!(
390            "{fail_count}/{} spot-checks unstable or disagreed — consider partial retrain.",
391            items.len()
392        )
393    };
394    Ok(SpotCheckReport {
395        profile_label: profile.header.label.clone(),
396        host_brand: info.brand.clone(),
397        items,
398        ok_count,
399        fail_count,
400        summary,
401    })
402}
403
404fn check_one(
405    info: &HardwareInfo,
406    eng: &MeasurementEngine,
407    name: &str,
408    winner: &str,
409    confidence: Confidence,
410) -> Result<SpotCheckItem> {
411    let (observation, ok) = if name.starts_with("memscan") || name.starts_with("memory") {
412        let mut op = MemOpBench::for_target(&info.topology, CacheTarget::L2);
413        let base = eng.measure(|| {
414            let _ = op.run_scan_stride();
415        })?;
416        let cand = eng.measure(|| {
417            let _ = op.run_copy();
418        })?;
419        let stable =
420            base.stability.label() != "UNSTABLE" && cand.stability.label() != "UNSTABLE";
421        (
422            format!(
423                "fresh L2 scan={:.0}ns copy={:.0}ns [{}|{}]",
424                base.median_ns,
425                cand.median_ns,
426                base.stability.label(),
427                cand.stability.label()
428            ),
429            stable,
430        )
431    } else if name.starts_with("float") {
432        let b = FloatBench::new(2048);
433        let s = eng.measure(|| {
434            let _ = b.run_baseline();
435        })?;
436        (
437            format!(
438                "float baseline median={:.0}ns {}",
439                s.median_ns,
440                s.stability.label()
441            ),
442            s.stability.label() != "UNSTABLE",
443        )
444    } else if name.starts_with("concurrency") {
445        let b = ConcurrencyBench {
446            iters: 5_000,
447            threads: 4.min(info.topology.thread_count().max(1)),
448        };
449        let base = eng.measure(|| {
450            let _ = b.run_baseline();
451        })?;
452        let cand = eng.measure(|| {
453            let _ = b.run_candidate();
454        })?;
455        let stable =
456            base.stability.label() != "UNSTABLE" && cand.stability.label() != "UNSTABLE";
457        (
458            format!(
459                "concurrency base={:.0} cand={:.0} profile_winner={winner}",
460                base.median_ns, cand.median_ns
461            ),
462            stable,
463        )
464    } else {
465        let b = IntegerBench { n: 42 };
466        let s = eng.measure(|| {
467            let _ = b.run_baseline();
468        })?;
469        (
470            format!(
471                "integer median={:.0}ns {} (profile_winner={winner})",
472                s.median_ns,
473                s.stability.label()
474            ),
475            s.stability.label() != "UNSTABLE",
476        )
477    };
478    Ok(SpotCheckItem {
479        workload: name.into(),
480        profile_winner: winner.into(),
481        profile_confidence: confidence.label().into(),
482        observation,
483        ok,
484    })
485}