Skip to main content

diskann_benchmark_simd/
lib.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6//! SIMD distance kernel benchmarks with regression detection.
7
8use std::{io::Write, num::NonZeroUsize};
9
10use diskann_utils::views::rowmajor::{self, Matrix};
11use diskann_vector::distance::simd;
12use diskann_wide::Architecture;
13use half::f16;
14use rand::{
15    distr::{Distribution, StandardUniform},
16    rngs::StdRng,
17    SeedableRng,
18};
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22use diskann_benchmark_runner::{
23    benchmark::{MatchContext, PassFail, Regression, Score},
24    utils::{
25        datatype::{AsDataType, DataType},
26        num::{relative_change, NonNegativeFinite},
27        percentiles, MicroSeconds,
28    },
29    Benchmark, Checker, Input, Registry,
30};
31
32////////////////
33// Public API //
34////////////////
35
36pub fn register(registry: &mut Registry) -> anyhow::Result<()> {
37    Ok(register_benchmarks_impl(registry)?)
38}
39
40///////////
41// Utils //
42///////////
43
44#[derive(Debug, Clone, Copy)]
45struct DisplayWrapper<'a, T: ?Sized>(&'a T);
46
47impl<T: ?Sized> std::ops::Deref for DisplayWrapper<'_, T> {
48    type Target = T;
49    fn deref(&self) -> &T {
50        self.0
51    }
52}
53
54////////////
55// Inputs //
56////////////
57
58#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum SimilarityMeasure {
61    SquaredL2,
62    InnerProduct,
63    Cosine,
64}
65
66impl std::fmt::Display for SimilarityMeasure {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let st = match self {
69            Self::SquaredL2 => "squared_l2",
70            Self::InnerProduct => "inner_product",
71            Self::Cosine => "cosine",
72        };
73        write!(f, "{}", st)
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79enum Arch {
80    #[serde(rename = "x86-64-v4")]
81    #[expect(non_camel_case_types)]
82    X86_64_V4,
83    #[serde(rename = "x86-64-v3")]
84    #[expect(non_camel_case_types)]
85    X86_64_V3,
86    Neon,
87    Scalar,
88    Reference,
89}
90
91impl std::fmt::Display for Arch {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        let st = match self {
94            Self::X86_64_V4 => "x86-64-v4",
95            Self::X86_64_V3 => "x86-64-v3",
96            Self::Neon => "neon",
97            Self::Scalar => "scalar",
98            Self::Reference => "reference",
99        };
100        write!(f, "{}", st)
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105struct Run {
106    distance: SimilarityMeasure,
107    dim: NonZeroUsize,
108    num_points: NonZeroUsize,
109    loops_per_measurement: NonZeroUsize,
110    num_measurements: NonZeroUsize,
111}
112
113#[derive(Debug, Serialize, Deserialize)]
114pub struct SimdOp {
115    query_type: DataType,
116    data_type: DataType,
117    arch: Arch,
118    runs: Vec<Run>,
119}
120
121macro_rules! write_field {
122    ($f:ident, $field:tt, $($expr:tt)*) => {
123        writeln!($f, "{:>18}: {}", $field, $($expr)*)
124    }
125}
126
127impl SimdOp {
128    fn summarize_fields(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write_field!(f, "query type", self.query_type)?;
130        write_field!(f, "data type", self.data_type)?;
131        write_field!(f, "arch", self.arch)?;
132        write_field!(f, "number of runs", self.runs.len())?;
133        Ok(())
134    }
135}
136
137impl std::fmt::Display for SimdOp {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        writeln!(f, "SIMD Operation\n")?;
140        write_field!(f, "tag", Self::tag())?;
141        self.summarize_fields(f)
142    }
143}
144
145impl Input for SimdOp {
146    type Raw = Self;
147
148    fn tag() -> &'static str {
149        "simd-op"
150    }
151
152    fn from_raw(raw: Self::Raw, _checker: &mut Checker) -> anyhow::Result<Self> {
153        Ok(raw)
154    }
155
156    fn serialize(&self) -> anyhow::Result<serde_json::Value> {
157        Ok(serde_json::to_value(self)?)
158    }
159
160    fn example() -> Self::Raw {
161        const DIM: [NonZeroUsize; 2] = [
162            NonZeroUsize::new(128).unwrap(),
163            NonZeroUsize::new(150).unwrap(),
164        ];
165
166        const NUM_POINTS: [NonZeroUsize; 2] = [
167            NonZeroUsize::new(1000).unwrap(),
168            NonZeroUsize::new(800).unwrap(),
169        ];
170
171        const LOOPS_PER_MEASUREMENT: NonZeroUsize = NonZeroUsize::new(100).unwrap();
172        const NUM_MEASUREMENTS: NonZeroUsize = NonZeroUsize::new(100).unwrap();
173
174        let runs = vec![
175            Run {
176                distance: SimilarityMeasure::SquaredL2,
177                dim: DIM[0],
178                num_points: NUM_POINTS[0],
179                loops_per_measurement: LOOPS_PER_MEASUREMENT,
180                num_measurements: NUM_MEASUREMENTS,
181            },
182            Run {
183                distance: SimilarityMeasure::InnerProduct,
184                dim: DIM[1],
185                num_points: NUM_POINTS[1],
186                loops_per_measurement: LOOPS_PER_MEASUREMENT,
187                num_measurements: NUM_MEASUREMENTS,
188            },
189        ];
190
191        Self {
192            query_type: DataType::Float32,
193            data_type: DataType::Float32,
194            arch: Arch::X86_64_V3,
195            runs,
196        }
197    }
198}
199
200//////////////////////
201// Regression Check //
202//////////////////////
203
204/// Tolerance thresholds for SIMD benchmark regression detection.
205///
206/// Each field specifies the maximum allowed relative increase in the corresponding metric.
207/// For example, a value of `0.10` means a 10% increase is tolerated.
208#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
209struct SimdTolerance {
210    min_time_regression: NonNegativeFinite,
211}
212
213impl Input for SimdTolerance {
214    type Raw = Self;
215
216    fn tag() -> &'static str {
217        "simd-tolerance"
218    }
219
220    fn from_raw(raw: Self::Raw, _checker: &mut Checker) -> anyhow::Result<Self> {
221        Ok(raw)
222    }
223
224    fn serialize(&self) -> anyhow::Result<serde_json::Value> {
225        Ok(serde_json::to_value(self)?)
226    }
227
228    fn example() -> Self {
229        const EXAMPLE: NonNegativeFinite = match NonNegativeFinite::new(0.10) {
230            Ok(v) => v,
231            Err(_) => panic!("use a non-negative finite please"),
232        };
233
234        SimdTolerance {
235            min_time_regression: EXAMPLE,
236        }
237    }
238}
239
240/// Per-run comparison result showing before/after percentile differences.
241#[derive(Debug, Serialize)]
242struct Comparison {
243    run: Run,
244    tolerance: SimdTolerance,
245    before_min: f64,
246    after_min: f64,
247}
248
249/// Aggregated result of the regression check across all runs.
250#[derive(Debug, Serialize)]
251struct CheckResult {
252    checks: Vec<Comparison>,
253}
254
255impl std::fmt::Display for CheckResult {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        let header = [
258            "Distance",
259            "Dim",
260            "Min Before (ns)",
261            "Min After (ns)",
262            "Change (%)",
263            "Remark",
264        ];
265
266        let mut table = diskann_benchmark_runner::utils::fmt::Table::new(header, self.checks.len());
267
268        for (i, c) in self.checks.iter().enumerate() {
269            let mut row = table.row(i);
270            let change = relative_change(c.before_min, c.after_min);
271
272            row.insert(c.run.distance, 0);
273            row.insert(c.run.dim, 1);
274            row.insert(format!("{:.3}", c.before_min), 2);
275            row.insert(format!("{:.3}", c.after_min), 3);
276            match change {
277                Ok(change) => {
278                    row.insert(format!("{:.3} %", change * 100.0), 4);
279                    if change > c.tolerance.min_time_regression.get() {
280                        row.insert("FAIL", 5);
281                    }
282                }
283                Err(err) => {
284                    row.insert("invalid", 4);
285                    row.insert(err, 5);
286                }
287            }
288        }
289
290        table.fmt(f)
291    }
292}
293
294////////////////////////////
295// Benchmark Registration //
296////////////////////////////
297
298fn register_benchmarks_impl(
299    registry: &mut diskann_benchmark_runner::Registry,
300) -> Result<(), diskann_benchmark_runner::RegistryError> {
301    // x86-64-v4
302    #[cfg(target_arch = "x86_64")]
303    {
304        registry.register_regression(
305            "simd-op-f32xf32-x86_64_V4",
306            Kernel::<diskann_wide::arch::x86_64::V4, f32, f32>::new(),
307        )?;
308        registry.register_regression(
309            "simd-op-f16xf16-x86_64_V4",
310            Kernel::<diskann_wide::arch::x86_64::V4, f16, f16>::new(),
311        )?;
312        registry.register_regression(
313            "simd-op-u8xu8-x86_64_V4",
314            Kernel::<diskann_wide::arch::x86_64::V4, u8, u8>::new(),
315        )?;
316        registry.register_regression(
317            "simd-op-i8xi8-x86_64_V4",
318            Kernel::<diskann_wide::arch::x86_64::V4, i8, i8>::new(),
319        )?;
320    }
321
322    // x86-64-v3
323    #[cfg(target_arch = "x86_64")]
324    {
325        registry.register_regression(
326            "simd-op-f32xf32-x86_64_V3",
327            Kernel::<diskann_wide::arch::x86_64::V3, f32, f32>::new(),
328        )?;
329        registry.register_regression(
330            "simd-op-f16xf16-x86_64_V3",
331            Kernel::<diskann_wide::arch::x86_64::V3, f16, f16>::new(),
332        )?;
333        registry.register_regression(
334            "simd-op-u8xu8-x86_64_V3",
335            Kernel::<diskann_wide::arch::x86_64::V3, u8, u8>::new(),
336        )?;
337        registry.register_regression(
338            "simd-op-i8xi8-x86_64_V3",
339            Kernel::<diskann_wide::arch::x86_64::V3, i8, i8>::new(),
340        )?;
341    }
342
343    // aarch64-neon
344    #[cfg(target_arch = "aarch64")]
345    {
346        registry.register_regression(
347            "simd-op-f32xf32-aarch64_neon",
348            Kernel::<diskann_wide::arch::aarch64::Neon, f32, f32>::new(),
349        )?;
350        registry.register_regression(
351            "simd-op-f16xf16-aarch64_neon",
352            Kernel::<diskann_wide::arch::aarch64::Neon, f16, f16>::new(),
353        )?;
354        registry.register_regression(
355            "simd-op-u8xu8-aarch64_neon",
356            Kernel::<diskann_wide::arch::aarch64::Neon, u8, u8>::new(),
357        )?;
358        registry.register_regression(
359            "simd-op-i8xi8-aarch64_neon",
360            Kernel::<diskann_wide::arch::aarch64::Neon, i8, i8>::new(),
361        )?;
362    }
363
364    // scalar
365    registry.register_regression(
366        "simd-op-f32xf32-scalar",
367        Kernel::<diskann_wide::arch::Scalar, f32, f32>::new(),
368    )?;
369    registry.register_regression(
370        "simd-op-f16xf16-scalar",
371        Kernel::<diskann_wide::arch::Scalar, f16, f16>::new(),
372    )?;
373    registry.register_regression(
374        "simd-op-u8xu8-scalar",
375        Kernel::<diskann_wide::arch::Scalar, u8, u8>::new(),
376    )?;
377    registry.register_regression(
378        "simd-op-i8xi8-scalar",
379        Kernel::<diskann_wide::arch::Scalar, i8, i8>::new(),
380    )?;
381
382    // reference
383    registry.register_regression(
384        "simd-op-f32xf32-reference",
385        Kernel::<Reference, f32, f32>::new(),
386    )?;
387    registry.register_regression(
388        "simd-op-f16xf16-reference",
389        Kernel::<Reference, f16, f16>::new(),
390    )?;
391    registry.register_regression(
392        "simd-op-u8xu8-reference",
393        Kernel::<Reference, u8, u8>::new(),
394    )?;
395    registry.register_regression(
396        "simd-op-i8xi8-reference",
397        Kernel::<Reference, i8, i8>::new(),
398    )?;
399    Ok(())
400}
401
402//////////////
403// Dispatch //
404//////////////
405
406/// Dispatch receiver for the reference implementations.
407struct Reference;
408
409struct Kernel<A, Q, D> {
410    _type: std::marker::PhantomData<(A, Q, D)>,
411}
412
413impl<A, Q, D> Kernel<A, Q, D> {
414    fn new() -> Self {
415        Self {
416            _type: std::marker::PhantomData,
417        }
418    }
419}
420
421#[derive(Debug, Error)]
422#[error("architecture {0} is not supported by this CPU")]
423pub(crate) struct ArchNotSupported(Arch);
424
425/// Lifting architecture enum variants into the Rust type domain.
426trait AsArch: Sized + 'static {
427    const ARCH: Arch;
428    const DISPLAY_NAME: &'static str;
429
430    fn is_available() -> bool {
431        true
432    }
433
434    fn try_new() -> Result<Self, ArchNotSupported>;
435
436    fn describe(arch: Arch) -> ArchDescribe {
437        if arch != Self::ARCH {
438            ArchDescribe::Mismatch {
439                expected: Self::ARCH,
440                got: arch,
441            }
442        } else if !Self::is_available() {
443            ArchDescribe::Unsupported(Self::ARCH)
444        } else {
445            ArchDescribe::Match(arch)
446        }
447    }
448}
449
450#[derive(Debug, Clone, Copy)]
451enum ArchDescribe {
452    Match(Arch),
453    Unsupported(Arch),
454    Mismatch { expected: Arch, got: Arch },
455}
456
457impl ArchDescribe {
458    fn is_match(&self) -> bool {
459        matches!(self, ArchDescribe::Match(_))
460    }
461}
462
463impl std::fmt::Display for ArchDescribe {
464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        match self {
466            Self::Match(arch) => write!(f, "matched {}", arch),
467            Self::Unsupported(arch) => {
468                write!(f, "matched {} but unsupported by this CPU", arch)
469            }
470            Self::Mismatch { expected, got } => write!(f, "expected {}, got {}", expected, got),
471        }
472    }
473}
474
475impl AsArch for Reference {
476    const ARCH: Arch = Arch::Reference;
477    const DISPLAY_NAME: &'static str = "loop based";
478
479    fn try_new() -> Result<Self, ArchNotSupported> {
480        Ok(Reference)
481    }
482}
483
484impl AsArch for diskann_wide::arch::Scalar {
485    const ARCH: Arch = Arch::Scalar;
486    const DISPLAY_NAME: &'static str = "scalar (compilation target CPU)";
487
488    fn try_new() -> Result<Self, ArchNotSupported> {
489        Ok(diskann_wide::arch::Scalar)
490    }
491}
492
493macro_rules! match_arch {
494    ($target_arch:literal, $arch:path, $enum:ident) => {
495        #[cfg(target_arch = $target_arch)]
496        impl AsArch for $arch {
497            const ARCH: Arch = Arch::$enum;
498            const DISPLAY_NAME: &'static str = stringify!($enum);
499
500            fn is_available() -> bool {
501                <$arch>::new_checked().is_some()
502            }
503
504            fn try_new() -> Result<Self, ArchNotSupported> {
505                <$arch>::new_checked().ok_or(ArchNotSupported(Arch::$enum))
506            }
507        }
508    };
509}
510
511match_arch!("x86_64", diskann_wide::arch::x86_64::V4, X86_64_V4);
512match_arch!("x86_64", diskann_wide::arch::x86_64::V3, X86_64_V3);
513match_arch!("aarch64", diskann_wide::arch::aarch64::Neon, Neon);
514
515impl<A, Q, D> Benchmark for Kernel<A, Q, D>
516where
517    Q: AsDataType,
518    D: AsDataType,
519    A: AsArch,
520    Kernel<A, Q, D>: RunBenchmark<A>,
521{
522    type Input = SimdOp;
523    type Output = Vec<RunResult>;
524
525    // Matching simply requires that we match the inner type.
526    fn try_match(&self, from: &SimdOp, context: &MatchContext) -> Score {
527        let mut score = context.success(0);
528
529        let desc = Q::describe(from.query_type);
530        if !desc.is_match() {
531            score.fail(10, &format_args!("Mismatched query type: {}", desc));
532        }
533
534        let desc = D::describe(from.data_type);
535        if !desc.is_match() {
536            score.fail(10, &format_args!("Mismatched data type: {}", desc));
537        }
538
539        let desc = A::describe(from.arch);
540        if !desc.is_match() {
541            let penalty = if from.arch == A::ARCH { 2 } else { 3 };
542            score.fail(penalty, &format_args!("Mismatched architecture: {}", desc));
543        }
544
545        score
546    }
547
548    fn run(
549        &self,
550        input: &SimdOp,
551        _: diskann_benchmark_runner::Checkpoint<'_>,
552        mut output: &mut dyn diskann_benchmark_runner::Output,
553    ) -> anyhow::Result<Self::Output> {
554        if input.arch != A::ARCH {
555            anyhow::bail!(
556                "architecture mismatch: input requested {:?}, but kernel implementation requires {:?}",
557                input.arch,
558                A::ARCH
559            );
560        }
561
562        let arch = A::try_new()?;
563        writeln!(output, "{}", input)?;
564        let results = self.run_benchmark(input, arch)?;
565        writeln!(output, "\n\n{}", DisplayWrapper(&*results))?;
566        Ok(results)
567    }
568
569    fn description(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        writeln!(f, "- Query Type: {}", Q::DATA_TYPE)?;
571        writeln!(f, "- Data Type: {}", D::DATA_TYPE)?;
572        writeln!(f, "- Implementation: {}", A::DISPLAY_NAME)?;
573        Ok(())
574    }
575}
576
577impl<A, Q, D> Regression for Kernel<A, Q, D>
578where
579    Q: AsDataType,
580    D: AsDataType,
581    A: AsArch,
582    Kernel<A, Q, D>: RunBenchmark<A>,
583{
584    type Tolerances = SimdTolerance;
585    type Pass = CheckResult;
586    type Fail = CheckResult;
587
588    fn check(
589        &self,
590        tolerance: &SimdTolerance,
591        _input: &SimdOp,
592        before: &Vec<RunResult>,
593        after: &Vec<RunResult>,
594    ) -> anyhow::Result<PassFail<CheckResult, CheckResult>> {
595        anyhow::ensure!(
596            before.len() == after.len(),
597            "before has {} runs but after has {}",
598            before.len(),
599            after.len(),
600        );
601
602        let mut passed = true;
603        let checks: Vec<Comparison> = std::iter::zip(before.iter(), after.iter())
604            .enumerate()
605            .map(|(i, (b, a))| {
606                anyhow::ensure!(b.run == a.run, "run {i} mismatched");
607
608                let computations_per_latency = b.computations_per_latency() as f64;
609
610                let before_min = b.percentiles.minimum.as_f64() * 1000.0 / computations_per_latency;
611                let after_min = a.percentiles.minimum.as_f64() * 1000.0 / computations_per_latency;
612
613                let comparison = Comparison {
614                    run: b.run.clone(),
615                    tolerance: *tolerance,
616                    before_min,
617                    after_min,
618                };
619
620                // Determine whether or not we pass.
621                match relative_change(before_min, after_min) {
622                    Ok(change) => {
623                        if change > tolerance.min_time_regression.get() {
624                            passed = false;
625                        }
626                    }
627                    Err(_) => passed = false,
628                };
629
630                Ok(comparison)
631            })
632            .collect::<anyhow::Result<Vec<Comparison>>>()?;
633
634        let check = CheckResult { checks };
635
636        if passed {
637            Ok(PassFail::Pass(check))
638        } else {
639            Ok(PassFail::Fail(check))
640        }
641    }
642}
643
644///////////////
645// Benchmark //
646///////////////
647
648trait RunBenchmark<A> {
649    fn run_benchmark(&self, input: &SimdOp, arch: A) -> Result<Vec<RunResult>, anyhow::Error>;
650}
651
652#[derive(Debug, Serialize, Deserialize)]
653struct RunResult {
654    /// The configuration for this run.
655    run: Run,
656    /// The latencies of individual runs.
657    latencies: Vec<MicroSeconds>,
658    /// Latency percentiles.
659    percentiles: percentiles::Percentiles<MicroSeconds>,
660}
661
662impl RunResult {
663    fn computations_per_latency(&self) -> usize {
664        self.run.num_points.get() * self.run.loops_per_measurement.get()
665    }
666}
667
668impl std::fmt::Display for DisplayWrapper<'_, [RunResult]> {
669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670        if self.is_empty() {
671            return Ok(());
672        }
673
674        let header = [
675            "Distance",
676            "Dim",
677            "Min Time (ns)",
678            "Mean Time (ns)",
679            "Points",
680            "Loops",
681            "Measurements",
682        ];
683
684        let mut table = diskann_benchmark_runner::utils::fmt::Table::new(header, self.len());
685
686        self.iter().enumerate().for_each(|(row, r)| {
687            let mut row = table.row(row);
688
689            let min_latency = r
690                .latencies
691                .iter()
692                .min()
693                .copied()
694                .unwrap_or(MicroSeconds::new(u64::MAX));
695            let mean_latency = r.percentiles.mean;
696
697            let computations_per_latency = r.computations_per_latency() as f64;
698
699            // Convert time from micro-seconds to nano-seconds.
700            let min_time = min_latency.as_f64() / computations_per_latency * 1000.0;
701            let mean_time = mean_latency / computations_per_latency * 1000.0;
702
703            row.insert(r.run.distance, 0);
704            row.insert(r.run.dim, 1);
705            row.insert(format!("{:.3}", min_time), 2);
706            row.insert(format!("{:.3}", mean_time), 3);
707            row.insert(r.run.num_points, 4);
708            row.insert(r.run.loops_per_measurement, 5);
709            row.insert(r.run.num_measurements, 6);
710        });
711
712        table.fmt(f)
713    }
714}
715
716fn run_loops<Q, D, F>(query: &[Q], data: rowmajor::Ref<D>, run: &Run, f: F) -> RunResult
717where
718    F: Fn(&[Q], &[D]) -> f32,
719{
720    let mut latencies = Vec::with_capacity(run.num_measurements.get());
721    let mut dst = vec![0.0; data.nrows()];
722
723    for _ in 0..run.num_measurements.get() {
724        let start = std::time::Instant::now();
725        for _ in 0..run.loops_per_measurement.get() {
726            std::iter::zip(dst.iter_mut(), data.rows()).for_each(|(d, r)| {
727                *d = f(query, r);
728            });
729            std::hint::black_box(&mut dst);
730        }
731        latencies.push(start.elapsed().into());
732    }
733
734    let percentiles = percentiles::compute_percentiles(&mut latencies).unwrap();
735    RunResult {
736        run: run.clone(),
737        latencies,
738        percentiles,
739    }
740}
741
742struct Data<Q, D> {
743    query: Box<[Q]>,
744    data: rowmajor::Owned<D>,
745}
746
747impl<Q, D> Data<Q, D> {
748    fn new(run: &Run) -> Self
749    where
750        StandardUniform: Distribution<Q>,
751        StandardUniform: Distribution<D>,
752    {
753        let mut rng = StdRng::seed_from_u64(0x12345);
754        let query: Box<[Q]> = (0..run.dim.get())
755            .map(|_| StandardUniform.sample(&mut rng))
756            .collect();
757        let data = rowmajor::Owned::<D>::from_fn(run.num_points.get(), run.dim.get(), |_| {
758            StandardUniform.sample(&mut rng)
759        });
760
761        Self { query, data }
762    }
763
764    fn run<F>(&self, run: &Run, f: F) -> RunResult
765    where
766        F: Fn(&[Q], &[D]) -> f32,
767    {
768        run_loops(&self.query, self.data.as_view(), run, f)
769    }
770}
771
772/////////////////////
773// Implementations //
774/////////////////////
775
776macro_rules! stamp {
777    (reference, $Q:ty, $D:ty, $f_l2:ident, $f_ip:ident, $f_cosine:ident) => {
778        impl RunBenchmark<Reference> for Kernel<Reference, $Q, $D> {
779            fn run_benchmark(
780                &self,
781                input: &SimdOp,
782                _arch: Reference,
783            ) -> Result<Vec<RunResult>, anyhow::Error> {
784                let mut results = Vec::new();
785                for run in input.runs.iter() {
786                    let data = Data::<$Q, $D>::new(run);
787                    let result = match run.distance {
788                        SimilarityMeasure::SquaredL2 => data.run(run, reference::$f_l2),
789                        SimilarityMeasure::InnerProduct => data.run(run, reference::$f_ip),
790                        SimilarityMeasure::Cosine => data.run(run, reference::$f_cosine),
791                    };
792                    results.push(result);
793                }
794                Ok(results)
795            }
796        }
797    };
798    ($arch:path, $Q:ty, $D:ty) => {
799        impl RunBenchmark<$arch> for Kernel<$arch, $Q, $D> {
800            fn run_benchmark(
801                &self,
802                input: &SimdOp,
803                arch: $arch,
804            ) -> Result<Vec<RunResult>, anyhow::Error> {
805                let mut results = Vec::new();
806
807                let l2 = &simd::L2 {};
808                let ip = &simd::IP {};
809                let cosine = &simd::CosineStateless {};
810
811                for run in input.runs.iter() {
812                    let data = Data::<$Q, $D>::new(run);
813                    // For each kernel, we need to do a two-step wrapping of closures so
814                    // the inner-most closure is executed by the architecture.
815                    //
816                    // This is required for the implementation of `simd_op` to be inlined
817                    // into the architecture run function so it can properly inherit
818                    // target features.
819                    let result = match run.distance {
820                        SimilarityMeasure::SquaredL2 => data.run(run, |q, d| {
821                            arch.run2(|q, d| simd::simd_op(l2, arch, q, d), q, d)
822                        }),
823                        SimilarityMeasure::InnerProduct => data.run(run, |q, d| {
824                            arch.run2(|q, d| simd::simd_op(ip, arch, q, d), q, d)
825                        }),
826                        SimilarityMeasure::Cosine => data.run(run, |q, d| {
827                            arch.run2(|q, d| simd::simd_op(cosine, arch, q, d), q, d)
828                        }),
829                    };
830                    results.push(result)
831                }
832                Ok(results)
833            }
834        }
835    };
836    ($target_arch:literal, $arch:path, $Q:ty, $D:ty) => {
837        #[cfg(target_arch = $target_arch)]
838        stamp!($arch, $Q, $D);
839    };
840}
841
842stamp!("x86_64", diskann_wide::arch::x86_64::V4, f32, f32);
843stamp!("x86_64", diskann_wide::arch::x86_64::V4, f16, f16);
844stamp!("x86_64", diskann_wide::arch::x86_64::V4, u8, u8);
845stamp!("x86_64", diskann_wide::arch::x86_64::V4, i8, i8);
846
847stamp!("x86_64", diskann_wide::arch::x86_64::V3, f32, f32);
848stamp!("x86_64", diskann_wide::arch::x86_64::V3, f16, f16);
849stamp!("x86_64", diskann_wide::arch::x86_64::V3, u8, u8);
850stamp!("x86_64", diskann_wide::arch::x86_64::V3, i8, i8);
851
852stamp!("aarch64", diskann_wide::arch::aarch64::Neon, f32, f32);
853stamp!("aarch64", diskann_wide::arch::aarch64::Neon, f16, f16);
854stamp!("aarch64", diskann_wide::arch::aarch64::Neon, u8, u8);
855stamp!("aarch64", diskann_wide::arch::aarch64::Neon, i8, i8);
856
857stamp!(diskann_wide::arch::Scalar, f32, f32);
858stamp!(diskann_wide::arch::Scalar, f16, f16);
859stamp!(diskann_wide::arch::Scalar, u8, u8);
860stamp!(diskann_wide::arch::Scalar, i8, i8);
861
862stamp!(
863    reference,
864    f32,
865    f32,
866    squared_l2_f32,
867    inner_product_f32,
868    cosine_f32
869);
870stamp!(
871    reference,
872    f16,
873    f16,
874    squared_l2_f16,
875    inner_product_f16,
876    cosine_f16
877);
878stamp!(
879    reference,
880    u8,
881    u8,
882    squared_l2_u8,
883    inner_product_u8,
884    cosine_u8
885);
886stamp!(
887    reference,
888    i8,
889    i8,
890    squared_l2_i8,
891    inner_product_i8,
892    cosine_i8
893);
894
895///////////////
896// Reference //
897///////////////
898
899// These are largely copied from the implementations in vector, with a tweak that we don't
900// use FMA when the current architecture is scalar.
901mod reference {
902    use diskann_wide::ARCH;
903    use half::f16;
904
905    trait MaybeFMA {
906        // Perform `a*b + c` using FMA when a hardware instruction is guaranteed to be
907        // available, otherwise decompose into a multiply and add.
908        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32;
909    }
910
911    impl MaybeFMA for diskann_wide::arch::Scalar {
912        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
913            a * b + c
914        }
915    }
916
917    #[cfg(target_arch = "x86_64")]
918    impl MaybeFMA for diskann_wide::arch::x86_64::V3 {
919        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
920            a.mul_add(b, c)
921        }
922    }
923
924    #[cfg(target_arch = "x86_64")]
925    impl MaybeFMA for diskann_wide::arch::x86_64::V4 {
926        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
927            a.mul_add(b, c)
928        }
929    }
930
931    #[cfg(target_arch = "aarch64")]
932    impl MaybeFMA for diskann_wide::arch::aarch64::Neon {
933        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
934            a.mul_add(b, c)
935        }
936    }
937
938    //------------//
939    // Squared L2 //
940    //------------//
941
942    pub(super) fn squared_l2_i8(x: &[i8], y: &[i8]) -> f32 {
943        assert_eq!(x.len(), y.len());
944        std::iter::zip(x.iter(), y.iter())
945            .map(|(&a, &b)| {
946                let a: i32 = a.into();
947                let b: i32 = b.into();
948                let diff = a - b;
949                diff * diff
950            })
951            .sum::<i32>() as f32
952    }
953
954    pub(super) fn squared_l2_u8(x: &[u8], y: &[u8]) -> f32 {
955        assert_eq!(x.len(), y.len());
956        std::iter::zip(x.iter(), y.iter())
957            .map(|(&a, &b)| {
958                let a: i32 = a.into();
959                let b: i32 = b.into();
960                let diff = a - b;
961                diff * diff
962            })
963            .sum::<i32>() as f32
964    }
965
966    pub(super) fn squared_l2_f16(x: &[f16], y: &[f16]) -> f32 {
967        assert_eq!(x.len(), y.len());
968        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
969            let a: f32 = a.into();
970            let b: f32 = b.into();
971            let diff = a - b;
972            ARCH.maybe_fma(diff, diff, acc)
973        })
974    }
975
976    pub(super) fn squared_l2_f32(x: &[f32], y: &[f32]) -> f32 {
977        assert_eq!(x.len(), y.len());
978        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
979            let diff = a - b;
980            ARCH.maybe_fma(diff, diff, acc)
981        })
982    }
983
984    //---------------//
985    // Inner Product //
986    //---------------//
987
988    pub(super) fn inner_product_i8(x: &[i8], y: &[i8]) -> f32 {
989        assert_eq!(x.len(), y.len());
990        std::iter::zip(x.iter(), y.iter())
991            .map(|(&a, &b)| {
992                let a: i32 = a.into();
993                let b: i32 = b.into();
994                a * b
995            })
996            .sum::<i32>() as f32
997    }
998
999    pub(super) fn inner_product_u8(x: &[u8], y: &[u8]) -> f32 {
1000        assert_eq!(x.len(), y.len());
1001        std::iter::zip(x.iter(), y.iter())
1002            .map(|(&a, &b)| {
1003                let a: i32 = a.into();
1004                let b: i32 = b.into();
1005                a * b
1006            })
1007            .sum::<i32>() as f32
1008    }
1009
1010    pub(super) fn inner_product_f16(x: &[f16], y: &[f16]) -> f32 {
1011        assert_eq!(x.len(), y.len());
1012        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
1013            let a: f32 = a.into();
1014            let b: f32 = b.into();
1015            ARCH.maybe_fma(a, b, acc)
1016        })
1017    }
1018
1019    pub(super) fn inner_product_f32(x: &[f32], y: &[f32]) -> f32 {
1020        assert_eq!(x.len(), y.len());
1021        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| ARCH.maybe_fma(a, b, acc))
1022    }
1023
1024    //--------//
1025    // Cosine //
1026    //--------//
1027
1028    #[derive(Default)]
1029    struct XY<T> {
1030        xnorm: T,
1031        ynorm: T,
1032        xy: T,
1033    }
1034
1035    pub(super) fn cosine_i8(x: &[i8], y: &[i8]) -> f32 {
1036        assert_eq!(x.len(), y.len());
1037        let r: XY<i32> =
1038            std::iter::zip(x.iter(), y.iter()).fold(XY::<i32>::default(), |acc, (&vx, &vy)| {
1039                let vx: i32 = vx.into();
1040                let vy: i32 = vy.into();
1041                XY {
1042                    xnorm: acc.xnorm + vx * vx,
1043                    ynorm: acc.ynorm + vy * vy,
1044                    xy: acc.xy + vx * vy,
1045                }
1046            });
1047
1048        if r.xnorm == 0 || r.ynorm == 0 {
1049            return 0.0;
1050        }
1051
1052        (r.xy as f32 / ((r.xnorm as f32).sqrt() * (r.ynorm as f32).sqrt())).clamp(-1.0, 1.0)
1053    }
1054
1055    pub(super) fn cosine_u8(x: &[u8], y: &[u8]) -> f32 {
1056        assert_eq!(x.len(), y.len());
1057        let r: XY<i32> =
1058            std::iter::zip(x.iter(), y.iter()).fold(XY::<i32>::default(), |acc, (&vx, &vy)| {
1059                let vx: i32 = vx.into();
1060                let vy: i32 = vy.into();
1061                XY {
1062                    xnorm: acc.xnorm + vx * vx,
1063                    ynorm: acc.ynorm + vy * vy,
1064                    xy: acc.xy + vx * vy,
1065                }
1066            });
1067
1068        if r.xnorm == 0 || r.ynorm == 0 {
1069            return 0.0;
1070        }
1071
1072        (r.xy as f32 / ((r.xnorm as f32).sqrt() * (r.ynorm as f32).sqrt())).clamp(-1.0, 1.0)
1073    }
1074
1075    pub(super) fn cosine_f16(x: &[f16], y: &[f16]) -> f32 {
1076        assert_eq!(x.len(), y.len());
1077        let r: XY<f32> =
1078            std::iter::zip(x.iter(), y.iter()).fold(XY::<f32>::default(), |acc, (&vx, &vy)| {
1079                let vx: f32 = vx.into();
1080                let vy: f32 = vy.into();
1081                XY {
1082                    xnorm: ARCH.maybe_fma(vx, vx, acc.xnorm),
1083                    ynorm: ARCH.maybe_fma(vy, vy, acc.ynorm),
1084                    xy: ARCH.maybe_fma(vx, vy, acc.xy),
1085                }
1086            });
1087
1088        if r.xnorm < f32::EPSILON || r.ynorm < f32::EPSILON {
1089            return 0.0;
1090        }
1091
1092        (r.xy / (r.xnorm.sqrt() * r.ynorm.sqrt())).clamp(-1.0, 1.0)
1093    }
1094
1095    pub(super) fn cosine_f32(x: &[f32], y: &[f32]) -> f32 {
1096        assert_eq!(x.len(), y.len());
1097        let r: XY<f32> =
1098            std::iter::zip(x.iter(), y.iter()).fold(XY::<f32>::default(), |acc, (&vx, &vy)| XY {
1099                xnorm: vx.mul_add(vx, acc.xnorm),
1100                ynorm: vy.mul_add(vy, acc.ynorm),
1101                xy: vx.mul_add(vy, acc.xy),
1102            });
1103
1104        if r.xnorm < f32::EPSILON || r.ynorm < f32::EPSILON {
1105            return 0.0;
1106        }
1107
1108        (r.xy / (r.xnorm.sqrt() * r.ynorm.sqrt())).clamp(-1.0, 1.0)
1109    }
1110}
1111
1112///////////
1113// Tests //
1114///////////
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    use diskann_benchmark_runner::{
1121        benchmark::{PassFail, Regression},
1122        utils::percentiles::compute_percentiles,
1123    };
1124
1125    fn tiny_run(distance: SimilarityMeasure) -> Run {
1126        Run {
1127            distance,
1128            dim: NonZeroUsize::new(8).unwrap(),
1129            num_points: NonZeroUsize::new(1).unwrap(),
1130            loops_per_measurement: NonZeroUsize::new(1).unwrap(),
1131            num_measurements: NonZeroUsize::new(1).unwrap(),
1132        }
1133    }
1134
1135    fn tiny_op() -> SimdOp {
1136        SimdOp {
1137            query_type: DataType::Float32,
1138            data_type: DataType::Float32,
1139            arch: Arch::Scalar,
1140            runs: vec![tiny_run(SimilarityMeasure::SquaredL2)],
1141        }
1142    }
1143
1144    fn tiny_result(distance: SimilarityMeasure, minimum: u64) -> RunResult {
1145        let run = tiny_run(distance);
1146        let minimum = MicroSeconds::new(minimum);
1147        let mut latencies = vec![minimum];
1148        let percentiles = compute_percentiles(&mut latencies).unwrap();
1149        RunResult {
1150            run,
1151            latencies,
1152            percentiles,
1153        }
1154    }
1155
1156    fn tolerance(limit: f64) -> SimdTolerance {
1157        SimdTolerance {
1158            min_time_regression: NonNegativeFinite::new(limit).unwrap(),
1159        }
1160    }
1161
1162    #[test]
1163    fn check_rejects_mismatched_runs() {
1164        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1165
1166        let err = kernel
1167            .check(
1168                &tolerance(0.0),
1169                &tiny_op(),
1170                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1171                &vec![tiny_result(SimilarityMeasure::Cosine, 100)],
1172            )
1173            .unwrap_err();
1174
1175        assert_eq!(err.to_string(), "run 0 mismatched");
1176    }
1177
1178    #[test]
1179    fn check_allows_negative_relative_change() {
1180        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1181
1182        let result = kernel
1183            .check(
1184                &tolerance(0.0),
1185                &tiny_op(),
1186                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1187                &vec![tiny_result(SimilarityMeasure::SquaredL2, 95)],
1188            )
1189            .unwrap();
1190
1191        assert!(matches!(result, PassFail::Pass(_)));
1192    }
1193
1194    #[test]
1195    fn check_passes_on_tolerance_boundary() {
1196        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1197
1198        let result = kernel
1199            .check(
1200                &tolerance(0.05),
1201                &tiny_op(),
1202                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1203                &vec![tiny_result(SimilarityMeasure::SquaredL2, 105)],
1204            )
1205            .unwrap();
1206
1207        assert!(matches!(result, PassFail::Pass(_)));
1208    }
1209
1210    #[test]
1211    fn check_fails_above_tolerance_boundary() {
1212        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1213
1214        let result = kernel
1215            .check(
1216                &tolerance(0.05),
1217                &tiny_op(),
1218                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1219                &vec![tiny_result(SimilarityMeasure::SquaredL2, 106)],
1220            )
1221            .unwrap();
1222
1223        assert!(matches!(result, PassFail::Fail(_)));
1224    }
1225
1226    #[test]
1227    fn check_result_display_includes_failure_details() {
1228        let check = CheckResult {
1229            checks: vec![Comparison {
1230                run: tiny_run(SimilarityMeasure::SquaredL2),
1231                tolerance: tolerance(0.05),
1232                before_min: 100.0,
1233                after_min: 106.0,
1234            }],
1235        };
1236
1237        let rendered = check.to_string();
1238        assert!(rendered.contains("Distance"), "rendered = {rendered}");
1239        assert!(rendered.contains("squared_l2"), "rendered = {rendered}");
1240        assert!(rendered.contains("100.000"), "rendered = {rendered}");
1241        assert!(rendered.contains("106.000"), "rendered = {rendered}");
1242        assert!(rendered.contains("6.000 %"), "rendered = {rendered}");
1243        assert!(rendered.contains("FAIL"), "rendered = {rendered}");
1244    }
1245
1246    // If a "before" value is 0, we should fail with an error because this means the
1247    // measurement was too fast for us to obtain a reliable signal, so we *could* be letting
1248    // a regression through.
1249    //
1250    // We require at least a non-zero value.
1251    #[test]
1252    fn zero_values_rejected() {
1253        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1254
1255        let result = kernel
1256            .check(
1257                &tolerance(0.05),
1258                &tiny_op(),
1259                &vec![tiny_result(SimilarityMeasure::SquaredL2, 0)],
1260                &vec![tiny_result(SimilarityMeasure::SquaredL2, 0)],
1261            )
1262            .unwrap();
1263
1264        assert!(matches!(result, PassFail::Fail(_)));
1265    }
1266}