BenoStreamDB Benchmarking Plan¶
Status: Completed. Owner: BenoStreamDB core.
Companion docs: BENCHMARKING.md (how to run the existing suite),
RESOURCE_LIMITS.md (memory/CPU knobs),
CONCURRENCY.md (writer/reader model).
This document is the complete plan for producing defensible, reproducible, competitor-comparable benchmark numbers for a public announcement. It covers the dataset suite, the competitor set, the harnesses, the resource-envelope rules, the workload classes, the metrics captured, the reporting format, and the methodology required to keep the numbers honest.
1. Goals and non-goals¶
Goals
Report an apples-to-apples recall@k / QPS / p50 / p99 curve for vector search against the standard ANN-Benchmarks datasets, versus reference implementations run under the same core/RAM envelope.
Report lexical (BM25), hybrid (RRF), graph, and SQL/analytical performance against established baselines.
Report production behaviour, not just steady-state throughput: tail latency under concurrent maintenance (compaction/vacuum), recovery time after a crash, provider throttling resilience, and pathological filter/skew cases.
Make every number reproducible from a committed script + a recorded environment, so a third party can regenerate it.
Non-goals
Winning every axis. Overlay indexes trade build cost and storage for query speed and reconstructibility; the report must state where we lose.
Tuned-vs-untuned comparisons. A tuned competitor compared to an untuned BenoStreamDB (or vice versa) is not a benchmark.
2. Datasets (external, standard, citable)¶
All datasets are public and used by existing competitors, so results map onto published numbers.
2.1 Vector (ANN-Benchmarks)¶
Source: http://ann-benchmarks.com (HDF5: train, test, neighbors).
Dataset |
Dim |
Metric |
Notes |
|---|---|---|---|
|
128 |
L2 |
The default; 1M train |
|
960 |
L2 |
High-dim, larger vectors |
|
784 |
L2 |
Mid-size |
|
784 |
L2 |
Mid-size |
|
100 |
Cosine |
NLP embeddings |
|
200 |
Cosine |
NLP embeddings |
|
256 |
Cosine |
Text |
|
64 |
Inner product |
Recommendation |
The harness downloads and caches these under benchmarks/ann_benchmarks/data/.
--limit N subsets the train set for smoke runs; exact ground truth is then
recomputed by brute force.
2.2 Lexical / hybrid (IR)¶
Dataset |
Use |
Notes |
|---|---|---|
BEIR subset (e.g. |
BM25 + hybrid retrieval |
Standard IR eval; nDCG@10 / recall@k |
MS MARCO passage (dev) |
BM25 + hybrid at scale |
Large; use a fixed passage slice |
A synthetic keyword corpus |
pathological filters, high-cardinality terms |
Determinism |
2.3 Graph¶
Dataset |
Use |
Notes |
|---|---|---|
LDBC SNB (SF1/SF10) |
traversal, shortest path, PageRank, communities |
Standard graph benchmark |
SNAP (e.g. |
scale + skew |
Public, citable |
Synthetic RMAT / power-law graphs |
adversarial degree skew |
Deterministic generator |
2.4 SQL / analytical¶
Dataset |
Use |
Notes |
|---|---|---|
ClickBench (hits) |
scan/aggregate/filter throughput |
Industry-standard |
TPC-H (SF1/SF10) |
joins, group-by, ordering |
via DataFusion / Flight SQL |
NYC TLC parquet |
wide-table scans, partition pruning |
Real-world |
3. Competitor set¶
Each competitor is run under the same --cores / --ram-gb envelope as the
BenoStreamDB run (see §5). Adapters live in benchmarks/competitors/.
Tier 1 — vector (must-have for launch)¶
Engine |
Role |
Notes |
|---|---|---|
FAISS (HNSW/IVF/PQ) |
the reference ANN library |
|
hnswlib |
the reference HNSW implementation |
same |
pgvector |
SQL-integrated ANN |
HNSW + IVFFlat; |
LanceDB |
embedded / columnar ANN |
HNSW/IVF_PQ |
Tier 1 — lexical / hybrid¶
Engine |
Role |
|---|---|
Elasticsearch / OpenSearch |
BM25 + kNN + RRF hybrid |
Tantivy |
embedded BM25 reference |
Tier 1 — graph¶
Engine |
Role |
|---|---|
Neo4j (GDS) |
traversal + centrality reference |
NetworkX |
single-threaded reference (correctness + slow baseline) |
GraphX / cuGraph |
distributed / GPU scale reference |
Tier 1 — SQL¶
Engine |
Role |
|---|---|
DuckDB |
embedded OLAP reference |
Trino |
distributed SQL |
ClickHouse |
columnar aggregation |
DataFusion |
same engine family (isolates our overhead) |
Tier 2 — storage / table format¶
Engine |
Role |
|---|---|
Iceberg + Spark/Trino |
table-format interop baseline |
Delta Lake |
format-cost comparison |
Rule: every competitor row in a report must name the version, the exact config, and the command used.
4. Harnesses¶
4.1 Vector: benchmarks/ann_benchmarks/run.py¶
Loads an ANN-Benchmarks HDF5 dataset into a BenoStreamDB table with a vector index, runs
--queriestest queries, and reports standard metrics.Parameter mapping (so the competitor config is matched exactly):
Harness flag
Meaning
Maps to
--mmax neighbours per node
HNSW
M(complexity)--ef-constructionbuild beam width
HNSW
ef_construction(quality)--ef-searchquery beam width
HNSW
efSearch--metricdistance
l2/cosine/inner_product--indexalgorithm
hnsw,hnsw_tq8,hnsw_pq(TQ4 was cut — recall too low)Two latency views are reported:
End-to-end (
to_arrow(vector_filter=...)): index search + row fetch.Pure index (
vector_search_scored): index only, no Parquet payload. This is the number directly comparable to ANN-Benchmarks.
Example:
python benchmarks/ann_benchmarks/run.py \
--dataset sift-128-euclidean \
--cores 8 --ram-gb 16 \
--m 32 --ef-construction 200 --ef-search 200 \
--queries 1000 --out benchmarks/ann_benchmarks/results/sift_tq8.md
4.2 Competitor adapters: benchmarks/competitors/¶
run_competitor.py is the single
runner; each engine is an adapter accepting the same --cores, --ram-gb,
--m, --ef-construction, --ef-search, --metric, --dataset, --queries
arguments and emitting the JSON schema of §6. Implemented so far:
Vector:
faiss,hnswlib(embedded);pgvector(--dsn),lancedb(--path),opensearch(--host).Graph:
networkx(reference),neo4jGDS (--uri),cugraph(GPU),benostreamdb.SQL:
duckdb,datafusion(embedded);clickhouse,trino(server);benostreamdb.Storage / table format: Iceberg round-trip (Spark/Trino) + Delta Lake —
benchmarks/iceberg_roundtrip/run.py.
Adapters report {"available": false, "error": ...} when the client library or
server is missing, so a partial environment still produces a clear report. See
benchmarks/competitors/README.md.
4.3 Production workload harness (to build)¶
Drives the classes in §7 against the Flight SQL gateway and the Python client,
with the Prometheus endpoint (/metrics) scraped for the duration.
5. Resource-envelope rules¶
The engine and the competitor must be given the same cores and RAM. The
harness sets the BenoStreamDB knobs from --cores / --ram-gb:
Env var |
Value |
Purpose |
|---|---|---|
|
|
data-parallel CPU |
|
|
query concurrency cap |
|
|
index build fan-out |
|
|
ingest memory ceiling |
|
|
ingest working set |
|
|
SQL operator memory |
For the competitor, enforce the same envelope with the process’s cgroup, or
docker run --cpus=<cores> --memory=<ram_gb>g, or the library’s own pool size.
Must be recorded per run: CPU model, core count, RAM, OS, disk type, and whether the data is warm (page cache) or cold.
Containerized runs (one profile for everyone).
benchmarks/competitors/docker_bench.sh
applies a single envelope (--cpus / --mem) to every participant — the
server engines and the runner that executes each client, including
BenoStreamDB — so no one gets more resources than another. It records the host
hardware profile (CPU model, cores, RAM, Docker/Compose versions, GPU list) to
results/hardware_profile.txt, and each result’s env carries the
container-visible cores/RAM.
CPU is the baseline. All engines run on CPU under the CPU envelope.
GPU pass runs every engine.
--gpu(or--both) adds a GPU pass (docker-compose.bench.gpu.yml,gpus: all) that runs the same engine set as the CPU pass. GPU-capable engines (FAISS, BenoStreamDB, cuGraph) use the GPU; the CPU-only competitors (pgvector, LanceDB, OpenSearch, Neo4j CPU GDS, DuckDB) run on CPU inside the GPU container under the same envelope — so the comparison shows which engines can actually exploit the GPU. Restrict with--gpu-enginesif a smaller pass is wanted.Results are tagged
{engine}_{dataset}_{device}.jsonwithdevice=cpu|gpu, androllup.mdhas a Device column.Lexical/hybrid (BEIR) uses the same envelope.
docker_bench.sh --workload beirruns BenoStreamDB, Tantivy, and the server-backed OpenSearch in containers under the same--cpus/--memprofile (previously the embedded engines ran in-process on the host while OpenSearch ran in a separately-sized container). BEIR is CPU-bound, so there is no GPU pass; each record carries abackendfield (the backend the algorithm actually ran on). Each engine emits a competitor-schema JSON record ({engine}_{dataset}_{workload}_{backend}.json) thatgenerate_summary.pyrolls into the consolidated report.
6. Metrics and output schema¶
Every run emits a machine-readable record:
{
"engine": "benostreamdb",
"engine_version": "0.13.0",
"dataset": "sift-128-euclidean",
"workload": "vector_ann",
"index": { "type": "hnsw_tq8", "m": 32, "ef_construction": 200, "ef_search": 200, "metric": "l2" },
"envelope": { "cores": 8, "ram_gb": 16, "warm_cache": true },
"build": { "seconds": 123.4, "index_mb": 512.0, "peak_rss_mb": 3100 },
"query": {
"count": 1000,
"recall_at_k": 0.982,
"k": 10,
"qps": 812.3,
"latency_ms": { "p50": 1.10, "p90": 1.60, "p99": 3.40, "max": 9.80 }
},
"env": { "cpu": "...", "os": "...", "disk": "nvme" },
"command": "python benchmarks/ann_benchmarks/run.py ...",
"notes": ""
}
Standard metrics:
Quality: recall@k (ANN), nDCG@10 / recall@k (IR), result-set equality for graph/SQL correctness checks.
Throughput: QPS (single-thread and at a fixed concurrency).
Latency: p50/p90/p99/max.
Cost: build seconds, index size on disk, peak RSS.
Stability: RSS growth over the run, error count, recovery time.
Reports are emitted as Markdown into benchmarks/**/results/ (and
benchmarks/competitive/benchmark_results/) plus the JSON above.
7. Production workload classes¶
Steady-state QPS is not enough for a launch. Each class below is a separate, scripted scenario with its own pass/fail criteria.
Concurrent queries — N clients at fixed concurrency; report QPS and p99/p999; assert no errors and bounded RSS.
p99 under maintenance — saturate readers while compaction and vacuum run; assert p99 stays within a stated multiple of the quiescent p99 and that no committed row is lost or duplicated (see the multi-writer isolation probes).
Recovery time — SIGKILL the process at injected crash points (
src/core/fault_injection.rs), restart, and measure time-to-first-correct-read and any data loss. Must be zero loss (WAL + atomic manifest commit).Object-store throttling — run against a store that fails/slows a fraction of requests (
FaultyStorepattern intests/test_multi_writer_concurrency.rs); assert no corruption and bounded latency inflation.Pathological filters — selective filters that prune almost nothing, high-cardinality
INlists,NOT/wildcard abuse; assert index-vs-full-scan agreement (differential oracle) and report the slowdown.Skewed data — power-law degree distributions (graph) and hot-key filter distributions; report tail latency.
Concurrency correctness — two+ writers on a shared store; assert exact row counts (no lost updates, no duplicate rows) under insert/delete/compact.
Mixed workload soak — long-running, memory-bounded CPU churn with an RSS ceiling; emits
[soak-stats]andsoak-report.md.
8. Methodology and reproducibility¶
Warmup. Discard a warmup phase before measuring; ANN-Benchmarks and the harness must report steady-state numbers, and state whether the index is resident (warm) or being re-read (cold).
Build the extension you measure. The Python numbers come from the native extension (
python/benostreamdb/benostreamdb.abi3.so). Rebuild it withmaturin develop --releasebefore any benchmark run; a stale.soinvalidates the result. (This bit us once — see §11.)Single source of truth for versions. Core, flight, search, dbt, Spark, and Trino versions derive from the core
Cargo.toml; record the resolved version.Determinism. Fixed RNG seeds; fixed query subsets; identical
k.Isolation. Close other heavy processes; pin CPU affinity where possible; state the cgroup/limits.
Repeat and report variance. Run each configuration at least 3 times and report the median plus spread.
Commit the exact command with every result.
9. Reporting format¶
One Markdown file per (engine, dataset, workload) in a
results/directory.A roll-up table per workload family:
Engine
Version
Recall@10
QPS
p50 (ms)
p99 (ms)
Build (s)
Index (MB)
RSS (MB)
A short “method + caveats” section on every report: envelope, warm/cold, and anything that could bias the comparison.
Production classes get a separate report with pass/fail per criterion.
10. Automation and CI¶
Local pre-release soak:
scripts/pre_release_soak.sh <minutes>(default 30;--quick= 1 min) runs differential-oracle, crash-injection, a long memory-bounded soak, and the multi-writer soak, and writessoak-report.md.CI gate:
.github/workflows/release.ymlsoak-gatejob runs the same correctness gates before a release; artifacts includesoak-report.md.Coverage:
.github/workflows/coverage.ymlenforces a line-coverage floor.Benchmark jobs:
.github/workflows/benchmarks.ymlruns the vector, graph, SQL, and BEIR lexical suites on a fixed runner and publishes JSON + Markdown toresults/. The BEIR step runs the embeddedbenostreamdb,tantivybaseline (the single CI runner is inherently equal-envelope); the canonical envelope-controlled run isdocker_bench.sh --workload beir.
11. Known findings and current status¶
Resolved: the ~250 ms → ~205 ms → ~20 ms → sub-ms ANN latency saga¶
The reported per-query latency was not the search algorithm, the manifest, or Parquet I/O. Three compounding defects, found and fixed:
Stale native extension. The first measurements used a
benostreamdb.abi3.sobuilt ~1 hour before the sources. Always rebuild withmaturin develop --releasebefore a run (§8.2). This alone took ~205 ms → ~20 ms.The declared vector index was never built.
add_indexon an empty table registered the config, but the incoming column was alist<double>(float64, e.g. frompandas).build_vector_index(src/core/index/build_vector.rs) silentlyreturned for any inner type other thanFloat32, so no index files were written and every query fell back to a brute-force scan. Fixes:build_vector_indexnow acceptsFloat64and narrows it to the engine’s canonicalFloat32(the engine indexes inf32throughout — HNSW/IVF/PQ/ TurboQuant, distances, and the temp vector file), with a warning.The write path (
src/core/table/write.rs) coerces any column with a declared vector index toFloat32, and the schema-reconcile step now casts mismatched columns to the target type, so data flows into an index declared before any data existed. Non-vector (e.g. string/BM25) indexes are unaffected — the coercion is dtype-guarded to float lists only.
The harness never waited for the background build.
benchmarks/ann_benchmarks/run.pynow callstable.wait_for_background_tasks()aftercommit()(counted inbuild_s) and fails loudly if zero index files were produced, so a silent full-scan fallback can never be published.
Also applied: Table::execute_vector_search_as_scored sets
.with_columns(Some(vec![])) for the explicit no-payload path.
Measured result (SIFT 20k subset, hnsw_tq8, M=16 / efc=200 / ef=200)¶
Metric |
Value |
|---|---|
Index files |
57 |
Index size |
26.1 MB |
Build time |
1.2 s |
recall@10 |
0.950 |
QPS (end-to-end) |
1097.8 |
p50 / p99 |
0.70 ms / 1.17 ms |
QPS (pure index) |
1146.0 |
pure p50 / p99 |
0.87 ms / 1.06 ms |
Artifact: benchmarks/ann_benchmarks/results/sift_20k_tq8.md. Correctness is
still guaranteed by the differential oracle (indexed == full scan), which passes
for HNSW/IVF/PQ/TurboQuant.
Open¶
Inner-product HNSW recall is low on unnormalized vectors. On
lastfm-64-dot(inner product, unnormalized) BenoStreamDB’s recall@10 is ~0.10 vs FAISS’s ~0.99, even though the inner-product and L2 ground truths are identical for that dataset (so the ranking is well-defined). Root cause: the hnswlib-style neighbour-selection heuristic assumes a metric, and inner product is not one;DistDotalso clamps1.0 - dotto>= 0, collapsing every candidate withdot > 1to distance 0. RaisingM/ef_construction/ef_searchdoes not help. Fixing this needs an inner-product-aware graph construction (or normalizing the vectors and using cosine). Tracked for v0.14.0.The full unfiltered scan (
read_async(None,None,None)) is ~16 ms for the 20k table; verify against release builds and decide whether Parquet decode is the floor.The
float64column is now indexed correctly, but the stored Parquet keepslist<double>; declaring vector columns asfloat32at the source avoids the per-build narrowing (and future read-path casts).A pre-existing unbalanced-delimiter bug in
src/core/index/hnsw_rs/arrow_hnsw.rs(unclosed closure in the search loop) was fixed to unblock the release build.
12. Roadmap¶
# |
Item |
State |
|---|---|---|
1 |
ANN-Benchmarks harness ( |
done |
2 |
Resource-envelope |
done |
3 |
Score-only fast path |
done |
4 |
Fix vector-index-never-built (float64) + harness wait/verify |
done |
5 |
|
done |
6 |
Lexical/hybrid harnesses (BEIR, Elasticsearch/OpenSearch, Tantivy) |
done |
7 |
Graph harnesses (LDBC, Neo4j GDS, NetworkX, cuGraph, BenoStreamDB) |
done ( |
8 |
SQL harnesses (ClickBench, DuckDB, DataFusion, ClickHouse, BenoStreamDB) |
done ( |
9 |
Production workload harness (concurrent, maintenance, recovery, throttling, filters, skew, concurrency correctness, soak) |
done |
10 |
Scheduled CI benchmark jobs + results site |
done ( |
11 |
Trino SQL competitor ( |
done |
12 |
Iceberg round-trip (Spark/Trino) + Delta Lake ( |
done |