Vector search is ~50× slower than Qdrant at 21M scale (wiki_dpr_e5 disk-rescore benchmark)
Labels: benchmark, performance, engine, investigation
Component: engine / TurboVec serving path
Summary
On the wiki_dpr_e5 workload from the Elastic-DiskBBQ-vs-Qdrant benchmark exchange
(21,015,300 × 768d, recall@100, closed-loop concurrency 4), LodeDB is projected to be
~50× lower throughput than Qdrant at 21M vectors, at lower recall (0.94 vs 0.96).
The gap is structural, not a tuning miss:
- The serving path is an exact O(N) SIMD scan — throughput scales as
1/N, while
Qdrant's HNSW is ~flat in N. At 1M we're within ~2.5×; at 21M the gap is ~50×.
- There is no fp32 rescore (codes-only storage), so recall caps at ~0.94 at 4-bit.
- The opt-in cluster-prune ANN cannot close it: it masks the blocked layout at
32-vector granularity over an insertion-ordered layout, so speedup ≈ 1/(32·f)
where f = candidate fraction. Reaching 0.96 recall needs f ≈ 20% → block-skip
saves ~nothing → ANN ≈ slower than exact.
This issue is a tracking / decision record, not a request to fix serving latency.
The recommendation is to not publish this benchmark as a head-to-head and to reframe
on LodeDB's actual strengths (footprint, zero-infra, sub-2M scale). Filed so the analysis
and repro are preserved.
Environment
- Host: Apple M5 (10-core), 32 GB, macOS (Darwin 25.4.0) — local spike, in-process.
Numbers are generous vs the benchmark's Graviton + network-client setup.
- LodeDB
1.3.1, native core built from source (uv sync), numpy 2.4.
- Harness:
benchmarks/wiki_dpr_disk_rescore/ (branch feat/wiki-dpr-disk-rescore-benchmark).
Reproduce
Data prep (streams shards into an on-disk float32 memmap, samples self-retrieval
queries, computes exact fp32 top-100 ground truth):
# 1. Fetch dataset shards (~80 GB) — e.g. huggingface_hub snapshot_download
# kenhktsui/wiki_dpr_e5, allow_patterns=["data/*.parquet"] into <shards>/data
# 2. Prepare a working scale (1M validates the trend; O(N) makes 21M an ÷21 extrapolation)
python benchmarks/wiki_dpr_disk_rescore/data_prep.py \
--shards-dir <shards>/data --out <data>/spike_1m \
--target-rows 1000000 --n-queries 1000
Exact 4-bit baseline (ingest + recall@100 + sequential + closed-loop-4):
python benchmarks/wiki_dpr_disk_rescore/lodedb_bench.py \
--data <data>/spike_1m --store <data>/store_1m_bw4 --bit-width 4 --loop-seconds 20
ANN cluster-prune operating point (constructor-time; each nprobe = its own store):
python benchmarks/wiki_dpr_disk_rescore/lodedb_bench.py \
--data <data>/spike_1m --store <data>/store_1m_ann16 --bit-width 4 \
--ann-clusters 1000 --ann-nprobe 16 --loop-seconds 20
Full 21M / serving-node-parity (4 vCPU / 16 GB) run via Modal — written, not yet run:
modal run benchmarks/wiki_dpr_disk_rescore/modal_bench.py::download
modal run benchmarks/wiki_dpr_disk_rescore/modal_bench.py::prepare
modal run benchmarks/wiki_dpr_disk_rescore/modal_bench.py::main
Measured results (Apple M5, in-process)
| Scale / config |
recall@100 |
seq latency |
closed-loop-4 QPS |
batch QPS |
| 1M, 4-bit exact |
0.9401 |
22 ms |
45.9 |
329 |
| 1M, ANN 1000cl/nprobe16 |
0.877 |
12.5 ms |
76.9 |
— |
| 500k, 4-bit exact |
0.9453 |
5.7 ms |
— |
— |
ANN recall/speedup frontier (100k, 1000 clusters), tracing the curve:
| nprobe |
candidate frac |
recall@100 |
speedup vs exact |
| 3 |
0.3% |
0.6559 |
1.25× |
| 15 |
1.5% |
0.8679 |
1.02× |
| 60 |
6% |
0.9348 |
0.68× (slower) |
| 200 |
20% |
0.9510 |
0.38× (slower) |
Reference (published, not rerun here): Qdrant two-stage 111.9 QPS / 35.7 ms @ 0.9596
(3×4vCPU/16GB); Elastic DiskBBQ 32.4 QPS / 122.6 ms @ 0.96 (3×7vCPU/26GB).
Analysis
- O(N) scan, cores already saturated. The TurboVec scan is rayon-parallel
(into_par_iter, third_party/turbovec/turbovec/src/search.rs), so one query uses all
cores and closed-loop concurrency ≈ single-thread throughput (GIL is not the lever —
shared / per-thread / per-process closed-loop modes all land near single-thread). 1M→21M
throughput ÷21 ⇒ ~2 QPS closed-loop vs Qdrant's ~flat 112.
- 500k→1M is super-linear (5.7→22 ms): memory-bandwidth-bound at scale, so 21M is
likely worse than the linear ÷21 estimate.
- ANN is a bitmask over the blocked layout. A 32-vector block is skipped only if all
32 slots miss (block_has_allowed, search.rs:1314); the layout is insertion-ordered so
candidates scatter. Blocks-scanned ≈ 1 − e^(−32f) ⇒ speedup ≈ 1/(32·f). Validated:
predicts 1.95× at the 1M nprobe=16 point vs 1.8× measured. Cluster count only sets the
granularity of f (capped at 4096, crates/lodedb-core/src/engine/mod.rs:5065); it does
not change the speedup-at-fixed-recall curve. Reaching 0.96 recall ⇒ f ≈ 20% ⇒ ANN
≈ slower than exact.
- No fp32 rescore (codes-only) ⇒ recall caps ~0.94 at 4-bit; no lever to reach 0.96.
Options (for discussion — none scoped here)
- Don't publish head-to-head; reframe on footprint (~8 GB 4-bit codes vs
60 GB fp32
kept for rescore), zero-infra embedded deployment, and ≤2M scale where exact scan wins.
(Recommended.)
- Cluster-contiguous blocked layout (reorder codes by cluster so inverted lists are
contiguous) — would let block-skipping actually prune; decouples f from scatter.
- A real sublinear index (HNSW / DiskANN-style) — closes the gap but is a major engine
project and a philosophical shift from the exact-scan core.
- fp32 rescore tier — needed to exceed the ~0.94 recall ceiling; contradicts codes-only
storage.
Open item
Not measured: whether finer clusters improve recall-per-candidate enough to matter (the
4096-cluster run was cut for time). The 1/(32·f) speedup curve is scatter-bound regardless,
so even optimistically this is break-even-with-exact at 0.96 recall, not a fix. The finished
4096-cluster sweep (~25 min, k-means build dominated) would close this out.
Vector search is ~50× slower than Qdrant at 21M scale (wiki_dpr_e5 disk-rescore benchmark)
Labels:
benchmark,performance,engine,investigationComponent: engine / TurboVec serving path
Summary
On the
wiki_dpr_e5workload from the Elastic-DiskBBQ-vs-Qdrant benchmark exchange(21,015,300 × 768d, recall@100, closed-loop concurrency 4), LodeDB is projected to be
~50× lower throughput than Qdrant at 21M vectors, at lower recall (0.94 vs 0.96).
The gap is structural, not a tuning miss:
1/N, whileQdrant's HNSW is ~flat in
N. At 1M we're within ~2.5×; at 21M the gap is ~50×.32-vector granularity over an insertion-ordered layout, so
speedup ≈ 1/(32·f)where
f= candidate fraction. Reaching 0.96 recall needsf ≈ 20%→ block-skipsaves ~nothing → ANN ≈ slower than exact.
This issue is a tracking / decision record, not a request to fix serving latency.
The recommendation is to not publish this benchmark as a head-to-head and to reframe
on LodeDB's actual strengths (footprint, zero-infra, sub-2M scale). Filed so the analysis
and repro are preserved.
Environment
Numbers are generous vs the benchmark's Graviton + network-client setup.
1.3.1, native core built from source (uv sync), numpy 2.4.benchmarks/wiki_dpr_disk_rescore/(branchfeat/wiki-dpr-disk-rescore-benchmark).Reproduce
Data prep (streams shards into an on-disk float32 memmap, samples self-retrieval
queries, computes exact fp32 top-100 ground truth):
Exact 4-bit baseline (ingest + recall@100 + sequential + closed-loop-4):
python benchmarks/wiki_dpr_disk_rescore/lodedb_bench.py \ --data <data>/spike_1m --store <data>/store_1m_bw4 --bit-width 4 --loop-seconds 20ANN cluster-prune operating point (constructor-time; each nprobe = its own store):
python benchmarks/wiki_dpr_disk_rescore/lodedb_bench.py \ --data <data>/spike_1m --store <data>/store_1m_ann16 --bit-width 4 \ --ann-clusters 1000 --ann-nprobe 16 --loop-seconds 20Full 21M / serving-node-parity (4 vCPU / 16 GB) run via Modal — written, not yet run:
Measured results (Apple M5, in-process)
ANN recall/speedup frontier (100k, 1000 clusters), tracing the curve:
Reference (published, not rerun here): Qdrant two-stage 111.9 QPS / 35.7 ms @ 0.9596
(3×4vCPU/16GB); Elastic DiskBBQ 32.4 QPS / 122.6 ms @ 0.96 (3×7vCPU/26GB).
Analysis
(
into_par_iter,third_party/turbovec/turbovec/src/search.rs), so one query uses allcores and closed-loop concurrency ≈ single-thread throughput (GIL is not the lever —
shared / per-thread / per-process closed-loop modes all land near single-thread). 1M→21M
throughput ÷21 ⇒ ~2 QPS closed-loop vs Qdrant's ~flat 112.
likely worse than the linear ÷21 estimate.
32 slots miss (
block_has_allowed,search.rs:1314); the layout is insertion-ordered socandidates scatter. Blocks-scanned ≈
1 − e^(−32f)⇒speedup ≈ 1/(32·f). Validated:predicts 1.95× at the 1M nprobe=16 point vs 1.8× measured. Cluster count only sets the
granularity of
f(capped at 4096,crates/lodedb-core/src/engine/mod.rs:5065); it doesnot change the speedup-at-fixed-recall curve. Reaching 0.96 recall ⇒
f ≈ 20%⇒ ANN≈ slower than exact.
Options (for discussion — none scoped here)
60 GB fp322M scale where exact scan wins.kept for rescore), zero-infra embedded deployment, and ≤
(Recommended.)
contiguous) — would let block-skipping actually prune; decouples
ffrom scatter.project and a philosophical shift from the exact-scan core.
storage.
Open item
Not measured: whether finer clusters improve recall-per-candidate enough to matter (the
4096-cluster run was cut for time). The
1/(32·f)speedup curve is scatter-bound regardless,so even optimistically this is break-even-with-exact at 0.96 recall, not a fix. The finished
4096-cluster sweep (~25 min, k-means build dominated) would close this out.