Skip to content

Optimize tree traversal performance - #213

Merged
oschwald merged 22 commits into
mainfrom
greg/agy-perf
May 25, 2026
Merged

Optimize tree traversal performance#213
oschwald merged 22 commits into
mainfrom
greg/agy-perf

Conversation

@oschwald

@oschwald oschwald commented May 20, 2026

Copy link
Copy Markdown
Owner

This PR introduces two performance optimizations for tree traversal in the MMDB reader:

  1. Remove redundant bounds checks: By performing a single safety check at the start of each traversal function, we can safely remove the per-bit hasBufferRange calls in the inner loops. This improves lookup speed by ~5-8%.
  2. Optimize IPv6 bit extraction: Processing the 16-byte IPv6 address in 32-bit chunks using binary.BigEndian.Uint32 reduces bit-shifting overhead and improves data locality.

Benchmarks show consistent improvements across standard lookups, with up to ~10% gain on specific decoding paths.

Summary by CodeRabbit

  • Performance

    • Optimized search-tree traversal with reduced bounds-checking overhead, improving query performance.
  • Bug Fixes

    • Enhanced early detection of corrupted or truncated database buffers with improved error messages.
    • Improved robustness of pointer-chain resolution during record decoding.
    • Fixed IPv4/IPv6 address conversion accuracy in network traversal operations.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR refactors MaxMind DB search-tree traversal to extract IP bits using 32-bit big-endian chunks with up-front bounds checks, centralizes decoder pointer-chain resolution into a resolveCtrlData helper, adds fast-path decoding for pointer-encoded map keys, optimizes reflection dispatch and string-cache admission, and includes comprehensive tests and benchmarks.

Changes

Core reader & decoder changes

Layer / File(s) Summary
Traversal bit-extraction and buffer-safety refactor
reader.go
traverseTree24/28/32 now perform single up-front validation that the reader buffer covers all node-record offsets, then use 32-bit big-endian chunks via encoding/binary to extract bits with a sliding per-chunk offset, eliminating per-iteration hasBufferRange checks.
Reader initialization and pointer resolution
reader.go
OpenBytes applies ReaderOptions to a stack-local value and creates explicit dataSection slice; setIPv4Start derives initial node via traverseTree(zeroIP, 0, 96); lookupPointer uses explicit conditional cases for node validation; resolveDataPointer validates pointer offsets by comparing pointer - minPointer against dataSectionSize.
Centralize decoder control-data pointer-chain resolution
internal/decoder/decoder.go
New resolveCtrlData helper centralizes walking through KindPointer control records until a non-pointer value is reached, returning resolved kind/size/dataOffset. decodeCtrlDataAndFollow and PeekKind now delegate to this helper; Offset gains documentation explaining why it avoids the helper.
Fast-path for pointer-encoded map keys
internal/decoder/data_decoder.go
New decodePointerKeyFast helper attempts zero-allocation decoding of pointer-targeted short strings; decodeKey branches on control-byte kind and tries the fast-path for KindPointer, falling back to slow-path validation for complex cases.
String cache admission check optimization
internal/decoder/string_cache.go
Replace atomic Swap(admissionValue) with atomic Load() followed by conditional Store() in stringCache.internAt, reducing atomic operations when the miss marker matches the expected value.
Reflection allocation tracking and unmarshaler dispatch
internal/decoder/reflection.go
Refactor decodeValueImpl cleanup to track allocated reflect values via fixed slots (allocated1/allocated2) plus overflow slice (allocatedMore); inline boxing into makeAddressable; replace conditional tryTypeAssert with native reflect.TypeAssert[Unmarshaler].
Kind example functions documentation
internal/decoder/kind_test.go
Add fmt import and three ExampleKind_* functions demonstrating String(), IsContainer(), and IsScalar() on representative Kind values with // Output: blocks.
Short-buffer bounds-check tests
bad_data_test.go
Add TestReadNodePairBySizeRejectsShortBuffers and TestTraverseTreeRejectsShortBuffers asserting that undersized node buffers are rejected with "bounds check failed" errors for all record sizes.
Decoder pointer-key and offset tests
internal/decoder/data_decoder_test.go, internal/decoder/decoder_test.go
Add three TestDecodePointerKeyFast* tests covering pointer sizes, non-string targets, and extended-size fallback behavior; add TestOffsetReturnsValueStart validating correct offset tracking through pointer indirection.
Reader tests, benchmarks, and IPv4 regression
reader_test.go, traverse_test.go
Add TestSetIPv4StartKnownValues regression test; introduce pointer-based City result structs and BenchmarkCityLookupWithPointers; add randomIPv6Address and BenchmarkCityLookupOnlyIPv6; add TestV4ToV16RoundTrip round-trip validation.
Supporting changes: options, addresses, verifier, lint
traverse.go, verifier.go, .golangci.yml
Use stack-local networkOptions in NetworksWithin; construct netip.Addr directly via AddrFrom16/AddrFrom4 in v4ToV16 and v6ToV4; simplify verifyDataSectionSeparator to full-array comparison; disable gomodguard linter.

