Gunnar

nordisk/znippy

.nornir/stree.md

# Static search tree (`stree`) — benchmarks & the Ragnar gap
<!-- nornir:class: reasoning -->

The id→position index under the OSM node store (`node_store_arrow.rs` →
`STree64Mmap`) is a **static search tree**, derived from Ragnar Groot
Koerkamp's `static-search-tree`
(<https://github.com/RagnarGrootKoerkamp/static-search-tree>, MIT). Source:
`znippy-zoomies/src/stree.rs`, re-exported into osm-katana as
`crate::stree64`.

This chapter records how it is benchmarked (nornir bencher, **not** criterion),
how it compares to Ragnar's published numbers, and the fixes that closed most of
the gap.

> **Acknowledgement.** The whole data structure — the cache-line S(+)-tree, the
> branchless SIMD compare, the prefetched batch traversal — is Ragnar Groot
> Koerkamp's (MIT, © 2025). Our work here is only measurement and a couple of
> i64-specific tunings on top of his design and his excellent writeup. **Thank
> you, Ragnar.** <https://curiouscoding.nl/posts/static-search-tree/>

## Benched through nornir bencher — no criterion

Per [benching.md](benching.md), znippy has **one** bench suite and it does not
use cargo's bench harness / criterion. The stree benches are `Bencher` impls in
`xtask/examples/nornir-bench.rs`, registered with `register_bench!`:

```
znippy.stree_8mb     result=stree_8mb     log2n=20   (~8 MB, fits L2/L3)
znippy.stree_128mb   result=stree_128mb   log2n=24   (~128 MB, ~L3 on the 3975WX)
znippy.stree_1gb     result=stree_1gb     log2n=27   (~1 GB, RAM-bound; heavy)
```

`stree_1gb` is gated behind `NORNIR_BENCH_SKIP_HEAVY` like the 500 MB stream
benches. Each emits reciprocal-throughput metrics in **ns/query** (the `_ns`
suffix renders "lower is better" — see `nornir::bench::unit_of`):

`binsearch_ns` (std `slice::binary_search` baseline on the same sorted slice),
`serial_ns` (`find_exact`, one at a time), `batch16_ns` / `batch32_ns` /
`batch128_ns` (`batch_stream::<P>`, software-pipelined + prefetched), plus
`build_ms`, `keys`, `data_mb`, and `speedup_x` (binsearch ÷ best batch — our own
"40×" number).

> **Does the bencher need a criterion connection? No.** nornir bencher is a
> deliberate fork *away* from criterion (single-shot real-workload numbers,
> historized by git SHA into Iceberg, gated for >10% regression). The stree
> benches plug straight into that — no `[[bench]]`, no `criterion`, no
> `#[bench]`. The size sweep (cache → RAM) is the methodology; the warehouse is
> the history.

## Ragnar's published numbers

"Static search trees: 40× faster than binary search"
(<https://curiouscoding.nl/posts/static-search-tree/>), 1 GB of uniform-random
`u32` (~250 M keys), single thread:

| method | ns/query |
|---|---|
| `std` binary search | ~1150 |
| Eytzinger binary search | ~200 |
| S-tree SIMD `find` (single) | ~115 |
| + batching (128) | ~45 |
| + prefetching | ~30 |
| + interleave / left-max | ~22–27 |
| 6 threads | ~7 |

The headline **40×** = 1150 → ~27 ns/query single-threaded. The "~9 ns" figure
is the *multi-threaded* best case (~7 ns on 6 threads, RAM-bandwidth limited).
Two things he stresses that matter below: **hugepages** dominate the RAM-bound
regime, and throughput saturates around **P≈16** in-flight queries (≈12 CPU
line-fill buffers) — larger P does not help.

## Our numbers (Threadripper PRO 3975WX, 32c, 128 MB L3)

Sparse sorted `i64` keys (OSM-node-id shaped), 1 M random *present* queries (hit
workload = full-depth traversal). ns/query, best-of-N.

**Default build** (`cargo build --release`, no target flags), after the fixes
below:

| dataset | binsearch | serial | batch16 | best batch | speedup |
|---|---|---|---|---|---|
| 8 MB (in-cache) | 123 | 122 | 41 | 40 | 3.0× |
| 128 MB | 646 | 300 | 65 | 65 | 10.0× |
| 1 GB (RAM) | 1067 | 507 | 96 | 96 | 11.2× |

**`RUSTFLAGS="-C target-cpu=native"`** (how the local planet pipeline is built),
after the fixes:

| dataset | binsearch | serial | batch16 | best batch | speedup |
|---|---|---|---|---|---|
| 8 MB (in-cache) | 111 | 56 | 23 | **19** | 5.8× |
| 128 MB | 646 | 181 | 45 | 44 | 14.8× |
| 1 GB (RAM) | 1057 | 315 | 72 | **66** | **16.0×** |

Our `binary_search` baseline (~1050 ns @ 1 GB) matches Ragnar's ~1150 ns, so the
harness is sound. In-cache (8 MB) we now hit **19 ns/query — squarely in his
range**. At 1 GB native we reach **66 ns** (16×); the residual vs his ~27 ns is
**architectural**, not a bug (see *Remaining levers*).

## The gap we found, and the fixes

The original code measured ~110 ns @ 1 GB and ~55 ns @ 8 MB batched — ~2–4× off
Ragnar even *in cache*. Diagnosed with a size sweep + a `target-cpu=native` A/B:

1. **AVX2 was never inlined in the default build.** `count_lt` ran
   `is_x86_feature_detected!("avx2")` **per node** and called a separate
   `#[target_feature]` function that cannot inline into a non-feature caller — so
   every node compare (≈9 levels × P) paid a branch + a non-inlined call.
   *Fix:* the hot traversals are now generic over the counter (`Cnt: Fn`), and
   each public entry dispatches the AVX2 feature check **once per query/batch**
   into a `#[target_feature(enable="avx2")]` wrapper, so `count_lt_avx2` inlines
   straight into the loop. Scalar path retained as the non-AVX2 fallback. This is
   the bulk of the in-cache win (8 MB batch 55→40 ns default; →19 ns native).

2. **The tree never asked for hugepages.** With `transparent_hugepage=madvise`
   (the common default), a plain `Vec` tree uses 4 KB pages, so every one of the
   ≈9 dependent loads per query is also a dTLB miss at 1 GB. *Fix:*
   `advise_hugepage()` (`madvise(MADV_HUGEPAGE)`) on the tree's backing store in
   both `STree64::new` and `STree64Mmap::new_with_stride`. Best-effort, same
   style as the existing `advise_willneed`. This is the RAM-bound win (1 GB batch
   108→96 ns default, serial 537→507; native serial 537→315).

Both fixes preserve the public API and all 32 correctness tests
(`cargo test -p znippy-zoomies stree::`), which check every batch variant
against serial `find_exact`.

## Remaining levers (not yet taken — need sign-off)

We are at 66 ns vs his ~27 ns at 1 GB native. The residual is inherent to our key
type / layout, not a defect:

- **i64 / B=8 → ~9 tree levels** vs his u32 / B=16 → ~7 levels: ~1.3× more
  dependent cache-line loads per query, baked into using 64-bit OSM ids in a
  one-cache-line node. (The tree is built in RAM at load time from the on-disk
  sorted records — `B` is **not** persisted, so it's a free perf knob, not a
  format decision.)
- **Build the planet binary with `target-cpu=native`.** osm-katana runs locally
  on a known machine; native is a free ~1.5–2× over the default build (table
  above) with zero code change. Not committed as a global `.cargo` flag because
  CI is `ubuntu-latest` and bench history compares same-machine numbers — set it
  for the local planet build / release bench instead.
- **Interleaved + left-max layout** (his last ~30→22 ns step):
  `lookup_batch_interleave` exists but isn't the production default;
  `node_store_arrow.rs` uses `lookup_batch_pipeline::<16>`. P≈16 is correct
  (matches his saturation point; our P=32/128 regress, as his do).
- **Multi-threading** the batch lookup is the path to his ~7 ns — the planet
  node-resolution pass is already chunked and could fan out.

## Tried & rejected: B=16 two-cache-line node

We prototyped Ragnar's larger-node idea at i64 width — `const B = 16` (128 B
node, two cache lines, branching factor 17, `count_lt_avx2` doing 4 AVX2 compares
per node). All 32 correctness tests passed, so it's *viable*; it's just slower
for our workload. Best-batch ns/query, B=8 → B=16:

| dataset | default | native |
|---|---|---|
| 8 MB | 40 → 47 | 15 → 20 |
| 128 MB | 68 → 82 | 45 → 52 |
| 1 GB | 104 → 120 | 74 → 76 |

B=16 *helps the serial single-query path* (1 GB serial 580→457 ns default — fewer
levels, fewer dependent misses), but **regresses the batched path at every
size** — and production (`node_store_arrow.rs`) uses batched
`lookup_batch_pipeline::<16>`. Why: P-way batching already hides the miss latency
that fewer levels would save, so all that remains is B=16's doubled per-node cost
(2 line loads + 4 compares + a longer leaf scan). Reverted to B=8. The
`count_lt_avx2` loop is left B-agnostic (`B/4` compares), so the constant is a
one-line flip if a serial-heavy workload ever wants it. Negative result, but a
clean one — and exactly the trade-off Ragnar discusses for node size.

## Reproduce

```bash
# Full suite (stree_1gb runs):
cargo run -p xtask --release --example nornir-bench
# Skip the 1 GB stree + other heavy workloads:
NORNIR_BENCH_SKIP_HEAVY=1 NORNIR_BENCH_SKIP_PLANET=1 \
  cargo run -p xtask --release --example nornir-bench
# Native (planet-build-equivalent) numbers:
RUSTFLAGS="-C target-cpu=native" cargo run -p xtask --release --example nornir-bench
```