Skip to main content

pictor_kernels/
tuning.rs

1//! Platform-aware performance tuning for kernel dispatch.
2//!
3//! Detects hardware characteristics (core counts, cache sizes, SIMD capabilities)
4//! at runtime and computes optimal thresholds for parallel dispatch decisions,
5//! tiled GEMM block sizes, and prefetch strategies.
6
7use std::sync::OnceLock;
8
9/// Group size for BlockQ1_0G128 (128 weights per block).
10const BLOCK_GROUP_SIZE: usize = 128;
11
12/// Global cached platform profile, detected once on first access.
13static GLOBAL_PROFILE: OnceLock<PlatformProfile> = OnceLock::new();
14
15/// Global cached tuned thresholds, computed once from the platform profile.
16static GLOBAL_THRESHOLDS: OnceLock<TunedThresholds> = OnceLock::new();
17
18/// Platform characteristics detected at init time.
19///
20/// Captures hardware topology (core counts, cache hierarchy) and SIMD
21/// capability flags for the current CPU. Used to derive optimal
22/// parallelism thresholds and tiling parameters.
23#[derive(Debug, Clone)]
24pub struct PlatformProfile {
25    /// Number of logical (hardware thread) cores.
26    pub logical_cores: usize,
27    /// Number of physical cores (estimated; logical/2 on x86 HT, = logical on ARM).
28    pub physical_cores: usize,
29    /// Cache line size in bytes (typically 64 on modern CPUs).
30    pub cache_line_bytes: usize,
31    /// Estimated L1 data cache size per core in bytes.
32    pub l1_cache_bytes: usize,
33    /// Estimated L2 cache size per core in bytes.
34    pub l2_cache_bytes: usize,
35    /// Whether AVX2 (256-bit SIMD) is available (x86-64 only).
36    pub has_avx2: bool,
37    /// Whether AVX-512 (512-bit SIMD) is available (x86-64 only).
38    pub has_avx512: bool,
39    /// Whether NEON (128-bit SIMD) is available (AArch64 only).
40    pub has_neon: bool,
41}
42
43/// Tuned thresholds for parallel dispatch and tiling.
44///
45/// These values are derived from the [`PlatformProfile`] and control
46/// when parallel execution is engaged and how tiled GEMM partitions work.
47#[derive(Debug, Clone)]
48pub struct TunedThresholds {
49    /// Minimum number of output rows before parallel GEMV is engaged.
50    /// Below this, sequential execution wins due to lower overhead.
51    pub par_gemv_min_rows: usize,
52    /// Minimum batch size before parallel GEMM is engaged.
53    pub par_gemm_min_batch: usize,
54    /// Block size along M (rows) for tiled GEMM.
55    pub tiled_gemm_block_m: usize,
56    /// Block size along N (columns) for tiled GEMM.
57    pub tiled_gemm_block_n: usize,
58    /// Block size along K (inner/reduction) for tiled GEMM.
59    /// Always a multiple of the block group size (128).
60    pub tiled_gemm_block_k: usize,
61}
62
63impl PlatformProfile {
64    /// Detect current platform capabilities.
65    ///
66    /// Uses `std::thread::available_parallelism()` for core counts and
67    /// compile-time/runtime feature detection for SIMD flags. Cache sizes
68    /// are estimated from typical values for the detected architecture.
69    pub fn detect() -> Self {
70        let logical_cores = std::thread::available_parallelism()
71            .map(|p| p.get())
72            .unwrap_or(1);
73
74        let physical_cores = Self::estimate_physical_cores(logical_cores);
75        let cache_line_bytes = 64; // Universal on modern x86-64 and AArch64
76
77        // L1/L2 estimates per core — conservative defaults
78        let (l1_cache_bytes, l2_cache_bytes) = Self::estimate_cache_sizes();
79
80        let has_avx2 = Self::detect_avx2();
81        let has_avx512 = Self::detect_avx512();
82        let has_neon = Self::detect_neon();
83
84        Self {
85            logical_cores,
86            physical_cores,
87            cache_line_bytes,
88            l1_cache_bytes,
89            l2_cache_bytes,
90            has_avx2,
91            has_avx512,
92            has_neon,
93        }
94    }
95
96    /// Get the global cached platform profile (detected once, reused).
97    pub fn global() -> &'static PlatformProfile {
98        GLOBAL_PROFILE.get_or_init(Self::detect)
99    }
100
101    /// Compute optimal thresholds for this platform.
102    ///
103    /// The logic scales parallel thresholds inversely with core count
104    /// (more cores => lower threshold to engage parallelism) and sizes
105    /// tiled GEMM blocks to fit in L1 cache.
106    pub fn compute_thresholds(&self) -> TunedThresholds {
107        let par_gemv_min_rows = self.compute_gemv_threshold();
108        let par_gemm_min_batch = self.compute_gemm_threshold();
109        let (block_m, block_n, block_k) = self.compute_tile_sizes();
110
111        TunedThresholds {
112            par_gemv_min_rows,
113            par_gemm_min_batch,
114            tiled_gemm_block_m: block_m,
115            tiled_gemm_block_n: block_n,
116            tiled_gemm_block_k: block_k,
117        }
118    }
119
120    /// Get the global cached thresholds (computed once from the global profile).
121    pub fn global_thresholds() -> &'static TunedThresholds {
122        GLOBAL_THRESHOLDS.get_or_init(|| Self::global().compute_thresholds())
123    }
124
125    /// Construct a profile with explicit values (useful for testing).
126    pub fn with_cores(logical: usize, physical: usize) -> Self {
127        Self {
128            logical_cores: logical.max(1),
129            physical_cores: physical.max(1),
130            cache_line_bytes: 64,
131            l1_cache_bytes: 32 * 1024,
132            l2_cache_bytes: 256 * 1024,
133            has_avx2: false,
134            has_avx512: false,
135            has_neon: false,
136        }
137    }
138
139    /// Construct a profile with custom cache sizes (useful for testing).
140    pub fn with_cache(l1_bytes: usize, l2_bytes: usize) -> Self {
141        Self {
142            logical_cores: 4,
143            physical_cores: 4,
144            cache_line_bytes: 64,
145            l1_cache_bytes: l1_bytes,
146            l2_cache_bytes: l2_bytes,
147            has_avx2: false,
148            has_avx512: false,
149            has_neon: false,
150        }
151    }
152
153    // ── Private helpers ──────────────────────────────────────────────
154
155    fn estimate_physical_cores(logical: usize) -> usize {
156        #[cfg(target_arch = "x86_64")]
157        {
158            // x86-64: assume hyperthreading (2 threads per physical core)
159            (logical / 2).max(1)
160        }
161        #[cfg(target_arch = "aarch64")]
162        {
163            // ARM: typically no SMT, logical == physical
164            logical
165        }
166        #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
167        {
168            // Conservative: assume no hyperthreading
169            logical
170        }
171    }
172
173    fn estimate_cache_sizes() -> (usize, usize) {
174        #[cfg(target_arch = "aarch64")]
175        {
176            // Apple Silicon and recent ARM server chips often have larger caches
177            // M1/M2 P-cores: 192KB L1d, 12MB shared L2 (but per-core share ~1.5MB)
178            // Conservative estimate for portability
179            (64 * 1024, 512 * 1024)
180        }
181        #[cfg(not(target_arch = "aarch64"))]
182        {
183            // Typical x86-64: 32KB L1d, 256KB L2 per core
184            (32 * 1024, 256 * 1024)
185        }
186    }
187
188    fn detect_avx2() -> bool {
189        #[cfg(target_arch = "x86_64")]
190        {
191            // Runtime check using cpuid — works even when not compiled with +avx2
192            if is_x86_feature_detected!("avx2") {
193                return true;
194            }
195            // Fall back to compile-time check
196            cfg!(target_feature = "avx2")
197        }
198        #[cfg(not(target_arch = "x86_64"))]
199        {
200            false
201        }
202    }
203
204    fn detect_avx512() -> bool {
205        #[cfg(target_arch = "x86_64")]
206        {
207            if is_x86_feature_detected!("avx512f") {
208                return true;
209            }
210            cfg!(target_feature = "avx512f")
211        }
212        #[cfg(not(target_arch = "x86_64"))]
213        {
214            false
215        }
216    }
217
218    fn detect_neon() -> bool {
219        #[cfg(target_arch = "aarch64")]
220        {
221            // NEON is mandatory on AArch64
222            true
223        }
224        #[cfg(not(target_arch = "aarch64"))]
225        {
226            false
227        }
228    }
229
230    /// Compute minimum rows for parallel GEMV.
231    ///
232    /// Scaling logic:
233    /// - 1-2 physical cores: 256 (sequential almost always wins)
234    /// - 3-4 cores: 128
235    /// - 5-8 cores: 64
236    /// - 9-15 cores: 48
237    /// - 16+ cores: 32
238    ///
239    /// SIMD availability shifts the break-even point: faster per-row
240    /// computation means you need more rows to amortize thread overhead.
241    fn compute_gemv_threshold(&self) -> usize {
242        let base = match self.physical_cores {
243            0..=2 => 256,
244            3..=4 => 128,
245            5..=8 => 64,
246            9..=15 => 48,
247            _ => 32,
248        };
249
250        // SIMD makes each row faster, so raise the threshold slightly
251        // (parallel overhead becomes relatively more expensive)
252        let simd_factor = if self.has_avx512 {
253            // AVX-512: very fast per-row, need more rows for parallel to win
254            3
255        } else if self.has_avx2 || self.has_neon {
256            2
257        } else {
258            1
259        };
260
261        // Scale: base * (1 + simd_factor * 0.25), rounded to multiple of 8
262        let adjusted = base + (base * simd_factor) / 4;
263        round_up_to(adjusted, 8)
264    }
265
266    /// Compute minimum batch for parallel GEMM.
267    ///
268    /// Similar scaling to GEMV but for the batch dimension.
269    fn compute_gemm_threshold(&self) -> usize {
270        match self.physical_cores {
271            0..=2 => 16,
272            3..=4 => 8,
273            5..=8 => 4,
274            9..=15 => 3,
275            _ => 2,
276        }
277    }
278
279    /// Compute tiled GEMM block sizes to fit in L1 cache.
280    ///
281    /// Strategy: for a tile of size block_m x block_n, we need:
282    ///   - block_m * block_k floats of input A
283    ///   - block_k * block_n packed weights (much smaller due to 1-bit)
284    ///   - block_m * block_n floats of output C
285    ///
286    /// We want the working set to fit in L1. Since weights are 1-bit packed,
287    /// the dominant memory is the f32 input/output tiles.
288    ///
289    /// Approximate: 3 * block_size^2 * sizeof(f32) <= L1 size
290    ///   => block_size = sqrt(L1 / (3 * 4))
291    fn compute_tile_sizes(&self) -> (usize, usize, usize) {
292        let l1 = self.l1_cache_bytes;
293        let sizeof_f32 = std::mem::size_of::<f32>();
294
295        // Leave some L1 headroom (use 75% of L1 for tiles)
296        let usable_l1 = (l1 * 3) / 4;
297
298        // block_size = sqrt(usable_l1 / (3 * sizeof(f32)))
299        let raw_block = isqrt(usable_l1 / (3 * sizeof_f32));
300
301        // Round down to nearest multiple of 8 for SIMD alignment, minimum 8
302        let block_mn = round_down_to(raw_block, 8).max(8);
303
304        // block_k: must be a multiple of the group size (128)
305        // Choose smallest multiple of 128 that doesn't exceed the computed block size
306        // but at minimum one group.
307        let block_k = if block_mn >= BLOCK_GROUP_SIZE {
308            round_down_to(block_mn, BLOCK_GROUP_SIZE)
309        } else {
310            BLOCK_GROUP_SIZE
311        };
312
313        (block_mn, block_mn, block_k)
314    }
315}
316
317impl TunedThresholds {
318    /// Check whether a GEMV with the given row count should use parallelism.
319    #[inline]
320    pub fn should_parallelize_gemv(&self, n_rows: usize) -> bool {
321        n_rows >= self.par_gemv_min_rows
322    }
323
324    /// Check whether a GEMM with the given batch size should use parallelism.
325    #[inline]
326    pub fn should_parallelize_gemm(&self, batch_size: usize) -> bool {
327        batch_size >= self.par_gemm_min_batch
328    }
329}
330
331/// Summary of the current platform's tuning configuration.
332///
333/// Useful for diagnostics and logging during model initialization.
334#[derive(Debug, Clone)]
335pub struct TuningSummary {
336    pub profile: PlatformProfile,
337    pub thresholds: TunedThresholds,
338}
339
340impl TuningSummary {
341    /// Build a summary from the global profile and thresholds.
342    pub fn current() -> Self {
343        Self {
344            profile: PlatformProfile::global().clone(),
345            thresholds: PlatformProfile::global_thresholds().clone(),
346        }
347    }
348}
349
350impl std::fmt::Display for TuningSummary {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        writeln!(f, "Platform Tuning Summary")?;
353        writeln!(
354            f,
355            "  Cores: {} logical, {} physical",
356            self.profile.logical_cores, self.profile.physical_cores
357        )?;
358        writeln!(
359            f,
360            "  Cache: L1={} KB, L2={} KB, line={} B",
361            self.profile.l1_cache_bytes / 1024,
362            self.profile.l2_cache_bytes / 1024,
363            self.profile.cache_line_bytes
364        )?;
365        writeln!(
366            f,
367            "  SIMD: AVX2={}, AVX-512={}, NEON={}",
368            self.profile.has_avx2, self.profile.has_avx512, self.profile.has_neon
369        )?;
370        writeln!(f, "  Thresholds:")?;
371        writeln!(
372            f,
373            "    par_gemv_min_rows: {}",
374            self.thresholds.par_gemv_min_rows
375        )?;
376        writeln!(
377            f,
378            "    par_gemm_min_batch: {}",
379            self.thresholds.par_gemm_min_batch
380        )?;
381        writeln!(
382            f,
383            "    tiled_gemm_block: {}x{}x{}",
384            self.thresholds.tiled_gemm_block_m,
385            self.thresholds.tiled_gemm_block_n,
386            self.thresholds.tiled_gemm_block_k
387        )?;
388        Ok(())
389    }
390}
391
392// ── Arithmetic helpers ──────────────────────────────────────────────────
393
394/// Integer square root (floor).
395fn isqrt(n: usize) -> usize {
396    if n == 0 {
397        return 0;
398    }
399    let mut x = n;
400    let mut y = x.div_ceil(2);
401    while y < x {
402        x = y;
403        y = (x + n / x) / 2;
404    }
405    x
406}
407
408/// Round `v` up to the nearest multiple of `align` (align must be > 0).
409fn round_up_to(v: usize, align: usize) -> usize {
410    debug_assert!(align > 0);
411    let rem = v % align;
412    if rem == 0 {
413        v
414    } else {
415        v + (align - rem)
416    }
417}
418
419/// Round `v` down to the nearest multiple of `align` (align must be > 0).
420fn round_down_to(v: usize, align: usize) -> usize {
421    debug_assert!(align > 0);
422    v - (v % align)
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn detect_returns_valid_profile() {
431        let profile = PlatformProfile::detect();
432        assert!(
433            profile.logical_cores > 0,
434            "must have at least 1 logical core"
435        );
436        assert!(
437            profile.physical_cores > 0,
438            "must have at least 1 physical core"
439        );
440        assert!(profile.physical_cores <= profile.logical_cores);
441        assert_eq!(profile.cache_line_bytes, 64);
442        assert!(
443            profile.l1_cache_bytes >= 16 * 1024,
444            "L1 should be at least 16KB"
445        );
446        assert!(
447            profile.l2_cache_bytes >= 64 * 1024,
448            "L2 should be at least 64KB"
449        );
450    }
451
452    #[test]
453    fn thresholds_are_reasonable() {
454        let profile = PlatformProfile::detect();
455        let thresholds = profile.compute_thresholds();
456        assert!(thresholds.par_gemv_min_rows >= 8);
457        assert!(thresholds.par_gemv_min_rows <= 1024);
458        assert!(thresholds.par_gemm_min_batch >= 1);
459        assert!(thresholds.par_gemm_min_batch <= 64);
460    }
461
462    #[test]
463    fn tile_sizes_multiple_of_8() {
464        let profile = PlatformProfile::detect();
465        let t = profile.compute_thresholds();
466        assert_eq!(t.tiled_gemm_block_m % 8, 0, "block_m must be multiple of 8");
467        assert_eq!(t.tiled_gemm_block_n % 8, 0, "block_n must be multiple of 8");
468        assert_eq!(
469            t.tiled_gemm_block_k % BLOCK_GROUP_SIZE,
470            0,
471            "block_k must be multiple of group size (128)"
472        );
473    }
474
475    #[test]
476    fn more_cores_lower_gemv_threshold() {
477        let p2 = PlatformProfile::with_cores(2, 2);
478        let p16 = PlatformProfile::with_cores(16, 16);
479        let t2 = p2.compute_thresholds();
480        let t16 = p16.compute_thresholds();
481        assert!(
482            t16.par_gemv_min_rows < t2.par_gemv_min_rows,
483            "16 cores ({}) should have lower threshold than 2 cores ({})",
484            t16.par_gemv_min_rows,
485            t2.par_gemv_min_rows
486        );
487    }
488
489    #[test]
490    fn more_cores_lower_gemm_threshold() {
491        let p2 = PlatformProfile::with_cores(2, 2);
492        let p16 = PlatformProfile::with_cores(16, 16);
493        let t2 = p2.compute_thresholds();
494        let t16 = p16.compute_thresholds();
495        assert!(
496            t16.par_gemm_min_batch < t2.par_gemm_min_batch,
497            "16 cores ({}) should have lower threshold than 2 cores ({})",
498            t16.par_gemm_min_batch,
499            t2.par_gemm_min_batch
500        );
501    }
502
503    #[test]
504    fn isqrt_correctness() {
505        assert_eq!(isqrt(0), 0);
506        assert_eq!(isqrt(1), 1);
507        assert_eq!(isqrt(4), 2);
508        assert_eq!(isqrt(9), 3);
509        assert_eq!(isqrt(10), 3); // floor
510        assert_eq!(isqrt(100), 10);
511        assert_eq!(isqrt(8192), 90); // sqrt(8192) ≈ 90.5
512    }
513
514    #[test]
515    fn round_helpers() {
516        assert_eq!(round_up_to(0, 8), 0);
517        assert_eq!(round_up_to(1, 8), 8);
518        assert_eq!(round_up_to(8, 8), 8);
519        assert_eq!(round_up_to(9, 8), 16);
520        assert_eq!(round_down_to(0, 8), 0);
521        assert_eq!(round_down_to(7, 8), 0);
522        assert_eq!(round_down_to(8, 8), 8);
523        assert_eq!(round_down_to(15, 8), 8);
524    }
525
526    #[test]
527    fn global_profile_consistent() {
528        let p1 = PlatformProfile::global();
529        let p2 = PlatformProfile::global();
530        // Should be the same reference (OnceLock)
531        assert_eq!(p1.logical_cores, p2.logical_cores);
532        assert_eq!(p1.physical_cores, p2.physical_cores);
533    }
534
535    #[test]
536    fn tuning_summary_display() {
537        let summary = TuningSummary::current();
538        let text = format!("{summary}");
539        assert!(text.contains("Platform Tuning Summary"));
540        assert!(text.contains("Cores:"));
541        assert!(text.contains("Cache:"));
542        assert!(text.contains("SIMD:"));
543    }
544
545    #[test]
546    fn should_parallelize_decisions() {
547        let p = PlatformProfile::with_cores(8, 8);
548        let t = p.compute_thresholds();
549        // Below threshold => no parallelism
550        assert!(!t.should_parallelize_gemv(1));
551        // Above threshold => parallelize
552        assert!(t.should_parallelize_gemv(t.par_gemv_min_rows));
553        assert!(t.should_parallelize_gemv(t.par_gemv_min_rows + 1));
554    }
555
556    #[test]
557    fn with_cache_custom_sizes() {
558        let p = PlatformProfile::with_cache(64 * 1024, 1024 * 1024);
559        assert_eq!(p.l1_cache_bytes, 64 * 1024);
560        assert_eq!(p.l2_cache_bytes, 1024 * 1024);
561        let t = p.compute_thresholds();
562        // Larger L1 => larger tile sizes
563        let p_small = PlatformProfile::with_cache(16 * 1024, 128 * 1024);
564        let t_small = p_small.compute_thresholds();
565        assert!(
566            t.tiled_gemm_block_m >= t_small.tiled_gemm_block_m,
567            "larger L1 ({}) should give >= block_m than smaller L1 ({})",
568            t.tiled_gemm_block_m,
569            t_small.tiled_gemm_block_m
570        );
571    }
572}