Skip to content

btree: interpolation search for rowid seeks using parent-page knots - #2311

Draft
timohuovinen wants to merge 1 commit into
tursodatabase:mainfrom
timohuovinen:interp-seek-rowid
Draft

timohuovinen wants to merge 1 commit into
tursodatabase:mainfrom
timohuovinen:interp-seek-rowid

Conversation

@timohuovinen

Copy link
Copy Markdown

Closes #2310

What

sqlite3BtreeTableMoveto() binary-searches every page of a rowid b-tree and ignores the key values. Rowids are integers and usually close to evenly spaced within a page. When the search descends into a child page, it already knows that child's key range: the interior cell pointing at the child holds the child's largest rowid, and the cell to its left bounds it from below.

This PR uses those two bounds ("knots") to decide where the existing search loop probes:

  1. The first probe goes to the slot predicted by linear interpolation between the knots.
  2. The next probes step 1, 2, 4, … cells away from it until the rowid is bracketed.
  3. The loop then continues with its usual (lwr+upr)/2.

The loop's existing < and > branches record the nearest keys on each side, and those become the knots of the child it descends into. A page that lacks a knot (the root, and pages on the left or right edge of the tree) reads its first or last cell to get one.

  • Same results as binary search. Only the probe position changes, and any probe inside [lwr,upr] keeps the binary-search invariant. On well-formed pages the result is identical, and the worst case is still O(log n). Stale separator keys only cost extra probes. A new assert() checks that every probe stays inside the bracket.
  • Scope.
    • Applies to table-b-tree pages with at least 16 cells (LIBSQL_INTERP_MIN_CELLS).
    • Not used for BTREE_APPEND seeks.
    • Index b-trees are untouched.
    • No change to the file format or the API.
  • Opt-out. -DLIBSQL_OMIT_INTERP_SEEK compiles it out. Builds with SQLITE_OMIT_FLOATING_POINT leave it out automatically.
  • Files.
    • src/btree.c: +103 lines, all inside sqlite3BtreeTableMoveto() plus a small inline helper.
    • The same change in both bundled amalgamations.
    • New test/interpseek.test.
    • A section in doc/libsql_extensions.md.

Benchmarks

Measured on a Ryzen 7 9700X under WSL2 with gcc 14.2 -O2, pinned to one core.

  • Interleaving. Variants run interleaved in rotating order.
  • Fresh binary copies. Every run executes a fresh copy of the binary. The page cache keeps a binary's code at fixed physical addresses, which biased every run of one file by up to 13% on a test that never runs this code.
  • What the tables show. Medians, plus how often the patch won.

Cells compared per seek (1M rows, 4 KiB pages, 200k random lookups; this count is deterministic):

rowids dense gaps random 64-bit clustered skewed u⁴ zipf
binary search (current) 19.2 19.2 19.2 19.1 19.1 19.2
this PR 7.1 7.2 11.1 10.5 19.8 19.6

Micro-benchmark (1M rows, 4 KiB pages, 1M operations per test, 5 rounds). The table shows the time change against current libSQL. The patch is faster in all 30 cells and in 150 of 150 paired samples; the geomean time ratio is 0.768.

rowids point hit point miss range seek rowid join random insert
dense −35% −27% −31% −37% −18%
gaps −35% −31% −28% −33% −20%
random 64-bit −18% −20% −17% −21% −18%
clustered −29% −33% −24% −33% −9%
skewed u⁴ −13% −37% −11% −15% −8%
zipf −11% −31% −12% −13% −10%

On the skewed distributions, interpolation needs about 3% more probes, but they land next to the guess, in the same cache lines, so lookups are still faster.

speedtest1 (user-mode CPU cycles from hardware counters).

Code-layout effects on this machine reached ±6% per test, even for tests that never run this code. To remove them, base and patch were each linked with 6 different random function orders (lld --shuffle-sections). "Wins" counts the (base layout, patch layout) pairs, out of 36, in which the patch was faster.

test --size 100 file --size 500 --memdb
170 SELECT text BETWEEN, indexed (index → rowid) −18.3% (36/36) −16.9% (36/36)
310 four-way joins −12.3% (36/36) −20.7% (36/36)
400 REPLACE on IPK −16.8% (36/36) −17.3% (36/36)
410 SELECT on IPK −13.2% (36/36) −27.6% (36/36)
240 UPDATE of individual rows −9.6% (36/36) −12.1% (36/36)
280 DELETE of individual rows −3.9% (36/36) −6.0% (34/36)
230 UPDATE numeric BETWEEN, indexed −2.9% (36/36) −3.4% (35/36)
160 / 161 numeric BETWEEN, indexed / PK −3.2% / −2.9% −1.9% / −2.7%
270 DELETE numeric BETWEEN, indexed +0.3% (9/36) +1.3% (10/36)
100 INSERT, append +0.5% (18/36) +0.7% (19/36)
all 32 tests, geomean −2.7% −3.3%
  • Tests that don't seek by rowid (the other 21) execute the same number of instructions within ±0.4%, and their cycle changes are within the layout noise.
  • Test 270 (indexed DELETE) is neutral. Deletes leave sparse pages and stale separators, so interpolation saves fewer probes there (13.8 → 10.1 per seek), and its instruction count is unchanged (−0.2% / +0.3%).

