You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
CPU profile of a validator under 500 tx/s, 30 s, from Accumulate soak 20260902T041031Z:
item
share
syscall.Syscall6 (flat)
18.45%
— of which pread
16.5% of total CPU
— of which segment.bloomTest → os.File.ReadAt
71.8% of the preads
segment.lookup (the binary search)
18.6% of the preads
segment.readValue (the actual record)
8.9% of the preads
Bloom.Test itself
1.17%
One CPU-second in six is pread. The cost of a read is finding the record, not returning it.
The cost structure, per segment
segment.lookup (segstore.go:339) calls bloomTest first, then binary-searches the index. Reading the code rather than inferring it:
bloomTest (segstore.go:289) already does the minimal thing — ByteMask computes a byte offset and it reads one byte, short-circuiting at the first clear bit. It does not read the whole filter.
K is 3 (segstore.go:3693, NewBloomSizedForKeys(count, 3)). BloomBitsPerKey = 12 is bits of space per key, not bits set — the two are separate, and the doc comment says so: "~1% false positives at capacity with k=3".
The binary search does one pread per probe (segstore.go:357), log₂(count) of them. The "one borrow for the whole binary search" is the file handle, not the reads.
outcome
preads
what it buys
filter says no
1–3
skips a merged segment — 20 blocks of records
filter says maybe
1–3 + ~14 + 1
the record, or nothing
At SealLimit = 12,500 records a segment, log₂(12,500) ≈ 14.
Two consequences worth stating plainly:
Skipping is cheap and the filter is doing its job. 1–3 syscalls to rule out 20 blocks is a good trade.
Being wrong about it is not. A false positive costs ~15 preads for nothing. At ~1% over a 40-segment walk that is ~0.4 wasted searches per read — roughly the cost of all 40 skips combined.
The validator is being handled elsewhere
Accumulate is adding a read cache for the records its executor reads every block — account URLs, the messages and transactions behind synthetic delivery, anchor chain elements (accumulatenetwork/accumulate#4186). Those are write-once and have strong locality, so a bounded cache removes the store lookup entirely for the protocol path. This issue is not about that path.
The API is a different problem
API reads — an explorer walking history, a client proving an old account state — have none of that locality: arbitrary keys, arbitrary depth, no working set to cache. They are exactly the reads that walk many segments and pay the costs above, and no amount of caching in the executor helps them.
Proposal: measure it outside the protocol first
A standalone benchmark against a synthetic store, with no Accumulate, no consensus and no network — so the read path can be changed and measured without a 20-minute soak per attempt.
Fixture. Build a store of S segments × 12,500 records at realistic value sizes, for S spanning what a chain reaches (10, 100, 1,000, 10,000 segments). Report build time and on-disk size so the fixture is reproducible.
Workloads, each as its own benchmark:
HitInWindow — a key in the newest 2N segments (what the validator does)
HitDeep — a key uniformly at random across all S (what an explorer does)
Miss — a key that is in no segment (the pure walk cost, and the false-positive rate falls out of it)
Variants to compare against the baseline:
filters resident for the newest 2N only (loadBloom on entry to the window, freeBloom on exit — both already exist)
index resident or mmapped, to attack the ~14 preads behind a filter hit
mmap the index file so probes are page faults rather than syscalls
Metrics per lookup, not just wall time: pread count, bytes read, resident bytes, and the filter's observed false-positive rate. Wall time on a warm page cache will flatter everything; the syscall count is what the profile actually caught.
What it should decide
Whether bloom residency for the 2N window is worth it. Sizing suggests it is cheap: 40 segments × ~18.3 KB × 8 shards ≈ 6 MB per partition engine, bounded by N rather than by chain age — which is the bound Roll the key filter over a window of blocks instead of all history #56 asks for. (18.3 KB is derived from nKeys × BloomBitsPerKey / 8; the benchmark should report the real figure.)
Whether the ~14-pread binary search is worth attacking, and at what memory cost. An index is far larger than a filter, so "keep the index resident too" is not obviously affordable the way the filter is — this is the question the benchmark exists to answer rather than guess at.
Whether the false-positive rate justifies a larger k or more bits per key, given a false positive costs ~15 preads and a skip costs 1–3.
Related: #56 (resident bloom memory and open-time scan growing without bound), #33 (a seal costs six fsyncs), accumulatenetwork/accumulate#4186.
What was measured
CPU profile of a validator under 500 tx/s, 30 s, from Accumulate soak
20260902T041031Z:syscall.Syscall6(flat)preadsegment.bloomTest→os.File.ReadAtsegment.lookup(the binary search)segment.readValue(the actual record)Bloom.TestitselfOne CPU-second in six is
pread. The cost of a read is finding the record, not returning it.The cost structure, per segment
segment.lookup(segstore.go:339) callsbloomTestfirst, then binary-searches the index. Reading the code rather than inferring it:bloomTest(segstore.go:289) already does the minimal thing —ByteMaskcomputes a byte offset and it reads one byte, short-circuiting at the first clear bit. It does not read the whole filter.segstore.go:3693,NewBloomSizedForKeys(count, 3)).BloomBitsPerKey = 12is bits of space per key, not bits set — the two are separate, and the doc comment says so: "~1% false positives at capacity with k=3".preadper probe (segstore.go:357),log₂(count)of them. The "one borrow for the whole binary search" is the file handle, not the reads.At
SealLimit= 12,500 records a segment,log₂(12,500)≈ 14.Two consequences worth stating plainly:
The validator is being handled elsewhere
Accumulate is adding a read cache for the records its executor reads every block — account URLs, the messages and transactions behind synthetic delivery, anchor chain elements (accumulatenetwork/accumulate#4186). Those are write-once and have strong locality, so a bounded cache removes the store lookup entirely for the protocol path. This issue is not about that path.
The API is a different problem
API reads — an explorer walking history, a client proving an old account state — have none of that locality: arbitrary keys, arbitrary depth, no working set to cache. They are exactly the reads that walk many segments and pay the costs above, and no amount of caching in the executor helps them.
Proposal: measure it outside the protocol first
A standalone benchmark against a synthetic store, with no Accumulate, no consensus and no network — so the read path can be changed and measured without a 20-minute soak per attempt.
Fixture. Build a store of S segments × 12,500 records at realistic value sizes, for S spanning what a chain reaches (10, 100, 1,000, 10,000 segments). Report build time and on-disk size so the fixture is reproducible.
Workloads, each as its own benchmark:
HitInWindow— a key in the newest 2N segments (what the validator does)HitDeep— a key uniformly at random across all S (what an explorer does)Miss— a key that is in no segment (the pure walk cost, and the false-positive rate falls out of it)Variants to compare against the baseline:
loadBloomon entry to the window,freeBloomon exit — both already exist)Metrics per lookup, not just wall time:
preadcount, bytes read, resident bytes, and the filter's observed false-positive rate. Wall time on a warm page cache will flatter everything; the syscall count is what the profile actually caught.What it should decide
nKeys × BloomBitsPerKey / 8; the benchmark should report the real figure.)Related: #56 (resident bloom memory and open-time scan growing without bound), #33 (a seal costs six fsyncs), accumulatenetwork/accumulate#4186.