Skip to content

feat(tm2): immutable B+32 tree — drop-in replacement for IAVL - #5438

Merged
jaekwon merged 219 commits into
masterfrom
feat/jae/bp32tree
Jun 17, 2026
Merged

jaekwon merged 219 commits into
masterfrom
feat/jae/bp32tree

Conversation

@jaekwon

@jaekwon jaekwon commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

The greatest gas cost in gno.land is disk IO, and the iavl tree in particular. The B+32 tree is a drop-in replacement for the IAVL tree with comparatively short Merkle proofs with drastically less disk reads/writes.


Implements tm2/pkg/bptree, a versioned persistent B+ tree with branching factor 32, as a complete alternative to tm2/pkg/iavl for blockchain state storage. Includes a
tm2/pkg/store/bptree CommitStore wrapper that satisfies the same interfaces as the IAVL store.

The core motivation: IAVL's binary tree has ~28 levels for 100M items, meaning 28 disk reads per GET and 28 COW'd node writes per SET. The B+32 tree has ~6 levels for the
same data — ~4x fewer disk operations per mutation.

Key design choices

  • B=32 branching factor — 6 levels for 100M items, all inner nodes fit in a 43MB cache
  • Out-of-line values — leaves store key + SHA256(value), values stored separately by content hash
  • Mini merkle per node — binary merkle over 32 slots with RFC 6962 domain separation (0x00/0x01/0x02), maps cleanly to ICS23 InnerOp chain
  • Sentinel short-circuit — empty subtrees at all depths hash to the same sentinel, enabling ICS23 EmptyChild for non-membership proofs
  • No leaf sibling pointers — stack-based iteration avoids COW cascade on splits
  • 90/10 split for sequential keys (leaves stay ~97% full vs 50% with naive splits)
  • Lazy node loading — LoadVersion loads only the root; children loaded on demand via sync.Mutex-guarded getChild
  • Dual-tree-walk pruning — no orphan index; compares child hash sets between adjacent versions

Disk I/O comparison (100M items, 10K node cache)

┌───────────────┬──────┬──────┬───────────┐
│ Operation │ B+32 │ IAVL │ IAVL+fast │
├───────────────┼──────┼──────┼───────────┤
│ GET reads │ 3 │ 15 │ 1 │
├───────────────┼──────┼──────┼───────────┤
│ SET reads │ 2 │ 15 │ 15 │
├───────────────┼──────┼──────┼───────────┤
│ SET writes │ 7 │ 28 │ 29 │
├───────────────┼──────┼──────┼───────────┤
│ SET total ops │ 9 │ 43 │ 44 │
└───────────────┴──────┴──────┴───────────┘

Full analysis in PERFORMANCE.md covering cold/warm cache, batched workloads, and bytes-per-SET tradeoffs.

Files

  • tm2/pkg/bptree/ — 25 source files, 318 tests
  • tm2/pkg/store/bptree/ — CommitStore wrapper with Query, Iterator, proof integration
  • tm2/pkg/store/rootmulti/proof.go — registers BptreeCommitmentOpDecoder alongside IAVL

Test plan

  • go test ./tm2/pkg/bptree/ ./tm2/pkg/store/bptree/ -race — 318 tests, zero races
  • Verify IAVL tests still pass: go test ./tm2/pkg/iavl/
  • Verify store tests still pass: go test ./tm2/pkg/store/...
  • Review PLAN.md for resolved design questions
  • Review PERFORMANCE.md for methodology and assumptions

ltzmaxwell and others added 30 commits March 13, 2026 21:44
Replace the start/stop/start/stop benchmarking pattern with a gap-free
SwitchOpCode model that atomically finalizes one op and starts the next
using a single time.Now() call, ensuring all CPU time is accounted for.

Key changes:
- New 4-primitive API: BeginOpCode/SwitchOpCode/ResumeOpCode/StopOpCode
- SwitchOpCode returns old op code; Go call stack replaces explicit stack
- Unified StartStore/StopStore suspend VM op timer and track store duration
- Add dedicated RealmDidUpdate/RealmFinalizeTx store operation codes
- Add Enabled const (OpsEnabled || StorageEnabled || NativeEnabled) to gate
  all collection uniformly; specific flags gate only export
