perf(core): Hand-rolled UnmarshalBinary for AggregatedBloomFilter#3796
perf(core): Hand-rolled UnmarshalBinary for AggregatedBloomFilter#3796brbrr wants to merge 2 commits into
Conversation
|
Claude finished @brbrr's task in 4m 49s —— View job Review: Hand-rolled
|
There was a problem hiding this comment.
Pull request overview
This PR optimizes core.AggregatedBloomFilter.UnmarshalBinary by replacing the generic binary.Read + per-row allocations with a hand-rolled decoder that reuses a single []uint64 backing buffer, reducing allocations/GC pressure in getEvents-related paths.
Changes:
- Added a package-level
wordsPerFilterRowhelper to compute the fixed word width for aNumBlocksPerFilter-bit row. - Reimplemented
AggregatedBloomFilter.UnmarshalBinaryto parse the on-disk format directly and build bitsets from a shared backing slice. - Added compatibility, corruption/bounds, fuzz, and allocation-regression tests for the new decoder.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| core/aggregated_bloom_filter.go | Implements the new manual UnmarshalBinary and shared backing allocation strategy. |
| core/aggregated_bloom_filter_test.go | Adds tests covering wire-format compatibility, corruption handling, fuzzing, and allocation limits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| f.fromBlock = binary.BigEndian.Uint64(data[0:bytesUint64]) | ||
| f.toBlock = binary.BigEndian.Uint64(data[bytesUint64 : 2*bytesUint64]) | ||
| count := int(binary.BigEndian.Uint32(data[2*bytesUint64 : headerSize])) | ||
| off := headerSize |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3796 +/- ##
==========================================
+ Coverage 75.21% 75.48% +0.27%
==========================================
Files 438 438
Lines 39577 39521 -56
==========================================
+ Hits 29767 29832 +65
+ Misses 7720 7627 -93
+ Partials 2090 2062 -28 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@brbrr how were the benchmark results achieved? |
|
@rodrodros I decided not to commit the bench test, as it's quite simple. Basically I've tested direct unmarshaling and compared it against main branch. Let me know if you think I should commit the bench test. Next week I'll look into it's effects on rpc latency. |
|
@brbrr yes, please commit the benchmarks in its dedicated bench_test file, it is good to have them |
Ehsan-saradar
left a comment
There was a problem hiding this comment.
Pulled the branch and reproduced the benchmark independently on a worst-case fully-populated filter (~8.1 MiB blob): allocs 49,157 → 2 and 25.75 MiB → 8.25 MiB match your table exactly, and the ~78% speedup holds on a slower box too. @rodrodros — that should cover the reproducibility question; committing the benchmark would let CI guard it from here on.
The per-row validation is tight, nice. One theme across the inline notes: the decoder fully trusts the three header fields that MarshalBinary always writes consistently (count, toBlock, and the total length), so a corrupt/truncated DB blob can decode "successfully" and blow up on a later query. All corrupt-input hardening, not happy-path — so flagging as a comment, not blocking.
| return err | ||
| f.fromBlock = binary.BigEndian.Uint64(data[0:bytesUint64]) | ||
| f.toBlock = binary.BigEndian.Uint64(data[bytesUint64 : 2*bytesUint64]) | ||
| count := int(binary.BigEndian.Uint32(data[2*bytesUint64 : headerSize])) |
There was a problem hiding this comment.
Building on the earlier note about count: since NewAggregatedFilter always creates exactly EventsBloomLength rows, I'd validate the header invariants right here instead of trusting them —
count == EventsBloomLength: a smaller (or zero) self-consistentcountdecodes fine, thenBlocksForKeyspanicsindex out of rangeon the next query. A zero-valueAggregatedBloomFilter{}marshals to a 20-byte header withcount == 0and hits this too.toBlock == fromBlock + NumBlocksPerFilter - 1: otherwise a corrupttoBlockwidens the accepted range while the matrix still has 8192 columns —Insertthen accepts an out-of-range block, grows a row past 8192 bits, and later intersections silently truncate to the shorter row → wrong results. Verified:toBlock = 1<<40decodes with no error andInsert(b, 20000)is accepted.
Two cheap guards here close all of it.
|
|
||
| off += blobLen | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Worth asserting off == len(data) before returning. Right now trailing bytes after the last row are silently ignored, so a valid blob with junk appended still decodes (err == nil) — verified by appending 4 bytes. Makes decode canonical and catches framing corruption/truncation.
| return err | ||
| bitLen := binary.BigEndian.Uint64(data[off:]) | ||
| nWords := (blobLen - bitLenSize) / bytesUint64 | ||
| if bitLen != NumBlocksPerFilter || nWords != wordsPerFilterRow || |
There was a problem hiding this comment.
Minor/readability: a valid row is always exactly rowSize - rowLenSize bytes, so these three conditions collapse to a single blobLen != <expected> — which also makes reading bitLen provably in-bounds and drops nWords entirely.
|
Claude finished @brbrr's task in 2m 15s —— View job Review: Hand-rolled
|
| return err | ||
| f.fromBlock = binary.BigEndian.Uint64(data[0:bytesUint64]) | ||
| f.toBlock = binary.BigEndian.Uint64(data[bytesUint64 : 2*bytesUint64]) | ||
| count := int(binary.BigEndian.Uint32(data[2*bytesUint64 : headerSize])) |
There was a problem hiding this comment.
[important] Validate the header invariants instead of trusting count / toBlock.
The decoder accepts any count, but every consumer indexes the matrix with the fixed constant — e.g. BlocksForKeys does f.bitmap[index%EventsBloomLength] (line 174) and clear loops for index := range EventsBloomLength. A corrupt/truncated blob encoding a self-consistent count < EventsBloomLength (a zero-value filter marshals to a 20-byte header with count == 0) decodes with err == nil and then panics index out of range on the next query — a DoS on corrupt DB bytes.
Similarly toBlock is stored verbatim; a widened toBlock makes Insert accept an out-of-range block and grow a row past 8192 bits, silently corrupting later intersections.
Both are cheap to close right here, since NewAggregatedFilter always produces exactly EventsBloomLength rows spanning NumBlocksPerFilter blocks:
if count != EventsBloomLength {
return ErrBloomFilterSizeMismatch
}
if f.toBlock != f.fromBlock+NumBlocksPerFilter-1 {
return ErrBloomFilterSizeMismatch
}Note this isn't a regression (the old binary.Read path was equally trusting), but since you're hand-rolling the decoder it's a good time to make it total.
|
|
||
| off += blobLen | ||
| } | ||
| return nil |
There was a problem hiding this comment.
[nit] Assert off == len(data) before returning nil. Trailing bytes after the last row are currently ignored, so a valid blob with junk appended still decodes successfully. A final if off != len(data) { return io.ErrUnexpectedEOF } makes the decode canonical and catches framing/truncation corruption.
Reduces allocations and the GC pressure for a
getEventscallBench results: