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::{Matrix, MatrixView};
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    #[allow(non_camel_case_types)]
82    X86_64_V4,
83    #[serde(rename = "x86-64-v3")]
84    #[allow(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: MatrixView<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.row_iter()).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: Matrix<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 = Matrix::<D>::new(
758            diskann_utils::views::Init(|| StandardUniform.sample(&mut rng)),
759            run.num_points.get(),
760            run.dim.get(),
761        );
762
763        Self { query, data }
764    }
765
766    fn run<F>(&self, run: &Run, f: F) -> RunResult
767    where
768        F: Fn(&[Q], &[D]) -> f32,
769    {
770        run_loops(&self.query, self.data.as_view(), run, f)
771    }
772}
773
774/////////////////////
775// Implementations //
776/////////////////////
777
778macro_rules! stamp {
779    (reference, $Q:ty, $D:ty, $f_l2:ident, $f_ip:ident, $f_cosine:ident) => {
780        impl RunBenchmark<Reference> for Kernel<Reference, $Q, $D> {
781            fn run_benchmark(
782                &self,
783                input: &SimdOp,
784                _arch: Reference,
785            ) -> Result<Vec<RunResult>, anyhow::Error> {
786                let mut results = Vec::new();
787                for run in input.runs.iter() {
788                    let data = Data::<$Q, $D>::new(run);
789                    let result = match run.distance {
790                        SimilarityMeasure::SquaredL2 => data.run(run, reference::$f_l2),
791                        SimilarityMeasure::InnerProduct => data.run(run, reference::$f_ip),
792                        SimilarityMeasure::Cosine => data.run(run, reference::$f_cosine),
793                    };
794                    results.push(result);
795                }
796                Ok(results)
797            }
798        }
799    };
800    ($arch:path, $Q:ty, $D:ty) => {
801        impl RunBenchmark<$arch> for Kernel<$arch, $Q, $D> {
802            fn run_benchmark(
803                &self,
804                input: &SimdOp,
805                arch: $arch,
806            ) -> Result<Vec<RunResult>, anyhow::Error> {
807                let mut results = Vec::new();
808
809                let l2 = &simd::L2 {};
810                let ip = &simd::IP {};
811                let cosine = &simd::CosineStateless {};
812
813                for run in input.runs.iter() {
814                    let data = Data::<$Q, $D>::new(run);
815                    // For each kernel, we need to do a two-step wrapping of closures so
816                    // the inner-most closure is executed by the architecture.
817                    //
818                    // This is required for the implementation of `simd_op` to be inlined
819                    // into the architecture run function so it can properly inherit
820                    // target features.
821                    let result = match run.distance {
822                        SimilarityMeasure::SquaredL2 => data.run(run, |q, d| {
823                            arch.run2(|q, d| simd::simd_op(l2, arch, q, d), q, d)
824                        }),
825                        SimilarityMeasure::InnerProduct => data.run(run, |q, d| {
826                            arch.run2(|q, d| simd::simd_op(ip, arch, q, d), q, d)
827                        }),
828                        SimilarityMeasure::Cosine => data.run(run, |q, d| {
829                            arch.run2(|q, d| simd::simd_op(cosine, arch, q, d), q, d)
830                        }),
831                    };
832                    results.push(result)
833                }
834                Ok(results)
835            }
836        }
837    };
838    ($target_arch:literal, $arch:path, $Q:ty, $D:ty) => {
839        #[cfg(target_arch = $target_arch)]
840        stamp!($arch, $Q, $D);
841    };
842}
843
844stamp!("x86_64", diskann_wide::arch::x86_64::V4, f32, f32);
845stamp!("x86_64", diskann_wide::arch::x86_64::V4, f16, f16);
846stamp!("x86_64", diskann_wide::arch::x86_64::V4, u8, u8);
847stamp!("x86_64", diskann_wide::arch::x86_64::V4, i8, i8);
848
849stamp!("x86_64", diskann_wide::arch::x86_64::V3, f32, f32);
850stamp!("x86_64", diskann_wide::arch::x86_64::V3, f16, f16);
851stamp!("x86_64", diskann_wide::arch::x86_64::V3, u8, u8);
852stamp!("x86_64", diskann_wide::arch::x86_64::V3, i8, i8);
853
854stamp!("aarch64", diskann_wide::arch::aarch64::Neon, f32, f32);
855stamp!("aarch64", diskann_wide::arch::aarch64::Neon, f16, f16);
856stamp!("aarch64", diskann_wide::arch::aarch64::Neon, u8, u8);
857stamp!("aarch64", diskann_wide::arch::aarch64::Neon, i8, i8);
858
859stamp!(diskann_wide::arch::Scalar, f32, f32);
860stamp!(diskann_wide::arch::Scalar, f16, f16);
861stamp!(diskann_wide::arch::Scalar, u8, u8);
862stamp!(diskann_wide::arch::Scalar, i8, i8);
863
864stamp!(
865    reference,
866    f32,
867    f32,
868    squared_l2_f32,
869    inner_product_f32,
870    cosine_f32
871);
872stamp!(
873    reference,
874    f16,
875    f16,
876    squared_l2_f16,
877    inner_product_f16,
878    cosine_f16
879);
880stamp!(
881    reference,
882    u8,
883    u8,
884    squared_l2_u8,
885    inner_product_u8,
886    cosine_u8
887);
888stamp!(
889    reference,
890    i8,
891    i8,
892    squared_l2_i8,
893    inner_product_i8,
894    cosine_i8
895);
896
897///////////////
898// Reference //
899///////////////
900
901// These are largely copied from the implementations in vector, with a tweak that we don't
902// use FMA when the current architecture is scalar.
903mod reference {
904    use diskann_wide::ARCH;
905    use half::f16;
906
907    trait MaybeFMA {
908        // Perform `a*b + c` using FMA when a hardware instruction is guaranteed to be
909        // available, otherwise decompose into a multiply and add.
910        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32;
911    }
912
913    impl MaybeFMA for diskann_wide::arch::Scalar {
914        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
915            a * b + c
916        }
917    }
918
919    #[cfg(target_arch = "x86_64")]
920    impl MaybeFMA for diskann_wide::arch::x86_64::V3 {
921        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
922            a.mul_add(b, c)
923        }
924    }
925
926    #[cfg(target_arch = "x86_64")]
927    impl MaybeFMA for diskann_wide::arch::x86_64::V4 {
928        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
929            a.mul_add(b, c)
930        }
931    }
932
933    #[cfg(target_arch = "aarch64")]
934    impl MaybeFMA for diskann_wide::arch::aarch64::Neon {
935        fn maybe_fma(self, a: f32, b: f32, c: f32) -> f32 {
936            a.mul_add(b, c)
937        }
938    }
939
940    //------------//
941    // Squared L2 //
942    //------------//
943
944    pub(super) fn squared_l2_i8(x: &[i8], y: &[i8]) -> f32 {
945        assert_eq!(x.len(), y.len());
946        std::iter::zip(x.iter(), y.iter())
947            .map(|(&a, &b)| {
948                let a: i32 = a.into();
949                let b: i32 = b.into();
950                let diff = a - b;
951                diff * diff
952            })
953            .sum::<i32>() as f32
954    }
955
956    pub(super) fn squared_l2_u8(x: &[u8], y: &[u8]) -> f32 {
957        assert_eq!(x.len(), y.len());
958        std::iter::zip(x.iter(), y.iter())
959            .map(|(&a, &b)| {
960                let a: i32 = a.into();
961                let b: i32 = b.into();
962                let diff = a - b;
963                diff * diff
964            })
965            .sum::<i32>() as f32
966    }
967
968    pub(super) fn squared_l2_f16(x: &[f16], y: &[f16]) -> f32 {
969        assert_eq!(x.len(), y.len());
970        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
971            let a: f32 = a.into();
972            let b: f32 = b.into();
973            let diff = a - b;
974            ARCH.maybe_fma(diff, diff, acc)
975        })
976    }
977
978    pub(super) fn squared_l2_f32(x: &[f32], y: &[f32]) -> f32 {
979        assert_eq!(x.len(), y.len());
980        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
981            let diff = a - b;
982            ARCH.maybe_fma(diff, diff, acc)
983        })
984    }
985
986    //---------------//
987    // Inner Product //
988    //---------------//
989
990    pub(super) fn inner_product_i8(x: &[i8], y: &[i8]) -> f32 {
991        assert_eq!(x.len(), y.len());
992        std::iter::zip(x.iter(), y.iter())
993            .map(|(&a, &b)| {
994                let a: i32 = a.into();
995                let b: i32 = b.into();
996                a * b
997            })
998            .sum::<i32>() as f32
999    }
1000
1001    pub(super) fn inner_product_u8(x: &[u8], y: &[u8]) -> f32 {
1002        assert_eq!(x.len(), y.len());
1003        std::iter::zip(x.iter(), y.iter())
1004            .map(|(&a, &b)| {
1005                let a: i32 = a.into();
1006                let b: i32 = b.into();
1007                a * b
1008            })
1009            .sum::<i32>() as f32
1010    }
1011
1012    pub(super) fn inner_product_f16(x: &[f16], y: &[f16]) -> f32 {
1013        assert_eq!(x.len(), y.len());
1014        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| {
1015            let a: f32 = a.into();
1016            let b: f32 = b.into();
1017            ARCH.maybe_fma(a, b, acc)
1018        })
1019    }
1020
1021    pub(super) fn inner_product_f32(x: &[f32], y: &[f32]) -> f32 {
1022        assert_eq!(x.len(), y.len());
1023        std::iter::zip(x.iter(), y.iter()).fold(0.0f32, |acc, (&a, &b)| ARCH.maybe_fma(a, b, acc))
1024    }
1025
1026    //--------//
1027    // Cosine //
1028    //--------//
1029
1030    #[derive(Default)]
1031    struct XY<T> {
1032        xnorm: T,
1033        ynorm: T,
1034        xy: T,
1035    }
1036
1037    pub(super) fn cosine_i8(x: &[i8], y: &[i8]) -> f32 {
1038        assert_eq!(x.len(), y.len());
1039        let r: XY<i32> =
1040            std::iter::zip(x.iter(), y.iter()).fold(XY::<i32>::default(), |acc, (&vx, &vy)| {
1041                let vx: i32 = vx.into();
1042                let vy: i32 = vy.into();
1043                XY {
1044                    xnorm: acc.xnorm + vx * vx,
1045                    ynorm: acc.ynorm + vy * vy,
1046                    xy: acc.xy + vx * vy,
1047                }
1048            });
1049
1050        if r.xnorm == 0 || r.ynorm == 0 {
1051            return 0.0;
1052        }
1053
1054        (r.xy as f32 / ((r.xnorm as f32).sqrt() * (r.ynorm as f32).sqrt())).clamp(-1.0, 1.0)
1055    }
1056
1057    pub(super) fn cosine_u8(x: &[u8], y: &[u8]) -> f32 {
1058        assert_eq!(x.len(), y.len());
1059        let r: XY<i32> =
1060            std::iter::zip(x.iter(), y.iter()).fold(XY::<i32>::default(), |acc, (&vx, &vy)| {
1061                let vx: i32 = vx.into();
1062                let vy: i32 = vy.into();
1063                XY {
1064                    xnorm: acc.xnorm + vx * vx,
1065                    ynorm: acc.ynorm + vy * vy,
1066                    xy: acc.xy + vx * vy,
1067                }
1068            });
1069
1070        if r.xnorm == 0 || r.ynorm == 0 {
1071            return 0.0;
1072        }
1073
1074        (r.xy as f32 / ((r.xnorm as f32).sqrt() * (r.ynorm as f32).sqrt())).clamp(-1.0, 1.0)
1075    }
1076
1077    pub(super) fn cosine_f16(x: &[f16], y: &[f16]) -> f32 {
1078        assert_eq!(x.len(), y.len());
1079        let r: XY<f32> =
1080            std::iter::zip(x.iter(), y.iter()).fold(XY::<f32>::default(), |acc, (&vx, &vy)| {
1081                let vx: f32 = vx.into();
1082                let vy: f32 = vy.into();
1083                XY {
1084                    xnorm: ARCH.maybe_fma(vx, vx, acc.xnorm),
1085                    ynorm: ARCH.maybe_fma(vy, vy, acc.ynorm),
1086                    xy: ARCH.maybe_fma(vx, vy, acc.xy),
1087                }
1088            });
1089
1090        if r.xnorm < f32::EPSILON || r.ynorm < f32::EPSILON {
1091            return 0.0;
1092        }
1093
1094        (r.xy / (r.xnorm.sqrt() * r.ynorm.sqrt())).clamp(-1.0, 1.0)
1095    }
1096
1097    pub(super) fn cosine_f32(x: &[f32], y: &[f32]) -> f32 {
1098        assert_eq!(x.len(), y.len());
1099        let r: XY<f32> =
1100            std::iter::zip(x.iter(), y.iter()).fold(XY::<f32>::default(), |acc, (&vx, &vy)| XY {
1101                xnorm: vx.mul_add(vx, acc.xnorm),
1102                ynorm: vy.mul_add(vy, acc.ynorm),
1103                xy: vx.mul_add(vy, acc.xy),
1104            });
1105
1106        if r.xnorm < f32::EPSILON || r.ynorm < f32::EPSILON {
1107            return 0.0;
1108        }
1109
1110        (r.xy / (r.xnorm.sqrt() * r.ynorm.sqrt())).clamp(-1.0, 1.0)
1111    }
1112}
1113
1114///////////
1115// Tests //
1116///////////
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    use diskann_benchmark_runner::{
1123        benchmark::{PassFail, Regression},
1124        utils::percentiles::compute_percentiles,
1125    };
1126
1127    fn tiny_run(distance: SimilarityMeasure) -> Run {
1128        Run {
1129            distance,
1130            dim: NonZeroUsize::new(8).unwrap(),
1131            num_points: NonZeroUsize::new(1).unwrap(),
1132            loops_per_measurement: NonZeroUsize::new(1).unwrap(),
1133            num_measurements: NonZeroUsize::new(1).unwrap(),
1134        }
1135    }
1136
1137    fn tiny_op() -> SimdOp {
1138        SimdOp {
1139            query_type: DataType::Float32,
1140            data_type: DataType::Float32,
1141            arch: Arch::Scalar,
1142            runs: vec![tiny_run(SimilarityMeasure::SquaredL2)],
1143        }
1144    }
1145
1146    fn tiny_result(distance: SimilarityMeasure, minimum: u64) -> RunResult {
1147        let run = tiny_run(distance);
1148        let minimum = MicroSeconds::new(minimum);
1149        let mut latencies = vec![minimum];
1150        let percentiles = compute_percentiles(&mut latencies).unwrap();
1151        RunResult {
1152            run,
1153            latencies,
1154            percentiles,
1155        }
1156    }
1157
1158    fn tolerance(limit: f64) -> SimdTolerance {
1159        SimdTolerance {
1160            min_time_regression: NonNegativeFinite::new(limit).unwrap(),
1161        }
1162    }
1163
1164    #[test]
1165    fn check_rejects_mismatched_runs() {
1166        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1167
1168        let err = kernel
1169            .check(
1170                &tolerance(0.0),
1171                &tiny_op(),
1172                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1173                &vec![tiny_result(SimilarityMeasure::Cosine, 100)],
1174            )
1175            .unwrap_err();
1176
1177        assert_eq!(err.to_string(), "run 0 mismatched");
1178    }
1179
1180    #[test]
1181    fn check_allows_negative_relative_change() {
1182        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1183
1184        let result = kernel
1185            .check(
1186                &tolerance(0.0),
1187                &tiny_op(),
1188                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1189                &vec![tiny_result(SimilarityMeasure::SquaredL2, 95)],
1190            )
1191            .unwrap();
1192
1193        assert!(matches!(result, PassFail::Pass(_)));
1194    }
1195
1196    #[test]
1197    fn check_passes_on_tolerance_boundary() {
1198        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1199
1200        let result = kernel
1201            .check(
1202                &tolerance(0.05),
1203                &tiny_op(),
1204                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1205                &vec![tiny_result(SimilarityMeasure::SquaredL2, 105)],
1206            )
1207            .unwrap();
1208
1209        assert!(matches!(result, PassFail::Pass(_)));
1210    }
1211
1212    #[test]
1213    fn check_fails_above_tolerance_boundary() {
1214        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1215
1216        let result = kernel
1217            .check(
1218                &tolerance(0.05),
1219                &tiny_op(),
1220                &vec![tiny_result(SimilarityMeasure::SquaredL2, 100)],
1221                &vec![tiny_result(SimilarityMeasure::SquaredL2, 106)],
1222            )
1223            .unwrap();
1224
1225        assert!(matches!(result, PassFail::Fail(_)));
1226    }
1227
1228    #[test]
1229    fn check_result_display_includes_failure_details() {
1230        let check = CheckResult {
1231            checks: vec![Comparison {
1232                run: tiny_run(SimilarityMeasure::SquaredL2),
1233                tolerance: tolerance(0.05),
1234                before_min: 100.0,
1235                after_min: 106.0,
1236            }],
1237        };
1238
1239        let rendered = check.to_string();
1240        assert!(rendered.contains("Distance"), "rendered = {rendered}");
1241        assert!(rendered.contains("squared_l2"), "rendered = {rendered}");
1242        assert!(rendered.contains("100.000"), "rendered = {rendered}");
1243        assert!(rendered.contains("106.000"), "rendered = {rendered}");
1244        assert!(rendered.contains("6.000 %"), "rendered = {rendered}");
1245        assert!(rendered.contains("FAIL"), "rendered = {rendered}");
1246    }
1247
1248    // If a "before" value is 0, we should fail with an error because this means the
1249    // measurement was too fast for us to obtain a reliable signal, so we *could* be letting
1250    // a regression through.
1251    //
1252    // We require at least a non-zero value.
1253    #[test]
1254    fn zero_values_rejected() {
1255        let kernel = Kernel::<diskann_wide::arch::Scalar, f32, f32>::new();
1256
1257        let result = kernel
1258            .check(
1259                &tolerance(0.05),
1260                &tiny_op(),
1261                &vec![tiny_result(SimilarityMeasure::SquaredL2, 0)],
1262                &vec![tiny_result(SimilarityMeasure::SquaredL2, 0)],
1263            )
1264            .unwrap();
1265
1266        assert!(matches!(result, PassFail::Fail(_)));
1267    }
1268}