- Fix FinishStore exporting with TypeNative instead of TypeStore
- Remove OpStaticTypeOf exclusion from benchmarking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- StartNative: finalize previous op BEFORE runtime.GC() so GC time
  is not attributed to any opcode
- Export format expanded from 10 to 14 bytes: now exports
  (totalDuration, totalSize, count) instead of pre-averaged values
- stats.go computes proper weighted averages and per-run stddev
- Add GAS_TODO.md with full gas metering audit findings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-load all benchmark packages and stdlibs before enabling recording,
so init-phase store operations don't contaminate benchmark data.

- Add explicit Recording flag to gate export (not implicit nil check)
- Extract loadBenchPackages() to separate loading from benchmarking
- Benchmark functions now take pre-loaded *PackageValue directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clarify that Fork() intentionally omits gasMeter and GC callback.
The caller is responsible for setting these when needed (e.g. the
Machine constructor does this for transactions). Query contexts
intentionally omit the gasMeter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…izes

The _alloc* constants represent unsafe.Sizeof for each GnoVM value
type, but many were stale — structs grew as fields were added
(ObjectInfo, FuncValue.Crossing, etc.). This caused systematic
under-charging of memory gas.

Changes:
- Update all _alloc* constants to match current unsafe.Sizeof values
- Replace _allocBase+_allocPointer with unified _allocHeap constant
- Fix allocPointer to include PointerValue size (was just _allocBase)
- Fix allocHeapItem to use actual HeapItemValue size (was _allocTypedValue)
- Fix allocMapItem from *3 to *2 (key + value TypedValues)
- Add detailed comments explaining the allocation model
- Update test golden values for new allocation sizes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Panics at startup if any _alloc* constant doesn't match
unsafe.Sizeof, so stale values are caught immediately when
struct fields change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the linear per-byte gas model (1 gas/byte) with a calibrated
power-law model based on actual Go malloc benchmarks on a dedicated
DigitalOcean instance (Intel Xeon 8168, 2-core).

The new allocGasTable uses 6 exact benchmark points (1B-32B) plus a
power-law fit (ns = 0.47 × size^0.925) for larger sizes. At runtime,
allocGas() does O(1) lookup via bits.Len64 + linear interpolation.

Key changes:
- Small allocs (e.g. 208B struct) drop from 208 gas to ~8 gas
- Large allocs use sublinear scaling instead of linear
- Allocation-heavy tests see 50-98% gas reduction
- CPU/store-dominated tests change <2%

Adds gnovm/cmd/calibrate/ with benchmarks, data, and tooling for
recalibration on different hardware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
shlAssign/shrAssign allocated closures on every call for overflow
checking that only runs during preprocessing (StagePre). This caused
unnecessary GC pressure at runtime.

- Remove checkOverflow closure pattern; inline StagePre guard
- Add shlCheckOverflow helper that short-circuits for shift > 64
  (any non-zero value overflows any fixed-width type)
- Cap UntypedBigintType shifts at 10000 bits to prevent DoS via
  huge big.Int allocations (e.g., 1 << 1_000_000_000)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…audit

Add comprehensive microbenchmarks for gas calibration:
- bench_ops_test.go: 234 benchmarks covering all 90 GnoVM op handlers,
  parameterized at powers of 10 (1, 10, 100, 1000, 10000)
- bench_gc_test.go: GC visitor traversal benchmarks with realistic object
  graphs (100 to 10M objects), reports ns/visit for VisitCpuFactor calibration
- op_handler_gas_audit.md: audit of every doOpXxx handler documenting
  cost-varying parameters, pessimistic inputs, and benchmark coverage gaps
