Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/mimir-bench-baseline-2026-07-18.md

Mimir 100-bench baseline — 2026-07-18

Machine Loki · 12 cores · nornir v0.5.4 · HEAVY (serial, multi-core pass) · target on /mnt/work4t · sccache.

Suite: src/bench_mimir.rs — 94 Mimir benchers (35 populate + 59 search) + 11 existing nornir benchers = 105 rows. All 105 tests green, 0 red (incl. the hard RRF recall guard). Run: cargo run --release --example nornir-bench. See mimir-bench-optimize-loop.md for the cycle procedure.

Throughput key per row is the natural one (rows/keys/edges/chunks/commits/queries/lookups/fuse per sec). cores = telemetry cores_busy_max (of 12).

Optimize cycle 1 — RRF hybrid fusion (SHIPPED, PROVEN)

src/knowledge/fusion.rs fuses the persisted Tantivy BM25 index and the dense cosine kNN by Reciprocal-Rank-Fusion (no new dep). On the planted corpus (half the relevant docs semantic-only, half lexical-only) the dense-only MCP path that ships today gets recall@k = 50%; the RRF hybrid gets 100% — +50 points at ~9 µs fuse cost:

The bench carries a red-when-broken guard (anyhow::ensure! — runs in release, recorded as a bench TestOutcome): if the fusion ever drops below the dense-only path the bench turns red. Plus unit tests in fusion.rs + tests/mimir_fusion.rs.

Optimize cycle 2 — core saturation (SHIPPED, PROVEN)

Rickard's complaint: the populate + search paths run on ONE CPU. Cycle-2 audit (100-bench cores_busy telemetry + a sat_probe wall-clock probe under the same /proc sampler — cargo run --release --example sat_probe -- <probe> <size> <secs> [k]) found the whole suite pinned at 1–2 / 12 cores. Root cause, path by path:

qps cores_busy_max cpu_avg
before (serial join) 13 4 / 12 31 %
after (gatling join) 38 12 / 12 41 %

