Skip to main content

lance_linalg/
distance.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Distance metrics
5//!
6//! This module provides distance metrics for vectors.
7//!
8//! - `bf16, f16, f32, f64` types are supported.
9//! - SIMD is used when available, on `x86_64`, `aarch64` and `loongarch64`
10//!   architectures.
11
12use std::sync::Arc;
13
14use arrow_array::cast::AsArray;
15use arrow_array::types::{Float16Type, Float32Type, Float64Type, UInt8Type};
16use arrow_array::{Array, ArrowPrimitiveType, FixedSizeListArray, Float32Array, ListArray};
17use arrow_schema::{ArrowError, DataType};
18
19pub mod cosine;
20pub mod cosine_u8;
21pub mod dot;
22pub mod dot_u8;
23pub mod hamming;
24pub mod l2;
25pub mod l2_u8;
26pub mod norm_l2;
27
28/// What a per-batch distance kernel yields.
29///
30/// The two batch paths produce different shapes: the build-baseline path maps
31/// lazily over the batch and allocates nothing, while a `#[target_feature]`
32/// kernel must collect eagerly because it cannot be inlined into a lazy
33/// closure. A concrete enum keeps both statically dispatched.
34///
35/// Only sub-AVX2 builds need this. On an AVX2-baseline build the batch methods
36/// return the bare `Map` instead, because any wrapper — trait object or enum —
37/// loses `TrustedLen` (so `.collect()` stops preallocating) and loses
38/// `Map::fold`'s inlined loop. Benchmarks showed that costing 2.5x on the dim-8
39/// batch, far more than the per-vector dispatch it was meant to remove.
40#[cfg(all(
41    target_arch = "x86_64",
42    not(all(target_feature = "avx2", target_feature = "fma"))
43))]
44pub(crate) enum BatchIter<L> {
45    /// Lazy per-vector map. No allocation.
46    Lazy(L),
47    /// Eagerly collected by a `#[target_feature]` kernel.
48    Eager(std::vec::IntoIter<f32>),
49}
50
51#[cfg(all(
52    target_arch = "x86_64",
53    not(all(target_feature = "avx2", target_feature = "fma"))
54))]
55impl<L: Iterator<Item = f32>> Iterator for BatchIter<L> {
56    type Item = f32;
57
58    #[inline]
59    fn next(&mut self) -> Option<Self::Item> {
60        match self {
61            Self::Lazy(iter) => iter.next(),
62            Self::Eager(iter) => iter.next(),
63        }
64    }
65
66    #[inline]
67    fn size_hint(&self) -> (usize, Option<usize>) {
68        match self {
69            Self::Lazy(iter) => iter.size_hint(),
70            Self::Eager(iter) => iter.size_hint(),
71        }
72    }
73
74    /// Delegated, not defaulted. `Map` overrides `fold` to drive the underlying
75    /// `ChunksExact` in one inlined, auto-vectorized loop; the default `fold`
76    /// would instead call `next()` per element, paying an enum branch and
77    /// losing that loop. On the dim-8 batch that costs ~2.5x.
78    #[inline]
79    fn fold<B, F>(self, init: B, f: F) -> B
80    where
81        F: FnMut(B, Self::Item) -> B,
82    {
83        match self {
84            Self::Lazy(iter) => iter.fold(init, f),
85            Self::Eager(iter) => iter.fold(init, f),
86        }
87    }
88
89    /// `for_each`, `sum` and `collect` all route through `fold`, so delegating
90    /// it covers them too. (`try_fold` cannot be overridden on stable: its
91    /// `Try` bound is unstable.)
92    #[inline]
93    fn for_each<F>(self, f: F)
94    where
95        F: FnMut(Self::Item),
96    {
97        match self {
98            Self::Lazy(iter) => iter.for_each(f),
99            Self::Eager(iter) => iter.for_each(f),
100        }
101    }
102}
103
104#[cfg(all(
105    target_arch = "x86_64",
106    not(all(target_feature = "avx2", target_feature = "fma"))
107))]
108impl<L: ExactSizeIterator<Item = f32>> ExactSizeIterator for BatchIter<L> {
109    #[inline]
110    fn len(&self) -> usize {
111        match self {
112            Self::Lazy(iter) => iter.len(),
113            Self::Eager(iter) => iter.len(),
114        }
115    }
116}
117
118pub use cosine::*;
119pub use dot::*;
120pub use hamming::{
121    BinaryHashValues, Cluster, ClusteringResult, PairwiseResult, UnionFind, cluster_edges,
122    cluster_pairwise_result, extract_binary_hashes_from_fixed_list, extract_hashes_from_fixed_list,
123    hamming_distance_arrow_batch, hamming_u64, pairwise_hamming_distance,
124    pairwise_hamming_distance_binary, pairwise_hamming_distance_binary_parallel,
125    pairwise_hamming_distance_parallel,
126};
127pub use l2::*;
128use lance_core::deepsize::DeepSizeOf;
129pub use norm_l2::*;
130
131use crate::Result;
132
133/// Distance metrics type.
134#[derive(Debug, Copy, Clone, PartialEq, DeepSizeOf)]
135pub enum DistanceType {
136    L2,
137    Cosine,
138    /// Dot Product
139    Dot,
140    /// Hamming Distance
141    Hamming,
142}
143
144/// For backwards compatibility.
145pub type MetricType = DistanceType;
146
147pub type DistanceFunc<T> = fn(&[T], &[T]) -> f32;
148pub type BatchDistanceFunc = fn(&[f32], &[f32], usize) -> Arc<Float32Array>;
149pub type ArrowBatchDistanceFunc = fn(&dyn Array, &FixedSizeListArray) -> Result<Arc<Float32Array>>;
150
151impl DistanceType {
152    /// Compute the distance from one vector to a batch of vectors.
153    ///
154    /// This propagates nulls to the output.
155    pub fn arrow_batch_func(&self) -> ArrowBatchDistanceFunc {
156        match self {
157            Self::L2 => l2_distance_arrow_batch,
158            Self::Cosine => cosine_distance_arrow_batch,
159            Self::Dot => dot_distance_arrow_batch,
160            Self::Hamming => hamming_distance_arrow_batch,
161        }
162    }
163
164    /// Returns the distance function between two vectors.
165    pub fn func<T: L2 + Cosine + Dot>(&self) -> DistanceFunc<T> {
166        match self {
167            Self::L2 => l2,
168            Self::Cosine => cosine_distance,
169            Self::Dot => dot_distance,
170            Self::Hamming => todo!(),
171        }
172    }
173}
174
175impl std::fmt::Display for DistanceType {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        write!(
178            f,
179            "{}",
180            match self {
181                Self::L2 => "l2",
182                Self::Cosine => "cosine",
183                Self::Dot => "dot",
184                Self::Hamming => "hamming",
185            }
186        )
187    }
188}
189
190impl TryFrom<&str> for DistanceType {
191    type Error = ArrowError;
192
193    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
194        match s.to_lowercase().as_str() {
195            "l2" | "euclidean" => Ok(Self::L2),
196            "cosine" => Ok(Self::Cosine),
197            "dot" => Ok(Self::Dot),
198            "hamming" => Ok(Self::Hamming),
199            _ => Err(ArrowError::InvalidArgumentError(format!(
200                "Metric type '{s}' is not supported"
201            ))),
202        }
203    }
204}
205
206pub fn multivec_distance(
207    query: &dyn Array,
208    vectors: &ListArray,
209    distance_type: DistanceType,
210) -> Result<Vec<f32>> {
211    let dim = if let DataType::FixedSizeList(_, dim) = vectors.value_type() {
212        dim as usize
213    } else {
214        return Err(ArrowError::InvalidArgumentError(
215            "vectors must be a list of fixed size list".to_string(),
216        ));
217    };
218
219    // check the query vectors type first
220    // because we don't want to check the vectors type for each vector
221    match query.data_type() {
222        DataType::Float16 | DataType::Float32 | DataType::Float64 | DataType::UInt8 => {}
223        _ => {
224            return Err(ArrowError::InvalidArgumentError(
225                "query must be a float array or binary array".to_string(),
226            ));
227        }
228    }
229
230    let mut dists = Vec::with_capacity(vectors.len());
231    for v in vectors.iter() {
232        match v {
233            None => dists.push(f32::NAN),
234            Some(v) => {
235                let multivector = v.as_fixed_size_list();
236                if multivector.len() == 0 {
237                    dists.push(f32::NAN);
238                    continue;
239                }
240
241                let sim = match distance_type {
242                    DistanceType::Hamming => {
243                        let query = query.as_primitive::<UInt8Type>().values();
244                        query
245                            .chunks_exact(dim)
246                            .map(|q| {
247                                multivector
248                                    .values()
249                                    .as_primitive::<UInt8Type>()
250                                    .values()
251                                    .chunks_exact(dim)
252                                    .map(|v| hamming::hamming(q, v))
253                                    .min_by(|a, b| a.partial_cmp(b).unwrap())
254                                    .unwrap()
255                            })
256                            .sum()
257                    }
258                    _ => match query.data_type() {
259                        DataType::Float16 => multivec_distance_impl::<Float16Type>(
260                            query,
261                            multivector,
262                            dim,
263                            distance_type,
264                        ),
265                        DataType::Float32 => multivec_distance_impl::<Float32Type>(
266                            query,
267                            multivector,
268                            dim,
269                            distance_type,
270                        ),
271                        DataType::Float64 => multivec_distance_impl::<Float64Type>(
272                            query,
273                            multivector,
274                            dim,
275                            distance_type,
276                        ),
277                        _ => unreachable!("missed to check query type"),
278                    },
279                };
280
281                dists.push(1.0 - sim);
282            }
283        }
284    }
285    Ok(dists)
286}
287
288fn multivec_distance_impl<T: ArrowPrimitiveType>(
289    query: &dyn Array,
290    multivector: &FixedSizeListArray,
291    dim: usize,
292    distance_type: DistanceType,
293) -> f32
294where
295    T::Native: L2 + Cosine + Dot,
296{
297    let query = query.as_primitive::<T>().values();
298    query
299        .chunks_exact(dim)
300        .map(|q| {
301            multivector
302                .values()
303                .as_primitive::<T>()
304                .values()
305                .chunks_exact(dim)
306                .map(|v| 1.0 - distance_type.func()(q, v))
307                .max_by(|a, b| a.total_cmp(b))
308                .unwrap()
309        })
310        .sum()
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    use std::sync::Arc;
318
319    use arrow_array::types::Float32Type;
320    use arrow_array::{Float32Array, ListArray};
321    use arrow_buffer::OffsetBuffer;
322    use arrow_schema::Field;
323
324    #[test]
325    fn test_multivec_distance_empty_row_is_nan() {
326        let query: Arc<dyn Array> = Arc::new(Float32Array::from_iter_values([1.0_f32, 2.0]));
327
328        let dim = 2;
329        let values = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
330            vec![Some(vec![Some(1.0_f32), Some(2.0)])],
331            dim,
332        );
333
334        // Two rows: first is empty list, second has one sub-vector.
335        let offsets = OffsetBuffer::from_lengths([0_usize, 1]);
336        let field = Arc::new(Field::new("item", values.data_type().clone(), true));
337        let vectors = ListArray::try_new(field, offsets, Arc::new(values), None).unwrap();
338
339        let dists = multivec_distance(query.as_ref(), &vectors, DistanceType::Dot).unwrap();
340        assert_eq!(dists.len(), 2);
341        assert!(dists[0].is_nan());
342        assert_eq!(dists[1], -4.0);
343    }
344}