- benchops: add OpAccumDur/OpCount accessor functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace flat VisitCpuFactor=8 with gcVisitGasTable, a 25-entry lookup
indexed by log2(visitCount). Per-visit cost scales with heap size due
to CPU cache effects: 6 gas/visit for small heaps (L2-resident) up to
135 gas/visit for huge heaps (DRAM+TLB bound).

Calibrated from BenchmarkGCVisit on DigitalOcean Dedicated 2-core,
Intel Xeon Platinum 8168 @ 2.70GHz, cpuBaseNs=5.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add parameterized benchmarks for accurate gas calibration:
- String ops (Add, Eql, Lss, Convert, Index1_MapStringKey, Slice) × 4 lengths
- BigInt ops (14 ops) × 4 bit-lengths (64, 256, 1024, 4096)
- BigDec ops (7 ops) × 4 precisions (10, 100, 1000, 10000 digits)
- Byte-array variants (Eql, ArrayLit, Index1, Slice) × 4 sizes
- Asymmetric BigInt benchmarks (11 cross-size combinations)

Add bytes.Equal fast path for byte array comparison in isEql,
replacing element-by-element DataByteValue wrapping (~850x speedup).

Total benchmark count: 337.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add benchmarks for call dispatch variants:
- BenchmarkOpPrecall_BoundMethod (method precall via BoundMethodValue)
- BenchmarkOpCall_Method (doOpCall with receiver)
- BenchmarkOpIfCond_FalseBranch (else branch dispatch)
- BenchmarkOpSelector_VPInterface_{1,10,100} (interface method dispatch)
- BenchmarkOpTypeAssert2_Interface_{Hit,Miss} (interface type assertion)
- BenchmarkOpTypeSwitch_Interface_{1,10,100} (interface type switch)

Extract shared helpers to reduce duplication:
- benchInterfaceAndImpl: builds interface + implementing type
- benchMethodSetup: builds method type/value/receiver

Fix pre-existing issues:
- Hoist FieldType construction out of hot loop in benchOpStructType
  and benchOpInterfaceType (was rebuilding strings + structs per iter)
- Standardize method naming in benchOpTypeAssert1_Interface (M%d)
- Remove redundant BenchmarkOpCall_Closure (duplicate of benchOpCall)

Total benchmark count: 350.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add benchmark findings section to op_handler_gas_audit.md with data on
byte array equality fix (850x), BigInt asymmetric costs, BigDec precision
scaling, interface method dispatch scaling, GC visit cache effects, and
ArrayValue dual representation. Mark completed items in GAS_TODO.md and
MEM_TODO.md. Add BENCH.md noting benchops superseded by Go-level benchmarks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…on data

Add allocation gas metering to op handler benchmarks: benchMachine() now
creates a real Allocator with a GasMeter so reportBenchops() can report
exact alloc-gas/op alongside ns/op(pure). This enables precise separation
of CPU gas from allocation gas in calibration analysis.

Save DO Xeon 8168 benchmark results and analysis report with per-op
alloc gas breakdown, proposed gas formulas, and mismatch identification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ation

Add higher-N benchmarks for parameterized ops to improve least-squares
fit accuracy: Define, Assign, StructType, InterfaceType, TypeSwitch,
TypeSwitch_Interface, ReturnCallDefers, Call (params and captures) at
N=1000. Add StructLitNamed benchmarks for the named-field code path.
Update analysis report with worst-case input verification section.
Update DO Xeon benchmark data with alloc-gas/op metrics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace fixed BenchmarkOpFuncType with benchOpFuncType(b, nParams, nResults)
measuring params and results independently at 0/1/10/100/1000 points each.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…arameterized ops

Calibrate all 68 OpCPU* constants against Xeon 8168 benchmarks (cpuBaseNs=5.2).
Add per-N CPU gas charging in 20 parameterized op handlers so gas scales with
input size (e.g., map entries, struct fields, array elements, function params).

Key changes:
- machine.go: update flat constants from benchmarks, add 22 slope constants
- op_assign.go: Define (15/LHS), Assign (17/LHS)
- op_expressions.go: ArrayLit (9/elt), SliceLit (4/elt), SliceLit2 (5/size),
  MapLit (60/entry), StructLit (9/field), FuncLit (7/capture),
  TypeAssert1/2 (67/method), Convert str↔runes (3/2 per char)