Sequence Diagram(s)

No sequence diagrams generated: the changes are primarily implementation refactoring of internal behavior, with mixed patterns affecting traversal loops, pointer resolution, caching, and reflection dispatch. Each checkpoint serves a distinct functional purpose without a singular primary flow to visualize.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • oschwald/maxminddb-golang#211: Both PRs modify traverseTree24/28/32 logic to refactor IPv4 search-tree walking and bounds-check behavior.
  • oschwald/maxminddb-golang#203: Both PRs modify OpenBytes/ReaderOption handling and refactor resolveDataPointer bounds/error validation logic.
  • oschwald/maxminddb-golang#207: Both PRs tighten MaxMindDB pointer validation and search-tree bounds checking by refactoring reader.go traversal and resolveDataPointer against computed data-section size.

Poem

🐰 Bit by bit, chunks of thirty-two,
Safety checked once, bounds verified true.
Pointers now walk in centralized grace,
Keys zip through fast paths, strings find their place.
Reflect trusts TypeAssert, cache admits with care—
All tested and benchmarked with rigor to spare!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Optimize tree traversal performance' directly aligns with the PR's primary objectives of removing redundant bounds checks and optimizing IPv6 bit extraction in tree traversal functions, which are the main performance-focused changes across reader.go, traverse.go, and related files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch greg/agy-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/decoder/decoder.go`:
- Around line 388-409: In resolveCtrlData: avoid following pointer-to-pointer
chains — when you see KindPointer from d.d.decodeCtrlData, call
d.d.decodePointer once to get the target (and set nextOffset if not set), then
invoke d.d.decodeCtrlData exactly one more time on the pointer target; if that
second decode returns KindPointer, return an error (reject pointer-to-pointer)
instead of looping, otherwise return the resolved kind/size/dataOffset and
nextOffset. Ensure you reference resolveCtrlData, d.d.decodeCtrlData,
KindPointer, d.d.decodePointer and nextOffset when making the change.

In `@internal/decoder/string_cache.go`:
- Around line 60-66: The current check-and-store on sc.recentMisses[i] using
Load followed by Store is non-atomic and can violate the "second consecutive
miss" rule under concurrency; change the logic in the admission path around
sc.recentMisses[i], admissionValue, and entry.Store(&cacheEntry{...}) to perform
a single read-modify-write (use atomic.Swap or an atomic CAS loop) so you
atomically detect the prior value and only admit the string when the prior value
equals admissionValue; ensure the chosen atomic operation returns the previous
value so you can decide whether to call entry.Store or to instead update the
miss counter atomically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 18e25e05-a1dd-44a3-825e-2b841059aa4b

📥 Commits

Reviewing files that changed from the base of the PR and between 1bd27fc and 6db9d44.

📒 Files selected for processing (14)
  • bad_data_test.go
  • internal/decoder/data_decoder.go
  • internal/decoder/decoder.go
  • internal/decoder/example_kind_test.go
  • internal/decoder/kind_test.go
  • internal/decoder/reflection.go
  • internal/decoder/string_cache.go
  • internal/decoder/type_assert_go125.go
  • internal/decoder/type_assert_pre125.go
  • mmap_windows.go
  • reader.go
  • reader_bounds_test.go
  • reader_test.go
  • traverse.go
💤 Files with no reviewable changes (4)
  • internal/decoder/type_assert_pre125.go
  • internal/decoder/example_kind_test.go
  • reader_bounds_test.go
  • internal/decoder/type_assert_go125.go

Comment thread internal/decoder/decoder.go
Comment on lines +60 to +66
if sc.recentMisses[i].Load() == admissionValue {
entry.Store(&cacheEntry{
str: str,
offset: offset,
})
} else {
sc.recentMisses[i].Store(admissionValue)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the admission update atomic.

Splitting the check and write across Load/Store breaks the documented "second consecutive miss" rule under concurrency. An intervening miss can slip in between those operations, so cold strings can be admitted spuriously and the slot churn goes up. Use a single RMW (Swap or a CAS loop) here.

Proposed fix
 	admissionValue := uint64(offset) + 1
-	if sc.recentMisses[i].Load() == admissionValue {
+	if sc.recentMisses[i].Swap(admissionValue) == admissionValue {
 		entry.Store(&cacheEntry{
 			str:    str,
 			offset: offset,
 		})
-	} else {
-		sc.recentMisses[i].Store(admissionValue)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if sc.recentMisses[i].Load() == admissionValue {
entry.Store(&cacheEntry{
str: str,
offset: offset,
})
} else {
sc.recentMisses[i].Store(admissionValue)
admissionValue := uint64(offset) + 1
if sc.recentMisses[i].Swap(admissionValue) == admissionValue {
entry.Store(&cacheEntry{
str: str,
offset: offset,
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/decoder/string_cache.go` around lines 60 - 66, The current
check-and-store on sc.recentMisses[i] using Load followed by Store is non-atomic
and can violate the "second consecutive miss" rule under concurrency; change the
logic in the admission path around sc.recentMisses[i], admissionValue, and
entry.Store(&cacheEntry{...}) to perform a single read-modify-write (use
atomic.Swap or an atomic CAS loop) so you atomically detect the prior value and
only admit the string when the prior value equals admissionValue; ensure the
chosen atomic operation returns the previous value so you can decide whether to
call entry.Store or to instead update the miss counter atomically.

