Skip to main content

silicera_lab/
bench.rs

1//! Microbenchmark workloads spanning memory / integer / float / branch / concurrency.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use silicera::topology::format_bytes;
7use silicera::topology::TopologyGraph;
8
9/// Workload domain.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum WorkloadKind {
12    /// Memory bandwidth / latency sensitive.
13    Memory,
14    /// Integer ALU.
15    Integer,
16    /// Floating point.
17    Float,
18    /// Branch-heavy.
19    Branch,
20    /// Multi-threaded.
21    Concurrency,
22}
23
24impl WorkloadKind {
25    /// Label.
26    pub fn label(self) -> &'static str {
27        match self {
28            WorkloadKind::Memory => "memory",
29            WorkloadKind::Integer => "integer",
30            WorkloadKind::Float => "float",
31            WorkloadKind::Branch => "branch",
32            WorkloadKind::Concurrency => "concurrency",
33        }
34    }
35}
36
37/// Working-set sized relative to a cache level.
38#[derive(Debug, Clone, Copy)]
39pub enum CacheTarget {
40    /// Fit in L1D.
41    L1,
42    /// Fit in L2.
43    L2,
44    /// Fit in L3.
45    L3,
46    /// Exceed L3 (DRAM-resident).
47    Dram,
48}
49
50impl CacheTarget {
51    /// Stable class id used in HNEP `size_classes`.
52    pub fn class_id(self) -> &'static str {
53        match self {
54            CacheTarget::L1 => "L1",
55            CacheTarget::L2 => "L2",
56            CacheTarget::L3 => "L3",
57            CacheTarget::Dram => "DRAM",
58        }
59    }
60
61    /// Resolve byte size from topology (fractional fit).
62    pub fn size_bytes(self, topo: &TopologyGraph) -> u64 {
63        let l1 = topo.typical_l1d_bytes().max(32 * 1024);
64        let l2 = topo.typical_l2_bytes().max(512 * 1024);
65        let l3 = topo
66            .packages
67            .first()
68            .and_then(|p| p.domains.first())
69            .and_then(|d| d.l3_bytes())
70            .unwrap_or(32 * 1024 * 1024);
71        match self {
72            CacheTarget::L1 => l1 / 2,
73            CacheTarget::L2 => l2 / 2,
74            CacheTarget::L3 => l3 / 2,
75            CacheTarget::Dram => l3.saturating_mul(4).max(64 * 1024 * 1024),
76        }
77    }
78
79    /// Upper threshold for this class (L1/L2/L3 boundaries; DRAM uses L3).
80    pub fn threshold_bytes(self, topo: &TopologyGraph) -> u64 {
81        let l1 = topo.typical_l1d_bytes().max(32 * 1024);
82        let l2 = topo.typical_l2_bytes().max(512 * 1024);
83        let l3 = topo
84            .packages
85            .first()
86            .and_then(|p| p.domains.first())
87            .and_then(|d| d.l3_bytes())
88            .unwrap_or(32 * 1024 * 1024);
89        match self {
90            CacheTarget::L1 => l1,
91            CacheTarget::L2 => l2,
92            CacheTarget::L3 => l3,
93            CacheTarget::Dram => l3,
94        }
95    }
96
97    /// Label including resolved size.
98    pub fn describe(self, topo: &TopologyGraph) -> String {
99        format!("{:?} (~{})", self, format_bytes(self.size_bytes(topo)))
100    }
101
102    /// All size classes in ascending order.
103    pub fn all() -> [CacheTarget; 4] {
104        [
105            CacheTarget::L1,
106            CacheTarget::L2,
107            CacheTarget::L3,
108            CacheTarget::Dram,
109        ]
110    }
111}
112
113/// Sequential memory touch (baseline-friendly).
114pub struct MemoryBench {
115    /// Buffer.
116    data: Vec<u8>,
117    /// Stride.
118    stride: usize,
119}
120
121impl MemoryBench {
122    /// Allocate for target cache level.
123    pub fn for_target(topo: &TopologyGraph, target: CacheTarget) -> Self {
124        let n = target.size_bytes(topo) as usize;
125        Self::with_bytes(n)
126    }
127
128    /// Allocate exact byte count.
129    pub fn with_bytes(n: usize) -> Self {
130        let mut data = vec![0u8; n.max(64)];
131        for (i, b) in data.iter_mut().enumerate() {
132            *b = (i % 251) as u8;
133        }
134        Self { data, stride: 64 }
135    }
136
137    /// Run once; returns checksum.
138    pub fn run(&self) -> u64 {
139        let mut sum = 0u64;
140        let mut i = 0usize;
141        while i < self.data.len() {
142            sum = sum.wrapping_add(self.data[i] as u64);
143            i += self.stride;
144        }
145        std::hint::black_box(sum)
146    }
147
148    /// Alternate access pattern (candidate).
149    pub fn run_prefetch_friendly(&self) -> u64 {
150        let mut sum = 0u64;
151        for chunk in self.data.chunks(64) {
152            for &b in chunk {
153                sum = sum.wrapping_add(b as u64);
154            }
155        }
156        std::hint::black_box(sum)
157    }
158}
159
160/// Flagship memscan / memcpy-style variants for size-class HNEPs.
161pub struct MemOpBench {
162    src: Vec<u8>,
163    dst: Vec<u8>,
164}
165
166impl MemOpBench {
167    /// Allocate for a cache target (src+dst each sized to the working set).
168    pub fn for_target(topo: &TopologyGraph, target: CacheTarget) -> Self {
169        let n = target.size_bytes(topo) as usize;
170        Self::with_bytes(n)
171    }
172
173    /// Exact working-set bytes.
174    pub fn with_bytes(n: usize) -> Self {
175        let n = n.max(64);
176        let mut src = vec![0u8; n];
177        for (i, b) in src.iter_mut().enumerate() {
178            *b = (i % 251) as u8;
179        }
180        let dst = vec![0u8; n];
181        Self { src, dst }
182    }
183
184    /// Baseline: strided read checksum (scan).
185    pub fn run_scan_stride(&self) -> u64 {
186        let mut sum = 0u64;
187        let mut i = 0usize;
188        while i < self.src.len() {
189            sum = sum.wrapping_add(self.src[i] as u64);
190            i += 64;
191        }
192        std::hint::black_box(sum)
193    }
194
195    /// Dense sequential scan.
196    pub fn run_scan_dense(&self) -> u64 {
197        let mut sum = 0u64;
198        for &b in &self.src {
199            sum = sum.wrapping_add(b as u64);
200        }
201        std::hint::black_box(sum)
202    }
203
204    /// Copy via `copy_from_slice` (memcpy stand-in).
205    pub fn run_copy(&mut self) -> u64 {
206        self.dst.copy_from_slice(&self.src);
207        std::hint::black_box(self.dst[0] as u64)
208    }
209
210    /// Manual byte loop copy.
211    pub fn run_copy_loop(&mut self) -> u64 {
212        for i in 0..self.src.len() {
213            self.dst[i] = self.src[i];
214        }
215        std::hint::black_box(self.dst[self.dst.len() - 1] as u64)
216    }
217
218    /// Unrolled 8-wide copy (scalar, stronger differentiation vs memcpy).
219    pub fn run_copy_unrolled8(&mut self) -> u64 {
220        let n = self.src.len();
221        let mut i = 0usize;
222        while i + 8 <= n {
223            self.dst[i] = self.src[i];
224            self.dst[i + 1] = self.src[i + 1];
225            self.dst[i + 2] = self.src[i + 2];
226            self.dst[i + 3] = self.src[i + 3];
227            self.dst[i + 4] = self.src[i + 4];
228            self.dst[i + 5] = self.src[i + 5];
229            self.dst[i + 6] = self.src[i + 6];
230            self.dst[i + 7] = self.src[i + 7];
231            i += 8;
232        }
233        while i < n {
234            self.dst[i] = self.src[i];
235            i += 1;
236        }
237        std::hint::black_box(self.dst[n - 1] as u64)
238    }
239}
240
241/// Alignment-aware memory checksum (same bytes, different base alignment).
242pub struct AlignmentBench {
243    /// Backing store (oversized so we can offset).
244    backing: Vec<u8>,
245    /// Byte offset into backing (0 = aligned, 1/7/15 = misaligned).
246    offset: usize,
247    /// Working length.
248    len: usize,
249}
250
251impl AlignmentBench {
252    /// Create with requested alignment offset and working-set length.
253    pub fn new(len: usize, offset: usize) -> Self {
254        let len = len.max(64);
255        let offset = offset % 64;
256        let mut backing = vec![0u8; len + offset + 64];
257        for (i, b) in backing.iter_mut().enumerate() {
258            *b = (i % 251) as u8;
259        }
260        Self {
261            backing,
262            offset,
263            len,
264        }
265    }
266
267    /// Slice view used by both variants (identical bytes).
268    fn view(&self) -> &[u8] {
269        &self.backing[self.offset..self.offset + self.len]
270    }
271
272    /// Dense scalar checksum.
273    pub fn run_scalar(&self) -> u64 {
274        let mut sum = 0u64;
275        for &b in self.view() {
276            sum = sum.wrapping_add(b as u64);
277        }
278        std::hint::black_box(sum)
279    }
280
281    /// Chunked checksum (64-byte steps) — may interact with alignment differently.
282    pub fn run_chunked(&self) -> u64 {
283        let mut sum = 0u64;
284        for chunk in self.view().chunks(64) {
285            for &b in chunk {
286                sum = sum.wrapping_add(b as u64);
287            }
288        }
289        std::hint::black_box(sum)
290    }
291
292    /// Alignment offset used.
293    pub fn offset(&self) -> usize {
294        self.offset
295    }
296}
297
298/// Host-ISA integer reduction: portable scalar vs AVX2 when detected.
299///
300/// This release's step beyond “algorithm stand-ins”: the fast path uses
301/// real `target_feature(enable = "avx2")` code selected at runtime via
302/// `is_x86_feature_detected!("avx2")`. It is still **not** a separately
303/// compiled `-march=native` binary — see docs for that distinction.
304pub struct HostIsaReduce {
305    data: Vec<i32>,
306}
307
308impl HostIsaReduce {
309    /// Create with `n` elements.
310    pub fn new(n: usize) -> Self {
311        let data = (0..n as i32).map(|i| i.wrapping_mul(3).wrapping_add(1)).collect();
312        Self { data }
313    }
314
315    /// Portable scalar sum.
316    pub fn run_portable(&self) -> i64 {
317        let mut s = 0i64;
318        for &x in &self.data {
319            s = s.wrapping_add(x as i64);
320        }
321        std::hint::black_box(s)
322    }
323
324    /// Host-ISA path: AVX2 when available, else scalar.
325    pub fn run_host_isa(&self) -> i64 {
326        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
327        {
328            if is_x86_feature_detected!("avx2") {
329                // SAFETY: feature detected at runtime before calling AVX2 path.
330                return std::hint::black_box(unsafe { Self::sum_avx2(&self.data) });
331            }
332        }
333        self.run_portable()
334    }
335
336    /// Whether AVX2 was selected on this process.
337    pub fn avx2_active() -> bool {
338        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
339        {
340            return is_x86_feature_detected!("avx2");
341        }
342        #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
343        {
344            false
345        }
346    }
347
348    /// Correctness: portable and host-ISA must agree.
349    pub fn verify_correctness(&self) -> bool {
350        self.run_portable() == self.run_host_isa()
351    }
352
353    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
354    #[target_feature(enable = "avx2")]
355    unsafe fn sum_avx2(data: &[i32]) -> i64 {
356        #[cfg(target_arch = "x86")]
357        use std::arch::x86::*;
358        #[cfg(target_arch = "x86_64")]
359        use std::arch::x86_64::*;
360
361        let mut i = 0usize;
362        let n = data.len();
363        let mut acc = _mm256_setzero_si256();
364        while i + 8 <= n {
365            // SAFETY: i..i+8 in-bounds by loop condition; data is aligned enough for loadu.
366            let v = unsafe { _mm256_loadu_si256(data.as_ptr().add(i) as *const __m256i) };
367            acc = _mm256_add_epi32(acc, v);
368            i += 8;
369        }
370        let mut tmp = [0i32; 8];
371        // SAFETY: tmp is 32-byte writable stack buffer.
372        unsafe {
373            _mm256_storeu_si256(tmp.as_mut_ptr() as *mut __m256i, acc);
374        }
375        let mut s: i64 = tmp.iter().map(|&x| x as i64).sum();
376        while i < n {
377            s = s.wrapping_add(data[i] as i64);
378            i += 1;
379        }
380        s
381    }
382}
383
384/// Host-ISA float dot product: portable scalar vs AVX (when detected).
385pub struct HostIsaDot {
386    a: Vec<f32>,
387    b: Vec<f32>,
388}
389
390impl HostIsaDot {
391    /// Create length-`n` vectors.
392    pub fn new(n: usize) -> Self {
393        let n = n.max(8);
394        let a = (0..n).map(|i| (i as f32) * 0.001 + 1.0).collect();
395        let b = (0..n).map(|i| (i as f32) * 0.0007 + 0.5).collect();
396        Self { a, b }
397    }
398
399    /// Portable scalar dot.
400    pub fn run_portable(&self) -> f32 {
401        let mut s = 0.0f32;
402        for i in 0..self.a.len() {
403            s += self.a[i] * self.b[i];
404        }
405        std::hint::black_box(s)
406    }
407
408    /// Host-ISA path.
409    pub fn run_host_isa(&self) -> f32 {
410        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
411        {
412            if is_x86_feature_detected!("avx") {
413                // SAFETY: AVX detected.
414                return std::hint::black_box(unsafe { Self::dot_avx(&self.a, &self.b) });
415            }
416        }
417        self.run_portable()
418    }
419
420    /// Relative agreement within tolerance.
421    pub fn verify_correctness(&self, rel_tol: f32) -> bool {
422        let p = self.run_portable();
423        let h = self.run_host_isa();
424        let denom = p.abs().max(1.0);
425        (p - h).abs() / denom <= rel_tol
426    }
427
428    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
429    #[target_feature(enable = "avx")]
430    unsafe fn dot_avx(a: &[f32], b: &[f32]) -> f32 {
431        #[cfg(target_arch = "x86")]
432        use std::arch::x86::*;
433        #[cfg(target_arch = "x86_64")]
434        use std::arch::x86_64::*;
435
436        let n = a.len().min(b.len());
437        let mut i = 0usize;
438        let mut acc = _mm256_setzero_ps();
439        while i + 8 <= n {
440            let va = unsafe { _mm256_loadu_ps(a.as_ptr().add(i)) };
441            let vb = unsafe { _mm256_loadu_ps(b.as_ptr().add(i)) };
442            acc = _mm256_add_ps(acc, _mm256_mul_ps(va, vb));
443            i += 8;
444        }
445        let mut tmp = [0f32; 8];
446        unsafe {
447            _mm256_storeu_ps(tmp.as_mut_ptr(), acc);
448        }
449        let mut s: f32 = tmp.iter().sum();
450        while i < n {
451            s += a[i] * b[i];
452            i += 1;
453        }
454        s
455    }
456}
457
458#[cfg(test)]
459mod host_isa_tests {
460    use super::*;
461
462    #[test]
463    fn reduce_portable_matches_host_isa() {
464        let b = HostIsaReduce::new(1024);
465        assert!(b.verify_correctness());
466    }
467
468    #[test]
469    fn dot_portable_matches_host_isa() {
470        let b = HostIsaDot::new(2048);
471        assert!(b.verify_correctness(1e-4));
472    }
473}
474
475/// Integer mix.
476pub struct IntegerBench {
477    /// Input.
478    pub n: u64,
479}
480
481impl IntegerBench {
482    /// Baseline dependent chain.
483    pub fn run_baseline(&self) -> u64 {
484        let mut x = self.n;
485        for _ in 0..10_000 {
486            x = x.wrapping_mul(1664525).wrapping_add(1013904223);
487        }
488        std::hint::black_box(x)
489    }
490
491    /// Slightly different mix (candidate — not claimed faster a priori).
492    pub fn run_candidate(&self) -> u64 {
493        let mut x = self.n;
494        for _ in 0..10_000 {
495            x ^= x << 13;
496            x ^= x >> 7;
497            x ^= x << 17;
498        }
499        std::hint::black_box(x)
500    }
501}
502
503/// Float mix.
504pub struct FloatBench {
505    /// Input vector.
506    data: Vec<f64>,
507}
508
509impl FloatBench {
510    /// Create with `n` elements.
511    pub fn new(n: usize) -> Self {
512        let data = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
513        Self { data }
514    }
515
516    /// Baseline sum of reciprocals.
517    pub fn run_baseline(&self) -> f64 {
518        let mut s = 0.0;
519        for &x in &self.data {
520            s += 1.0 / x;
521        }
522        std::hint::black_box(s)
523    }
524
525    /// Candidate: pairwise summation (often more accurate; timing varies).
526    pub fn run_candidate(&self) -> f64 {
527        fn pair(a: &[f64]) -> f64 {
528            match a.len() {
529                0 => 0.0,
530                1 => 1.0 / a[0],
531                _ => {
532                    let mid = a.len() / 2;
533                    pair(&a[..mid]) + pair(&a[mid..])
534                }
535            }
536        }
537        std::hint::black_box(pair(&self.data))
538    }
539}
540
541/// Branch-heavy workload.
542pub struct BranchBench {
543    /// Data.
544    data: Vec<u32>,
545}
546
547impl BranchBench {
548    /// Create.
549    pub fn new(n: usize) -> Self {
550        let data = (0..n as u32).map(|i| i.wrapping_mul(2654435761)).collect();
551        Self { data }
552    }
553
554    /// Unpredictable branches.
555    pub fn run_baseline(&self) -> u64 {
556        let mut c = 0u64;
557        for &x in &self.data {
558            if x % 3 == 0 {
559                c = c.wrapping_add(x as u64);
560            } else if x % 5 == 0 {
561                c = c.wrapping_add(1);
562            } else {
563                c = c.wrapping_sub(1);
564            }
565        }
566        std::hint::black_box(c)
567    }
568
569    /// Branchless-ish candidate.
570    pub fn run_candidate(&self) -> u64 {
571        let mut c = 0u64;
572        for &x in &self.data {
573            let m3 = ((x % 3 == 0) as u64).wrapping_mul(x as u64);
574            let m5 = ((x % 5 == 0) as u64) & (((x % 3 != 0) as u64).wrapping_neg());
575            let other = (((x % 3 != 0) && (x % 5 != 0)) as u64).wrapping_neg();
576            c = c.wrapping_add(m3).wrapping_add(m5 & 1).wrapping_add(other);
577        }
578        std::hint::black_box(c)
579    }
580}
581
582/// Shared-counter concurrency microbench.
583pub struct ConcurrencyBench {
584    /// Iterations per thread.
585    pub iters: u64,
586    /// Thread count.
587    pub threads: usize,
588}
589
590impl ConcurrencyBench {
591    /// Contended atomic (baseline).
592    pub fn run_baseline(&self) -> u64 {
593        let counter = Arc::new(AtomicU64::new(0));
594        let mut handles = Vec::new();
595        for _ in 0..self.threads {
596            let c = Arc::clone(&counter);
597            let iters = self.iters;
598            handles.push(std::thread::spawn(move || {
599                for _ in 0..iters {
600                    c.fetch_add(1, Ordering::Relaxed);
601                }
602            }));
603        }
604        for h in handles {
605            let _ = h.join();
606        }
607        std::hint::black_box(counter.load(Ordering::Relaxed))
608    }
609
610    /// Per-thread locals then reduce (candidate).
611    pub fn run_candidate(&self) -> u64 {
612        let mut handles = Vec::new();
613        for _ in 0..self.threads {
614            let iters = self.iters;
615            handles.push(std::thread::spawn(move || {
616                let mut local = 0u64;
617                for _ in 0..iters {
618                    local = local.wrapping_add(1);
619                }
620                local
621            }));
622        }
623        let mut sum = 0u64;
624        for h in handles {
625            sum = sum.wrapping_add(h.join().unwrap_or(0));
626        }
627        std::hint::black_box(sum)
628    }
629}
630