- op_call.go: Call (9/param + 5/capture)
- op_exec.go: TypeSwitch (49/clause), ForLoop heap (8/var), RangeIter (2/elt)
- op_binary.go: Eql array (27/elt), Eql struct (26/field)
- op_types.go: FuncType (4/param+result), StructType (6/field), InterfaceType (5/method)
- op_decl.go: ValueDecl (6/name)
- Add calibrate.analysis and calibrate.plot Makefile targets
- Add gen_analysis.py, plot_fits.py, op_gas_formulas.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r depth

Add calibrated per-bit-width gas charging for BigInt and BigDec
arithmetic operations, and per-depth charging for NameExpr block
traversal. BigInt uses per-kilobit slopes, BigDec uses per-100-digit
slopes, and Mul/Quo use quadratic models.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…as target

Add per-N gas charging for remaining BigInt ops:
- Rem: quadratic model (same as Mul/Quo)
- Shl: per-kilobit of shift amount (output growth)
- Shr: per-kilobit of input bit width

Add unified `make calibrate.gas` target that runs op benchmarks,
allocation benchmarks, analysis, and plot generation end-to-end.
Also add `make calibrate.alloc` for allocation-only calibration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The compound assignment handlers (+=, -=, *=, etc.) in op_assign.go
were missing per-N BigInt/BigDec gas charging, allowing users to
bypass per-N gas by using `x += bigval` instead of `x = x + bigval`.

