tidaldb/docs/profiling/scale-baselines.md
jx12n bb21e69ae6 feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning
m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe +
POST /vector_search (standalone + region node, merge-by-distance) +
tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force
cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit).
Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs.
Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force.

m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference
vector, related=seed embedding (similar_to), graceful scan-fallback. Cached
per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so
trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains
--feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms.

m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion,
shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover
usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs.
Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears
G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%).
Recall corpus is now clustered (Gaussian mixture) in grid + harness.
2026-06-14 11:07:09 -06:00

278 lines
14 KiB
Markdown
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.

# Scale Benchmarks: 1M-Item Baselines
## Hardware
macOS Darwin 23.6.0 (Apple Silicon / x86-64 — see run date below).
**Run command:**
```bash
cargo bench --manifest-path tidal/Cargo.toml --bench scale
```
**Date:** 2026-02-23
## Dataset
| Parameter | Value |
|-----------|-------|
| Items | 1,000,000 |
| Creators | 10,000 (100 items/creator) |
| Categories | 20 |
| Embedding dim | 128 (not 1536 — reduced for bench RAM) |
| Signal coverage | 10% view, 5% like |
| Bench tool | Criterion (sample_size=10, 30s measurement, Flat mode) — **closed-loop, single-threaded; reports the mean, not the tail** |
## Measurement contract (read before trusting any number below)
> The `time:` figures Criterion reports are **single-threaded mean per-op cost
> under a closed loop** — the `[lower mean upper]` triple is a *confidence
> interval on the mean*, **not** a latency distribution. They are
> **regression tripwires**, not tail-SLO evidence.
>
> A `p99`/`p999`/tail SLO can only be honored by an **open-loop,
> coordinated-omission-corrected** measurement under real concurrency (the
> `tidal-stress` ramp). A closed-loop mean cannot observe the tail it hides:
> when the system stalls, a closed-loop harness simply *stops sending*, so the
> queue that would inflate p99 never forms. Reporting a mean under a `p99` label
> understates the tail by 10100× under load.
>
> Therefore every row below is labelled **isolated per-op cost (mean)**. Tail
> SLOs are validated separately — see `tidal-stress` (open-loop) for the
> authoritative p99/p999 under the production workload.
## Acceptance Criteria (isolated per-op cost — regression tripwires, NOT tail SLOs)
| Benchmark | Tail SLO (validated open-loop) | Isolated per-op cost (mean, closed-loop) | Tripwire |
|-----------|--------------------------------|------------------------------------------|----------|
| RETRIEVE | < 50ms p99 | **152 µs** (for_you) | well under |
| SEARCH | < 100ms p99 | **28.9 ms** (text_only) | well under |
| Signal write | < 100µs p99 | **82 ns** (rotating 1K) | well under |
The mean per-op costs sit far under the tail targets a necessary but **not
sufficient** condition for the p99 SLO. "Well under" means the *mean* clears the
target with headroom; the p99 itself is signed off only by the open-loop
`tidal-stress` run, never by this table.
## Benchmark Results
### RETRIEVE (1M items)
```
retrieve_1m/for_you time: [151.88 µs 152.13 µs 152.40 µs]
retrieve_1m/trending time: [127.96 µs 128.25 µs 128.52 µs]
retrieve_1m/new_filtered time: [ 7.5636 µs 7.5855 µs 7.6058 µs]
```
**All RETRIEVE queries < 200µs.** The 50ms target is beaten by 3 orders of magnitude.
- `for_you`: signal-scored ranking over full 1M-item universe 152µs
- `trending`: windowed view count ranking 128µs
- `new_filtered`: category filter at ~5% selectivity 7.6µs (bitmap pre-filter eliminates 95% of candidates)
### SEARCH (1M items)
```
search_1m/text_only time: [28.844 ms 28.934 ms 29.021 ms]
search_1m/text_filtered time: [ 1.8972 ms 1.9104 ms 1.9220 ms]
```
**Both SEARCH queries < 30ms.** The 100ms target is beaten by 3-50×.
- `text_only`: BM25 over 1M documents 28.9ms (most expensive path; dominated by Tantivy posting list traversal)
- `text_filtered`: BM25 with category filter reduces candidate set 1.9ms
### Signal Write (1M-item DB, rotating 1K entities)
```
signal_write_1m/write_rotating_1k_entities time: [82.033 ns 82.286 ns 82.535 ns]
```
**82 ns per write.** The 100µs target is beaten by 1,200×. DashMap hot-path write amortises to sub-100ns across 1K rotating entity IDs.
## Setup Notes
The `LazyLock<TidalDb>` pattern ensures the 1M-item database is built exactly once per bench run. Build time ~30s on the reference hardware above. The text syncer waits 3s after ingestion.
## Database Build Time
Approximately **30 seconds** on reference hardware (observed from `[scale bench] Database ready` log line).
## Analysis
tidalDB's **isolated per-op mean cost** sits well within all three
acceptance-criteria targets at 1M items. The dominant cost is SEARCH text_only at ~29ms driven by Tantivy posting list traversal across 1M documents. The LogMergePolicy tuning (< 20 segments at steady state) keeps this below the 100ms target with headroom. **The p99 tail SLOs themselves are signed off by the open-loop `tidal-stress` ramp, not by these closed-loop means** (see the measurement contract above).
Signal writes at 82ns confirm the DashMap hot-path is not a bottleneck at this scale. The 5M-entry LRU trimming threshold (DEFAULT_MAX_SIGNAL_ENTRIES) provides ample headroom for the 100K-item signal coverage in this benchmark (~200K entries = ~218MB).
## Read-recall harness — recall@k + true p99 (m12p1, open-loop)
Every number above is an *isolated per-op mean* and says nothing about **recall**
the fraction of the true nearest neighbours an ANN query actually returns.
m12p1 adds the open-loop harness that measures recall@k AND true p99 at the
production shape, so G1 (p99 10ms) and G2 (recall@10 0.95) can be steered
against real numbers instead of absent ones.
### How it works
1. `tidal-stress --verify-recall` seeds the corpus with **deterministic,
id-keyed** embeddings, so the generator reconstructs the exact indexed vectors
and computes a **brute-force cosine ground truth** in RAM (the exact answer the
HNSW index approximates).
2. It ramps `POST /vector_search` probes **open-loop** (coordinated-omission
corrected) and scores each response's `recall@k` against the precomputed
ground truth, recording the **true p99** (a genuine tail, not a closed-loop
mean).
3. The verdict reports the **read-knee**: the highest sustained QPS at which
`p99 ≤ target AND recall@k ≥ target` both hold, plus a machine-readable JSON
summary and a `--fail-on-knee` PASS/FAIL exit for the soak gate.
`/vector_search` is a **pure k-NN probe** (`TidalDb::vector_search_items`): raw
HNSW nearest neighbours with NO profile scoring, fusion, or diversity, so the
number is the ANN index quality in isolation exactly the G2 metric. It is a
single-index measurement: run it against a standalone node or a cluster at the
S=1 shape (every replica holds the full corpus). RAM for the brute-force oracle
is `corpus × dim × 4` bytes (~0.6 GB at 100k/1536D, ~6 GB at 1M/1536D).
### Measured (local, standalone, 1536-dim)
Verified end-to-end against a real standalone server (`--schema` content_vector =
1536, release build, Apple Silicon laptop):
| Corpus / dim | recall@10 (vs brute-force cosine) | per-query cost | p99 @ 100 rps |
|--------------|-----------------------------------|----------------|---------------|
| 20,000 / 1536D | **0.9997** | ~16 ms (single request) | ~37 ms |
The HNSW index (M=16, ef_construction=400, ef_search=200, F16) returns essentially
the exact neighbour set recall@10 0.9997, far above the 0.95 G2 target. The
~1619 ms per-query cost on a single laptop node is well over the 10 ms G1 target
and saturates past ~400 rps; G1 is the work of m12p2 (ANN candidate-gen in
RETRIEVE) + m12p3 (index tuning + per-request `ef_search`), measured by this same
harness at scale.
### Running the exit-gate shape (100k AND 1M)
```bash
# 100k corpus, 1536-dim, against a single-index node:
tidal-stress --target http://<node>:9500 --verify-recall \
--corpus 100000 --embedding-dim 1536 --recall-queries 1000 \
--ramp "200:30,500:30,1000:30,2000:30" \
--read-p99-target-ms 10 --recall-target 0.95 \
--json-summary recall-100k.json --fail-on-knee
# 1M corpus: same, --corpus 1000000 (oracle needs ~6 GB RAM on the generator).
```
The 1M run requires the k3s cluster (RAM + a seeded 1M/1536D corpus) and is
tracked as the cluster-side step of the m12p1 exit gate; the harness, the probe
endpoint, and the verdict are proven locally at 20k/1536D above.
## RETRIEVE with ANN candidate generation (m12p2)
m12p2 replaced the arbitrary low-id scan slice in candidate generation with
relevance-bounded sources: `for_you`/`related` source candidates by ANN
nearest-neighbour (over the user's preference vector / the seed item's
embedding), and `trending` by a cached per-signal-type top-K. Measured against a
real 1536-dim standalone server (release, Apple Silicon laptop):
| Profile | Candidate source | Retrieve feed p99 | G1 (≤10ms) |
|---------|------------------|-------------------|------------|
| `trending` | cached `SignalRanked(view)` top-K | **3.57.7 ms** (6001200 rps, concurrent writes) | met |
| `for_you` | ANN over the user preference vector | ~24 ms (100400 rps) | see below |
- **`trending`** holds p99 10ms because the cached top-K serves candidates in
O(K) (the O(N) rebuild is throttled off the read hot path), so it ranks the
actually-viewed corpus at any id while staying fast even under a concurrent
write stream.
- **`for_you`** is ANN-backed (the candidate recall is the m12p1 `/vector_search`
probe, 0.9997). Its ~24ms p99 on this single laptop node is dominated NOT by
the ANN search (~1.5ms raw at 10k/1536D see `ann_recall_at_10_1536d` in
`benches/vector.rs`) but by the **Stage-3 preference boost**, which reads each
of the ~240 candidates' embeddings from storage and recomputes cosine to the
preference vector. That O(K) per-candidate recompute is the m12p3 target the
risk register's "materialized-score layer" / reuse the ANN distance already
computed in candidate-gen. The absolute 10ms at 1M is an m12p3 (`ef_search`
tuning) + production-hardware result; m12p2 delivered the algorithmic change
(relevance-bounded candidate generation) and surfaced the remaining bottleneck.
Reproduce the per-profile retrieve p99 with the harness:
```bash
# for_you (ANN): build preference vectors, force the profile, gate at 10ms.
tidal-stress --target http://<node> --mix "feed=1" \
--feed-profile for_you --seed-preferences \
--corpus 100000 --embedding-dim 1536 --users 10000 --max-p99-ms 10
# trending (cached SignalRanked): peach mix writes views; force the profile.
tidal-stress --target http://<node> --mix peach --feed-profile trending --skip-seed \
--corpus 100000 --embedding-dim 1536 --max-p99-ms 10
```
## Index tuning + recall/memory frontier at the production shape (m12p3)
m12p3 tuned the HNSW at the **production shape (1536-D)** earlier tuning was
only validated at 128-D and characterized the recall/latency/memory frontier
with a real exact oracle. The full method, the chosen `M`/`ef`, and the
reproduce commands live in [usearch-tuning.md](usearch-tuning.md); the headline
recall + per-1M memory sizing is below.
The numbers come from `cargo run --release --example ann_grid_search`: a
deterministic id-keyed corpus, an exact `BruteForceIndex` (F32) oracle, recall@10
scored per query, and the index's true `memory_usage()` extrapolated to 1M.
The corpus is a **Gaussian mixture** (clustered), not uniform-random: uniform
high-dimensional vectors are pathological for recall@k (every pair orthogonal,
so top-k is an arbitrary draw from an equidistant shell recall@10 measured
~0.97 at 10k but ~0.54 at 100k, a corpus-size artifact, not an index regression).
Real embeddings cluster on a manifold; the clustered corpus reflects that, so
recall@k is meaningful and scale-stable. Details in
[usearch-tuning.md](usearch-tuning.md).
### Recall@10 at 1536-D (measured, 100k clustered corpus, vs exact brute-force cosine)
| Config (F16) | recall@10 | p99 (raw ANN) | G2 (≥0.95) | G1 (≤10ms) |
|---|---|---|---|---|
| M=16, ef_c=400, ef_s=128 | 0.9970 | 1.0 ms | | |
| **M=16, ef_c=400, ef_s=200 (default)** | **0.9970** | **1.4 ms** | | |
| M=24, ef_c=400, ef_s=200 | 0.9985 | 2.2 ms | | |
| M=32, ef_c=400, ef_s=200 | 0.9975 | 2.9 ms | | |
The production default (M=16, ef_c=400, F16) **clears both G1 and G2 at
100k/1536-D** with wide margin. `ef_search` is the latency lever and recall
saturates by ef_s=128, so the per-query override (m12p3) lets a latency-sensitive
read drop to ef_s=128 (p99 1.0 ms) without losing recall. **Live cross-check:**
`tidal-stress --verify-recall` against a real server returned recall@10 = 1.0000
at 20k/1536-D (clustered), at the default beam and at `--recall-ef-search 400`.
### Per-1M memory sizing by quantization (true measured footprint, extrapolated from 100k)
| Quantization | recall@10 (M=24, ef_c=400, ef_s=400) | mem/1M (GB) | Verdict |
|---|---|---|---|
| F32 | 0.9985 | 10.53 | exact, 2× RAM |
| **F16 (default)** | **0.9960** | **5.53** | 0.25% recall, half RAM **chosen** |
| Int8 | 0.7150 | 3.03 | **rejected**: 28% recall at 1536-D |
The footprint is `USearch`'s true `memory_usage()` (graph links + quantized
vectors), so F16 is ** 5.25.5 GB/1M**, not the ~3.4 GB a vectors-only estimate
(`1536 × 2 B`) gives the ~1.8 GB difference is the HNSW graph. Size pods at
5.5 GB/1M for F16. **Int8's 1.8 GB option is rejected**: it loses a quarter of
the neighbours at 1536-D and would need quantization-aware scaling first.
### Per-query `ef_search` is now honored
The `ef_search` column in the frontier table is a **per-request** knob as of
m12p3 (it was silently ignored before). A low-latency surface can request a
narrow beam and a high-recall surface a wide one against the same shared index,
concurrently see [usearch-tuning.md](usearch-tuning.md#per-query-ef_search-m12p3-now-honored).
`tidal-stress --verify-recall --recall-ef-search <N>` sweeps it open-loop.
### The 1M run
The 100k numbers above fit one laptop (the F32 oracle is 0.6 GB). The 1M
exit-gate run needs 6 GB for the oracle plus the HNSW, so it runs on the k3s
node:
```bash
cargo run --release --example ann_grid_search -- \
--corpus 1000000 --dim 1536 --queries 200 --k 10
```