Correctness

  • TCL suite: testrunner.tcl veryquick, 1,208 files: 0 errors out of 328,573 tests.
  • CI make test steps: srctree-check and sourcetest pass. fuzztest reports 0 errors out of 46,388 in fuzzcheck and 0 crashes in 339 sessionfuzz cases.
  • Debug build with the new path forced (SQLITE_DEBUG with LIBSQL_INTERP_MIN_CELLS=2, so it runs on almost every page, with the bracket assertion on):
    • fuzztest: 0 errors and 0 crashes.
    • veryquick: 1 error out of 328,844, in altercorrupt-1.1. That test opens a deliberately corrupt database, and the forced build reports the corruption as error in index … orphan index instead of the expected database disk image is malformed. On a corrupt page the keys aren't sorted, so a different probe order can find a different symptom first.
    • With the default threshold this test passes.
  • New test/interpseek.test (61 tests, about 7 s):
    • Loads six rowid distributions into an INTEGER PRIMARY KEY table (dense, gaps, random, skewed, negative, INT64 edges) and into a mirror table that can only be scanned.
    • Compares =, <, <=, >, >= and BETWEEN at page sizes 512 and 4096.
    • Repeats the comparison after deletes, updates and replaces that leave separator keys stale, then runs integrity_check.
    • Probing one cell outside the bracket (an off-by-one in the step check) makes it crash.
  • Differential test: 8 seeds × 4 rowid distributions × page sizes 512 B–64 KiB × 40k mixed seeks, inserts, replaces, updates and deletes. The result hash is identical for current libSQL, this patch, the patch built with LIBSQL_OMIT_INTERP_SEEK, the forced debug build, and an ASan+UBSan build (-fno-sanitize-recover=all).
  • Rust: cargo test -p libsql --test integration_tests passes 31 tests (1 ignored by default). cargo test -p libsql --features encryption --test encryption, which builds the SQLite3MultipleCiphers amalgamation, passes.

Notes

  • Not tested: the CI configuration with --enable-wasm-runtime-dynamic and make rusttestwasm (no WasmEdge locally), and the full cargo nextest run workspace.
  • Measurement environment: WSL2. The tables use paired, interleaved runs and the deterministic probe counts so that they don't depend on absolute timings.
  • Possible follow-up: the same idea could apply to index b-trees whose first column is an integer.

sqlite3BtreeTableMoveto() binary-searches every page and ignores the key
values.  Rowids are integers and usually close to evenly spaced within a
page, and the search already knows each child's key range when it
descends: the interior cell pointing at the child holds the child's
largest rowid and its left neighbour bounds the child from below.

Use those bounds ("knots") to choose where the existing search loop
probes: first the interpolated slot, then steps of 1, 2, 4, ... cells
away from it until intKey is bracketed, then the usual (lwr+upr)/2.  The
loop's own < and > branches record the nearest keys on each side, which
become the knots of the child; a page that lacks a knot (the root and the
left/right edge of the tree) probes its first or last cell to get one.

Only the probe position changes.  Any probe inside [lwr,upr] keeps the
binary-search invariant, so results are identical to binary search on
well-formed pages and the worst case stays O(log n); stale separators
only cost probes.  An assert() checks that every probe stays inside the
bracket.  Applies to pages with at least LIBSQL_INTERP_MIN_CELLS (16)
cells, not to BTREE_APPEND seeks.  No file-format or API change.
-DLIBSQL_OMIT_INTERP_SEEK compiles it out; builds with
SQLITE_OMIT_FLOATING_POINT leave it out automatically.

Cells compared per seek (1M rows, 4 KiB pages) fall from 19.2 to 7.1 for
dense rowids and to 11.1 for random 64-bit rowids.  Rowid point lookups,
range seeks, joins and inserts are 8-37% faster across six key
distributions; speedtest1's rowid-seeking tests are 2-28% faster and the
other tests run the same instruction count.

The same change is applied to both bundled amalgamations.  Adds
test/interpseek.test and documents the options in
doc/libsql_extensions.md.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Faster rowid seeks: interpolation search inside table b-tree pages

1 participant