pgserve 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -19
- package/bin/pglite-server.js +58 -7
- package/package.json +1 -1
- package/src/cluster.js +81 -9
- package/src/index.js +2 -0
- package/src/postgres.js +1 -0
- package/src/stats-collector.js +267 -0
- package/src/stats-dashboard.js +382 -0
- package/tests/benchmarks/runner.js +871 -50
- package/tests/benchmarks/vector-generator.js +368 -0
- package/tests/quick-bench.js +135 -0
- package/tests/stress-test.js +439 -0
|
@@ -9,14 +9,218 @@
|
|
|
9
9
|
|
|
10
10
|
import { Database } from 'bun:sqlite';
|
|
11
11
|
import { PGlite } from '@electric-sql/pglite';
|
|
12
|
+
import { vector } from '@electric-sql/pglite/vector';
|
|
12
13
|
import { startMultiTenantServer } from '../../src/index.js';
|
|
13
14
|
import fs from 'fs';
|
|
14
15
|
import path from 'path';
|
|
15
16
|
import os from 'os';
|
|
16
17
|
import pg from 'pg';
|
|
18
|
+
import { loadEmbeddings, generateQueryVectors, formatPgVector, getEmbeddingsPath, getGroundTruth, calculateRecall } from './vector-generator.js';
|
|
17
19
|
|
|
18
20
|
const { Pool } = pg;
|
|
19
21
|
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// ANSI Colors and Visual Utilities (stress-test style)
|
|
24
|
+
// ============================================================================
|
|
25
|
+
const C = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
bold: '\x1b[1m',
|
|
28
|
+
dim: '\x1b[2m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
cyan: '\x1b[36m',
|
|
32
|
+
red: '\x1b[31m',
|
|
33
|
+
magenta: '\x1b[35m',
|
|
34
|
+
blue: '\x1b[34m',
|
|
35
|
+
white: '\x1b[37m',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Print benchmark banner
|
|
40
|
+
*/
|
|
41
|
+
function banner() {
|
|
42
|
+
console.log(`
|
|
43
|
+
${C.cyan}${C.bold}╔════════════════════════════════════════════════════════════════╗
|
|
44
|
+
║ pgserve UNIFIED BENCHMARK SUITE ║
|
|
45
|
+
║ ║
|
|
46
|
+
║ Comparing: SQLite │ PGlite │ PostgreSQL │ pgserve ║
|
|
47
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Print section header
|
|
53
|
+
*/
|
|
54
|
+
function section(name, description) {
|
|
55
|
+
console.log(`
|
|
56
|
+
${C.yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}
|
|
57
|
+
${C.bold}${C.cyan}▶ ${name}${C.reset}
|
|
58
|
+
${C.dim} ${description}${C.reset}
|
|
59
|
+
${C.yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Progress bar
|
|
65
|
+
*/
|
|
66
|
+
function progressBar(current, total, width = 30) {
|
|
67
|
+
const pct = Math.min(1, Math.max(0, current / total || 0));
|
|
68
|
+
// filled is clamped to [0, width], so (width - filled) is always non-negative
|
|
69
|
+
const filled = Math.max(0, Math.min(width, Math.round(pct * width)));
|
|
70
|
+
const empty = width - filled;
|
|
71
|
+
return `[${C.green}${'█'.repeat(filled)}${C.dim}${'░'.repeat(empty)}${C.reset}] ${(pct * 100).toFixed(0)}%`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Calculate score for an engine
|
|
76
|
+
* Higher is better - weighted combination of throughput and latency
|
|
77
|
+
*/
|
|
78
|
+
function calculateScore(results) {
|
|
79
|
+
if (results.skipped) return 0;
|
|
80
|
+
|
|
81
|
+
// Score formula:
|
|
82
|
+
// - Base: throughput QPS (main factor)
|
|
83
|
+
// - Bonus: low latency (P99 < 10ms gets bonus)
|
|
84
|
+
// - Penalty: errors
|
|
85
|
+
const throughputScore = results.throughput || 0;
|
|
86
|
+
const latencyBonus = results.p99 > 0 ? Math.max(0, (10 - results.p99) * 10) : 0;
|
|
87
|
+
const errorPenalty = (results.errors || 0) * 100;
|
|
88
|
+
|
|
89
|
+
return Math.round(throughputScore + latencyBonus - errorPenalty);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Print final results table with scores
|
|
94
|
+
*/
|
|
95
|
+
function printFinalResults(allResults, vectorResults, canUseRam) {
|
|
96
|
+
console.log(`
|
|
97
|
+
${C.cyan}${C.bold}
|
|
98
|
+
╔════════════════════════════════════════════════════════════════╗
|
|
99
|
+
║ FINAL RESULTS ║
|
|
100
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}
|
|
101
|
+
`);
|
|
102
|
+
|
|
103
|
+
// Aggregate results per engine
|
|
104
|
+
// Note: recallCount tracks only SEARCH scenarios (INSERT has 'N/A' recall)
|
|
105
|
+
const engines = {
|
|
106
|
+
sqlite: { name: 'SQLite', crudQps: 0, vecQps: 0, vecRecall: 0, p50: 0, p99: 0, errors: 0, count: 0, vecCount: 0, recallCount: 0 },
|
|
107
|
+
pglite: { name: 'PGlite', crudQps: 0, vecQps: 0, vecRecall: 0, p50: 0, p99: 0, errors: 0, count: 0, vecCount: 0, recallCount: 0 },
|
|
108
|
+
postgres: { name: 'PostgreSQL', crudQps: 0, vecQps: 0, vecRecall: 0, p50: 0, p99: 0, errors: 0, count: 0, vecCount: 0, recallCount: 0, skipped: false },
|
|
109
|
+
pgserve: { name: 'pgserve (disk)', crudQps: 0, vecQps: 0, vecRecall: 0, p50: 0, p99: 0, errors: 0, count: 0, vecCount: 0, recallCount: 0 },
|
|
110
|
+
pgserveRam: { name: 'pgserve (RAM)', crudQps: 0, vecQps: 0, vecRecall: 0, p50: 0, p99: 0, errors: 0, count: 0, vecCount: 0, recallCount: 0 },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Aggregate CRUD results
|
|
114
|
+
for (const r of allResults) {
|
|
115
|
+
for (const [key, eng] of Object.entries(engines)) {
|
|
116
|
+
const data = r[key];
|
|
117
|
+
if (data && !data.skipped) {
|
|
118
|
+
eng.crudQps += data.throughput || 0;
|
|
119
|
+
eng.p50 += data.p50 || 0;
|
|
120
|
+
eng.p99 += data.p99 || 0;
|
|
121
|
+
eng.errors += data.errors || 0;
|
|
122
|
+
eng.count++;
|
|
123
|
+
} else if (data?.skipped) {
|
|
124
|
+
eng.skipped = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Aggregate Vector results (with recall)
|
|
130
|
+
// Note: INSERT scenarios have recall='N/A', only SEARCH scenarios have numeric recall
|
|
131
|
+
for (const r of vectorResults) {
|
|
132
|
+
for (const [key, eng] of Object.entries(engines)) {
|
|
133
|
+
const data = r[key];
|
|
134
|
+
if (data && !data.skipped) {
|
|
135
|
+
eng.vecQps += data.throughput || 0;
|
|
136
|
+
eng.vecCount++;
|
|
137
|
+
// Only count recall for SEARCH scenarios (not INSERT which has 'N/A')
|
|
138
|
+
const recallValue = parseFloat(data.recall);
|
|
139
|
+
if (!isNaN(recallValue)) {
|
|
140
|
+
eng.vecRecall += recallValue;
|
|
141
|
+
eng.recallCount++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Average the results
|
|
148
|
+
for (const eng of Object.values(engines)) {
|
|
149
|
+
if (eng.count > 0) {
|
|
150
|
+
eng.crudQps = Math.round(eng.crudQps / eng.count);
|
|
151
|
+
eng.p50 = (eng.p50 / eng.count).toFixed(1);
|
|
152
|
+
eng.p99 = (eng.p99 / eng.count).toFixed(1);
|
|
153
|
+
}
|
|
154
|
+
if (eng.vecCount > 0) {
|
|
155
|
+
eng.vecQps = Math.round(eng.vecQps / eng.vecCount);
|
|
156
|
+
} else {
|
|
157
|
+
eng.vecQps = 0;
|
|
158
|
+
}
|
|
159
|
+
// Average recall only from SEARCH scenarios (recallCount), not INSERT scenarios
|
|
160
|
+
if (eng.recallCount > 0) {
|
|
161
|
+
eng.vecRecall = (eng.vecRecall / eng.recallCount).toFixed(1);
|
|
162
|
+
} else {
|
|
163
|
+
eng.vecRecall = 'N/A';
|
|
164
|
+
}
|
|
165
|
+
eng.score = Math.round(eng.crudQps * 0.6 + eng.vecQps * 0.4 - eng.errors * 10);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Print table header (with Recall column)
|
|
169
|
+
const hasVec = vectorResults.length > 0;
|
|
170
|
+
if (hasVec) {
|
|
171
|
+
console.log(`${C.bold}Engine │ CRUD QPS │ Vec QPS │ Recall │ P50 │ P99 │ Errors │ SCORE${C.reset}`);
|
|
172
|
+
console.log(`${'─'.repeat(90)}`);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`${C.bold}Engine │ CRUD QPS │ P50 │ P99 │ Errors │ SCORE${C.reset}`);
|
|
175
|
+
console.log(`${'─'.repeat(70)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Print each engine row
|
|
179
|
+
const engineOrder = ['sqlite', 'pglite', 'postgres', 'pgserve'];
|
|
180
|
+
if (canUseRam) engineOrder.push('pgserveRam');
|
|
181
|
+
|
|
182
|
+
let maxScore = 0;
|
|
183
|
+
let winner = '';
|
|
184
|
+
|
|
185
|
+
for (const key of engineOrder) {
|
|
186
|
+
const eng = engines[key];
|
|
187
|
+
if (eng.skipped) {
|
|
188
|
+
if (hasVec) {
|
|
189
|
+
console.log(`${C.dim}${eng.name.padEnd(17)} │ ${'-'.padStart(8)} │ ${'-'.padStart(7)} │ ${'-'.padStart(6)} │ ${'-'.padStart(7)} │ ${'-'.padStart(7)} │ ${'-'.padStart(6)} │ ${'-'.padStart(7)}${C.reset}`);
|
|
190
|
+
} else {
|
|
191
|
+
console.log(`${C.dim}${eng.name.padEnd(17)} │ ${'-'.padStart(8)} │ ${'-'.padStart(7)} │ ${'-'.padStart(7)} │ ${'-'.padStart(6)} │ ${'-'.padStart(7)}${C.reset}`);
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const color = eng.errors > 0 ? C.yellow : C.green;
|
|
197
|
+
const scoreColor = eng.score > maxScore ? C.magenta + C.bold : color;
|
|
198
|
+
|
|
199
|
+
if (eng.score > maxScore) {
|
|
200
|
+
maxScore = eng.score;
|
|
201
|
+
winner = eng.name;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (hasVec) {
|
|
205
|
+
// vecRecall is 'N/A' for INSERT-only scenarios, or a numeric string like '100.0' for SEARCH
|
|
206
|
+
const recallStr = eng.vecRecall !== 'N/A' ? `${eng.vecRecall}%` : 'N/A';
|
|
207
|
+
const vecQpsStr = eng.vecCount > 0 ? eng.vecQps.toLocaleString() : 'N/A';
|
|
208
|
+
console.log(`${color}${eng.name.padEnd(17)} │ ${String(eng.crudQps.toLocaleString()).padStart(8)} │ ${vecQpsStr.padStart(7)} │ ${recallStr.padStart(6)} │ ${(eng.p50 + 'ms').padStart(7)} │ ${(eng.p99 + 'ms').padStart(7)} │ ${String(eng.errors).padStart(6)} │ ${scoreColor}${String(eng.score.toLocaleString()).padStart(7)}${C.reset}`);
|
|
209
|
+
} else {
|
|
210
|
+
console.log(`${color}${eng.name.padEnd(17)} │ ${String(eng.crudQps.toLocaleString()).padStart(8)} │ ${(eng.p50 + 'ms').padStart(7)} │ ${(eng.p99 + 'ms').padStart(7)} │ ${String(eng.errors).padStart(6)} │ ${scoreColor}${String(eng.score.toLocaleString()).padStart(7)}${C.reset}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`${'─'.repeat(hasVec ? 90 : 70)}`);
|
|
215
|
+
|
|
216
|
+
// Winner announcement
|
|
217
|
+
console.log(`
|
|
218
|
+
${C.magenta}${C.bold}╔═══════════════════════════════════════════════════╗
|
|
219
|
+
║ 🏆 WINNER: ${winner.padEnd(20)} SCORE: ${String(maxScore).padStart(7)} ║
|
|
220
|
+
╚═══════════════════════════════════════════════════╝${C.reset}
|
|
221
|
+
`);
|
|
222
|
+
}
|
|
223
|
+
|
|
20
224
|
// Global error handlers (suppress expected PGlite WASM ExitStatus errors)
|
|
21
225
|
process.on('unhandledRejection', (reason, promise) => {
|
|
22
226
|
if (reason && reason.name === 'ExitStatus') return;
|
|
@@ -34,10 +238,10 @@ const RESULTS_DIR = new URL('./results', import.meta.url).pathname;
|
|
|
34
238
|
// PostgreSQL Server configuration (Docker with tmpfs for fair RAM-to-RAM comparison)
|
|
35
239
|
const POSTGRES_CONFIG = {
|
|
36
240
|
host: 'localhost',
|
|
37
|
-
port:
|
|
241
|
+
port: 5433, // pgvector/pgvector:pg17 Docker container
|
|
38
242
|
user: 'postgres',
|
|
39
|
-
password: '
|
|
40
|
-
database: '
|
|
243
|
+
password: 'postgres',
|
|
244
|
+
database: 'postgres'
|
|
41
245
|
};
|
|
42
246
|
|
|
43
247
|
/**
|
|
@@ -69,6 +273,47 @@ const scenarios = [
|
|
|
69
273
|
}
|
|
70
274
|
];
|
|
71
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Vector benchmark scenarios (pgvector)
|
|
278
|
+
* Requires: --include-vector flag
|
|
279
|
+
*/
|
|
280
|
+
/**
|
|
281
|
+
* Vector benchmark scenarios
|
|
282
|
+
* Following industry-standard methodology (ANN-Benchmarks, Qdrant, VectorDBBench):
|
|
283
|
+
* - Measure Recall@k alongside QPS
|
|
284
|
+
* - Compare approximate results to brute-force ground truth
|
|
285
|
+
* - Report both metrics together (can't compare QPS without knowing recall)
|
|
286
|
+
*/
|
|
287
|
+
const vectorScenarios = [
|
|
288
|
+
{
|
|
289
|
+
name: 'Vector INSERT (1000 vectors)',
|
|
290
|
+
description: 'Bulk insert performance - where RAM mode shows benefits',
|
|
291
|
+
type: 'INSERT',
|
|
292
|
+
dimension: 1536,
|
|
293
|
+
insertCount: 1000, // Insert 1000 vectors to measure write speed
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'k-NN Search (k=10)',
|
|
297
|
+
description: 'Recall@10 and QPS on 10k vectors, 100 queries',
|
|
298
|
+
type: 'SEARCH',
|
|
299
|
+
dimension: 1536,
|
|
300
|
+
corpusSize: 10000,
|
|
301
|
+
queryCount: 100,
|
|
302
|
+
k: 10,
|
|
303
|
+
warmupQueries: 20 // Warm-up before measuring
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'k-NN Search (k=100)',
|
|
307
|
+
description: 'Recall@100 and QPS on 10k vectors - harder recall target',
|
|
308
|
+
type: 'SEARCH',
|
|
309
|
+
dimension: 1536,
|
|
310
|
+
corpusSize: 10000,
|
|
311
|
+
queryCount: 100,
|
|
312
|
+
k: 100,
|
|
313
|
+
warmupQueries: 20
|
|
314
|
+
}
|
|
315
|
+
];
|
|
316
|
+
|
|
72
317
|
/**
|
|
73
318
|
* Performance metrics
|
|
74
319
|
*/
|
|
@@ -367,9 +612,9 @@ async function benchmarkPostgreSQL(scenario) {
|
|
|
367
612
|
|
|
368
613
|
return metrics.getReport();
|
|
369
614
|
} catch (error) {
|
|
370
|
-
console.error(' PostgreSQL benchmark
|
|
371
|
-
await pool.end();
|
|
372
|
-
return { throughput: 0, p50: 0, p99: 0, errors:
|
|
615
|
+
console.error(' PostgreSQL benchmark skipped:', error.message);
|
|
616
|
+
await pool.end().catch(() => {});
|
|
617
|
+
return { throughput: 0, p50: 0, p99: 0, errors: 0, lockTimeouts: 0, totalOps: 0, skipped: true };
|
|
373
618
|
}
|
|
374
619
|
}
|
|
375
620
|
|
|
@@ -504,13 +749,428 @@ async function benchmarkPgserve(scenario, useRam = false) {
|
|
|
504
749
|
}
|
|
505
750
|
}
|
|
506
751
|
|
|
752
|
+
// ============================================================================
|
|
753
|
+
// VECTOR BENCHMARKS (pgvector)
|
|
754
|
+
// ============================================================================
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* PGlite Vector Benchmark
|
|
758
|
+
* Supports both INSERT and SEARCH scenarios
|
|
759
|
+
*/
|
|
760
|
+
async function benchmarkPGliteVector(scenario, embeddings, queryVectors, groundTruth) {
|
|
761
|
+
console.log(' 🔹 Running PGlite vector benchmark...');
|
|
762
|
+
|
|
763
|
+
const dataDir = path.join(RESULTS_DIR, 'pglite-vector-bench');
|
|
764
|
+
if (fs.existsSync(dataDir)) {
|
|
765
|
+
fs.rmSync(dataDir, { recursive: true });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
// Create PGlite with pgvector extension
|
|
770
|
+
const db = new PGlite(dataDir, { extensions: { vector } });
|
|
771
|
+
await db.exec('CREATE EXTENSION IF NOT EXISTS vector');
|
|
772
|
+
|
|
773
|
+
// Setup schema
|
|
774
|
+
await db.exec(`
|
|
775
|
+
DROP TABLE IF EXISTS embeddings;
|
|
776
|
+
CREATE TABLE embeddings (
|
|
777
|
+
id INTEGER PRIMARY KEY,
|
|
778
|
+
vector vector(${scenario.dimension})
|
|
779
|
+
)
|
|
780
|
+
`);
|
|
781
|
+
|
|
782
|
+
// INSERT scenario - measure insert speed
|
|
783
|
+
if (scenario.type === 'INSERT') {
|
|
784
|
+
console.log(` Inserting ${scenario.insertCount} vectors (measured)...`);
|
|
785
|
+
const latencies = [];
|
|
786
|
+
|
|
787
|
+
for (let i = 0; i < scenario.insertCount; i++) {
|
|
788
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
789
|
+
const start = performance.now();
|
|
790
|
+
await db.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
791
|
+
latencies.push(performance.now() - start);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
795
|
+
const qps = Math.round((scenario.insertCount / totalTime) * 1000);
|
|
796
|
+
latencies.sort((a, b) => a - b);
|
|
797
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
798
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
799
|
+
|
|
800
|
+
try { await db.close(); } catch { /* ignore */ }
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
throughput: qps,
|
|
804
|
+
recall: 'N/A',
|
|
805
|
+
p50: parseFloat(p50),
|
|
806
|
+
p99: parseFloat(p99),
|
|
807
|
+
errors: 0,
|
|
808
|
+
totalOps: scenario.insertCount,
|
|
809
|
+
skipped: false
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// SEARCH scenario - measure recall and QPS
|
|
814
|
+
// Phase 1: Insert all vectors (not measured)
|
|
815
|
+
console.log(' Inserting vectors...');
|
|
816
|
+
for (let i = 0; i < embeddings.vectors.length; i++) {
|
|
817
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
818
|
+
await db.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Phase 2: Warm-up queries (not measured)
|
|
822
|
+
console.log(' Warming up...');
|
|
823
|
+
for (let i = 0; i < scenario.warmupQueries; i++) {
|
|
824
|
+
const queryVec = formatPgVector(queryVectors[i % queryVectors.length]);
|
|
825
|
+
await db.query(`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`, [queryVec, scenario.k]);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Phase 3: Measured queries with recall tracking
|
|
829
|
+
console.log(' Running measured queries...');
|
|
830
|
+
const latencies = [];
|
|
831
|
+
const approximateResults = [];
|
|
832
|
+
|
|
833
|
+
for (let i = 0; i < scenario.queryCount; i++) {
|
|
834
|
+
const queryVec = formatPgVector(queryVectors[i]);
|
|
835
|
+
const start = performance.now();
|
|
836
|
+
const result = await db.query(
|
|
837
|
+
`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`,
|
|
838
|
+
[queryVec, scenario.k]
|
|
839
|
+
);
|
|
840
|
+
latencies.push(performance.now() - start);
|
|
841
|
+
|
|
842
|
+
// Collect IDs for recall calculation
|
|
843
|
+
approximateResults.push(result.rows.map(r => r.id));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Calculate recall
|
|
847
|
+
const { recall } = calculateRecall(approximateResults, groundTruth, scenario.k);
|
|
848
|
+
|
|
849
|
+
// Calculate metrics
|
|
850
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
851
|
+
const qps = Math.round((scenario.queryCount / totalTime) * 1000);
|
|
852
|
+
latencies.sort((a, b) => a - b);
|
|
853
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
854
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
855
|
+
|
|
856
|
+
try { await db.close(); } catch { /* ignore */ }
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
throughput: qps,
|
|
860
|
+
recall: (recall * 100).toFixed(1), // as percentage
|
|
861
|
+
p50: parseFloat(p50),
|
|
862
|
+
p99: parseFloat(p99),
|
|
863
|
+
errors: 0,
|
|
864
|
+
totalOps: scenario.queryCount,
|
|
865
|
+
skipped: false
|
|
866
|
+
};
|
|
867
|
+
} catch (error) {
|
|
868
|
+
console.error(' PGlite vector benchmark failed:', error.message);
|
|
869
|
+
return { throughput: 0, recall: 0, p50: 0, p99: 0, errors: 1, totalOps: 0, skipped: true };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* PostgreSQL Server Vector Benchmark
|
|
875
|
+
* Supports both INSERT and SEARCH scenarios
|
|
876
|
+
*/
|
|
877
|
+
async function benchmarkPostgreSQLVector(scenario, embeddings, queryVectors, groundTruth) {
|
|
878
|
+
console.log(' 🔷 Running PostgreSQL vector benchmark...');
|
|
879
|
+
|
|
880
|
+
const pool = new Pool({
|
|
881
|
+
...POSTGRES_CONFIG,
|
|
882
|
+
max: 20
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
// Test connection and check for pgvector
|
|
887
|
+
await pool.query('SELECT 1');
|
|
888
|
+
|
|
889
|
+
// Check if pgvector is available
|
|
890
|
+
try {
|
|
891
|
+
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
892
|
+
} catch (e) {
|
|
893
|
+
console.error(' pgvector extension not available. Use pgvector/pgvector:pg17 Docker image.');
|
|
894
|
+
await pool.end();
|
|
895
|
+
return { throughput: 0, recall: 'N/A', p50: 0, p99: 0, errors: 1, totalOps: 0, skipped: true };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Setup schema
|
|
899
|
+
await pool.query(`
|
|
900
|
+
DROP TABLE IF EXISTS embeddings;
|
|
901
|
+
CREATE TABLE embeddings (
|
|
902
|
+
id INTEGER PRIMARY KEY,
|
|
903
|
+
vector vector(${scenario.dimension})
|
|
904
|
+
)
|
|
905
|
+
`);
|
|
906
|
+
|
|
907
|
+
// INSERT scenario - measure insert speed
|
|
908
|
+
if (scenario.type === 'INSERT') {
|
|
909
|
+
console.log(` Inserting ${scenario.insertCount} vectors (measured)...`);
|
|
910
|
+
const latencies = [];
|
|
911
|
+
|
|
912
|
+
for (let i = 0; i < scenario.insertCount; i++) {
|
|
913
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
914
|
+
const start = performance.now();
|
|
915
|
+
await pool.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
916
|
+
latencies.push(performance.now() - start);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
920
|
+
const qps = Math.round((scenario.insertCount / totalTime) * 1000);
|
|
921
|
+
latencies.sort((a, b) => a - b);
|
|
922
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
923
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
924
|
+
|
|
925
|
+
await pool.query('DROP TABLE IF EXISTS embeddings');
|
|
926
|
+
await pool.end();
|
|
927
|
+
|
|
928
|
+
return {
|
|
929
|
+
throughput: qps,
|
|
930
|
+
recall: 'N/A',
|
|
931
|
+
p50: parseFloat(p50),
|
|
932
|
+
p99: parseFloat(p99),
|
|
933
|
+
errors: 0,
|
|
934
|
+
totalOps: scenario.insertCount,
|
|
935
|
+
skipped: false
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// SEARCH scenario - Phase 1: Insert all vectors (not measured)
|
|
940
|
+
console.log(' Inserting vectors...');
|
|
941
|
+
for (let i = 0; i < embeddings.vectors.length; i++) {
|
|
942
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
943
|
+
await pool.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Phase 2: Warm-up queries (not measured)
|
|
947
|
+
console.log(' Warming up...');
|
|
948
|
+
for (let i = 0; i < scenario.warmupQueries; i++) {
|
|
949
|
+
const queryVec = formatPgVector(queryVectors[i % queryVectors.length]);
|
|
950
|
+
await pool.query(`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`, [queryVec, scenario.k]);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Phase 3: Measured queries with recall tracking
|
|
954
|
+
console.log(' Running measured queries...');
|
|
955
|
+
const latencies = [];
|
|
956
|
+
const approximateResults = [];
|
|
957
|
+
|
|
958
|
+
for (let i = 0; i < scenario.queryCount; i++) {
|
|
959
|
+
const queryVec = formatPgVector(queryVectors[i]);
|
|
960
|
+
const start = performance.now();
|
|
961
|
+
const result = await pool.query(
|
|
962
|
+
`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`,
|
|
963
|
+
[queryVec, scenario.k]
|
|
964
|
+
);
|
|
965
|
+
latencies.push(performance.now() - start);
|
|
966
|
+
|
|
967
|
+
// Collect IDs for recall calculation
|
|
968
|
+
approximateResults.push(result.rows.map(r => r.id));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Calculate recall
|
|
972
|
+
const { recall } = calculateRecall(approximateResults, groundTruth, scenario.k);
|
|
973
|
+
|
|
974
|
+
// Calculate metrics
|
|
975
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
976
|
+
const qps = Math.round((scenario.queryCount / totalTime) * 1000);
|
|
977
|
+
latencies.sort((a, b) => a - b);
|
|
978
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
979
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
980
|
+
|
|
981
|
+
// Cleanup
|
|
982
|
+
await pool.query('DROP TABLE IF EXISTS embeddings');
|
|
983
|
+
await pool.end();
|
|
984
|
+
|
|
985
|
+
return {
|
|
986
|
+
throughput: qps,
|
|
987
|
+
recall: (recall * 100).toFixed(1), // as percentage
|
|
988
|
+
p50: parseFloat(p50),
|
|
989
|
+
p99: parseFloat(p99),
|
|
990
|
+
errors: 0,
|
|
991
|
+
totalOps: scenario.queryCount,
|
|
992
|
+
skipped: false
|
|
993
|
+
};
|
|
994
|
+
} catch (error) {
|
|
995
|
+
console.error(' PostgreSQL vector benchmark skipped:', error.message);
|
|
996
|
+
await pool.end().catch(() => {});
|
|
997
|
+
return { throughput: 0, recall: 'N/A', p50: 0, p99: 0, errors: 0, totalOps: 0, skipped: true };
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* pgserve Vector Benchmark
|
|
1003
|
+
* Supports both INSERT and SEARCH scenarios
|
|
1004
|
+
*/
|
|
1005
|
+
async function benchmarkPgserveVector(scenario, embeddings, queryVectors, groundTruth, useRam = false) {
|
|
1006
|
+
const mode = useRam ? 'RAM' : 'disk';
|
|
1007
|
+
console.log(` 🚀 Running pgserve (${mode}) vector benchmark...`);
|
|
1008
|
+
|
|
1009
|
+
let server;
|
|
1010
|
+
try {
|
|
1011
|
+
// Start pgserve (use different ports for vector benchmarks to avoid conflicts)
|
|
1012
|
+
const port = useRam ? 18435 : 18434;
|
|
1013
|
+
server = await startMultiTenantServer({
|
|
1014
|
+
port,
|
|
1015
|
+
logLevel: 'error',
|
|
1016
|
+
useRam
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// Wait for server to be fully ready
|
|
1020
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
1021
|
+
|
|
1022
|
+
const pool = new Pool({
|
|
1023
|
+
host: 'localhost',
|
|
1024
|
+
port,
|
|
1025
|
+
database: 'vector_bench',
|
|
1026
|
+
user: 'postgres',
|
|
1027
|
+
password: 'postgres',
|
|
1028
|
+
max: 20,
|
|
1029
|
+
connectionTimeoutMillis: 30000
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// Wait for connection with retries
|
|
1033
|
+
let connected = false;
|
|
1034
|
+
for (let i = 0; i < 10; i++) {
|
|
1035
|
+
try {
|
|
1036
|
+
await pool.query('SELECT 1');
|
|
1037
|
+
connected = true;
|
|
1038
|
+
break;
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (i === 9) throw error;
|
|
1041
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (!connected) {
|
|
1046
|
+
throw new Error('Failed to connect to pgserve');
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Enable pgvector extension
|
|
1050
|
+
try {
|
|
1051
|
+
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
console.error(` pgvector extension not available in pgserve. Install pgvector files to ~/.pgserve/bin/<platform>/`);
|
|
1054
|
+
await pool.end();
|
|
1055
|
+
await server.stop();
|
|
1056
|
+
return { throughput: 0, recall: 'N/A', p50: 0, p99: 0, errors: 0, totalOps: 0, skipped: true, reason: 'pgvector not installed' };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Setup schema
|
|
1060
|
+
await pool.query(`
|
|
1061
|
+
DROP TABLE IF EXISTS embeddings;
|
|
1062
|
+
CREATE TABLE embeddings (
|
|
1063
|
+
id INTEGER PRIMARY KEY,
|
|
1064
|
+
vector vector(${scenario.dimension})
|
|
1065
|
+
)
|
|
1066
|
+
`);
|
|
1067
|
+
|
|
1068
|
+
// INSERT scenario - measure insert speed
|
|
1069
|
+
if (scenario.type === 'INSERT') {
|
|
1070
|
+
console.log(` Inserting ${scenario.insertCount} vectors (measured)...`);
|
|
1071
|
+
const latencies = [];
|
|
1072
|
+
|
|
1073
|
+
for (let i = 0; i < scenario.insertCount; i++) {
|
|
1074
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
1075
|
+
const start = performance.now();
|
|
1076
|
+
await pool.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
1077
|
+
latencies.push(performance.now() - start);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
1081
|
+
const qps = Math.round((scenario.insertCount / totalTime) * 1000);
|
|
1082
|
+
latencies.sort((a, b) => a - b);
|
|
1083
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
1084
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
1085
|
+
|
|
1086
|
+
await pool.query('DROP TABLE IF EXISTS embeddings');
|
|
1087
|
+
await pool.end();
|
|
1088
|
+
await server.stop();
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
throughput: qps,
|
|
1092
|
+
recall: 'N/A',
|
|
1093
|
+
p50: parseFloat(p50),
|
|
1094
|
+
p99: parseFloat(p99),
|
|
1095
|
+
errors: 0,
|
|
1096
|
+
totalOps: scenario.insertCount,
|
|
1097
|
+
skipped: false
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// SEARCH scenario - Phase 1: Insert all vectors (not measured)
|
|
1102
|
+
console.log(' Inserting vectors...');
|
|
1103
|
+
for (let i = 0; i < embeddings.vectors.length; i++) {
|
|
1104
|
+
const vec = formatPgVector(embeddings.vectors[i]);
|
|
1105
|
+
await pool.query('INSERT INTO embeddings (id, vector) VALUES ($1, $2::vector)', [i + 1, vec]);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Phase 2: Warm-up queries (not measured)
|
|
1109
|
+
console.log(' Warming up...');
|
|
1110
|
+
for (let i = 0; i < scenario.warmupQueries; i++) {
|
|
1111
|
+
const queryVec = formatPgVector(queryVectors[i % queryVectors.length]);
|
|
1112
|
+
await pool.query(`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`, [queryVec, scenario.k]);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Phase 3: Measured queries with recall tracking
|
|
1116
|
+
console.log(' Running measured queries...');
|
|
1117
|
+
const latencies = [];
|
|
1118
|
+
const approximateResults = [];
|
|
1119
|
+
|
|
1120
|
+
for (let i = 0; i < scenario.queryCount; i++) {
|
|
1121
|
+
const queryVec = formatPgVector(queryVectors[i]);
|
|
1122
|
+
const start = performance.now();
|
|
1123
|
+
const result = await pool.query(
|
|
1124
|
+
`SELECT id FROM embeddings ORDER BY vector <-> $1::vector LIMIT $2`,
|
|
1125
|
+
[queryVec, scenario.k]
|
|
1126
|
+
);
|
|
1127
|
+
latencies.push(performance.now() - start);
|
|
1128
|
+
|
|
1129
|
+
// Collect IDs for recall calculation
|
|
1130
|
+
approximateResults.push(result.rows.map(r => r.id));
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Calculate recall
|
|
1134
|
+
const { recall } = calculateRecall(approximateResults, groundTruth, scenario.k);
|
|
1135
|
+
|
|
1136
|
+
// Calculate metrics
|
|
1137
|
+
const totalTime = latencies.reduce((a, b) => a + b, 0);
|
|
1138
|
+
const qps = Math.round((scenario.queryCount / totalTime) * 1000);
|
|
1139
|
+
latencies.sort((a, b) => a - b);
|
|
1140
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)]?.toFixed(1) || 0;
|
|
1141
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)]?.toFixed(1) || 0;
|
|
1142
|
+
|
|
1143
|
+
// Cleanup
|
|
1144
|
+
await pool.query('DROP TABLE IF EXISTS embeddings');
|
|
1145
|
+
await pool.end();
|
|
1146
|
+
await server.stop();
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
throughput: qps,
|
|
1150
|
+
recall: (recall * 100).toFixed(1), // as percentage
|
|
1151
|
+
p50: parseFloat(p50),
|
|
1152
|
+
p99: parseFloat(p99),
|
|
1153
|
+
errors: 0,
|
|
1154
|
+
totalOps: scenario.queryCount,
|
|
1155
|
+
skipped: false
|
|
1156
|
+
};
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
console.error(` pgserve (${mode}) vector benchmark failed:`, error.message);
|
|
1159
|
+
if (server) {
|
|
1160
|
+
try { await server.stop(); } catch { /* ignore */ }
|
|
1161
|
+
}
|
|
1162
|
+
return { throughput: 0, recall: 'N/A', p50: 0, p99: 0, errors: 0, totalOps: 0, skipped: true, reason: error.message };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
507
1166
|
/**
|
|
508
1167
|
* Generate comparison report
|
|
509
1168
|
*/
|
|
510
|
-
function generateReport(results) {
|
|
1169
|
+
function generateReport(results, vectorResults = []) {
|
|
511
1170
|
const report = {
|
|
512
1171
|
timestamp: new Date().toISOString(),
|
|
513
|
-
scenarios: results
|
|
1172
|
+
scenarios: results,
|
|
1173
|
+
vectorScenarios: vectorResults
|
|
514
1174
|
};
|
|
515
1175
|
|
|
516
1176
|
// Save JSON
|
|
@@ -614,6 +1274,58 @@ function generateReport(results) {
|
|
|
614
1274
|
}
|
|
615
1275
|
}
|
|
616
1276
|
|
|
1277
|
+
// Vector benchmark results (with Recall@k)
|
|
1278
|
+
if (vectorResults && vectorResults.length > 0) {
|
|
1279
|
+
md += '---\n\n';
|
|
1280
|
+
md += '## Vector Benchmarks (pgvector) - Recall@k Methodology\n\n';
|
|
1281
|
+
md += '*Following industry-standard ANN-Benchmarks methodology: comparing approximate results to brute-force ground truth.*\n\n';
|
|
1282
|
+
|
|
1283
|
+
for (const scenario of vectorResults) {
|
|
1284
|
+
md += `### ${scenario.name}\n\n`;
|
|
1285
|
+
md += `${scenario.description}\n\n`;
|
|
1286
|
+
|
|
1287
|
+
const { pglite, postgres, pgserve, pgserveRam, k } = scenario;
|
|
1288
|
+
|
|
1289
|
+
md += '```\n';
|
|
1290
|
+
md += '┌─────────────────┬──────────┬──────────┬──────────┬─────────────┐\n';
|
|
1291
|
+
md += '│ Metric │ PGlite │ PostgreSQL│ pgserve │ pgserve RAM │\n';
|
|
1292
|
+
md += '├─────────────────┼──────────┼──────────┼──────────┼─────────────┤\n';
|
|
1293
|
+
|
|
1294
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
1295
|
+
const val = (r, key) => r.skipped ? 'N/A' : r[key];
|
|
1296
|
+
const recallVal = (r) => r.skipped ? 'N/A' : `${r.recall}%`;
|
|
1297
|
+
|
|
1298
|
+
md += `│ Recall@${String(k || 10).padEnd(8)}│ ${pad(recallVal(pglite), 8)} │ ${pad(recallVal(postgres), 9)} │ ${pad(recallVal(pgserve), 8)} │ ${pad(recallVal(pgserveRam), 11)} │\n`;
|
|
1299
|
+
md += `│ Throughput (qps)│ ${pad(val(pglite, 'throughput'), 8)} │ ${pad(val(postgres, 'throughput'), 9)} │ ${pad(val(pgserve, 'throughput'), 8)} │ ${pad(val(pgserveRam, 'throughput'), 11)} │\n`;
|
|
1300
|
+
md += `│ P50 latency (ms)│ ${pad(val(pglite, 'p50'), 8)} │ ${pad(val(postgres, 'p50'), 9)} │ ${pad(val(pgserve, 'p50'), 8)} │ ${pad(val(pgserveRam, 'p50'), 11)} │\n`;
|
|
1301
|
+
md += `│ P99 latency (ms)│ ${pad(val(pglite, 'p99'), 8)} │ ${pad(val(postgres, 'p99'), 9)} │ ${pad(val(pgserve, 'p99'), 8)} │ ${pad(val(pgserveRam, 'p99'), 11)} │\n`;
|
|
1302
|
+
md += `│ Errors │ ${pad(val(pglite, 'errors'), 8)} │ ${pad(val(postgres, 'errors'), 9)} │ ${pad(val(pgserve, 'errors'), 8)} │ ${pad(val(pgserveRam, 'errors'), 11)} │\n`;
|
|
1303
|
+
md += '└─────────────────┴──────────┴──────────┴──────────┴─────────────┘\n';
|
|
1304
|
+
md += '```\n\n';
|
|
1305
|
+
|
|
1306
|
+
// Find winner among non-skipped (considering both recall and throughput)
|
|
1307
|
+
const candidates = {};
|
|
1308
|
+
if (!pglite.skipped) candidates.pglite = { recall: parseFloat(pglite.recall), qps: pglite.throughput };
|
|
1309
|
+
if (!postgres.skipped) candidates.postgres = { recall: parseFloat(postgres.recall), qps: postgres.throughput };
|
|
1310
|
+
if (!pgserve.skipped) candidates.pgserve = { recall: parseFloat(pgserve.recall), qps: pgserve.throughput };
|
|
1311
|
+
if (pgserveRam && !pgserveRam.skipped) candidates.pgserveRam = { recall: parseFloat(pgserveRam.recall), qps: pgserveRam.throughput };
|
|
1312
|
+
|
|
1313
|
+
if (Object.keys(candidates).length > 0) {
|
|
1314
|
+
const nameMap = { pglite: 'PGlite', postgres: 'PostgreSQL', pgserve: 'pgserve', pgserveRam: 'pgserve RAM' };
|
|
1315
|
+
// Winner = highest QPS among those with 100% recall, otherwise highest recall
|
|
1316
|
+
const perfect = Object.entries(candidates).filter(([, v]) => v.recall === 100);
|
|
1317
|
+
let winnerKey;
|
|
1318
|
+
if (perfect.length > 0) {
|
|
1319
|
+
winnerKey = perfect.reduce((a, b) => a[1].qps > b[1].qps ? a : b)[0];
|
|
1320
|
+
md += `**${nameMap[winnerKey]} wins** (100% recall @ ${candidates[winnerKey].qps} qps)\n\n`;
|
|
1321
|
+
} else {
|
|
1322
|
+
winnerKey = Object.entries(candidates).reduce((a, b) => a[1].recall > b[1].recall ? a : b)[0];
|
|
1323
|
+
md += `**${nameMap[winnerKey]} wins** (${candidates[winnerKey].recall}% recall @ ${candidates[winnerKey].qps} qps)\n\n`;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
617
1329
|
md += '---\n\n';
|
|
618
1330
|
md += '## Why pgserve?\n\n';
|
|
619
1331
|
md += '- **TRUE Concurrency**: Native PostgreSQL process forking\n';
|
|
@@ -636,10 +1348,36 @@ function generateReport(results) {
|
|
|
636
1348
|
* Main runner
|
|
637
1349
|
*/
|
|
638
1350
|
async function main() {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1351
|
+
// Parse CLI args
|
|
1352
|
+
const args = process.argv.slice(2);
|
|
1353
|
+
const includeVector = args.includes('--include-vector') || args.includes('--vector');
|
|
1354
|
+
const vectorOnly = args.includes('--vector-only');
|
|
1355
|
+
|
|
1356
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
1357
|
+
console.log(`
|
|
1358
|
+
pgserve Benchmark Suite
|
|
1359
|
+
|
|
1360
|
+
Usage:
|
|
1361
|
+
bun tests/benchmarks/runner.js [options]
|
|
1362
|
+
|
|
1363
|
+
Options:
|
|
1364
|
+
--include-vector Include vector (pgvector) benchmarks
|
|
1365
|
+
--vector-only Run only vector benchmarks
|
|
1366
|
+
--help, -h Show this help
|
|
1367
|
+
|
|
1368
|
+
Vector benchmarks require:
|
|
1369
|
+
- PGLite: Built-in pgvector support
|
|
1370
|
+
- PostgreSQL: Docker image pgvector/pgvector:pg17
|
|
1371
|
+
- pgserve: Not yet supported (marked as skipped)
|
|
1372
|
+
`);
|
|
1373
|
+
process.exit(0);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Print banner
|
|
1377
|
+
banner();
|
|
1378
|
+
if (includeVector || vectorOnly) {
|
|
1379
|
+
console.log(`${C.dim} + Vector benchmarks (pgvector) enabled${C.reset}\n`);
|
|
1380
|
+
}
|
|
643
1381
|
|
|
644
1382
|
// Ensure results directory exists
|
|
645
1383
|
if (!fs.existsSync(RESULTS_DIR)) {
|
|
@@ -647,54 +1385,137 @@ async function main() {
|
|
|
647
1385
|
}
|
|
648
1386
|
|
|
649
1387
|
const results = [];
|
|
1388
|
+
const vectorResults = [];
|
|
650
1389
|
|
|
651
1390
|
// Check if RAM mode is available (Linux only with /dev/shm)
|
|
652
1391
|
const canUseRam = os.platform() === 'linux' && fs.existsSync('/dev/shm');
|
|
653
1392
|
if (canUseRam) {
|
|
654
|
-
console.log(
|
|
1393
|
+
console.log(`${C.green}💾 RAM mode available (/dev/shm detected)${C.reset}\n`);
|
|
655
1394
|
} else {
|
|
656
|
-
console.log(
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
for (const scenario of scenarios) {
|
|
660
|
-
console.log(`\n📊 Scenario: ${scenario.name}`);
|
|
661
|
-
console.log(` ${scenario.description}\n`);
|
|
662
|
-
|
|
663
|
-
const sqlite = await benchmarkSQLite(scenario);
|
|
664
|
-
const pglite = await benchmarkPGlite(scenario);
|
|
665
|
-
const postgres = await benchmarkPostgreSQL(scenario);
|
|
666
|
-
const pgserve = await benchmarkPgserve(scenario, false); // disk mode
|
|
667
|
-
const pgserveRam = canUseRam
|
|
668
|
-
? await benchmarkPgserve(scenario, true) // RAM mode
|
|
669
|
-
: { throughput: 0, p50: 0, p99: 0, errors: 0, lockTimeouts: 0, totalOps: 0, skipped: true };
|
|
670
|
-
|
|
671
|
-
results.push({
|
|
672
|
-
name: scenario.name,
|
|
673
|
-
description: scenario.description,
|
|
674
|
-
sqlite,
|
|
675
|
-
pglite,
|
|
676
|
-
postgres,
|
|
677
|
-
pgserve,
|
|
678
|
-
pgserveRam
|
|
679
|
-
});
|
|
1395
|
+
console.log(`${C.yellow}⚠️ RAM mode not available (Linux /dev/shm required)${C.reset}\n`);
|
|
1396
|
+
}
|
|
680
1397
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1398
|
+
// Run CRUD benchmarks (unless --vector-only)
|
|
1399
|
+
if (!vectorOnly) {
|
|
1400
|
+
for (const scenario of scenarios) {
|
|
1401
|
+
section(scenario.name, scenario.description);
|
|
1402
|
+
|
|
1403
|
+
const sqlite = await benchmarkSQLite(scenario);
|
|
1404
|
+
const pglite = await benchmarkPGlite(scenario);
|
|
1405
|
+
const postgres = await benchmarkPostgreSQL(scenario);
|
|
1406
|
+
const pgserve = await benchmarkPgserve(scenario, false); // disk mode
|
|
1407
|
+
const pgserveRam = canUseRam
|
|
1408
|
+
? await benchmarkPgserve(scenario, true) // RAM mode
|
|
1409
|
+
: { throughput: 0, p50: 0, p99: 0, errors: 0, lockTimeouts: 0, totalOps: 0, skipped: true };
|
|
1410
|
+
|
|
1411
|
+
results.push({
|
|
1412
|
+
name: scenario.name,
|
|
1413
|
+
description: scenario.description,
|
|
1414
|
+
sqlite,
|
|
1415
|
+
pglite,
|
|
1416
|
+
postgres,
|
|
1417
|
+
pgserve,
|
|
1418
|
+
pgserveRam
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// Scenario results summary
|
|
1422
|
+
console.log(`\n ${C.bold}Results:${C.reset}`);
|
|
1423
|
+
console.log(` ${C.dim}──────────────────────────────────────────${C.reset}`);
|
|
1424
|
+
console.log(` ${C.yellow}🔸${C.reset} SQLite: ${C.bold}${sqlite.throughput}${C.reset} qps, P50=${sqlite.p50}ms, P99=${sqlite.p99}ms`);
|
|
1425
|
+
console.log(` ${C.blue}🔹${C.reset} PGlite: ${C.bold}${pglite.throughput}${C.reset} qps, P50=${pglite.p50}ms, P99=${pglite.p99}ms`);
|
|
1426
|
+
console.log(` ${C.cyan}🔷${C.reset} PostgreSQL: ${postgres.skipped ? `${C.dim}SKIPPED${C.reset}` : `${C.bold}${postgres.throughput}${C.reset} qps, P50=${postgres.p50}ms, P99=${postgres.p99}ms`}`);
|
|
1427
|
+
console.log(` ${C.green}🚀${C.reset} pgserve: ${C.bold}${pgserve.throughput}${C.reset} qps, P50=${pgserve.p50}ms, P99=${pgserve.p99}ms`);
|
|
1428
|
+
if (canUseRam) {
|
|
1429
|
+
console.log(` ${C.magenta}⚡${C.reset} pgserve (RAM): ${C.bold}${pgserveRam.throughput}${C.reset} qps, P50=${pgserveRam.p50}ms, P99=${pgserveRam.p99}ms`);
|
|
1430
|
+
}
|
|
687
1431
|
}
|
|
688
1432
|
}
|
|
689
1433
|
|
|
690
|
-
|
|
691
|
-
|
|
1434
|
+
// Run vector benchmarks (if --include-vector or --vector-only)
|
|
1435
|
+
if (includeVector || vectorOnly) {
|
|
1436
|
+
console.log(`\n${C.cyan}${C.bold}
|
|
1437
|
+
╔════════════════════════════════════════════════════════════════╗
|
|
1438
|
+
║ Vector Benchmarks (pgvector) - Recall@k Methodology ║
|
|
1439
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}\n`);
|
|
1440
|
+
|
|
1441
|
+
// Load or generate embeddings
|
|
1442
|
+
console.log(`${C.dim}📦 Loading embeddings...${C.reset}`);
|
|
1443
|
+
const dimension = 1536;
|
|
1444
|
+
const corpusSize = 10000; // 10k vectors (~60MB) to exceed buffer cache and show RAM vs disk difference
|
|
1445
|
+
|
|
1446
|
+
// Ensure embeddings file exists
|
|
1447
|
+
getEmbeddingsPath(corpusSize, dimension);
|
|
1448
|
+
const embeddings = loadEmbeddings(`embeddings-${corpusSize}-${dimension}.json`);
|
|
1449
|
+
const queryVectors = generateQueryVectors(100, dimension);
|
|
1450
|
+
console.log(`${C.dim} Loaded ${embeddings.vectors.length} corpus vectors, ${queryVectors.length} query vectors${C.reset}\n`);
|
|
1451
|
+
|
|
1452
|
+
for (const scenario of vectorScenarios) {
|
|
1453
|
+
section(`Vector: ${scenario.name}`, scenario.description);
|
|
1454
|
+
|
|
1455
|
+
let groundTruth = null;
|
|
1456
|
+
|
|
1457
|
+
// Only compute ground truth for SEARCH scenarios
|
|
1458
|
+
if (scenario.type === 'SEARCH') {
|
|
1459
|
+
console.log(`${C.dim} 📐 Computing ground truth (brute-force k=${scenario.k})...${C.reset}`);
|
|
1460
|
+
groundTruth = getGroundTruth(
|
|
1461
|
+
embeddings.vectors,
|
|
1462
|
+
queryVectors.slice(0, scenario.queryCount),
|
|
1463
|
+
scenario.k,
|
|
1464
|
+
`corpus-${corpusSize}-dim-${dimension}-queries-${scenario.queryCount}`
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Run benchmarks
|
|
1469
|
+
const pglite = await benchmarkPGliteVector(scenario, embeddings, queryVectors, groundTruth);
|
|
1470
|
+
const postgres = await benchmarkPostgreSQLVector(scenario, embeddings, queryVectors, groundTruth);
|
|
1471
|
+
const pgserve = await benchmarkPgserveVector(scenario, embeddings, queryVectors, groundTruth, false);
|
|
1472
|
+
const pgserveRam = canUseRam
|
|
1473
|
+
? await benchmarkPgserveVector(scenario, embeddings, queryVectors, groundTruth, true)
|
|
1474
|
+
: { throughput: 0, recall: 'N/A', p50: 0, p99: 0, errors: 0, totalOps: 0, skipped: true };
|
|
1475
|
+
|
|
1476
|
+
vectorResults.push({
|
|
1477
|
+
name: scenario.name,
|
|
1478
|
+
description: scenario.description,
|
|
1479
|
+
type: scenario.type,
|
|
1480
|
+
k: scenario.k,
|
|
1481
|
+
pglite,
|
|
1482
|
+
postgres,
|
|
1483
|
+
pgserve,
|
|
1484
|
+
pgserveRam
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// Vector scenario results summary
|
|
1488
|
+
const isInsert = scenario.type === 'INSERT';
|
|
1489
|
+
console.log(`\n ${C.bold}Results (${isInsert ? 'INSERT QPS' : `Recall@${scenario.k} + QPS`}):${C.reset}`);
|
|
1490
|
+
console.log(` ${C.dim}──────────────────────────────────────────────────────${C.reset}`);
|
|
1491
|
+
const formatResult = (r, icon, color, name) => {
|
|
1492
|
+
if (r.skipped) return ` ${color}${icon}${C.reset} ${name.padEnd(14)} ${C.dim}SKIPPED${r.reason ? ` (${r.reason})` : ''}${C.reset}`;
|
|
1493
|
+
if (isInsert) {
|
|
1494
|
+
return ` ${color}${icon}${C.reset} ${name.padEnd(14)} ${C.bold}${r.throughput}${C.reset} inserts/sec, P50=${r.p50}ms, P99=${r.p99}ms`;
|
|
1495
|
+
}
|
|
1496
|
+
return ` ${color}${icon}${C.reset} ${name.padEnd(14)} Recall: ${C.bold}${r.recall}%${C.reset}, ${C.bold}${r.throughput}${C.reset} qps, P50=${r.p50}ms`;
|
|
1497
|
+
};
|
|
1498
|
+
console.log(formatResult(pglite, '🔹', C.blue, 'PGlite:'));
|
|
1499
|
+
console.log(formatResult(postgres, '🔷', C.cyan, 'PostgreSQL:'));
|
|
1500
|
+
console.log(formatResult(pgserve, '🚀', C.green, 'pgserve:'));
|
|
1501
|
+
if (canUseRam) {
|
|
1502
|
+
console.log(formatResult(pgserveRam, '⚡', C.magenta, 'pgserve (RAM):'));
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Save detailed JSON report
|
|
1508
|
+
generateReport(results, vectorResults);
|
|
1509
|
+
|
|
1510
|
+
// Print final results table with scores
|
|
1511
|
+
printFinalResults(results, vectorResults, canUseRam);
|
|
692
1512
|
|
|
693
|
-
console.log(
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1513
|
+
console.log(`${C.cyan}${C.bold}╔════════════════════════════════════════════════════════════════╗
|
|
1514
|
+
║ Benchmarks Complete! ║
|
|
1515
|
+
║ ║
|
|
1516
|
+
║ Try it yourself: npx pgserve ║
|
|
1517
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}
|
|
1518
|
+
`);
|
|
698
1519
|
}
|
|
699
1520
|
|
|
700
1521
|
main().catch(console.error);
|