ruvector/scripts/sift1m_hnswlib_bench.mjs
rUv feb4ee2753
perf(hnsw): 4-acc AVX-512 + parallel-insert — +9.7% build throughput (query QPS unchanged: memory-bound at 1M scale) (#619)
* 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>
2026-06-28 20:45:35 -04:00

178 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.');