Skip to content

perf: optimize B-tree traversal and freelist merging - #1240

Open
mrueg wants to merge 1 commit into
etcd-io:mainfrom
mrueg:main
Open

perf: optimize B-tree traversal and freelist merging#1240
mrueg wants to merge 1 commit into
etcd-io:mainfrom
mrueg:main

Conversation

@mrueg

@mrueg mrueg commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Eliminate closure allocation overhead from sort.Search calls and reduce heap allocations during cursor creation.

  1. Implement custom binary search routines to avoid sort.Search closures:

    • Added Inodes.Search and Inodes.SearchExact to internal/common/inode.go.
    • Added Page.SearchBranchPageElements and Page.SearchLeafPageElements to internal/common/page.go.
    • Replaced sort.Search in node.go (childIndex, put, del) and cursor.go (searchNode, searchPage, nsearch).
    • Inlined binary search inside Mergepgids in internal/common/page.go to optimize sorted union merging of page IDs.

    SearchExact breaks early on a zero comparison instead of running the search to completion with a side-effecting closure. This is equivalent: keys are unique within a node, and the old sort.Search always probed the index it returned (when < n), so the exact flag was only ever set at that index.

  2. Pre-allocate the Cursor search stack:

    • Added a pre-allocated initialStack [16]elemRef array inside the Cursor struct in cursor.go.
    • Cursor.stack is re-bound to initialStack[:0] at the start of each traversal (first, last, seek), avoiding a separate heap allocation for the stack at tree depth <= 16.

Benchmarks

Measured against 3fb8889 with ad-hoc benchmarks over a 100k-key bucket (8-byte keys, 32-byte values, NoSync), 15 interleaved rounds per binary, compared with benchstat:

             │    base     │                 PR                  │
             │   sec/op    │   sec/op     vs base                │
CursorGet-8    347.1n ± 2%   307.1n ± 5%  -11.52% (p=0.000 n=15)
CursorSeek-8   343.7n ± 4%   301.9n ± 4%  -12.16% (p=0.000 n=15)
CursorScan-8   1.099m ± 3%   1.114m ± 2%        ~ (p=0.187 n=15)
CursorPut-8    20.19µ ± 5%   21.06µ ± 4%        ~ (p=0.116 n=15)

             │    base    │                 PR                 │
             │ allocs/op  │ allocs/op   vs base                │
CursorGet-8    3.000 ± 0%   1.000 ± 0%  -66.67% (p=0.000 n=15)
CursorSeek-8   3.000 ± 0%   1.000 ± 0%  -66.67% (p=0.000 n=15)
CursorScan-8   3.000 ± 0%   1.000 ± 0%  -66.67% (p=0.000 n=15)
CursorPut-8    61.00 ± 0%   59.00 ± 0%   -3.28% (p=0.000 n=15)

The gain is on point lookups, where cursor setup is a meaningful share of the work. A full sequential scan (CursorScan) is unchanged, as expected: the cursor is allocated once and amortized over 100k Next() calls.

Note on cmd/bbolt bench: I originally quoted a ~10.5% BenchmarkRead improvement from it. That number does not reproduce reliably — across 15 interleaved rounds it reports ±100%+ variance because each run creates and writes a ~150MB database, so filesystem state dominates a change measured in nanoseconds. The Go benchmarks above are the meaningful measurement.

Notes for reviewers

Copying a Cursor. stack is backed by the cursor's own initialStack, so c2 := *c1 would give the copy a slice header pointing into the original's array. This hazard is not new: before this change a copy's slice header aliased the original's heap-allocated backing array in exactly the same way. Binding at the start of every traversal rather than once at construction means a copied cursor re-binds to its own array on the next First/Last/Seek, which makes copies strictly safer than before. The type is documented as non-copyable after first use. Nothing in bbolt copies a Cursor — only *Cursor is ever handed out by Bucket.Cursor()/Tx.Cursor().

Memory trade-off. elemRef is 24 bytes, so Cursor grows from 32 to 416 bytes and the cursor-allocating paths go from 168 B/op in 3 allocations to 416 B/op in 1. Fewer allocations, more bytes. Happy to drop the array to 8 entries if reviewers prefer, since bbolt trees are rarely deeper than ~5 levels.

@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: mrueg
Once this PR has been reviewed and has the lgtm label, please assign ahrtr for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Eliminate closure allocation overhead from sort.Search calls and reduce heap allocations during cursor creation.

1. Implement custom binary search routines to avoid sort.Search closures:
   - Added Inodes.Search and Inodes.SearchExact to internal/common/inode.go.
   - Added Page.SearchBranchPageElements and Page.SearchLeafPageElements to internal/common/page.go.
   - Replaced sort.Search in node.go (childIndex, put, del) and cursor.go (searchNode, searchPage, nsearch).
   - Inlined binary search inside Mergepgids in internal/common/page.go to optimize sorted union merging of page IDs.

2. Pre-allocate Cursor search stack:
   - Added a pre-allocated array initialStack [16]elemRef inside the Cursor struct in cursor.go.
   - Modified Bucket.Cursor in bucket.go to initialize the stack using this array slice backing, avoiding heap allocations during B-tree traversal for depth <= 16.

Synthetic read/write benchmark results:
BenchmarkRead: 15.30 ns/op -> 13.69 ns/op (~10.5% improvement in read latency).

Signed-off-by: Manuel Rüger <manuel@rueg.eu>
@fuweid

fuweid commented Sep 1, 2026

Copy link
Copy Markdown
Member

@mrueg Thanks! I will take a look on it.

@fuweid
fuweid requested review from fuweid and a lite review from Copilot September 1, 2026 15:56

Copilot AI left a comment

Copy link
Copy Markdown

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 optimizes B+tree traversal and freelist merging hot paths by replacing sort.Search closure-based binary searches with custom non-closure implementations and by reducing allocations during cursor creation/traversal.

Changes:

  • Added custom binary search helpers for common.Inodes and common.Page element searches, and updated cursor/node search paths to use them.
  • Inlined binary search in Mergepgids to avoid sort.Search closure overhead.
  • Preallocated a small cursor stack buffer ([16]elemRef) and wired Bucket.Cursor() to reuse it for typical tree depths.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
node.go Switches child/key lookups from sort.Search to Inodes.Search.
internal/common/page.go Inlines binary search in Mergepgids and adds page-element search helpers.
internal/common/inode.go Adds Inodes.Search / Inodes.SearchExact custom binary searches.
cursor.go Uses new binary searches and adds initialStack backing storage for the cursor stack.
bucket.go Initializes cursor stack using Cursor.initialStack to avoid heap allocations for shallow traversals.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cursor.go
Comment on lines 20 to 24
type Cursor struct {
bucket *Bucket
stack []elemRef
bucket *Bucket
stack []elemRef
initialStack [16]elemRef
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

3 participants