→ 2.9× throughput, 4→12/12 cores, output byte-identical. (At the suite's k=10 the per-query WAREHOUSE RE-READ dominates, not the join, so the microbench row barely moves — the win lands on the real retrieval where the candidate set is large and the view is warm.)

Ceilings hit (documented, not forced)

Most remaining suite rows are NOT bounded by an our-code serial loop — forcing a gatling map there would be theatre. The real bottlenecks:

Net: the two our-code bulk paths that were serial AND CPU-bound are now gatling — the Graph-RAG join saturates 12/12 when it is the bulk cost. Parallelism is gatling-only (grep -E 'rayon|thread::spawn|thread::scope' on the diff is clean); every conversion is byte-identical with a red-when-broken test.

Optimize cycle 3 — RagnarLog::build sort: tested, NO net win (NOT shipped)

Cycle 2 filed "RagnarLog::build is sort-bound; repoint onto a gatling parallel sort once znippy-zoomies has one." That primitive now exists (znippy_zoomies::gatling_sort::gatling_sort_by, znippy-zoomies 027e4ce — a byte-identical drop-in for slice::sort_by; path-dep already resolves it, no bump needed). Cycle 3 A/B-tested the repoint before shipping — via a new ragnar probe added to examples/sat_probe.rs (serial slice::sort_by vs gatling_sort_by on the exact (key, orig, payload) tuples the build sorts, under the same /proc Sampler; asserts the two orders are byte-identical — they are). 10M keys, Loki, contended:

fixture serial sort_by gatling sort_by verdict
sorted (= the ragnar_build_* bench fixture; ≈ production append-ordered snapshot-ids / ts_micros) 91.8M keys/s · 1 core 57.4M keys/s · 2 cores gatling 1.6× SLOWER
shuffled (adversarial, keys in random order) 14.6M keys/s · 1 core 37.8M keys/s · 2 cores gatling 2.6× faster

Two reasons the repoint is not a win here, both load-bearing:

  1. The input is (near-)sorted. The warehouse commit-log keys are monotone snapshot-ids / ts_micros arriving in append order; the bench fixture ((k*2, k)) is likewise pre-sorted. slice::sort_by is adaptive (timsort) — O(n) single-pass on sorted runs — so a parallel merge-sort does strictly more work and loses (1.6×). gatling only wins on genuinely shuffled input, which this path does not see.
  2. The sort is not the bottleneck. Sort-only throughput is ~578M keys/s (1M, sorted) but the full RagnarLog::build is ~95M keys/s — the STree64 construction

    • Eytzinger layout + allocations dominate, not the sort_by. Even a free sort would barely move the build.

Also: even the shuffled "win" tops out at 2/12 cores — the sort is memory-bandwidth-bound on 24-byte tuples (10M ≈ 240 MB), so it does not approach the 11/12 the core-saturation law targets. Decision: do NOT repoint RagnarLog::build (the lib change was written, A/B-measured, and reverted). The sort primitive is sound and re-usable elsewhere — but the right consumer is a large unsorted collection on a CPU-bound (not bandwidth-bound) hot path, and no such sort exists in the populate/index-build paths (the other candidates — StreeIndex hash sort, event-log sort_by ts — are either small or likewise append-ordered). The ragnar sat_probe arm is kept as the reusable evidence tool. Next-cycle target: move off sorts entirely — the remaining honest saturation levers are the write-cadence ceiling (iceberg_commit_*, ~35 commits/s, Parquet+manifest per commit) and vec_knn_100k brute-force flat-scan → HNSW ANN (done in cycle 4 below).

Optimize cycle 4 — HNSW ANN behind the VectorIndex seam (SHIPPED)

Mimir's dense kNN was a brute-force flat scan (znippy_zoomies::vann SIMD cosine over every row) — the vec_knn_100k 2.85 ms single-query wall. Cycle 4 adds a real HNSW (Hierarchical Navigable Small World) approximate index so the live vector_search MCP path gets O(log n) dense retrieval, keeping the flat scan as the exact 100%-recall oracle + small-corpus fallback.

ANN engine — VENDORED, rayon-free. The two embeddable Rust HNSW crates both break the gatling-only ROOT LAW: hnsw_rs (hnswlib-rs) and instant-distance each pull rayon into their parallel build. So the HNSW is written directly onto std in znippy_zoomies::vann::hnsw — zero new external deps. Proof the crate that gained the code stays clean:

$ cd znippy-zoomies && cargo tree -e normal | grep -i rayon   # → (empty)

(nornir itself shows rayon only transitively via the pre-existing candle embedder — not introduced here and not on the vector path.) Parallelism, when added, goes through this crate's own gatling_forkjoin pool, never rayon. The build is single-threaded (graph mutation is serial); the win is the query descent. Follow-up lever if the 100k build proves too slow single-threaded: parallelise construction onto the gatling pool (see #88 HEAVY sweep).

Wiring.

Recall (PROVEN, red-when-broken). HnswParams default M=16, efConstruction=128, efSearch=64, cosine metric (dist = 1 − cosine). Two gates, both green:

Latency. New benchers nornir.mimir.search.vec_hnsw_{1k,10k,100k} report HNSW p50 + recall@10 + the flat baseline on the same corpus (speedup). Measured numbers land via the bench runner on a quiet box:

Latency/recall table pending the HEAVY sweep on a quiet box (#88); the recall@10 floor (≥0.90 vs the flat oracle) is already proven green by the unit tests above.

POPULATE (35 benchers)

bench throughput/s p50 µs p99 µs cores note
populate.call_edge_index_100k 2689619 53231.8 53487.8 2 —
populate.call_edge_index_10k 5103751 2348.6 2367.1 2 —
populate.call_edge_index_1k 5120226 238.6 246.1 1 —
populate.call_edge_index_1m 2677941 525363.4 528650.7 2 —
populate.embeddings_1k 235636 4243.8 4243.8 1 —
populate.embeddings_20k 368615 54257.2 54257.2 1 —
populate.embeddings_50k 420538 118895.4 118895.4 2 —
populate.embeddings_5k 312623 15993.7 15993.7 2 —
populate.iceberg_commit_16 126 8185.5 13271.2 2 —
populate.iceberg_commit_32 73 14031.8 24734.3 2 —
populate.iceberg_commit_64 40 25469.3 47161.7 2 —
populate.iceberg_commit_8 200 5213.9 7431.0 1 —
populate.linear_build_100k 1085499357 105.8 114.5 1 —
populate.linear_build_10m 89902207 111238.2 111238.2 2 —
populate.linear_build_1k 937734434 1.0 1.3 0 —
populate.linear_build_1m 702505401 1440.7 1489.3 1 —
populate.ragnar_build_100k 160964836 623.1 629.0 1 —
populate.ragnar_build_10k 155094906 60.9 92.9 1 —
populate.ragnar_build_10m 36154780 270654.7 270654.7 2 —
populate.ragnar_build_1k 134200447 6.9 20.4 1 —
populate.ragnar_build_1m 95305094 9092.2 15331.2 1 —
populate.real_src_embed 274275 20946.1 20946.1 2 —
populate.symbol_facts_100k 376986 209904.0 209904.0 2 —
populate.symbol_facts_1k 128449 7535.2 10570.7 2 —
populate.symbol_facts_25k 368690 53380.2 55325.7 2 —
populate.symbol_facts_5k 307552 13544.7 15470.0 2 —
populate.vann_build_100k_d128 9007255 11102.2 11102.2 2 —
populate.vann_build_100k_d256 4650175 21504.6 21504.6 2 —
populate.vann_build_100k_d64 16409885 6093.9 6093.9 2 —
populate.vann_build_10k_d128 9725782 1028.2 1028.2 1 —
populate.vann_build_10k_d256 4894310 2043.2 2043.2 1 —
populate.vann_build_10k_d64 18640128 536.5 536.5 1 —
populate.vann_build_1k_d128 8820676 113.4 113.4 1 —
populate.vann_build_1k_d256 4730839 211.4 211.4 1 —
populate.vann_build_1k_d64 11870845 84.2 84.2 1 —

SEARCH (59 benchers)

bench throughput/s p50 µs p99 µs cores note
search.call_radius_100k 485330 1.9 2.6 2 —
search.call_radius_10k 978010 0.9 1.3 1 —
search.call_radius_1k 1274393 0.7 0.9 1 —
search.callgraph_100k 657 2994.9 3571.6 2 —
search.callgraph_10k 6593 303.4 310.5 2 —
search.callgraph_1k 64761 30.7 34.5 1 —
search.callgraph_500k 108 18470.2 18800.1 2 —
search.context_slice_10k 117 8578.9 9283.4 2 —
search.context_slice_10k_radius 34 29223.7 32781.6 2 —
search.context_slice_1k 593 1661.2 1951.3 2 —
search.context_slice_1k_radius 293 3385.1 3753.8 2 —
search.context_slice_20k 59 16449.6 26191.6 2 —
search.context_slice_5k 208 4768.1 5287.6 2 —
search.context_slice_5k_radius 70 14091.2 15933.1 2 —
search.hybrid_rrf_1k 111612 8.8 14.5 2 recall 50%→100%
search.hybrid_rrf_200 118403 8.3 11.3 1 recall 50%→100%
search.ragnar_as_of_100k 58883676 0.0 0.1 2 2002× vs linear
search.ragnar_as_of_10k 54211536 0.0 0.1 2 188× vs linear
search.ragnar_as_of_10m 20363647 0.0 0.2 5 90316× vs linear
search.ragnar_as_of_1k 64175857 0.0 0.0 1 26× vs linear
search.ragnar_as_of_1m 41419445 0.0 0.2 6 15116× vs linear
search.ragnar_at_exact_100k 22197790 0.0 0.1 5 704× vs linear
search.ragnar_at_exact_10k 33549159 0.0 0.0 3 103× vs linear
search.ragnar_at_exact_10m 12522035 0.1 0.6 12 54203× vs linear
search.ragnar_at_exact_1k 39991408 0.0 0.0 1 13× vs linear
search.ragnar_at_exact_1m 17091859 0.1 0.3 2 5511× vs linear
search.ragnar_boundary_100k 66895586 0.0 0.1 7 5719× vs linear
search.ragnar_boundary_10k 72868300 0.0 0.0 4 616× vs linear
search.ragnar_boundary_10m 29863369 0.0 0.2 8 340331× vs linear
search.ragnar_boundary_1k 88001332 0.0 0.1 1 92× vs linear
search.ragnar_boundary_1m 46361917 0.0 0.1 12 50087× vs linear
search.real_src_vec_knn 2133 421.7 1137.2 12 —
search.sql_agg_500 749 1063.1 4962.7 12 —
search.sql_agg_5k 543 1443.0 4662.8 11 —
search.sql_call_chain_500 478 1977.7 3194.9 5 —
search.sql_call_chain_5k 292 3253.9 6569.8 5 —
search.sql_change_radius_500 323 3103.3 4434.6 6 —
search.sql_change_radius_5k 215 4652.7 6187.3 4 —
search.sql_def_use_500 401 2072.2 6401.4 5 —
search.sql_def_use_5k 454 2152.7 3521.4 5 —
search.sql_point_500 729 977.4 4359.8 6 —
search.sql_point_5k 944 924.9 1881.7 6 —
search.sql_why_lineage_500 865 869.2 2459.8 10 —
search.sql_why_lineage_5k 994 833.1 1740.8 12 —
search.vec_knn_100k 236 3874.9 8143.6 11 —
search.vec_knn_10k 1376 578.1 3804.6 6 —
search.vec_knn_1k 2624 283.2 2030.6 6 —
search.vec_knn_500 3136 251.4 2077.9 10 —
search.vec_knn_50k 443 1845.4 5956.0 11 —
search.vec_knn_batch_100k 1909 532.2 612.6 12 —
search.vec_knn_batch_10k 24330 42.1 50.2 9 —
search.vec_knn_batch_1k 61424 15.3 20.9 6 —
search.vec_knn_batch_500 116473 8.4 10.0 10 —
search.vec_knn_batch_50k 6313 159.4 183.4 12 —
search.vec_knn_k1 793 889.1 6881.5 7 —
search.vec_knn_k10 342 2873.2 8777.7 11 —
search.vec_knn_k100 868 882.6 3268.2 6 —
search.vec_knn_k200 703 1089.0 4165.6 7 —
search.vec_knn_k50 476 1179.3 6900.2 10 —

Optimize cycle 5 — append_symbol_scan fsync batching: tested, NO measurable win (NOT shipped)

Cycle 4 left "iceberg_commit_64 35 commits/s … batch commits" as the top POPULATE lever (see Slowest paths below). Cycle 5 built it and A/B-measured it before shipping — the win did not materialise, so it was NOT shipped.

What was built. IcebergWarehouse::append_symbol_scan_async issued one append_batch → one fast_append → one redb transaction per non-empty table — up to 4 fsyncs per scan. The patch extracts the columnize into symbol_scan_batches(), stages every table through stage_for_multi() / split_for_append(), and commits all of them in one skade::ingest_multi ⇒ 1 fsync — the same cross-table primitive the deep-scan flush (ingest_symbol_scans_async) already uses. split_for_append mirrors skade's own append_parallel_props rule (APPEND_PARALLEL_MIN_ROWS = 8192, one file per core, row-aligned zero-copy slices) so the data-file layout is unchanged, not merely equivalent.

Byte-identity: PROVEN (this part is keepable and reusable). The test warehouse::iceberg::tests::multi_commit_matches_per_table_append_byte_for_byte drives BOTH strategies from the SAME symbol_scan_batches output — one arm replaying the old per-table append_batch loop, one the new single-transaction path — and compares, per table, the Parquet data-file count, per-file byte length, and full payload, plus decoded row equality. Fixture crosses the 8192-row split threshold so both the multi-file and single-file paths are covered. Green, and it genuinely runs (lib test count 926 → 927; not one of the #![cfg(feature)]-silenced files). Notably the payload assert did not fail, which settles an open question: skade's ArrowWriter encode and iceberg's ParquetWriter single-file path emit identical bytes here.

Performance: NO measurable win. Measured on the t14s working box (16 cores, heavily contended — ~60 concurrent fleet cargo processes), light arms only (heavy sweep still owed to an idle box, #88). A first naive BEFORE/AFTER pass suggested iceberg_commit_64 +34 % — that was drift, not signal. Re-measured with two saved binaries run interleaved round-by-round so machine drift hits both arms equally (scratchpad/interleave.sh, median-of-3 per round):

arm R1 B→A R2 B→A R3 B→A (load spike)
iceberg_commit_64 15.7 → 15.8 /s 16.5 → 16.6 /s 12.5 → 11.9 /s
iceberg_commit_8 66.0 → 71.9 /s 71.6 → 71.5 /s 59.5 → 42.6 /s
symbol_facts_1k 46603 → 48859 /s 48644 → 49092 /s 42965 → 27572 /s

In the two clean rounds the arms are statistically indistinguishable (+0.6 % on the target arm); round 3 is a load spike that degraded both. Same binary re-run gave iceberg_commit_64 11.9–16.6 /s across the session — the noise band is wider than any effect being claimed.

Two load-bearing reasons the batching does not pay here:

  1. The benchers structurally cannot show it. mk_scan (bench_mimir.rs) sets features: vec![] and tests: vec![], so every arm writes only 2 of the 4 tables — the benched case is 2 fsyncs → 1, never the full 4 → 1. The 4-table shape is only reachable from the real single-scan callers (bin/nornir.rs, monitor.rs); the deep-scan bulk path already batches.
  2. The fsync is not the bottleneck at these sizes. At 200–5 000 rows the Parquet encode + manifest/metadata rewrite dominates a commit (exactly what the cycle-4 note said: "Parquet+manifest per commit"), so removing one fsync moves ~nothing.

Decision: do NOT ship the write-path change (loop law: keep only if faster AND byte-identical — byte-identical yes, faster no). Patch preserved at /home/rickard/nornir-append-symbol-scan-batching.patch. It remains a genuine crash-consistency improvement (the four tables would flip all-or-nothing instead of a reader being able to observe symbol_facts advanced while call_edges is not) — that is a correctness argument, not a perf one, and is Rickard's call to make on its own merits, not something to smuggle in under a perf banner.

Before this lever is revisited, fix the measurement: give mk_scan non-empty features/tests (or add a 4-table arm) so the benched path matches the shipped one, and re-run on an idle box. A lever whose benefit the suite cannot express is not a proven lever.

Slowest paths (next optimize targets)

Ragnar STree64/Eytzinger point+range lookups beat the LinearScan baseline by 5,500×–340,000× at 1M–10M keys (O(log n) vs O(n)); pure latency microbenches (1 core, exempt from core-saturation).