This PR speeds up wallet fast-rescan by executing the filter checks in parallel while ensuring that the filters are updated properly so that no output scripts are missed. Benchmarks, outlined below, show considerable improvement that tapers off at around 8x speedup at 8 threads.
Prerequisite PRs
- #34667 - modify the fast-rescan test to ensure that it fails when the filter is not updated properly.
- #34681 - refactor
CWallet::ScanForWalletTransactionsto prepare for the work in this PR.
Benchmarks
NOTE: to reproduce, please tune your system with pyperf system tune
EDIT
Set up your node to use block filters by setting blockfilterindex=1 in your bitcoin.conf file and ensure your blockfilterindex is synced to the tip before attempting to reproduce.
Using the following command on mainnet with a wallet with no scripts and hyperfine version 1.20.0:
- On master
hyperfine --show-output --export-markdown master.md --export-json master.json \
--sort command \
--runs 3 \
--prepare 'cmake --build build -j 20 && build/bin/bitcoind -blockfilterindex=1 -networkactive=0 && sleep 10 && build/bin/bitcoin-cli loadwalllet <wallet-name>' \
--conclude 'build/bin/bitcoin-cli stop && sleep 10' \
'build/bin/bitcoin-cli rescanblockchain 700000 900000'
- On this PR:
hyperfine --show-output --export-markdown results.md --export-json results.json \
--sort command \
--runs 3 \
-L num_threads 1,2,3,4,5,6,7,8,9,16 \
--prepare 'cmake --build build -j 20 && build/bin/bitcoind -blockfilterindex=1 -networkactive=0 -walletpar={num_threads} && sleep 10 && build/bin/bitcoin-cli loadwalllet <wallet-name>' \
--conclude 'build/bin/bitcoin-cli stop && sleep 10' \
'build/bin/bitcoin-cli rescanblockchain 700000 900000'
<details> <summary>Table 1 was obtained on a machine with the following specifications</summary>
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 46 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 20
On-line CPU(s) list: 0-19
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) Ultra 7 265
CPU family: 6
Model: 198
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 20
Stepping: 2
CPU(s) scaling MHz: 41%
CPU max MHz: 4800.0000
CPU min MHz: 800.0000
BogoMIPS: 4761.60
</details>
| Branch | Mean [s] | Min [s] | Max [s] |
|---|---|---|---|
| master | 272.222 ± 0.183 | 272.064 | 272.423 |
| new-rescan (num_threads = 1) | 274.964 ± 0.593 | 274.547 | 275.643 |
| new-rescan (num_threads = 2) | 131.177 ± 0.201 | 131.026 | 131.405 |
| new-rescan (num_threads = 4) | 65.633 ± 0.203 | 65.423 | 65.829 |
| new -rescan (num_threads = 6) | 44.129 ± 0.084 | 44.067 | 44.224 |
| new-rescan (num_threads = 8) | 34.790 ± 0.048 | 34.761 | 34.845 |
| new-rescan (num_threads = 10) | 34.762 ± 0.178 | 34.633 | 34.965 |
| new-rescan (num_threads = 16) | 34.813 ± 0.136 | 34.691 | 34.959 |
Table 1. Table of results of a mainnet benchmark of scanning 200000 blocks. The improvements seem to peak at 8x speedup despite the machine having an excess number of Cores (20).
Worst Case
This parallel fast rescan checks the filters for a series of blocks in parallel. One of the following cases can occur:
- No blocks matched; the wallet can skip this series of blocks
- The last block in the series matched; the wallet scans the last block and updates the filters if the wallet scripts have changed.
- One of the blocks before the last block matched; the wallet scans this block, updates the filters if new scripts are added, and rechecks filters for the blocks after the matched block. This is the worst-case scenario.
This Python script patch python script creates custom chains designed with payments at specified intervals to observe the performance of parallel fast rescan in two scenarios:
- the payments are made to the next index in the descriptor range, the expected case.
- the payments are made to the last index in the descriptor range, the worst case.
<details> <summary> Fig 1 shown below was produced using this patch on a machine with the following specifications</summary>
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 48 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 16
On-line CPU(s) list: 0-15
Vendor ID: AuthenticAMD
Model name: AMD Ryzen 9 8945HS w/ Radeon 780M Graphics
CPU family: 25
Model: 117
Thread(s) per core: 2
Core(s) per socket: 8
Socket(s): 1
Stepping: 2
Frequency boost: enabled
CPU(s) scaling MHz: 63%
CPU max MHz: 5263.0000
CPU min MHz: 400.0000
</details>
<img width="4760" height="5350" alt="benchmark_comparison" src="https://github.com/user-attachments/assets/9bb8536c-ef05-454a-93f1-e2d929da840f" />
Fig 1. Time to scan a 5000-block chain on Regtest with payments at varying intervals, comparing parallel Fast Rescan against serial Fast Rescan (baseline). Parallel Fast Rescan outperforms serial Fast Rescan at longer payment intervals, in both the expected and worst case. The Slow Rescan graph is included separately to check whether this PR causes any regression in Slow Rescan performance. The gap between the worst-case and expected-case runtimes on the Slow Rescan graph comes from the worst case triggering a KEYPOOL_SIZE TopUp each time a new address is found on-chain. These benchmarks use this commit as a baseline instead of master, since master lacks the -walletpar config parameter. All materials for this custom benchmark are available here.
Although not explicitly checked with Valgrind, hyperfine reported that memory usage stayed the same across all runs. I'm not sure to what degree Hyperfine's memory usage report can be trusted, but the PR limits the number of block hashes that can be held in memory for processing to 1000 (not configurable by the user).