perf(core): Hand-rolled UnmarshalBinary for AggregatedBloomFilter#3796
perf(core): Hand-rolled UnmarshalBinary for AggregatedBloomFilter#3796brbrr wants to merge 3 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.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3796 +/- ##
==========================================
+ Coverage 75.21% 75.28% +0.06%
==========================================
Files 438 438
Lines 39577 39355 -222
==========================================
- Hits 29767 29627 -140
+ Misses 7720 7654 -66
+ Partials 2090 2074 -16 ☔ 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.
|
Claude finished @brbrr's task in 2m 15s —— View job Review: Hand-rolled
|
RafaelGranza
left a comment
There was a problem hiding this comment.
LGTM. But please address the changes I considered to be fair.
Just as a guarantee, I ran the fuzzer for 1M execs and saw no panics or crashes.
|
Claude finished @brbrr's task in 1m 24s —— View job Re-review after
|
26b1787 to
07b7594
Compare
|
Claude finished @brbrr's task in 2m 35s —— View job Re-review after
|
07b7594 to
80fc145
Compare
|
Claude finished @brbrr's task in 2m 9s —— View job Re-review after
|
|
@rodrodros I've addressed all the feedback. please give it another look when you'll have some time. |
| 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 |
There was a problem hiding this comment.
I assume this variable represents, an offset, why not call it offset?
rodrodros
left a comment
There was a problem hiding this comment.
Solving the issue is not good enough if the code that accompanies is plagued by readability issues – causing issues for future developers.
I would like for you to reinforce on this side of code writing and doing self reviews.
| // wordsPerFilterRow is the number of uint64 words bitset uses to store one | ||
| // NumBlocksPerFilter-bit row. | ||
| var wordsPerFilterRow = func() int { | ||
| row := makeBitset() | ||
| return len(row.Words()) | ||
| }() |
There was a problem hiding this comment.
Maybe we can make this a constant equal to NumBlocksPerFilter - 1. I believe it might be a bit more legible.
| // UnmarshalBinary decodes MarshalBinary's output in place. It never panics on | ||
| // corrupt DB bytes, returning an error instead. On error f may be partially | ||
| // written and must be discarded. | ||
| func (f *AggregatedBloomFilter) UnmarshalBinary(data []byte) error { |
There was a problem hiding this comment.
Wording like "It never panics on corrup DB bytes, returning an error instead" may have a more direct wording of: "returns an error in case of DB corruption". There is no need to say what it doesn't do, if it doesn't do it. It is enough to say what the function does.
// UnmarshalBinary decodes MarshalBinary's output in place. Returns an error
// in case of DB corruption. On error f may be partially written and must be
// discarded. | blobLenWant := bitLenSize + wordsPerFilterRow*bytesUint64 | ||
| rowSize := rowLenSize + blobLenWant |
There was a problem hiding this comment.
Both blobLenWant and rowSize could be constants, assuming that the previous comment is applied (i.e. making wordsPerFilterRow a constant)
| 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 |
There was a problem hiding this comment.
Why is off created here when it is used 23 lines below? Why it is not defined right before being used?
| return io.ErrUnexpectedEOF | ||
| } | ||
|
|
||
| backing := make([]uint64, count*wordsPerFilterRow) |
There was a problem hiding this comment.
Why not use a matrix here? [][]uint64?
Have you consider using a static array, since count must be EventsBloomLength and wordsFilterPerRow is fixed as well: [a][b]uint64?
| return io.ErrUnexpectedEOF | ||
| } | ||
|
|
||
| backing := make([]uint64, count*wordsPerFilterRow) |
There was a problem hiding this comment.
It also seems that you don't need all of that space at once. You only need one row that is re-used per loop and passed to the bitmap in i, or am I mistaken?
There was a problem hiding this comment.
AFAIU, the real improvement of the PR comes from exactly this. Before, there were thousands of allocations per filter, and now we allocate a big array once and use pieces of it to back all the thousands of slices, avoiding allocating them.
Reusing a single small array is not possible because we need individual backing arrays for all the slices/rows since each one of them stores different filters
|
@RafaelGranza I would've expect for you to catch some the things I catched on my review. As said previously on dailies, reviews are not only to check if an issue is solved, but to also check that is done in the best way possible – readability wise included. @brbrr please be more careful in follow up PRs and take your time to familiarize with what you wrote and try to give yourself AI-less self reviews. |
thiagodeev
left a comment
There was a problem hiding this comment.
Nice improvement!
Partial review
| // UnmarshalBinary decodes MarshalBinary's output in place. It never panics on | ||
| // corrupt DB bytes, returning an error instead. On error f may be partially | ||
| // written and must be discarded. |
There was a problem hiding this comment.
| // UnmarshalBinary decodes MarshalBinary's output in place. It never panics on | |
| // corrupt DB bytes, returning an error instead. On error f may be partially | |
| // written and must be discarded. | |
| // UnmarshalBinary decodes MarshalBinary's output in place. It never panics on | |
| // corrupt DB bytes, returning an error instead. On error, f may be partially | |
| // written and must be discarded. |
| // Header layout (see MarshalBinary): fromBlock + toBlock + count. | ||
| headerSize = bytesUint64 + bytesUint64 + bytesUint32 | ||
| rowLenSize = bytesUint32 | ||
| bitLenSize = bytesUint64 |
There was a problem hiding this comment.
Can we rename bitLenSize to bitsetLenSize?
| if blobLen != blobLenWant { | ||
| return ErrBloomFilterSizeMismatch | ||
| } | ||
| if bitLen := binary.BigEndian.Uint64(data[off:]); bitLen != NumBlocksPerFilter { |
There was a problem hiding this comment.
Can we rename bitLen to bitsetLen?
|
|
||
| // wordsPerFilterRow is the number of uint64 words bitset uses to store one | ||
| // NumBlocksPerFilter-bit row. | ||
| var wordsPerFilterRow = func() int { |
There was a problem hiding this comment.
We can make this a const instead of using len(row.Words()).
const wordsPerFilterRow = int((NumBlocksPerFilter + 63) / 64) // 128This is the formula actually being used by BitSet.WriteTo/ BitSet.MarshalBinary to calculate the used words - ref, ref, ref ((int(i) + wordMask) >> log2WordSize == (i + 63) / 64).
By calling len(row.Words()) we are getting the length of the internal storage slice, while BitSet.WriteTo/ BitSet.MarshalBinary only write the actual used words, obtained by calling wordCount().
This works today because makeBitset() happens to allocate exactly that many words, but that isn’t really an API guarantee (thinking about a future change in the lib). Having it as a fixed const is better IMO since we are creating our own custom decode function with fixed values.
Reduces allocations and the GC pressure for a
getEventscallBench results: