diff --git a/src/utils/distance.ts b/src/utils/distance.ts index 524f2837..36e9e8e5 100644 --- a/src/utils/distance.ts +++ b/src/utils/distance.ts @@ -1,58 +1,60 @@ /** - * Distance functions for vector similarity calculations - * Optimized pure JavaScript implementations using enhanced array methods - * Faster than GPU for small vectors (384 dims) due to no transfer overhead + * Distance functions for vector similarity calculations. + * + * Pure-JavaScript implementations using allocation-free indexed loops: a single + * pass over the two vectors with scalar accumulators and no per-element closures + * or intermediate objects. This is the open-core distance path (the native + * provider owns the SIMD/quantized billion-scale path); for the small/medium + * vectors it serves (e.g. 384-dim sentence embeddings) a tight loop keeps the + * whole computation in registers with zero GC pressure. + * + * MEASURED (tests/benchmarks/distance-microbench.mjs, dim=384, N=20000, median + * of 41): rewriting cosine from an object-accumulating `reduce` to this loop is + * ~6x on `number[]`; euclidean ~1.4x. (`number[]` is also measurably faster than + * `Float32Array` here — V8 widens f32→f64 on every element read — so the + * resident representation stays `number[]`.) */ import { DistanceFunction, Vector } from '../coreTypes.js' /** - * Calculates the Euclidean distance between two vectors - * Lower values indicate higher similarity - * Optimized using array methods for Node.js 23.11+ + * Calculates the Euclidean (L2) distance between two vectors. + * Lower values indicate higher similarity. */ -export const euclideanDistance: DistanceFunction = ( - a: Vector, - b: Vector -): number => { +export const euclideanDistance: DistanceFunction = (a: Vector, b: Vector): number => { if (a.length !== b.length) { throw new Error('Vectors must have the same dimensions') } - // Use array.reduce for better performance in Node.js 23.11+ - const sum = a.reduce((acc, val, i) => { - const diff = val - b[i] - return acc + diff * diff - }, 0) - + let sum = 0 + const len = a.length + for (let i = 0; i < len; i++) { + const diff = a[i] - b[i] + sum += diff * diff + } return Math.sqrt(sum) } /** - * Calculates the cosine distance between two vectors - * Lower values indicate higher similarity - * Range: 0 (identical) to 2 (opposite) - * Optimized using array methods for Node.js 23.11+ + * Calculates the cosine distance between two vectors. + * Lower values indicate higher similarity. Range: 0 (identical) to 2 (opposite). */ -export const cosineDistance: DistanceFunction = ( - a: Vector, - b: Vector -): number => { +export const cosineDistance: DistanceFunction = (a: Vector, b: Vector): number => { if (a.length !== b.length) { throw new Error('Vectors must have the same dimensions') } - // Use array.reduce to calculate all values in a single pass - const { dotProduct, normA, normB } = a.reduce( - (acc, val, i) => { - return { - dotProduct: acc.dotProduct + val * b[i], - normA: acc.normA + val * val, - normB: acc.normB + b[i] * b[i] - } - }, - { dotProduct: 0, normA: 0, normB: 0 } - ) + let dotProduct = 0 + let normA = 0 + let normB = 0 + const len = a.length + for (let i = 0; i < len; i++) { + const av = a[i] + const bv = b[i] + dotProduct += av * bv + normA += av * av + normB += bv * bv + } if (normA === 0 || normB === 0) { return 2 // Maximum distance for zero vectors @@ -64,150 +66,60 @@ export const cosineDistance: DistanceFunction = ( } /** - * Calculates the Manhattan (L1) distance between two vectors - * Lower values indicate higher similarity - * Optimized using array methods for Node.js 23.11+ + * Calculates the Manhattan (L1) distance between two vectors. + * Lower values indicate higher similarity. */ -export const manhattanDistance: DistanceFunction = ( - a: Vector, - b: Vector -): number => { +export const manhattanDistance: DistanceFunction = (a: Vector, b: Vector): number => { if (a.length !== b.length) { throw new Error('Vectors must have the same dimensions') } - // Use array.reduce for better performance in Node.js 23.11+ - return a.reduce((sum, val, i) => sum + Math.abs(val - b[i]), 0) + let sum = 0 + const len = a.length + for (let i = 0; i < len; i++) { + sum += Math.abs(a[i] - b[i]) + } + return sum } /** - * Calculates the dot product similarity between two vectors - * Higher values indicate higher similarity - * Converted to a distance metric (lower is better) - * Optimized using array methods for Node.js 23.11+ + * Calculates the dot-product similarity between two vectors, negated to a + * distance metric (lower is better). */ -export const dotProductDistance: DistanceFunction = ( - a: Vector, - b: Vector -): number => { +export const dotProductDistance: DistanceFunction = (a: Vector, b: Vector): number => { if (a.length !== b.length) { throw new Error('Vectors must have the same dimensions') } - // Use array.reduce for better performance in Node.js 23.11+ - const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0) - - // Convert to a distance metric (lower is better) + let dotProduct = 0 + const len = a.length + for (let i = 0; i < len; i++) { + dotProduct += a[i] * b[i] + } return -dotProduct } /** - * Batch distance calculation using optimized JavaScript - * More efficient than GPU for small vectors due to no memory transfer overhead + * Batch distance calculation: the query vector against each candidate. * - * @param queryVector The query vector to compare against all vectors - * @param vectors Array of vectors to compare against - * @param distanceFunction The distance function to use - * @returns Promise resolving to array of distances + * With the distance functions now allocation-free indexed loops, this is a thin + * map over the (monomorphic, JIT-inlined) `distanceFunction` — no worker, no + * stringify/`new Function` reconstruction. Kept `async` for call-site + * compatibility with the HNSW search path. + * + * @param queryVector The query vector to compare against all candidates. + * @param vectors The candidate vectors. + * @param distanceFunction The distance function to use (default: Euclidean). + * @returns The distances, index-aligned with `vectors`. */ export async function calculateDistancesBatch( queryVector: Vector, vectors: Vector[], distanceFunction: DistanceFunction = euclideanDistance ): Promise { - // For small batches, use the standard distance function - if (vectors.length < 10) { - return vectors.map((vector) => distanceFunction(queryVector, vector)) - } - - try { - // Function for optimized batch distance calculation - const distanceCalculator = (args: { - queryVector: Vector - vectors: Vector[] - distanceFnString: string - }) => { - const { queryVector, vectors, distanceFnString } = args - - // Optimized JavaScript implementations for different distance functions - let distances: number[] - - if (distanceFnString.includes('euclideanDistance')) { - // Euclidean distance: sqrt(sum((a - b)^2)) - distances = vectors.map((vector) => { - let sum = 0 - for (let i = 0; i < queryVector.length; i++) { - const diff = queryVector[i] - vector[i] - sum += diff * diff - } - return Math.sqrt(sum) - }) - } else if (distanceFnString.includes('cosineDistance')) { - // Cosine distance: 1 - (a·b / (||a|| * ||b||)) - distances = vectors.map((vector) => { - let dotProduct = 0 - let queryNorm = 0 - let vectorNorm = 0 - - for (let i = 0; i < queryVector.length; i++) { - dotProduct += queryVector[i] * vector[i] - queryNorm += queryVector[i] * queryVector[i] - vectorNorm += vector[i] * vector[i] - } - - queryNorm = Math.sqrt(queryNorm) - vectorNorm = Math.sqrt(vectorNorm) - - if (queryNorm === 0 || vectorNorm === 0) { - return 1 // Maximum distance for zero vectors - } - - const cosineSimilarity = dotProduct / (queryNorm * vectorNorm) - return 1 - cosineSimilarity - }) - } else if (distanceFnString.includes('manhattanDistance')) { - // Manhattan distance: sum(|a - b|) - distances = vectors.map((vector) => { - let sum = 0 - for (let i = 0; i < queryVector.length; i++) { - sum += Math.abs(queryVector[i] - vector[i]) - } - return sum - }) - } else if (distanceFnString.includes('dotProductDistance')) { - // Dot product distance: -sum(a * b) - distances = vectors.map((vector) => { - let dotProduct = 0 - for (let i = 0; i < queryVector.length; i++) { - dotProduct += queryVector[i] * vector[i] - } - return -dotProduct - }) - } else { - // For unknown distance functions, use the provided function - const distanceFunction = new Function( - 'return ' + distanceFnString - )() as DistanceFunction - - distances = vectors.map((vector) => - distanceFunction(queryVector, vector) - ) - } - - return { distances } - } - - // Use the optimized distance calculator - const result = distanceCalculator({ - queryVector, - vectors, - distanceFnString: distanceFunction.toString() - }) - - return result.distances - } catch (error) { - // If anything fails, fall back to the standard distance function - console.error('Batch distance calculation failed:', error) - return vectors.map((vector) => distanceFunction(queryVector, vector)) + const out = new Array(vectors.length) + for (let i = 0; i < vectors.length; i++) { + out[i] = distanceFunction(queryVector, vectors[i]) } + return out } diff --git a/tests/benchmarks/distance-microbench.mjs b/tests/benchmarks/distance-microbench.mjs new file mode 100644 index 00000000..a3119a90 --- /dev/null +++ b/tests/benchmarks/distance-microbench.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Distance microbenchmark — measures the vector-distance hot path in isolation. + * + * Compares the reduce-based implementations (current `src/utils/distance.ts`) + * against allocation-free indexed for-loops, on both `number[]` and + * `Float32Array`, to establish MEASURED evidence for the Float32Array Fork X + * change. Per the evidence-based-claims rule, no % is published without this. + * + * Run: node tests/benchmarks/distance-microbench.mjs + */ + +const DIM = 384 +const N = 20000 // candidate vectors compared per pass +const ITERS = 41 // repeat passes; report the median (robust to GC blips) + +// --- reduce-based (current distance.ts) --- +const cosineReduce = (a, b) => { + const { dotProduct, normA, normB } = a.reduce( + (acc, val, i) => ({ + dotProduct: acc.dotProduct + val * b[i], + normA: acc.normA + val * val, + normB: acc.normB + b[i] * b[i] + }), + { dotProduct: 0, normA: 0, normB: 0 } + ) + if (normA === 0 || normB === 0) return 2 + return 1 - dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)) +} +const euclideanReduce = (a, b) => { + const sum = a.reduce((acc, val, i) => { + const d = val - b[i] + return acc + d * d + }, 0) + return Math.sqrt(sum) +} + +// --- allocation-free indexed for-loop (proposed) --- +const cosineLoop = (a, b) => { + let dot = 0, + na = 0, + nb = 0 + const len = a.length + for (let i = 0; i < len; i++) { + const av = a[i] + const bv = b[i] + dot += av * bv + na += av * av + nb += bv * bv + } + if (na === 0 || nb === 0) return 2 + return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb)) +} +const euclideanLoop = (a, b) => { + let sum = 0 + const len = a.length + for (let i = 0; i < len; i++) { + const d = a[i] - b[i] + sum += d * d + } + return Math.sqrt(sum) +} + +function makeVectors(Ctor) { + const alloc = () => (Ctor === Array ? new Array(DIM) : new Ctor(DIM)) + const q = alloc() + for (let i = 0; i < DIM; i++) q[i] = Math.sin(i * 0.1) * 0.5 + Math.cos(i * 0.03) * 0.5 + const vs = [] + for (let n = 0; n < N; n++) { + const v = alloc() + for (let i = 0; i < DIM; i++) v[i] = Math.sin((n + i) * 0.07) + vs.push(v) + } + return { q, vs } +} + +let sink = 0 +function bench(name, fn, q, vs) { + for (let w = 0; w < 3; w++) for (let i = 0; i < vs.length; i++) sink += fn(q, vs[i]) + const times = [] + for (let it = 0; it < ITERS; it++) { + const t0 = process.hrtime.bigint() + for (let i = 0; i < vs.length; i++) sink += fn(q, vs[i]) + times.push(Number(process.hrtime.bigint() - t0) / 1e6) + } + times.sort((a, b) => a - b) + const median = times[Math.floor(times.length / 2)] + const opsPerSec = (vs.length / median) * 1000 + console.log(` ${name.padEnd(28)} ${median.toFixed(2).padStart(8)} ms ${(opsPerSec / 1e6).toFixed(2).padStart(6)} M ops/s`) + return median +} + +console.log(`Distance microbench — dim=${DIM}, N=${N}, iters=${ITERS} (median)\n`) +for (const [label, Ctor] of [ + ['number[]', Array], + ['Float32Array', Float32Array] +]) { + const { q, vs } = makeVectors(Ctor) + console.log(`--- ${label} ---`) + const cr = bench('cosine reduce (current)', cosineReduce, q, vs) + const cl = bench('cosine for-loop', cosineLoop, q, vs) + const er = bench('euclid reduce (current)', euclideanReduce, q, vs) + const el = bench('euclid for-loop', euclideanLoop, q, vs) + console.log(` → cosine for-loop is ${(cr / cl).toFixed(2)}x, euclid for-loop is ${(er / el).toFixed(2)}x\n`) +} +if (sink === Infinity) console.log('(unreachable guard)')