Also: use GetBigInt()/GetBigDec() consistently in unary helpers,
use max() builtin, remove stale Makefile dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The 3B gas-wanted was a leftover from an intermediate state where the
loop reduction (100→2 iterations) landed before alloc gas recalibration.
Actual gas used is ~22M, so 100M provides adequate headroom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update expected gas values in txtar tests after per-N gas charging
for BigInt/BigDec ops and compound assign handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes thelper lint errors in bench_ops_test.go (71 functions),
bench_gc_test.go (1 function), and alloc_bench_test.go (1 function).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jaekwon and others added 4 commits June 11, 2026 16:47
112 conflicts resolved take-master except a short keep-list, because
nearly every gas/VM stream on this branch was upstreamed (#5289 #5291
#5415 #5629) and evolved on master: all gnovm core files, goldens, txtar
fixtures, vm keeper/params, gas configs, ADRs and CLAUDE.md resolve to
master wholesale with zero golden regeneration.

Kept from the branch (the entire diff vs master is ~100 files, +25.7k,
dominated by tm2/pkg/bptree):
- tm2/pkg/bptree + tm2/pkg/store/bptree: the B+32 tree (branch payload)
- iavl/store.go: /subspace ABCI query stays disabled (unbounded
  response DoS); master's live handler dropped, branch tests kept
- types/options.go: KeepEvery>1 panic guard (waypoint retention is
  broken); PruneSyncable uses KeepEvery=0
- config: pebbledb default marked correctly in the TOML comment (code
  already agreed); config_test registers backends via db/_all
- gnoland app_test: gas-price test keeps unmetered bookkeeping reads,
  making its exact-price math pricing-independent (passes both models)
- gnovm/Makefile calibrate.* convenience targets

Forward-fixes folded in so the merge commit builds and passes:
- store/bptree: DepthEstimator ported to master's 3-method 100x
  fixed-point interface (log32 via bits.Len64*20, floor 100); iterator
  call sites gain the leading GasContext; depth test ported
- benchops/ops.go + gnoland mock_test.go: auto-merge duplicate
  declarations deduped (typed StoreOp consts win; master's mock stubs win)
- gnovm/cmd/benchops removed entirely (master deleted it for
  cmd/calibrate; the branch leftover was a main-less package)
- store/cache/store_gas_test.go removed (pinned the branch's old
  MinDepth charging semantics; a rewrite against master's fractional
  depth model follows separately)

Verified at merge time: go build ./... (CGO=1), store suites, bptree
suite + FuzzTreeOps corpus, vm -run Gas, gnoland gas-price test, vet on
touched modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h model

The pre-merge version pinned the branch's draft semantics (integer
MinDepth flooring even flat stores; unmetered iteration). Re-pin against
the merged model: 100x fixed-point depths via a stub three-method
estimator, Min* floors and Fixed* overrides (floors do NOT reach flat
stores), Set = setRead+write depth formulas, dedup/refund and
Write()-clears-ledger unchanged, and iteration now metered (flat seek +
per-step + value bytes; empty ranges still pay the seek).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merge auto-inserted the branch's older calibrate.* section above
master's identical-purpose one, making make warn about overridden
targets on every invocation. Remove the stale copy; the Makefile is now
byte-identical to origin/master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merge junk-sweep cleanup removed it assuming it was local state;
it is a tracked upstream file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tm2/pkg/bptree/checksum.go Dismissed
jaekwon and others added 6 commits June 11, 2026 21:50
rootmulti and baseapp wire SetInitialVersion via a type-assert, so the
bptree store silently misaligned on chains starting at InitialHeight>1
(the only missing piece for hardfork migrations onto the B+32 store).
The tree half already existed: bptree.MutableTree.SetInitialVersion
makes the first SaveVersion commit as H. Add the store method (mirroring
iavl's, including the immutable guard) and the Commit prune-skip for
toRelease below the initial version.

Tree invariants were audited: nothing assumes versions start at 1 or
are contiguous (discoverVersions derives [first,latest] from root
records; prune no-ops below first; findNextVersion is gap-tolerant).
Test: first commit at v100, pruning across the boundary with
KeepRecent=2, cold reload over [104,106], next commit at v107.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaced by reviewing the bp32tree-related PRs (#5438/#5570/#5571/#5591/#5442)
against the current branch. All higher-severity findings were already fixed
in-branch; these LOW/cosmetic items remained:

- AvailableVersions: one PrefixRoot scan instead of O(versions) db.Has calls,
  and return nil rather than a silently-truncated list on a mid-scan iterator
  error (mirrors discoverVersions)
- splitInner: copy the promoted separator, closing a latent aliasing bug and
  matching splitLeaf's key-ownership invariant
- replace the runtime miniMerkleDepth() loop with a const + init assertion
- remove the dead AsyncPruning option (field + setter)
- remove the dead sumSizes function

Three-subagent review (correctness, regression/build, completeness): GO.
bptree, store/bptree, rootmulti suites green; go vet clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lared, thelper)

Pre-existing lint failures across the package; none from the prior commit:

- dogsled: dedup the two identical Has methods into a treeHas helper
  (treeLookup returns 5 values, Has needs only found+err)
- errorlint: %v -> %w for wrapped errors, and == / != error comparisons ->
  errors.Is (export.go, the mutable_tree.go poison errors, and several tests)
- predeclared: rename a local `make` closure to mkTree; drop the local `max`
  func in favor of the Go 1.21 builtin
- thelper: add Helper() to test helpers; name testing.TB params `tb`

make lint: 0 issues. bptree, store/bptree, and rootmulti suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The disk benchmarks started measuring from a completely cold node LRU
and reported counts averaged over every iteration, so short runs were
dominated by warmup (and an equal-memory 330K-cache run never warmed at
all). Add -disk-warmup-ops: an untimed pre-loop before ResetTimer —
random gets for GetRandom/GetMiss (distinct seeds so the timed keys
aren't pre-touched beyond normal residency), whole untimed blocks for
BlockWrite. The driver script gains WARMUP (default 50000, sized for
the 10K cache) and passes it to both benches.

Also drop BUILD_BATCH/-disk-build-batch defaults from 100k to 25k —
the 100k populate batches OOM on smaller boxes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jaekwon
jaekwon force-pushed the feat/jae/bp32tree branch from db07435 to 6b8dcb2 Compare June 13, 2026 01:04
jaekwon and others added 7 commits June 12, 2026 18:30
Whole-run averages mix cold-cache, ramp, and steady regimes (measured on
the 100M fixture: run-1 vs run-2 wall times differed 2-4x while counts
were identical — the in-process node LRU restarts cold every run and
only the OS page cache persists). No arrangement of runs fixes an
average; the benchmark itself must separate the regimes.

The read meter now records per-segment (ops, reads, writes, wall-ns)
at its existing fold/snap boundaries — reload intervals for the Get
benches, blocks for BlockWrite — with wall time from b.Elapsed() deltas
so StopTimer'd reload/prune gaps are excluded by construction. Segments
group into at most 8 windows from the end (tails never runts), and each
bench reports alongside its whole-run averages:

  tail-ns/<op|write>     steady-state estimate (final window; median
                         across blocks, so a compaction stall cannot
                         flip a write window)
  tail-reads/<...>       final-window counts (+ tail-writes for blocks)
  conv-ns-% conv-reads-% signed last-vs-previous-window deltas
  windows                how many windows existed at all

plus a logged oldest->newest per-window series, so one pasted run
proves whether steady state was reached: conv deltas within threshold
and a flat (non-monotone) tail = converged; the avg-vs-tail gap
quantifies the ramp that used to pollute the average.

Also: GetMiss gains the read meter (the miss path is the headline
in-memory-reject vs disk-descend comparison and was count-blind); the
height metric moved after ResetTimer, which has been silently clearing
it (testing.B.ResetTimer wipes ReportMetric extras); warmup loops gate
on b.N>1 so go-bench's calibration pass doesn't pay them twice; the
driver runs GetMiss, defaults READ_N to 500k (5 windows at the 100k
reload cadence), and prints per-bench CONVERGED/NOT verdicts in its
summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngth

Two follow-ups for the 100M disk runs:

- ensureDiskFixture now logs the actual tree size + height at the start
  of every benchmark, and BlockWrite logs the post-run size. The fixture
  is not a fixed 100M: BlockWrite inserts fresh keys every run (the
  update-frac<1 half ≈ 500/block), so the persistent fixture drifts
  upward across runs — the logged counts keep successive runs honest and
  let us separate "tree grew" from "cache state changed".

- Driver defaults WRITE_N 300->1200 and READ_N 500000->4000000 (2x the
  last run), to test whether IAVL's block-write latency truly plateaus
  under sustained compaction or keeps climbing: with 1200 blocks the
  per-window ns series runs long enough to tell a flat plateau from a
  monotone rise. Lower via env for quick checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The start-of-run size log lived in ensureDiskFixture, which runs on the
outer *testing.B — its output never surfaced in benchmark results, so
the read benches printed no item count at all. Move it inside each
b.Run closure (inner b) so every bench logs "fixture: N items, height H"
at the start; BlockWrite still logs the post-run count, making per-run
growth visible as start->end on one block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the model-only doc with a measured-results section anchored to the
~101M-key PebbleDB runs (Ryzen 7 5700G), and correct the model where the data
disagrees. The modeled tables stay as the cross-size extrapolation framework
but are now explicitly subordinate to measurement at 100M.

Counts (converged <0.1%, page-cache-independent, reproduced across runs):
  GET reads/op       B+32 3.66  IAVL+fast 0.97
  GET-miss reads/op  B+32 2.66  IAVL+fast 1.00
  SET reads/write    B+32 2.86  IAVL+fast 34.1
  SET writes/write   B+32 4.36  IAVL+fast 19.6

Model corrections forced by the data:
- B+32 GET reads 3.0 -> 3.66: the "10K cache holds all inner nodes up to
  ~200M items" claim is wrong (holds ~200K); at 100M the ~207K level-1
  inners are uncached, so GET misses inner+leaf+value.
- IAVL SET reads 15 -> 34.1, and measured height 32 (not 28).
- B+32 SET reads 2.0 -> 2.86. Writes confirmed (4.36 / 19.6).

Honest read/write split (corrects "B+32 wins reads clearly"):
- Writes: B+32 wins 4.5-4.9x (latency) and ~4.5x (ops), reproducibly.
- Latest-version point reads: IAVL+fast wins ~4x (1 read vs 3.66).
- B+32 wins historical reads + proofs (fast node can't serve them).

Also documents the IAVL sustained-write plateau (540-640 us, compaction
read-amp offsets cache warming; the 454 us rerun was a warm-cache transient)
and the fixture's ~325K-keys/run growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Realm package-level state is bound to the consensus app hash only because
the package block escapes (refcount >= 2 via PackageValue.Block + each file
block's parent edge) and thus gets its hash written to the iavl store; the
rootless PackageValue above it is deliberately never escaped. That binding
was an emergent property of the ownership/escape walk, unguarded — a future
refactor could drop the block to refcount 1, silently inlining package state
into the uncommitted PackageValue and dropping it from the app hash.

Add a debugAssert in saveObject: when a realm package block (Block whose
GetSource is a *PackageNode with a realm path) is persisted, it must be
escaped. debugAssert-gated, so zero production/consensus/encoding impact;
it makes the invariant fail loudly under `make test.debugAssert` instead of
silently corrupting state commitment.

Validated: fired 0 times across the entire zrealm_* filetest corpus (the
package block is escaped in every tested realm shape, including data-only
realms).

NOTE: `make test.debugAssert` is currently red for realm filetests due to
PRE-EXISTING, unrelated asserts (toRefValue's "non-escaped object should not
have zero hash"/"refcount > 1" at realm.go:2065-2072, and a "hash chain
broken" check in zrealm_hiv_real_rejected) — confirmed by stashing this
change. Making that target green is a separate task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an opt-in latest-version "fast index" to the B+32 tree: a flat
user-key -> version||value map (prefix 'F') that accelerates committed
point Gets of present keys to a single DB read, instead of a full tree
descent plus the out-of-line value read (~3.66 logical reads at 100M
keys). Reads of present keys drop to ~1.0 read (~18-24us, IAVL-parity).

It is an unauthenticated read accelerator -- NOT part of the Merkle
commitment -- so the app hash and ICS23 proofs are byte-identical with it
on or off, and a node may enable/disable it without forking. Off by
default (Options.FastIndex / FastIndexOption; store toggle
bptree.FastIndexEnabled).

Design:
- Inline encoding stamps the value's version in the entry's first 8
  bytes; a read trusts an entry only when version <= the snapshot version
  and otherwise falls back to the authoritative tree walk, so index
  completeness is a performance property, never a correctness one. A miss
  is never authoritative.
- Maintained in the same atomic batch as the tree (Set/Remove stage into
  ndb.batch; SaveVersion stamps completeness), so the inline copy and the
  authoritative value can never disagree, even across a crash; rollback
  and errors drop it via the existing DiscardBatch.
- Prune is fully decoupled (the value is self-contained, no dangling).
- Load rebuilds the index from the latest root when absent/stale
  (enabling on an existing DB); Import suppresses the stamp so the next
  Load rebuilds (it bypasses per-entry maintenance).
- Only committed snapshots consult it (gated at the newImmutable
  chokepoint); read-your-writes Snapshots and the working MutableTree
  stay on the tree walk.

Cost: +1 value-sized write per Set and the latest value stored a second
time (~+1.6 GB at 100M x 20-byte values, flat across retention) -- worth
it for small values like escaped hashes.

Includes the store-layer toggle, a bptree-fast benchmark factory reusing
the existing fixture, fast_index_test.go (differential fuzz vs index-off
across retained versions for present and absent keys, encoding lock,
value-equality sweep, version-check, rollback, rebuild-on-Load,
import-rebuild, empty-value, corrupt self-heal, prune, off-by-default),
and a store-level app-hash/value/proof parity test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enabling the fast index on a fixture/DB built without it triggers a
rebuild on the next Load (it reads and re-stores every live value). At
100M keys that is a long, one-time operation, so:

- Log start/progress/done from rebuildFastIndex via the tree logger
  (Info level, ~every 4M entries; no-op under NopLogger).
- Add TestDiskFastIndexBackfill: an explicit, observable command that
  opens the existing "bptree" disk fixture with the index enabled and a
  stderr logger and Loads it (triggering the backfill). Idempotent — a
  second run finds the stamp current and is a no-op.

  go test ./tm2/pkg/bptree/benchmarks/ -run TestDiskFastIndexBackfill -v \
    -disk-dir=/data/bptree-bench -disk-node-cache=10000 -timeout=24h

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread tm2/pkg/bptree/fast_index.go Dismissed
Comment thread tm2/pkg/bptree/fast_index.go Dismissed
jaekwon and others added 6 commits June 13, 2026 22:58
Review follow-ups (no behavior change):
- Move the 'rebuilding fast index' start log before clearFastIndex, so a
  re-backfill over an existing index isn't silent during the clear.
- Fix two comments that said a rebuild failure is non-fatal to Load: Load
  actually propagates the error (the loaded tree is unaffected; a retry
  Load re-attempts the rebuild). Reword to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up. fastValueEqualitySweep compared each 'F' entry's inline
value against tr.GetVersioned(key, latest) on the same index-ON tree, whose
committed-snapshot Get resolves through the very same 'F' entry -- so a wrong
inline value written by setFastIndex was compared against itself and would
pass. Resolve the expected value via MutableTree.Get instead (the tree walk,
which never consults the fast index), and drop the now-unused latest param.
Now got ('F' payload) and want (PrefixVal via the tree walk) come from
disjoint code paths and key prefixes, so a wrong inline value is genuinely
caught. (The wrong-inline-value class was already caught independently by
TestFastIndex_Differential; this hardens the targeted sweep to match its
comment.)

Also correct TestDiskFastIndexBackfill's doc: it opens the "bptree-disk"
sub-DB, not "bptree".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The disk Get benchmarks read via fx.tree.Get -> MutableTree.Get, which is
index-free by design: the fast index serves only committed ImmutableTree.Get
(the ABCI-query path). So bptree-fast showed no read win (~3.66 reads/op, same
as plain bptree) -- the index was never consulted.

- TreeBench.CommittedReader(version) + iavl/bptree wrappers: open one held
  GetImmutable(version) snapshot and return a point-read closure + closer
  (bptree's releases the version-reader reservation; iavl's is a no-op). Held
  once, not GetVersioned per-call, so the snapshot-open's root read isn't
  counted per op.
- -disk-committed-read flag + pointReadFn: route GetRandom/GetMiss reads
  (warmup + loop) through that snapshot. Both trees then use their fast layer
  -- apples-to-apples, and it corrects a prior bias against bptree (iavl's
  MutableTree.Get already used fast nodes; bptree's does not).
- run-disk-bench.sh: default FACTORIES iavl->bptree-fast; COMMITTED_READ env
  (default 1) wires the flag into the Get benches only.

gno.land benefit (verified): real but scoped to latest-height ABCI queries
(RPC/query serving), not block execution; contingent on the IAVL->bptree
store migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
golangci-lint v2.11.4 was red on the branch:
- thelper: snap/fold/pointReadFn test helpers missing b.Helper()
- errorlint: `== ErrExportDone` must use errors.Is for wrapped errors

snap/fold + the errorlint predate the committed-read commit; pointReadFn
came with it. Test-only; no production code touched. Verified 0 issues
across all bptree packages with the CI-pinned linter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jaekwon
jaekwon merged commit 5189216 into master Jun 17, 2026
89 of 90 checks passed
@jaekwon
jaekwon deleted the feat/jae/bp32tree branch June 17, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a/blockchain Consensus, IBC, TM2 team amino Issues and PRs related to amino 🐳 devops 🛠️ gnodev 🐹 golang Pull requests that update Go code 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related 📦 🤖 gnovm Issues or PRs gnovm related 🧾 package/realm Tag used for new Realms or Packages. 📄 top-level-md

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

7 participants