-
Notifications
You must be signed in to change notification settings - Fork 38.7k
refactor: optimize block index comparisons (1.4-6.8x faster) #33637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/33637. ReviewsSee the guideline for information on the review process.
If your review is incorrectly listed, please copy-paste ConflictsReviewers, this pull request conflicts with the following ones:
If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first. LLM Linter (✨ experimental)Possible places where named args may be used (e.g.
2025-11-25 |
|
Approach ACK I have tested the new block index comparator but I’ll refrain from acking the added benchmarks/tests |
f74572e to
c15d839
Compare
|
I attempted to run the script, not really sure what these results indicate. Just pasting what the results were Details |
|
Thanks for the measurements @Christewart, this is how your measurements compare to mine (but most importantly how it compares to |
laanwj
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code review ACK c15d839aefba017e64f14cd9cd2779655352f4b6
i did not run the benchmarks but the code changes look good, using <=> makes sense.
c15d839 to
a558471
Compare
|
Rebased after #29640 - only change was adjusting the comment |
mzumsande
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concept ACK
Not 100% sure yet about 2dd0f2ced35a268bcab661c671e7c70271cdd91f, seems like inlining gives most of the speedup, whereas the gain of using the spaceship operator (which comes at the cost of readability) is marginal.
src/test/blockchain_tests.cpp
Outdated
| if (pa->nChainWork > pb->nChainWork) return false; | ||
| if (pa->nChainWork < pb->nChainWork) return true; | ||
|
|
||
| // ... then by earliest time received, ... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should take the latest comments from the old function after #29640
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have already done that, which parts do you think I'm missing? Edit: ah in the tests, good point, let me update it
| @@ -117,6 +117,43 @@ BOOST_AUTO_TEST_CASE(num_chain_tx_max) | |||
| BOOST_CHECK_EQUAL(block_index.m_chain_tx_count, std::numeric_limits<uint64_t>::max()); | |||
| } | |||
|
|
|||
| BOOST_AUTO_TEST_CASE(cblockindex_comparator_equivalence) | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this looks like a fuzz test, why not make it an actual fuzz target out of this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I look at fuzz tests as exploratory tools that are hard to write and run and debug and do code coverage on - not trivial to maintain (see #33731), it's definitely not my go-to way to test something. This is just a randomized property based unit test - easy to debug, easy to run and understand.
But looking at the tests again, I can make it more deterministic so that it hits all important branches which allows me to reduce the iteration count - code coverage and debugging shows this regularly tests each branch now (green line on left side):

Added you as coauthor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I look at fuzz tests as exploratory tools that are hard to write and run and debug and do code coverage ... not trivial to maintain ... not my go-to way to test something
There is a learning curve to writing and using fuzz testing but it is 100% worth getting in to. The maintenance aspect only relates to running them using libFuzzer on macOS.
On average, coverage guided fuzzing will be magnitudes better at finding bugs than this style of unit test. For something as small as the comparator your approach is probably good enough, although you could just have a fixed list of test cases that enumerate all possibilities (for the old comparator) instead? (maybe you do already, didn't look)
| @@ -117,6 +117,43 @@ BOOST_AUTO_TEST_CASE(num_chain_tx_max) | |||
| BOOST_CHECK_EQUAL(block_index.m_chain_tx_count, std::numeric_limits<uint64_t>::max()); | |||
| } | |||
|
|
|||
| BOOST_AUTO_TEST_CASE(cblockindex_comparator_equivalence) | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would suggest to add a bit more explanation, e.g. mentioning that old_comparator is a snapshot of an old implementation, so that people who look at this in years understand where it's coming from.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I strongly dislike code comments, I prefer explaining with live code over dead comments - and if people disagree they can always do a blame which instantly reveals the purpose since I also over-explain in commit messages usually.
Are the names old_comparator in a cblockindex_comparator_equivalence not enough to make it obvious that? I
I have added a comment anyway, let me know if it helps.
src/node/blockstorage.h
Outdated
| @@ -87,12 +87,20 @@ static constexpr uint32_t UNDO_DATA_DISK_OVERHEAD{STORAGE_HEADER_BYTES + uint256 | |||
| using BlockMap = std::unordered_map<uint256, CBlockIndex, BlockHasher>; | |||
|
|
|||
| struct CBlockIndexWorkComparator { | |||
| bool operator()(const CBlockIndex* pa, const CBlockIndex* pb) const; | |||
| // First sort by most total work (descending), then by earliest activatable time (ascending), then by pointer value (ascending). | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I find "ascending/descending" confusing here, I would have expected the opposites: "descending work" means most work first, but the strict weak ordering of C++ comparators would place the one with the lower work first.
Would suggest something like
"First compare by work (less first), then by earliest activatable time (higher sequence first), then by pointer value (higher first)."
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to use the SQL order-by terminology here
Ascending order puts smaller values first, where “smaller” is defined in terms of the < operator.
Similarly, descending order is determined with the > operator.
But you're right, I inverted the terminology, added a dedicated test to check for hard-coded order (first param is increasing, second is decreasing), updated the comments (this is why I find comments a lazy solution, it's too easy to keep them up-to-date), added you as coauthor.
da5eb75 to
5cbb9a4
Compare
|
Pushed, thanks for the review, you had a few good points, added you as coauthor.
the spaceship might not be very familiar to us yet, but it's arguably simpler, having fewer moving parts, and some compilers prefer that over the manual comparisons. |
|
utACK 5cbb9a40873203ea5a4dd0aa7127c9756da2f607 An evident, localized micro-optimization in a hot-path. I haven't run measurements. |
5cbb9a4 to
237eec0
Compare
I don't follow here. The issue is about the additional Instead of mentioning an unrelated issue, I think it could be better to mention what real-world effect this improvement has. I think that is |
The original issue won't be fixed since it's not exactly a bug, but we could still speed up the regression by optimizing another bottleneck.
Yes, this was mentioned in the benchmarking section.
Yes, it's used explicitly in git grep 'setBlockIndexCandidates.' src/validation.cpp | wc -l
44 |
| // Pointer tiebreak should only happen with blocks loaded from disk, as those share the same id: 0 for blocks on the best chain, 1 for all others. | ||
| bool operator()(const CBlockIndex* pa, const CBlockIndex* pb) const noexcept | ||
| { | ||
| return std::tie(pa->nChainWork, pb->nSequenceId, pb) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought of eliminating the worst case here (when pa == pb, where it has to do the comparison for every byte) with something like:
return pa != pb &&
std::tie(pa->nChainWork, pb->nSequenceId, pb)
< std::tie(pb->nChainWork, pa->nSequenceId, pa);But the benchmarks indicate that it's not really faster.
benchmark with pointer duplicates
// Copyright (c) 2025-present The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
#include <arith_uint256.h>
#include <bench/bench.h>
#include <chain.h>
#include <node/blockstorage.h>
#include <random.h>
#include <uint256.h>
#include <algorithm>
#include <memory>
#include <vector>
static void CBlockIndexWorkComparator(benchmark::Bench& bench)
{
FastRandomContext rng{/*fDeterministic=*/true};
constexpr size_t n{1'000};
std::vector<std::shared_ptr<CBlockIndex>> blocks;
blocks.reserve(n);
for (size_t i{0}; i < n; ++i) {
auto block{std::make_shared<CBlockIndex>()};
block->nChainWork = UintToArith256(rng.rand256());
block->nSequenceId = int32_t(rng.rand32());
if (i % 10 == 1) {
// Have some duplicates
if (rng.randbool()) {
block = blocks.back();
} else {
if (rng.randbool()) block->nChainWork = blocks.back()->nChainWork;
if (rng.randbool()) block->nSequenceId = blocks.back()->nSequenceId;
}
}
blocks.push_back(std::move(block));
}
std::ranges::shuffle(blocks, rng);
const node::CBlockIndexWorkComparator comparator;
bench.batch(n * n).unit("cmp").run([&] {
for (size_t i{0}; i < n; ++i) {
const auto* lhs{blocks[i].get()};
for (size_t j{0}; j < n; ++j) {
const auto* rhs{blocks[j].get()};
const bool result{comparator(lhs, rhs)};
ankerl::nanobench::doNotOptimizeAway(result);
}
}
});
}
BENCHMARK(CBlockIndexWorkComparator, benchmark::PriorityLevel::HIGH);b17504b refactor: optimize CBlockIndexWorkComparator with std::tie
| ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark |
|---|---|---|---|---|---|---|---|---|---|
| 2.13 | 469,485,340.38 | 0.2% | 15.07 | 5.09 | 2.959 | 4.02 | 0.1% | 11.01 | CBlockIndexWorkComparator |
| ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark |
|---|---|---|---|---|---|---|---|---|---|
| 90,265.10 | 11,078.48 | 0.1% | 637,536.00 | 215,946.18 | 2.952 | 178,844.00 | 0.0% | 10.84 | CheckBlockIndex |
2b77e56 refactor: short-circuit CBlockIndex comparator for equality
| ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark |
|---|---|---|---|---|---|---|---|---|---|
| 2.60 | 385,002,212.20 | 0.0% | 18.00 | 6.22 | 2.894 | 5.00 | 0.0% | 11.00 | CBlockIndexWorkComparator |
| ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark |
|---|---|---|---|---|---|---|---|---|---|
| 94,110.53 | 10,625.80 | 0.0% | 682,454.03 | 225,242.05 | 3.030 | 186,486.01 | 0.0% | 11.00 | CheckBlockIndex |
Profiling shows this comparator consumes a significant portion of `CheckBlockIndex()`:
... ChainstateManager::CheckBlockIndex()
... std::_Rb_tree<...>::find(...)
... node::CBlockIndexWorkComparator::operator()(...)
... base_uint<256u>::CompareTo(...) const
This commit introduces a benchmark that performs pairwise comparisons on 1000 randomly generated block indices (with some duplicates) to establish baseline performance metrics before further optimization.
| ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark
|--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:----------
| 9.92 | 100,772,511.62 | 0.0% | 63.98 | 35.64 | 1.795 | 14.17 | 1.9% | 5.50 | `CBlockIndexWorkComparator`
| ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark
|--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:----------
| 109,996.46 | 9,091.20 | 0.2% | 1,014,421.11 | 394,979.29 | 2.568 | 313,025.11 | 0.0% | 5.50 | `CheckBlockIndex`
Add equivalence tests to verify behavioral compatibility when optimizing comparison operations. These are duplicating behavior for now, but this way the reviewers can validate that they behave the same way before the optimizations. The `arith_uint256` test verifies that the spaceship operator produces identical results to the original `CompareTo` method for all comparison operators (<, >, <=, >=, ==, !=). The `CBlockIndexWorkComparator` test captures the current comparison logic in a lambda and verifies that optimized versions maintain identical sorting behavior for chain work, sequence ID, and pointer tiebreaking. To make sure all paths are taken, we randomly assign previously encountered values. You can run the tests with: > cmake -B build && cmake --build build && ./build/bin/test_bitcoin --run_test=arith_uint256_tests,blockchain_tests Co-authored-by: Martin Zumsande <[email protected]>
Remove the `CompareTo` method and inline its logic directly into `operator<=>`, updating related comments. This eliminates function call overhead in the hot path during block generation and chain selection. The comparison algorithm remains unchanged, iterating from most significant to least significant byte, but now returns `std::strong_ordering` directly, removing an extra conversion and enabling better inlining. | ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 6.48 | 154,439,491.66 | 0.0% | 31.01 | 23.25 | 1.334 | 7.16 | 3.8% | 5.50 | `CBlockIndexWorkComparator` | ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 105,754.86 | 9,455.83 | 0.1% | 913,130.11 | 379,588.20 | 2.406 | 276,692.11 | 0.0% | 5.50 | `CheckBlockIndex`
Replace multiple comparisons with a single C++20 spaceship operator call directly. | ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 6.37 | 156,990,488.06 | 0.0% | 28.85 | 22.87 | 1.261 | 8.61 | 3.2% | 5.50 | `CBlockIndexWorkComparator` | ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 83,803.10 | 11,932.73 | 0.0% | 743,565.09 | 300,824.84 | 2.472 | 232,646.08 | 0.0% | 5.56 | `CheckBlockIndex`
Replace manual comparison branches with a single tuple comparison, allowing the compilers to generate more efficient comparison code. Also, moved it to the header to allow inlining. For symmetry, `CBlockIndexHeightOnlyComparator` was also moved to the header. | ns/cmp | cmp/s | err% | ins/cmp | cyc/cmp | IPC | bra/cmp | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 1.52 | 656,674,205.98 | 0.0% | 13.18 | 5.47 | 2.410 | 3.06 | 0.1% | 5.50 | `CBlockIndexWorkComparator` | ns/op | op/s | err% | ins/op | cyc/op | IPC | bra/op | miss% | total | benchmark |--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:---------- | 67,726.84 | 14,765.19 | 0.2% | 585,826.07 | 243,155.90 | 2.409 | 181,920.07 | 0.0% | 5.54 | `CheckBlockIndex` Co-authored-by: Raimo33 <[email protected]> Co-authored-by: Martin Zumsande <[email protected]>
237eec0 to
6e9061f
Compare
|
Rebased after 4f65a1c#diff-f14e9423bd43609969886b2e42751c55606344e0b71780564cdb23515802cec0R12, ready for review again. |
| ns/op | op/s | err% | total | benchmark |--------------------:|--------------------:|--------:|----------:|:---------- | 131.79 | 7,588,049.73 | 0.1% | 10.78 | `PrivateBroadcastBench`

Context
The comparator is often called either directly or for the sorting of setBlockIndexCandidates, a red-black tree-set containing valid block headers where the comparator is invoked extensively.
Besided the sorted set usages, the comparator is used directly in
ChainstateinFindMostWorkChain,PruneBlockIndexCandidates,ActivateBestChainandInvalidateBlock; and inChainstateManagerinLoadBlockIndex,CheckBlockIndex,ActivateSnapshotandPopulateAndValidateSnapshot.It was profiled during the investigation of #33618 (comment).
Testing
To ensure the optimized implementations are both fast and correct, the first commit adds a dedicated benchmark to measure
CBlockIndexWorkComparatorperformance, and the second commit adds randomized tests comparing the new implementation with the original one.Results
CBlockIndexWorkComparator: 100,772,511 cmp/s → 656,674,205 cmp/s = 6.51x fasterCheckBlockIndex: 9,091 ops/s → 14,765 ops/s = 1.62x fasterCBlockIndexWorkComparator: 100,451,893 cmp/s → 683,414,234 cmp/s = 6.8x fasterCheckBlockIndex: 10,322 ops/s → 14,376 ops/s = 1.39x fasterSee https://corecheck.dev/bitcoin/bitcoin/pulls/33637 for the CI's
CheckBlockIndexperformance.gcc and clang measurements
CBlockIndexWorkComparatorCheckBlockIndexe2e0217 refactor: inline
arith_uint256comparison operatorCBlockIndexWorkComparatorCheckBlockIndex85b74b0 refactor: optimize
arith_uint256comparison with spaceship operatorCBlockIndexWorkComparatorCheckBlockIndexdeb58ee refactor: optimize
CBlockIndexWorkComparatorwith std::tieCBlockIndexWorkComparatorCheckBlockIndexb60450f bench: add benchmark to measure
CBlockIndexWorkComparatorperformanceCBlockIndexWorkComparatorCheckBlockIndexe2e0217 refactor: inline
arith_uint256comparison operatorCBlockIndexWorkComparatorCheckBlockIndex85b74b0 refactor: optimize
arith_uint256comparison with spaceship operatorCBlockIndexWorkComparatorCheckBlockIndexdeb58ee refactor: optimize
CBlockIndexWorkComparatorwith std::tieCBlockIndexWorkComparatorCheckBlockIndexReproducer
Run the equivalence tests with:
Each commit shows how it changes the relevant benchmarks.
Benchmark script
Note: something similar was already started in #33334, but this is a broader optimization that doesn't use the same technique: added as coauthor regardless.