Repository navigation
feat(tm2): immutable B+32 tree — drop-in replacement for IAVL - #5438
Merged
Merged
Conversation
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>
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>
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
force-pushed
the
feat/jae/bp32tree
branch
from
June 13, 2026 01:04
db07435 to
6b8dcb2
Compare
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
Test plan