oschwald added 10 commits May 24, 2026 02:03
Remove hasBufferRange calls from the inner loops of traverseTree24/28/32.
A single safety check is added at the start of each function to ensure
the buffer is large enough for the entire search tree. This maintains
safety while reducing overhead during IP lookups.
Process the 16-byte IPv6 address in 32-bit chunks using BigEndian.Uint32.
This reduces bit-shifting arithmetic and improves data locality during
tree traversal.
Replace manual bit-shifting of individual slice elements
with binary.BigEndian.Uint32. This simplifies the code
in traverseTree24, traverseTree28, and traverseTree32
while maintaining equivalent performance.
Use netip.AddrFrom4 with slice-to-array conversion to avoid
the overhead of slice validation in netip.AddrFromSlice.
Disable gomodguard in .golangci.yml since it is deprecated
and generates a warning on stderr, which blocks the git
pre-commit hook.
Replace the byte-by-byte loop with a direct slice-to-array
comparison against a zero-initialized array. This makes
the code cleaner and more declarative.
Deduplicate tree traversal by reusing the traverseTree function
to find the IPv4 start node in setIPv4Start. This removes
the duplicate loop and logic.
Remove redundant type_assert_go125.go and type_assert_pre125.go,
defining tryTypeAssert directly in reflection.go using Go 1.25's
reflect.TypeAssert feature.
Consolidate makeAddressable and newAddressableValue into a
single makeAddressable function to eliminate duplicate code.
Merge reader_bounds_test.go into bad_data_test.go, and merge
internal/decoder/example_kind_test.go into
internal/decoder/kind_test.go.

This reduces the number of redundant test files and keeps the test suite
focused and clean.
oschwald added 11 commits May 24, 2026 02:06
Construct the 16-byte representation using a sparse array literal
instead of a temporary array and copy helper.

This avoids the overhead of copy and slice bounds checks, making
v4ToV16 over 4x faster.

Also add a round-trip test (TestV4ToV16RoundTrip) covering both
v4ToV16 and v6ToV4 directly, replacing the prior indirect coverage
through lookup parity tests.
Use a shared helper to resolve decoder control data through pointer
chains.

This keeps the pointer-following logic in one place for PeekKind,
Offset, and decodeCtrlDataAndFollow, and documents how the helper
differs from decodeCtrlData.
Collapse the corrupt-pointer checks into a single resolved-offset
bounds check while preserving the existing behavior on invalid
pointers.
Reuse the checked dataSectionStart/dataSectionEnd bounds when
creating the decoder instead of recomputing the slice indices.
Keep OpenBytes option processing on a local readerOptions value
instead of allocating a pointer up front.
Keep NetworksWithin option processing on a local networkOptions value
inside the iterator instead of taking a pointer literal up front.
Check the successful node > nodeCount case first and handle the
empty-record sentinel separately without changing the invalid-node
fallback.
Replace the atomic Swap call in internAt with a conditional Load and
Store check. This avoids read-modify-write LOCK instructions on cache
misses, reducing bus locks.

Benchmark impact: Single-threaded stringCache benchmarks are slightly
faster (Hot path: ~1.08 ns/op vs ~1.12 ns/op, ~3% speedup). Concurrent
lookups are clean under -race and benefit from reduced LOCK instruction
overhead on misses.
Avoid slice allocations on the happy path in decodeValueImpl by
tracking up to two allocated pointer values in scalar variables. Falls
back to a slice only when pointer nesting depth exceeds two.

Benchmark impact: Avoids heap slice allocations entirely during
successful decoding into pointer fields. While CityLookup (which
decodes into a pointer-less benchmarkCity struct) is unaffected, this
library-wide optimization prevents GC and allocation overhead for
pointer-heavy custom destination structs.
Fast-path key decoding when the control byte represents a pointer that
resolves directly to a string under 29 bytes. This avoids calling
decodeCtrlData and decodePointer twice for heavily duplicated keys.

Benchmark impact: Yields a substantial **8.0% speedup** on
BenchmarkCityLookup (reducing it from 1485 ns/op to 1366 ns/op) and a
**5% to 7% throughput increase** on concurrent lookups, as it avoids
calling decodeCtrlData and decodePointer twice for heavily duplicated
pointer keys.
Add BenchmarkCityLookupWithPointers to measure allocations and speed
when decoding into pointer-heavy structures, and BenchmarkCityLookupOnlyIPv6
to measure traversal performance for IPv6.
Document the pointer-decoding allocation savings, key-decoder pointer
fast path, IPv6 tree traversal speedups, and stack-allocation changes.
@oschwald
oschwald merged commit e96d41e into main May 25, 2026
18 checks passed
@oschwald
oschwald deleted the greg/agy-perf branch May 25, 2026 19:10
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.

1 participant