Skip to content

btindex: standalone PrefixIndex with adaptive node distribution (40-62% faster) - #20180

Closed
awskii wants to merge 19 commits into
mainfrom
awskii/prefix-index-standalone
Closed

awskii wants to merge 19 commits into
mainfrom
awskii/prefix-index-standalone

Conversation

@awskii

@awskii awskii commented Mar 26, 2026 •

Copy link
Copy Markdown
Member

New PrefixIndex search engine for domain .kv files, offered as an alternative to BpsTree lookups and gated behind ERIGON_USE_PREFIX_INDEX (default off).

Design

  • [65536]prefixBucket{firstDI, endDI, nodes} — O(1) 2-byte-prefix lookup replaces the binary search over the pivot list
  • up to 8 cached nodes per bucket, evenly spaced, for a second narrowing step
  • L1 (256 entries) computed from L2 at build time, used when the exact 2-byte bucket is empty
  • exact-match shortcut when a cached node equals the search key

Benchmarks

The earlier "40-62% faster" numbers came from a synthetic 1M-key file, which is the most favourable shape possible: 1M keys over 65536 buckets is ~15 keys per bucket, so the node cache nearly indexes every key individually. Re-measured on an idle 12-core box, warm page cache, main merged in so M=64 and interpolation search are both in the baseline, 3 runs per cell, keys sampled across the whole DI range.

Synthetic, 1M keys: Get -46% (734 -> 393 ns), Seek -52% (878 -> 418 ns).

Real mainnet domain files:

file keys Get BpsTree Get PrefixIndex Seek BpsTree Seek PrefixIndex PrefixIndex build
accounts.8192-8704 45.4M 1351 ns 1208 ns (-10.6%) 1780 ns 1057 ns (-40.6%) 1.23 s vs 2 ms
accounts.0-8192 338.9M 1860 ns 1583 ns (-14.9%) 2278 ns 1596 ns (-29.9%) 8.9 s vs 12 ms
code.8192-8704 9.8M 1123 ns 1098 ns (-2.2%) 1477 ns 1013 ns (-31.4%) 1.93 s vs ~0
storage.8960-9088 35.8M 1983 ns 2904 ns (+46.4%) 2371 ns 2942 ns (+24.1%) 4.2 s vs 2 ms

So: Seek is consistently faster, Get is roughly a wash, and storage regresses on both.

Why storage regresses. Its keys are address+slot, so every slot of one contract falls in a single 2-byte bucket. Probes drawn uniformly over keys land in big buckets in proportion to their size, so the effective window is set by the largest contracts, not by the 546-key mean. Accounts hold one key per address, so occupancy is even and the mean is representative — which is why accounts.0-8192 wins despite a higher mean occupancy (5171 keys/bucket) than storage.

TestNarrowingWindowByDensity measures the mechanism without timing noise. BpsTree's window is bounded by M; PrefixIndex's grows with bucket occupancy:

keys/bucket BpsTree window PrefixIndex window
48 62 29
781 62 93

