mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-25 00:24:19 +00:00
* perf(hnsw): 4-accumulator AVX-512 kernels + SIMD wiring into search hot path - Replace single-accumulator AVX-512 distance kernels with 4-accumulator versions in simd_intrinsics.rs (euclidean, cosine, dot, manhattan). On Zen 5 with 4-cycle FMA latency, single-accumulator was latency-bound (96 cycles for 384-dim); 4-accumulator hides this to ~24 cycles. - Wire HNSW search hot path in DistanceFn::eval to call simd_intrinsics directly (inline, no Result wrapping, no simsimd FFI overhead). - Enable parallel batch insert via hnsw_rs::parallel_insert_slice (rayon). Measured: 6-10% QPS improvement on 128-dim/1K-vector bench; larger gains expected on 1M-vector workloads where distance computation dominates. 228 unit tests pass. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf * perf(hnsw): gate parallel_insert_slice behind 10K-vector threshold Rayon-based parallel insert (hnsw_rs::parallel_insert_slice) degrades graph connectivity for small batches (<10K vectors) because worker threads can't see each other's in-flight insertions, reducing optimal neighbor links. Add PARALLEL_THRESHOLD=10_000: use parallel insert only when the batch is large enough that the graph quality converges. Below threshold: sequential insert_data (same as before this PR). Above threshold: parallel_insert_slice for build-time speedup. 228 unit tests pass. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf * bench(sift1m): add SIFT-1M fvecs benchmark + hnswlib comparison tooling Adds two benchmark binaries driven by the real TEXMEX SIFT-1M dataset: * crates/ruvector-sota-bench/src/bin/sift1m_bench.rs Reads sift_base.fvecs / sift_query.fvecs / sift_groundtruth.ivecs directly (no HDF5 required). Sweeps ef_search to produce a recall@10 vs QPS table used for before/after PR #619 comparison. * scripts/sift1m_hnswlib_bench.mjs Same sweep via hnswlib-node (C++ HNSW) to measure the competitive gap. Cargo.toml: add simd-avx512 feature to sota-bench dependency so the full optimised kernel path is exercised. Measured on AMD Ryzen 9 9950X (Zen 5, AVX-512), M=16, efC=200, 1M vecs: Source Build ef=100 recall ef=100 QPS ef=200 recall ef=200 QPS before PR 849 s 0.9585 1,849 0.9713 1,058 after PR (#619) 774 s 0.9592 1,768 0.9722 1,024 hnswlib-node 322 s 0.9828 5,339 0.9957 2,897 Build speedup: +9.7 %. Query QPS at 1M-scale: within noise (memory- bandwidth bound, not compute-bound). Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf * style: cargo fmt for sift1m benchmark binary Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf --------- Co-authored-by: ruvnet <ruvnet@gmail.com>
178 lines
6.1 KiB
JavaScript
178 lines
6.1 KiB
JavaScript
/**
|
||
* SIFT-1M HNSW benchmark using hnswlib-node for comparison against ruvector.
|
||
*
|
||
* Usage:
|
||
* node scripts/sift1m_hnswlib_bench.mjs [data_dir] [corpus_limit] [query_limit]
|
||
*
|
||
* Reads from fvecs/ivecs files directly (no HDF5 needed).
|
||
* Sweeps ef_search at [20, 40, 80, 100, 200, 400] to produce recall@10 vs QPS.
|
||
*/
|
||
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { createRequire } from 'module';
|
||
const require = createRequire(import.meta.url);
|
||
const { HierarchicalNSW } = require('hnswlib-node');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// fvecs / ivecs readers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function readFvecs(filePath, maxVecs = Infinity) {
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const vecs = [];
|
||
let dims = 0;
|
||
const dimBuf = Buffer.allocUnsafe(4);
|
||
|
||
while (vecs.length < maxVecs) {
|
||
const bytesRead = fs.readSync(fd, dimBuf, 0, 4, null);
|
||
if (bytesRead < 4) break;
|
||
const d = dimBuf.readUInt32LE(0);
|
||
if (dims === 0) dims = d;
|
||
else if (d !== dims) throw new Error(`Inconsistent dims: expected ${dims}, got ${d}`);
|
||
|
||
const vecBuf = Buffer.allocUnsafe(d * 4);
|
||
fs.readSync(fd, vecBuf, 0, d * 4, null);
|
||
const vec = new Array(d);
|
||
for (let i = 0; i < d; i++) vec[i] = vecBuf.readFloatLE(i * 4);
|
||
vecs.push(vec);
|
||
}
|
||
fs.closeSync(fd);
|
||
return { vecs, dims };
|
||
}
|
||
|
||
function readIvecs(filePath, maxVecs = Infinity) {
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const vecs = [];
|
||
let dims = 0;
|
||
const dimBuf = Buffer.allocUnsafe(4);
|
||
|
||
while (vecs.length < maxVecs) {
|
||
const bytesRead = fs.readSync(fd, dimBuf, 0, 4, null);
|
||
if (bytesRead < 4) break;
|
||
const d = dimBuf.readUInt32LE(0);
|
||
if (dims === 0) dims = d;
|
||
else if (d !== dims) throw new Error(`Inconsistent dims in ivecs`);
|
||
|
||
const vecBuf = Buffer.allocUnsafe(d * 4);
|
||
fs.readSync(fd, vecBuf, 0, d * 4, null);
|
||
const vec = new Int32Array(d);
|
||
for (let i = 0; i < d; i++) vec[i] = vecBuf.readInt32LE(i * 4);
|
||
vecs.push(vec);
|
||
}
|
||
fs.closeSync(fd);
|
||
return { vecs, dims };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recall@k
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function recallAtK(resultIds, groundTruth, k) {
|
||
const gt = new Set(Array.from(groundTruth).slice(0, k));
|
||
let hits = 0;
|
||
const limit = Math.min(resultIds.length, k);
|
||
for (let i = 0; i < limit; i++) {
|
||
if (gt.has(resultIds[i])) hits++;
|
||
}
|
||
return hits / Math.min(k, gt.size);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const args = process.argv.slice(2);
|
||
const dataDir = args[0] || 'bench_data/sift';
|
||
const corpusLimit = parseInt(args[1] || '1000000', 10);
|
||
const queryLimit = parseInt(args[2] || '10000', 10);
|
||
const M = 16;
|
||
const efConstruction = 200;
|
||
const k = 10;
|
||
const efValues = [20, 40, 80, 100, 200, 400];
|
||
|
||
console.log('=== SIFT-1M HNSW Benchmark (hnswlib-node) ===');
|
||
console.log(`Data dir : ${dataDir}`);
|
||
console.log(`Corpus cap : ${corpusLimit}`);
|
||
console.log(`Query cap : ${queryLimit}`);
|
||
console.log(`M : ${M}`);
|
||
console.log(`efConstruct: ${efConstruction}`);
|
||
console.log(`k : ${k}`);
|
||
console.log(`ef sweep : ${efValues.join(',')}`);
|
||
console.log();
|
||
|
||
// Load data
|
||
process.stdout.write('Loading corpus... ');
|
||
let t = Date.now();
|
||
const { vecs: corpus, dims } = readFvecs(path.join(dataDir, 'sift_base.fvecs'), corpusLimit);
|
||
console.log(`${corpus.length} vectors × ${dims}d (${((Date.now()-t)/1000).toFixed(1)}s)`);
|
||
|
||
process.stdout.write('Loading queries... ');
|
||
t = Date.now();
|
||
const { vecs: queries } = readFvecs(path.join(dataDir, 'sift_query.fvecs'), queryLimit);
|
||
console.log(`${queries.length} vectors × ${dims}d (${((Date.now()-t)/1000).toFixed(1)}s)`);
|
||
|
||
process.stdout.write('Loading ground truth... ');
|
||
t = Date.now();
|
||
const { vecs: groundTruth } = readIvecs(path.join(dataDir, 'sift_groundtruth.ivecs'), queryLimit);
|
||
console.log(`${groundTruth.length} lists (${((Date.now()-t)/1000).toFixed(1)}s)`);
|
||
console.log();
|
||
|
||
// Build index
|
||
console.log(`Building HNSW index (M=${M}, efC=${efConstruction})...`);
|
||
const hnsw = new HierarchicalNSW('l2', dims);
|
||
hnsw.initIndex(corpusLimit, M, efConstruction, 0 /* seed */);
|
||
|
||
const tBuild = Date.now();
|
||
for (let i = 0; i < corpus.length; i++) {
|
||
hnsw.addPoint(corpus[i], i);
|
||
if (i > 0 && i % 100_000 === 0) {
|
||
const elapsed = (Date.now() - tBuild) / 1000;
|
||
const rate = i / elapsed;
|
||
console.log(` Inserted ${i} (${rate.toFixed(0)} vec/s, ${elapsed.toFixed(1)}s elapsed)`);
|
||
}
|
||
}
|
||
const buildSecs = (Date.now() - tBuild) / 1000;
|
||
const buildRate = corpus.length / buildSecs;
|
||
const memMB = (corpus.length * dims * 4) / (1024 * 1024) * 1.5;
|
||
console.log(`Build done: ${buildSecs.toFixed(1)}s (${buildRate.toFixed(0)} vec/s, ${memMB.toFixed(0)} MB estimated)`);
|
||
console.log();
|
||
|
||
// ef_search sweep
|
||
const header = 'ef'.padEnd(8) + ' ' + 'recall@10'.padStart(10) + ' ' + 'QPS'.padStart(10) + ' ' + 'p50_us'.padStart(12) + ' ' + 'p99_us'.padStart(10);
|
||
console.log(header);
|
||
console.log('-'.repeat(58));
|
||
|
||
for (const ef of efValues) {
|
||
hnsw.setEf(ef);
|
||
const latenciesNs = [];
|
||
let recallSum = 0;
|
||
|
||
for (let qi = 0; qi < queries.length; qi++) {
|
||
const tq = process.hrtime.bigint();
|
||
const result = hnsw.searchKnn(queries[qi], k);
|
||
const elapsedNs = Number(process.hrtime.bigint() - tq);
|
||
latenciesNs.push(elapsedNs);
|
||
|
||
recallSum += recallAtK(result.neighbors, groundTruth[qi], k);
|
||
}
|
||
|
||
const meanRecall = recallSum / queries.length;
|
||
const totalS = latenciesNs.reduce((a, b) => a + b, 0) / 1e9;
|
||
const qps = queries.length / totalS;
|
||
|
||
const sorted = [...latenciesNs].sort((a, b) => a - b);
|
||
const p50Us = sorted[Math.floor(sorted.length * 0.50)] / 1000;
|
||
const p99Us = sorted[Math.floor(sorted.length * 0.99)] / 1000;
|
||
|
||
console.log(
|
||
String(ef).padEnd(8) + ' ' +
|
||
meanRecall.toFixed(4).padStart(10) + ' ' +
|
||
qps.toFixed(0).padStart(10) + ' ' +
|
||
p50Us.toFixed(1).padStart(12) + ' ' +
|
||
p99Us.toFixed(1).padStart(10)
|
||
);
|
||
}
|
||
|
||
console.log();
|
||
console.log('Done.');
|