Skip to content

chainbase: skip post_modify walk for non-key modifies#300

Open
heifner wants to merge 2 commits into
masterfrom
feature/chainbase-perf
Open

chainbase: skip post_modify walk for non-key modifies#300
heifner wants to merge 2 commits into
masterfrom
feature/chainbase-perf

Conversation

@heifner
Copy link
Copy Markdown
Contributor

@heifner heifner commented Apr 18, 2026

Summary

  • Perf: adds a fast path to chainbase::undo_index::modify() that skips the per-secondary-index iterator_to + neighbor-compare + fixup walk when the modifier didn't change that index's key. modify() snapshots secondary keys before calling the modifier; post_modify compares post-modify key to pre-key; if equivalent under the index's comparator, the whole fixup for that index is skipped.
  • Safety: the fast path is engaged only when the extractor is a plain boost::multi_index::member<T,F,PMF> AND F is trivially copyable AND F is not a shallow/aliasing type (is_shallow_copy blacklists pointers, reference_wrapper, string_view, span, array, C arrays, optional, variant and their recursive combinations). composite_key<...> and any extractor whose return captures a reference to the source falls through to the original full-walk. The revert path uses the original key-less post_modify unchanged.
  • Bench infra fix: libraries/chaindb/benchmark/bench.cpp used to stack-allocate the undo_index, but offset_node_base packs tree pointers as 42-bit self-relative offsets (+/-8 TiB). Stack-header vs heap-mapped-node distance can exceed that on some ASLR placements and SIGSEGVs in rebalance. Mirror libraries/chaindb/test/undo_index.cpp's undo_index_in_segment wrapper so the index sits next to its nodes. Also splits the driver into three scenarios for targeted measurement.

Measurements

chainbase_bench (heap mode, Release, 3 runs each):

scenario before after delta
original (1 idx, no session, 32M iters) 23.25 s 18.96 s -18%
two_idx_nonkey_modify (2 idx, 16M modifies) 16.66 s 12.74 s -23%
undo_session_churn (2M sessions, squash) 2.53 s 2.52 s -0.4% (single-idx)

two_idx is the direct target (non-key modify across secondary indexes). Note: microbench only; real nodeop block-apply impact not yet measured.

Defense in depth

Three layers guard against the composite_key-style aliasing bug class (previously discovered and fixed during development of this PR):

  1. Compile-time trait: chainbase::detail::fast_path_eligible_v + is_shallow_copy; static_asserts fingerprint the trait itself.
  2. Compile-time extractor fingerprint (unittests/chainbase_fast_path_tests.cpp): static_asserts against every chainbase-backed production secondary member<> extractor. Any future table change that replaces a trivially-copyable field with an aliasing/non-trivial type fails the build.
  3. Runtime invariant check (Debug / asserton CI only, elided in Release): after every fast-path skip, re-verify the tree-local sort order around the modified node. Any trait regression or unforeseen aliasing corrupts ordering and asserts on the next modify. The asserton platform at .cicd/platforms/asserton.Dockerfile drops -DNDEBUG explicitly, so this assert runs in every CI matrix cell that uses it.

heifner added 2 commits April 17, 2026 13:54
The original benchmark stack-allocated the undo_index. offset_node_base
packs tree pointers as signed 42-bit offsets (<<2 => 8 TiB reach)
relative to each node, so when the index header lives outside the
segment the nodes live in, the node-to-header distance can exceed 8 TiB
and the packed offset truncates, producing bogus parent pointers that
SIGSEGV during AVL rebalancing. Effect was ASLR-dependent, showed up
as ~50% crash rate.

Mirror libraries/chaindb/test/undo_index.cpp's `undo_index_in_segment`
wrapper so the index (and its header) lives alongside its nodes.

Also split the driver into three scenarios targeted at specific
optimization candidates, switch pinnable_mapped_file to heap mode with
an 8 GiB region, and give stopwatch a label so per-scenario timings
print meaningfully.
post_modify<N>() was called for every modify() on every secondary index,
doing iterator_to + two neighbor comparisons even when the modified
object's key for that index hadn't moved. For tables where modifies
usually touch non-indexed fields (account recv_sequence, resource
state, etc.), that's pure overhead.

Snapshot the secondary-index keys in modify() before invoking the
modifier, then in post_modify compare each index's post-modify key to
the saved pre-key; if unchanged, skip the fixup for that index
entirely.

The fast path is only engaged when it is provably safe: the index's
key extractor is a plain `boost::multi_index::member<T, F, PMF>` AND
the field type F is trivially copyable AND the copy is value-owning
(checked via an `is_shallow_copy` blacklist for pointers, pointer-to-
member, `std::reference_wrapper`, `std::string_view`, `std::span`,
and recursively through `std::array`, C arrays `T[N]`, `std::optional`,
and `std::variant`). This rules out `composite_key<...>` (whose
`composite_key_result` holds a reference to the source value, making
the "snapshot" alias the live node) and any extractor returning a
view, reference wrapper, raw pointer, or an aggregate containing one.
Ineligible indexes keep the original iterator_to + neighbor-compare
full walk.

The revert path still uses the original key-less post_modify because
after `node_ref = std::move(*backup)` the tree position may be wrong
while the keys match pre_keys.

Belt-and-suspenders: the trait and the `fast_path_eligible_v` alias
live in `chainbase::detail` so external call sites can static_assert
their own extractor types against it; a Debug-only (NDEBUG==0) runtime
assertion inside the fast path re-verifies the tree-local sort
invariant immediately after every skip, so any aliasing bug that
slipped past the trait would fire in CI.

Measured on chainbase_bench (heap mode, Release, 3 runs each):
  scenario_original               23.25 -> 18.96 s  (-18%)
  scenario_two_idx_nonkey_modify  16.66 -> 12.74 s  (-23%)
  scenario_undo_session_churn      2.53 ->  2.52 s  (unchanged; 1 idx)

Test coverage:
 - libraries/chaindb/test/undo_index.cpp
   - test_composite_key_modify: mutating a composite-key component
     correctly reorders the secondary index (regresses with an
     unconditional fast path).
   - test_composite_key_modify_undo: under an undo session, modifying
     a composite-key field stays sorted and undo restores ordering.
   - test_mixed_member_and_composite: fast-path-eligible plain member
     index coexists with a composite-key index; modifying a
     non-indexed field touches neither, modifying the composite key
     reorders only the composite index, modifying the plain member
     reorders only that index.
   - test_composite_key_modify_uniqueness_conflict: revert on
     uniqueness violation under composite_key works correctly.
 - unittests/chainbase_fast_path_tests.cpp: compile-time static_assert
   that every chainbase-backed production secondary member<> extractor
   (account_object.by_name, account_metadata_object.by_name,
   resource_object.by_owner, resource_pending_object.by_owner) passes
   fast_path_eligible_v. Future table changes that swap a trivially-
   copyable field for an aliasing or non-trivial type fail the build.

chainbase_test, unit_test (sys-vm), plugin_test, contracts_unit_test
(sys-vm), test_fc all pass.
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