Overlap with the recent .bt work

  • Interpolation search (db/datastruct/btindex: interpolation search in BTree leaf #21813) runs in BpsTree.Get only, not Seek. PrefixIndex has neither. That is the asymmetry in every row above: Get is a wash, Seek is where PrefixIndex wins. Applying interpolation to Seek would capture most of that without a second index. Interpolation's stated benefit is cold-read page locality, and all of these numbers are warm.
  • M 256 -> 64 (denser btree index for domains #22901) shrank BpsTree's window 4x while PrefixIndex's is unchanged. The files measured above still carry M=256 .bts; rebuilt at M=64 the gap narrows further.

Build cost

NewPrefixIndexWithNodes scans the whole .kv at open — 1-9 s per file warm, a full file read cold — against milliseconds for NewBpsTreeWithNodes, which reads its pivots straight from the .bt. Persisting buckets into the .bt (#21872) is a precondition for this to be usable at all, independent of the search-speed question.

Note also that with the flag on, BtIndex builds both engines; bplus is dead weight for lookups but is what makes the A/B benchmark possible in one process.

Fixes in this branch

  • keys shorter than 2 bytes were dropped by record(), so they landed in no bucket and lookup() returned a DI range starting after them: Get reported not-found for keys that exist and Seek skipped past them. Commitment-domain keys are nibble paths and do get this short. TestPrefixIndexMatchesBpsTree is a differential test over mixed-length keys covering both constructors.
  • addNode kept the first 8 pre-built nodes per bucket rather than evenly spaced ones, so a busy bucket's tail had nothing to narrow against. Fixing this moved accounts.0-8192 Get from -2.6% to -14.9% and storage from +72% to +46.4%.
  • two redundant full .kv scans removed from NewPrefixIndexWithNodes.
  • benchmarks wrote the .kv uncompressed and read it back through the compressed getter, so the file carried no pattern dictionary and the measured path was decompression-free.

What would actually compose

Keep all pivots, bucketed, so the window stays bounded by M like BpsTree, and put the O(1) bucket lookup in front of it — rather than a fixed 8-node cache whose quality degrades with bucket skew. Combined with persisting the buckets in the .bt, that keeps the Seek win without the build cost or the storage regression.

Co-Authored-By: Shuo shuo@erigon.dev

awskii and others added 5 commits March 26, 2026 12:59
New PrefixIndex type — drop-in replacement for BpsTree lookups.
Per-prefix bucket architecture with adaptive node filling.

- [65536]prefixBucket{firstDI, endDI, nodes} — O(1) prefix lookup
- Adaptive: supplementary scan fills empty buckets with middle key
- narrowWithNodes: per-bucket binary search (max 8 nodes)
- Exact match shortcut: skip disk search on node cache hit
- L1 computed from L2 at build time

Benchmarks (same .kv file, random access):
  100K keys: Seek 53% faster, Get 62% faster
  1M keys:   Seek 46% faster, Get 37% faster

Co-Authored-By: Shuo <shuo@erigon.dev>
PrefixIndex is only built when ERIGON_USE_PREFIX_INDEX=true.
Default: false (BpsTree only, current behavior).

Usage: ERIGON_USE_PREFIX_INDEX=true ./erigon ...

Co-Authored-By: Shuo <shuo@erigon.dev>
Fix F3: Get returns (nil, false) instead of ErrBtIndexLookupBounds
for non-existent key in last bucket where endDI==count.

Fix F4: addNode copies key bytes via common.Copy to prevent
mutation risk from external nodes slice.

Add 11 correctness tests: boundary keys, exact match, non-existent,
concurrent reads, BpsTree comparison, node key stability.

Co-Authored-By: Shuo <shuo@erigon.dev>
…h/erigon into awskii/prefix-index-standalone
@awskii
awskii force-pushed the awskii/prefix-index-standalone branch from fb47e1a to 37a86f1 Compare March 27, 2026 16:19
Lint fixes:
- Replace bytes.Repeat([]byte{0}, N) with make([]byte, N) (gocritic zeroByteRepeat)
- Replace string comparison with bytes.Equal (gocritic stringXbytes)
- Remove trailing newline (gofmt)

Bench fixes:
- Add missing buildBtreeIndex calls in BenchmarkPrefixIndexGet,
  BenchmarkSeekComparison, and BenchmarkGetComparison so the .bt
  index file exists before OpenBtreeIndexAndDataFile is called
- Add dbg.UsePrefixIndex=true in BenchmarkPrefixIndexSeek so
  bt.search (PrefixIndex) is initialized

Co-authored-by: shuo <shuo@erigon.dev>
@awskii
awskii force-pushed the awskii/prefix-index-standalone branch from 37a86f1 to d6f336b Compare March 27, 2026 16:22
awskii and others added 8 commits March 27, 2026 16:23
…tandalone

# Conflicts:
#	db/datastruct/btindex/bpstree_bench_test.go
…tion

- Remove keyCmpFunc callback from PrefixIndex struct and constructors
- Add compareKey() method using seg.Reader.MatchCmp directly (zero-copy)
- Simplify Seek() binary search: no error return from compare path
- Fix comparison direction (cmp < 0 was inverted)
- Update tests to match new NewPrefixIndex/NewPrefixIndexWithNodes signatures
@awskii

awskii commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

#21872 could encode nodes from prefix tree to avoid it during startup

@AskAlexSharov

Copy link
Copy Markdown
Collaborator

#21872 could encode nodes from prefix tree to avoid it during startup

Startup optimization is important. I guess it can be 3.8 headliner.

Currently i focusing on RAM usage reduction - because bloatnet did use 90% of RAM on 64G machine (now it using 20GB: 4gb domain existence, 1.3gb bt, 0.5gb compress dicts, 2gb exec. Everything else is: peaks during merge build of rs/bt/kv etc...)

FYI:

3.5G /erigon-data/snapshots/domain/v1.1-storage.928000-936000.bt
zstd shows compression ratio 4x

If you have some advise "what we can add into .bt file disk format - i can add it now. (don't understand from your message).

pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Jun 19, 2026
…s) (erigontech#21875)

## Problem

The only heap-resident structure of an open `.bt` is `BpsTree`'s
pivot-key cache. On a mainnet node it was ~1.3 GB
(`btindex.decodeListNodes` in heap profiles): ~44M pivots × 32 B/node
(`Node{key []byte (24B), di uint64 (8B)}`), dominated by the storage
domain. The pivot keys already point into mmap (no heap copy) — the cost
was purely the per-node struct.

## Change

Replace `mx []Node` with:
- `keysBlob []byte` — the `[keyLen:u16][key]` records; mmap-backed for
on-disk files (zero heap), heap only in `WarmUp`.
- `nodeOfft []uint64` — byte offset of each pivot record (the only
per-node heap cost).
- `nodeStride uint64` — `di` is derived as `nodeDi(i) = i*nodeStride`,
not stored.

Net: **32 B → 8 B per node (~4×)**, i.e. ~1.3 GB → ~340 MB. No on-disk
format change — `decodeNodes`/`decodeListNodesV0` now return offsets
into the existing mmap'd nodes section instead of materializing structs.

`uint64` (not `uint32`) offsets: a single `.bt`'s nodes section is
bounded by its file size, and the largest storage file is already ~3.5
GB — near the uint32 4 GB ceiling and growing.

`di` is uniformly `i*M` for all real files (footer and released-3.4
legacy). For legacy v0 files the stride is recovered from the on-disk
`di` so a file opened with a different `M` than it was written with
stays correct.

## Notes

- Lookup cost stays flat: the old code already read pivot key bytes from
mmap; the offset path adds only a 2-byte length read on the same cache
line, and the offsets array is more cache-friendly. `bs`/`Get`
benchmarks: 0 allocs, timings unchanged.
- Structured to be range-friendly for a future PrefixIndex (erigontech#20180)
rebase: buckets can hold index ranges into `nodeOfft` with derived `di`.

Result: 
Bloatnet: `1.18G -> 0.028G`

---------

Co-authored-by: moskud <sudeepdino008@gmail.com>
@AskAlexSharov
AskAlexSharov requested a review from Copilot July 3, 2026 23:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new PrefixIndex search engine for domain .kv files (as a faster alternative to BpsTree lookups) and wires it into BtIndex behind an environment flag.

Changes:

  • Add standalone PrefixIndex implementation with per-prefix buckets, cached nodes, and MatchCmp-based comparisons.
  • Integrate PrefixIndex into BtIndex (conditional selection for Get/Seek/stats methods).
  • Add extensive correctness tests and new benchmarks comparing PrefixIndex vs BpsTree.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
docs/plans/20260415-prefixindex-matchcmp-integration.md Implementation plan + benchmark notes for MatchCmp integration.
db/datastruct/btindex/testhelpers_test.go Adds controlled/minimal .kv generators and helpers used by new tests.
db/datastruct/btindex/prefix_index.go New PrefixIndex implementation (bucket ranges, node narrowing, MatchCmp compare).
db/datastruct/btindex/prefix_index_test.go Large correctness/concurrency suite for PrefixIndex + equivalence checks vs BpsTree.
db/datastruct/btindex/btree_index.go Adds optional PrefixIndex search engine to BtIndex and routes calls when enabled.
db/datastruct/btindex/bpstree_bench_test.go Adds benchmarks for PrefixIndex and comparison benchmarks.
common/dbg/dbg_env.go Adds env-backed dbg.UsePrefixIndex toggle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread db/datastruct/btindex/prefix_index_test.go Outdated
Comment thread db/datastruct/btindex/testhelpers_test.go Outdated
Comment thread db/datastruct/btindex/bpstree_bench_test.go
Comment thread common/dbg/dbg_env.go
Comment thread db/datastruct/btindex/prefix_index.go Outdated
…tandalone

# Conflicts:
#	db/datastruct/btindex/btree_index.go
#	db/datastruct/btindex/testhelpers_test.go
Resolve btree_index.go: main moved M from the global constant to the
per-file footer value (m); keep the PrefixIndex wiring on top.
Drop the M argument from OpenBtreeIndexAndDataFile call sites.
… bytes

record() dropped every key of length <2, so those keys landed in no bucket
and lookup() returned a DI range that starts after them: Get missed them and
Seek skipped past them. Commitment domain keys are nibble paths and do get
this short. Zero-pad a 1-byte key into the same bucket as the keys it sorts
just before, which keeps every bucket a contiguous DI range.

Also: drop two redundant full .kv scans from NewPrefixIndexWithNodes (counts
now come from the first scan), derive the node-spacing divisor from
maxNodesPerBucket, and align the benchmarks' write/read compression flags.

Tests: differential test against BpsTree over mixed-length keys, both the
scan and the pre-built-nodes constructor.
… and density benchmarks

addNode kept the first maxNodesPerBucket pre-built nodes in each bucket. Nodes
sit M keys apart, so a busy bucket holds many more than the cap and the kept
ones all cluster at its head, leaving the tail with nothing to narrow against.
Pick evenly spaced nodes instead: at 781 keys/bucket the narrowed window drops
from 180 to 93 on average (max 333 to 127).

Adds BenchmarkRealDomainComparison, which A/Bs both engines over a real domain
file with keys sampled across the whole DI range rather than the head of the
file, and TestNarrowingWindowByDensity, which measures narrowing directly.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (5)

db/datastruct/btindex/prefix_index_test.go:1794

  • BtIndexWriter keeps a pivot node for di==0 as well (see btree_index.go:264-266). Excluding di==0 here means the test doesn’t cover PrefixIndexWithNodes behavior with the first pivot present.
		if di > 0 && di%M == 0 {

db/datastruct/btindex/prefix_index_test.go:751

  • BtIndexWriter keeps a pivot node for di==0 as well (see btree_index.go:264-266). Excluding di==0 here makes this test’s node slice diverge from the actual .bt layout.
		if di > 0 && di%M == 0 {

db/datastruct/btindex/prefix_index_test.go:100

  • BtIndexWriter keeps a pivot node for di==0 as well (see btree_index.go:264-266). Dropping di==0 here means the "with nodes" path isn’t exercising the real on-disk layout and may miss edge cases around the first pivot.

This issue also appears in the following locations of the same file:

  • line 751
  • line 1794
		if di > 0 && di%M == 0 {

db/datastruct/btindex/prefix_index_test.go:1539

  • This test uses t.Skip when the chosen gapKey overshoots the next key. That makes the test outcome data-dependent and can silently drop coverage. Prefer selecting a pair that satisfies the precondition (gap < next) and assert if none exists.
	mid := len(keys) / 2
	gap := gapKey(keys[mid])
	if bytes.Compare(gap, keys[mid+1]) >= 0 {
		// Gap overshot, skip this variant.
		t.Skip("gap key overshot next key")

db/datastruct/btindex/bpstree_bench_test.go:104

  • BenchmarkPrefixIndexSeek reads a key from getter and then passes that slice into Seek using the same reader. seg.Reader.Next returns slices backed by the reader’s internal buffer, and Seek will reset/read via the same reader, so the search key can be overwritten mid-call and the probe stream is disrupted. Use a separate probe reader and copy the key into a stable buffer before calling Seek (no per-iteration alloc needed).
		key, _ = getter.Next(key[:0])
		getter.Skip()
		c, err := bt.search.Seek(getter, key)
		require.NoError(t, err)

@awskii

awskii commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

closed in favour of #23841

@awskii awskii closed this Sep 7, 2026
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.

3 participants