Files
proffesor-for-testing__agen…/scripts/benchmark-hnsw-loading.ts
Profa 2253d05618 feat: flatten project structure — archive v2, promote v3 to root
Remove the nested v3/ subdirectory by moving all active source code,
tests, configs, and assets directly to the project root. Archive v2/
(dead code, 847MB) to v2-archive.tar.gz.

Key changes:
- Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/
- Move v3/tsconfig.json, v3/vitest.config.ts to root
- Merge v3/package.json into root package.json (ESM, scripts, deps, exports)
- Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/
- Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes
- Fix all init installer path resolution (3-level-up → 2-level-up)
- Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe"
- Update settings.json ADR/DDD directories to docs/implementation/
- Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks)
- Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces
- Update 15 guidance shards: v3/src/ → src/ path references
- Fix sync interfaces: remove v3/ and v2/ path references
- Fix test path calculations (process.cwd() + '..' no longer needed)
- Fix flaky perf test threshold (500ms → 1000ms for CI environments)
- Fix complexity-analyzer test to match ADR-051 keyword-based eligibility

Build passes, 17,901 tests pass, CLI and MCP bundles verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:35:41 +00:00

543 lines
18 KiB
TypeScript
Raw Permalink 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.
/**
* HNSW Loading Benchmark
*
* Compares vector index build performance across implementations:
* 1. Pure JS InMemoryHNSWIndex (current, efConstruction=200)
* 2. Pure JS InMemoryHNSWIndex with reduced efConstruction=50
* 3. @ruvector/gnn differentiableSearch (Rust/NAPI)
* 4. Optimized JS with Float32Array deserialization
*
* Same scenario for all: 5073 vectors, 768 dimensions, cosine similarity.
* Measures: build time, search time, memory usage, recall quality.
*/
import { performance } from 'perf_hooks';
// ============================================================================
// Scenario Configuration (identical for all implementations)
// ============================================================================
const NUM_VECTORS = 5073;
const DIMENSIONS = 768;
const NUM_SEARCHES = 100;
const SEARCH_K = 5;
// ============================================================================
// Generate deterministic test data (same seed for all)
// ============================================================================
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xFFFFFFFF;
return (s >>> 0) / 0xFFFFFFFF;
};
}
function generateVectors(count: number, dim: number): { ids: string[]; embeddings: number[][] } {
const rand = seededRandom(42);
const ids: string[] = [];
const embeddings: number[][] = [];
for (let i = 0; i < count; i++) {
ids.push(`code-intelligence:kg:node:${i}`);
const vec: number[] = new Array(dim);
let norm = 0;
for (let d = 0; d < dim; d++) {
vec[d] = rand() * 2 - 1;
norm += vec[d] * vec[d];
}
// Normalize for cosine similarity
norm = Math.sqrt(norm);
for (let d = 0; d < dim; d++) vec[d] /= norm;
embeddings.push(vec);
}
return { ids, embeddings };
}
function generateQueries(count: number, dim: number): number[][] {
const rand = seededRandom(12345);
const queries: number[][] = [];
for (let i = 0; i < count; i++) {
const vec: number[] = new Array(dim);
let norm = 0;
for (let d = 0; d < dim; d++) {
vec[d] = rand() * 2 - 1;
norm += vec[d] * vec[d];
}
norm = Math.sqrt(norm);
for (let d = 0; d < dim; d++) vec[d] /= norm;
queries.push(vec);
}
return queries;
}
// Simulate SQLite buffer format (Float32LE)
function vectorToBuffer(vec: number[]): Buffer {
const buf = Buffer.alloc(vec.length * 4);
for (let i = 0; i < vec.length; i++) buf.writeFloatLE(vec[i], i * 4);
return buf;
}
// Current slow deserialization
function bufferToFloatArraySlow(buffer: Buffer, dimensions: number): number[] {
const arr: number[] = [];
for (let i = 0; i < dimensions; i++) arr.push(buffer.readFloatLE(i * 4));
return arr;
}
// Optimized deserialization via Float32Array view
function bufferToFloatArrayFast(buffer: Buffer, dimensions: number): number[] {
const f32 = new Float32Array(buffer.buffer, buffer.byteOffset, dimensions);
return Array.from(f32);
}
// ============================================================================
// Brute-force ground truth for recall measurement
// ============================================================================
function cosineSim(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
function bruteForceTopK(query: number[], embeddings: number[][], ids: string[], k: number): string[] {
const scored = ids.map((id, i) => ({ id, score: cosineSim(query, embeddings[i]) }));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k).map(s => s.id);
}
function recallAtK(predicted: string[], actual: string[]): number {
const actualSet = new Set(actual);
let hits = 0;
for (const p of predicted) {
if (actualSet.has(p)) hits++;
}
return hits / actual.length;
}
// ============================================================================
// Implementation 1: Pure JS HNSW (current)
// ============================================================================
async function benchmarkPureJS(
label: string,
efConstruction: number,
buffers: Buffer[],
ids: string[],
embeddings: number[][],
queries: number[][],
groundTruth: string[][]
) {
// Import from compiled output
const { InMemoryHNSWIndex } = await import('../dist/kernel/unified-memory-hnsw.js');
const index = new InMemoryHNSWIndex();
if (efConstruction !== 200) {
(index as any).efConstruction = efConstruction;
}
const memBefore = process.memoryUsage().heapUsed;
// Measure deserialization + build
const buildStart = performance.now();
for (let i = 0; i < buffers.length; i++) {
const embedding = bufferToFloatArraySlow(buffers[i], DIMENSIONS);
index.add(ids[i], embedding);
}
const buildMs = performance.now() - buildStart;
const memAfter = process.memoryUsage().heapUsed;
// Measure search
const searchStart = performance.now();
const searchResults: string[][] = [];
for (const q of queries) {
const results = index.search(q, SEARCH_K);
searchResults.push(results.map(r => r.id));
}
const searchMs = performance.now() - searchStart;
// Compute recall
let totalRecall = 0;
for (let i = 0; i < queries.length; i++) {
totalRecall += recallAtK(searchResults[i], groundTruth[i]);
}
const avgRecall = totalRecall / queries.length;
return {
label,
buildMs: Math.round(buildMs),
searchMs: Math.round(searchMs),
avgSearchMs: (searchMs / queries.length).toFixed(2),
memoryMB: Math.round((memAfter - memBefore) / 1024 / 1024),
recall: (avgRecall * 100).toFixed(1),
indexSize: index.size(),
};
}
// ============================================================================
// Implementation 2: Pure JS with Fast Deserialization
// ============================================================================
async function benchmarkPureJSFastDeser(
buffers: Buffer[],
ids: string[],
embeddings: number[][],
queries: number[][],
groundTruth: string[][]
) {
const { InMemoryHNSWIndex } = await import('../dist/kernel/unified-memory-hnsw.js');
const index = new InMemoryHNSWIndex();
const memBefore = process.memoryUsage().heapUsed;
const buildStart = performance.now();
for (let i = 0; i < buffers.length; i++) {
const embedding = bufferToFloatArrayFast(buffers[i], DIMENSIONS);
index.add(ids[i], embedding);
}
const buildMs = performance.now() - buildStart;
const memAfter = process.memoryUsage().heapUsed;
const searchStart = performance.now();
const searchResults: string[][] = [];
for (const q of queries) {
const results = index.search(q, SEARCH_K);
searchResults.push(results.map(r => r.id));
}
const searchMs = performance.now() - searchStart;
let totalRecall = 0;
for (let i = 0; i < queries.length; i++) {
totalRecall += recallAtK(searchResults[i], groundTruth[i]);
}
return {
label: 'Pure JS (efC=200) + Float32Array deser',
buildMs: Math.round(buildMs),
searchMs: Math.round(searchMs),
avgSearchMs: (searchMs / queries.length).toFixed(2),
memoryMB: Math.round((memAfter - memBefore) / 1024 / 1024),
recall: ((totalRecall / queries.length) * 100).toFixed(1),
indexSize: index.size(),
};
}
// ============================================================================
// Implementation 3: @ruvector/gnn (Rust/NAPI)
// ============================================================================
async function benchmarkRuvector(
buffers: Buffer[],
ids: string[],
embeddings: number[][],
queries: number[][],
groundTruth: string[][]
) {
const { differentiableSearch, init } = await import('@ruvector/gnn');
init();
const memBefore = process.memoryUsage().heapUsed;
// ruvector doesn't have a persistent index - it does brute-force differentiable search
// Build = converting buffers to Float32Arrays (the "index" is just the array of embeddings)
const buildStart = performance.now();
const indexedVectors: Float32Array[] = [];
for (let i = 0; i < buffers.length; i++) {
const f32 = new Float32Array(buffers[i].buffer, buffers[i].byteOffset, DIMENSIONS);
indexedVectors.push(new Float32Array(f32)); // copy since buffer may be reused
}
const buildMs = performance.now() - buildStart;
const memAfter = process.memoryUsage().heapUsed;
// Search
const searchStart = performance.now();
const searchResults: string[][] = [];
for (const q of queries) {
const queryF32 = new Float32Array(q);
const result = differentiableSearch(
queryF32 as unknown as number[],
indexedVectors as unknown as number[][],
SEARCH_K,
1.0
);
searchResults.push(result.indices.map(idx => ids[idx]));
}
const searchMs = performance.now() - searchStart;
let totalRecall = 0;
for (let i = 0; i < queries.length; i++) {
totalRecall += recallAtK(searchResults[i], groundTruth[i]);
}
return {
label: '@ruvector/gnn differentiableSearch (Rust)',
buildMs: Math.round(buildMs),
searchMs: Math.round(searchMs),
avgSearchMs: (searchMs / queries.length).toFixed(2),
memoryMB: Math.round((memAfter - memBefore) / 1024 / 1024),
recall: ((totalRecall / queries.length) * 100).toFixed(1),
indexSize: indexedVectors.length,
};
}
// ============================================================================
// Implementation 4: Optimized JS HNSW (reduced efConstruction + fast deser)
// ============================================================================
async function benchmarkOptimizedJS(
buffers: Buffer[],
ids: string[],
embeddings: number[][],
queries: number[][],
groundTruth: string[][]
) {
const { InMemoryHNSWIndex } = await import('../dist/kernel/unified-memory-hnsw.js');
const index = new InMemoryHNSWIndex();
(index as any).efConstruction = 50;
const memBefore = process.memoryUsage().heapUsed;
const buildStart = performance.now();
for (let i = 0; i < buffers.length; i++) {
const embedding = bufferToFloatArrayFast(buffers[i], DIMENSIONS);
index.add(ids[i], embedding);
}
const buildMs = performance.now() - buildStart;
const memAfter = process.memoryUsage().heapUsed;
const searchStart = performance.now();
const searchResults: string[][] = [];
for (const q of queries) {
const results = index.search(q, SEARCH_K);
searchResults.push(results.map(r => r.id));
}
const searchMs = performance.now() - searchStart;
let totalRecall = 0;
for (let i = 0; i < queries.length; i++) {
totalRecall += recallAtK(searchResults[i], groundTruth[i]);
}
return {
label: 'Optimized JS (efC=50 + Float32Array deser)',
buildMs: Math.round(buildMs),
searchMs: Math.round(searchMs),
avgSearchMs: (searchMs / queries.length).toFixed(2),
memoryMB: Math.round((memAfter - memBefore) / 1024 / 1024),
recall: ((totalRecall / queries.length) * 100).toFixed(1),
indexSize: index.size(),
};
}
// ============================================================================
// Implementation 5: RuvectorFlatIndex (actual integrated implementation)
// ============================================================================
async function benchmarkRuvectorFlatIndex(
buffers: Buffer[],
ids: string[],
embeddings: number[][],
queries: number[][],
groundTruth: string[][]
) {
const { RuvectorFlatIndex } = await import('../dist/kernel/unified-memory-hnsw.js');
const index = new RuvectorFlatIndex();
const memBefore = process.memoryUsage().heapUsed;
// Simulate loadVectorIndex: deserialize buffers + add to index
const buildStart = performance.now();
for (let i = 0; i < buffers.length; i++) {
const f32 = new Float32Array(buffers[i].buffer, buffers[i].byteOffset, DIMENSIONS);
index.add(ids[i], Array.from(f32));
}
const buildMs = performance.now() - buildStart;
const memAfter = process.memoryUsage().heapUsed;
// Search
const searchStart = performance.now();
const searchResults: string[][] = [];
for (const q of queries) {
const results = index.search(q, SEARCH_K);
searchResults.push(results.map((r: any) => r.id));
}
const searchMs = performance.now() - searchStart;
let totalRecall = 0;
for (let i = 0; i < queries.length; i++) {
totalRecall += recallAtK(searchResults[i], groundTruth[i]);
}
return {
label: 'RuvectorFlatIndex [NEW IMPL]',
buildMs: Math.round(buildMs),
searchMs: Math.round(searchMs),
avgSearchMs: (searchMs / queries.length).toFixed(2),
memoryMB: Math.round((memAfter - memBefore) / 1024 / 1024),
recall: ((totalRecall / queries.length) * 100).toFixed(1),
indexSize: index.size(),
};
}
// ============================================================================
// Deserialization-only micro benchmark
// ============================================================================
function benchmarkDeserialization(buffers: Buffer[]) {
// Slow path
const slowStart = performance.now();
for (const buf of buffers) {
bufferToFloatArraySlow(buf, DIMENSIONS);
}
const slowMs = performance.now() - slowStart;
// Fast path
const fastStart = performance.now();
for (const buf of buffers) {
bufferToFloatArrayFast(buf, DIMENSIONS);
}
const fastMs = performance.now() - fastStart;
return { slowMs: Math.round(slowMs), fastMs: Math.round(fastMs) };
}
// ============================================================================
// Main
// ============================================================================
async function main() {
console.log('='.repeat(70));
console.log('HNSW Loading Benchmark');
console.log(`Scenario: ${NUM_VECTORS} vectors × ${DIMENSIONS} dims, ${NUM_SEARCHES} searches, k=${SEARCH_K}`);
console.log('='.repeat(70));
// Generate data
console.log('\nGenerating test data...');
const { ids, embeddings } = generateVectors(NUM_VECTORS, DIMENSIONS);
const queries = generateQueries(NUM_SEARCHES, DIMENSIONS);
// Convert to SQLite buffer format (simulates reading from DB)
const buffers = embeddings.map(vectorToBuffer);
// Compute brute-force ground truth
console.log('Computing ground truth (brute force)...');
const groundTruth = queries.map(q => bruteForceTopK(q, embeddings, ids, SEARCH_K));
// Deserialization micro-benchmark
console.log('\n--- Deserialization Benchmark ---');
const deser = benchmarkDeserialization(buffers);
console.log(` readFloatLE loop: ${deser.slowMs}ms`);
console.log(` Float32Array view: ${deser.fastMs}ms`);
console.log(` Speedup: ${(deser.slowMs / deser.fastMs).toFixed(1)}x`);
// Results table
const results: any[] = [];
// 1. Current implementation (Pure JS, efC=200, slow deser)
console.log('\n[1/6] Pure JS HNSW (efC=200, readFloatLE) — current baseline...');
results.push(await benchmarkPureJS(
'Pure JS (efC=200) + readFloatLE [CURRENT]',
200, buffers, ids, embeddings, queries, groundTruth
));
console.log(` Build: ${results[results.length-1].buildMs}ms`);
// Force GC between tests
if (global.gc) global.gc();
// 2. Pure JS with fast deserialization
console.log('\n[2/6] Pure JS HNSW (efC=200, Float32Array)...');
results.push(await benchmarkPureJSFastDeser(buffers, ids, embeddings, queries, groundTruth));
console.log(` Build: ${results[results.length-1].buildMs}ms`);
if (global.gc) global.gc();
// 3. Optimized JS (efC=50 + fast deser)
console.log('\n[3/6] Optimized JS HNSW (efC=50, Float32Array)...');
results.push(await benchmarkOptimizedJS(buffers, ids, embeddings, queries, groundTruth));
console.log(` Build: ${results[results.length-1].buildMs}ms`);
if (global.gc) global.gc();
// 4. Pure JS with efC=50 but slow deser
console.log('\n[4/6] Pure JS HNSW (efC=50, readFloatLE)...');
results.push(await benchmarkPureJS(
'Pure JS (efC=50) + readFloatLE',
50, buffers, ids, embeddings, queries, groundTruth
));
console.log(` Build: ${results[results.length-1].buildMs}ms`);
if (global.gc) global.gc();
// 5. @ruvector/gnn raw
console.log('\n[5/6] @ruvector/gnn differentiableSearch (Rust/NAPI)...');
try {
results.push(await benchmarkRuvector(buffers, ids, embeddings, queries, groundTruth));
console.log(` Build: ${results[results.length-1].buildMs}ms, Search: ${results[results.length-1].searchMs}ms`);
} catch (e) {
console.log(` SKIPPED: ${(e as Error).message}`);
}
if (global.gc) global.gc();
// 6. RuvectorFlatIndex (actual integrated implementation)
console.log('\n[6/6] RuvectorFlatIndex (integrated, ruvector + cosine scores)...');
try {
results.push(await benchmarkRuvectorFlatIndex(buffers, ids, embeddings, queries, groundTruth));
console.log(` Build: ${results[results.length-1].buildMs}ms, Search: ${results[results.length-1].searchMs}ms`);
} catch (e) {
console.log(` SKIPPED: ${(e as Error).message}`);
}
// Print results table
console.log('\n' + '='.repeat(70));
console.log('RESULTS');
console.log('='.repeat(70));
console.log('');
console.log(
'Implementation'.padEnd(48) +
'Build(ms)'.padStart(10) +
'Search(ms)'.padStart(11) +
'Avg/q(ms)'.padStart(10) +
'Mem(MB)'.padStart(8) +
'Recall%'.padStart(8)
);
console.log('-'.repeat(95));
for (const r of results) {
console.log(
r.label.padEnd(48) +
String(r.buildMs).padStart(10) +
String(r.searchMs).padStart(11) +
String(r.avgSearchMs).padStart(10) +
String(r.memoryMB).padStart(8) +
String(r.recall + '%').padStart(8)
);
}
console.log('-'.repeat(95));
// Speedup summary
if (results.length >= 2) {
const baseline = results[0].buildMs;
console.log('\nSpeedup vs current baseline:');
for (let i = 1; i < results.length; i++) {
const speedup = (baseline / results[i].buildMs).toFixed(1);
console.log(` ${results[i].label}: ${speedup}x faster build`);
}
}
}
main().catch(console.error);