Skip to content

wallet: parallel fast rescan (approx 8x speed up with 8 threads)#34400

Draft
Eunovo wants to merge 14 commits into
bitcoin:masterfrom
Eunovo:new-rescan
Draft

wallet: parallel fast rescan (approx 8x speed up with 8 threads)#34400
Eunovo wants to merge 14 commits into
bitcoin:masterfrom
Eunovo:new-rescan

Conversation

@Eunovo

@Eunovo Eunovo commented Jan 24, 2026

Copy link
Copy Markdown
Contributor

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

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'
Table 1 was obtained on a machine with the following specifications
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
Branch Mean [s] Min [s] Max [s]
master 272.222 ± 0.183 272.064 272.423
new-rescan (num_threads = 1) 272.502 ± 0.358 272.130 272.844
new-rescan (num_threads = 2) 129.947 ± 0.310 129.631 130.250
new-rescan (num_threads = 4) 65.012 ± 0.202 64.838 65.234
new -rescan (num_threads = 6) 43.352 ± 0.043 43.306 43.391
new-rescan (num_threads = 8) 33.839 ± 0.066 33.791 33.914
new-rescan (num_threads = 10) 33.744 ± 0.021 33.723 33.765
new-rescan (num_threads = 16) 33.747 ± 0.115 33.615 33.824

Table 1. Table of results of a mainnent 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 to trigger a top-up and add new scripts to the wallet, the worst case.
Fig 1 shown below was produced using this patch on a machine with the following specifications
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
benchmark_comparison

Fig 1. Graphs showing the average time to complete a scan of a chain 5000 blocks high with payments at different intervals on Regtest. In the expected case, parallel fast rescan is faster for payments at all intervals for thread count > 1. In the worst case, below 8 threads, parallel fast rescan is slightly slower than baseline at short intervals (<=100 blocks). At 8 threads, parallel fast rescan performs better even at the 50-block interval. These benchmarks use 33faeab as a baseline in place of master because master doesn't have the -walletpar config parameter. All materials for this custom benchmark can be found 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).

@DrahtBot

DrahtBot commented Jan 24, 2026

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34400.

Reviews

See the guideline and AI policy for information on the review process.

Type Reviewers
Concept ACK w0xlt, ismaelsadeeq

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #bitcoin-core/gui/700 (Dialog for allowing the user to choose the change output when bumping a tx by achow101)
  • #35716 (wallet: Replace mapWallet and wtxOrdered with a boost::multi_index by achow101)
  • #34909 (wallet, refactor: modularise wallet by extracting out legacy wallet migration by rkrux)
  • #34907 (wallet, test: make wallet_fast_rescan robust by rkrux)
  • #32857 (wallet: allow skipping script paths by Sjors)
  • #31260 (scripted-diff: Type-safe settings retrieval by ryanofsky)
  • #30343 (wallet, logging: Replace WalletLogPrintf() with LogInfo() by ryanofsky)
  • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)
  • #27865 (wallet: Track no-longer-spendable TXOs separately by achow101)
  • #25722 (refactor: Use util::Result class for wallet loading by ryanofsky)

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.

@DrahtBot

Copy link
Copy Markdown
Contributor

🚧 At least one of the CI tasks failed.
Task test max 6 ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/21320629356/job/61369934184
LLM reason (✨ experimental): Compilation failed due to an unused private member (m_thread_pool) in wallet.h being treated as an error under -Werror.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@luke-jr

luke-jr commented Jan 29, 2026

Copy link
Copy Markdown
Member

I would have expected rescanning to be I/O bound rather than CPU, in which case parallelization could make things worse (more random seeking). Have you benchmarked this on a non-SSD?

@Eunovo

Eunovo commented Jan 29, 2026

Copy link
Copy Markdown
Contributor Author

I would have expected rescanning to be I/O bound rather than CPU, in which case parallelization could make things worse (more random seeking). Have you benchmarked this on a non-SSD?

Fast rescan checks block filters, which involves considerable hashing. This PR parallelises the checking of block filters, and my benchmarks show considerable improvements in rescan speeds with block filters. Slow rescan, which is I/O bound, remains the same. I expect the speedup to be transferable to non-SSD machines, but I haven't benchmarked this.

Comment thread src/wallet/scan.cpp Outdated
@Eunovo

Eunovo commented Feb 11, 2026

Copy link
Copy Markdown
Contributor Author

#33689 has been merged; the cherry-picked Threadpool commit has been removed.

@furszy furszy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the PR conceptually but I think it would be nice to first improve the current scanning code structure, then land the parallelization feature. The current code mixes a lot responsibilities.
Similar to what you did in 6335316, but into a separate PR so we can first land some good building blocks for this to happen.

Some quick pseudo-code structuring how I imagine it, which is similar to yours:

Scan(wallet, start_block_hash, end_block_hash, fn_filter_block, fn_process_block, interrupt) {
     it_current_hash = start_block_hash;

     while (it_current_hash != end_block_hash || interrupt) {
         // Skip block if needed (this function contains the BlockFilterIndex check if enabled)
         if (fn_filter_block(it_current_hash)) continue;
    
         // (this is more or less how we currently do it, we fetch the block and the next block hash at the same time)
         block = chain.find_block(it_current_hash).next_block(it_current_hash);
    
         // (inside this function the wallet will digest the block update the filter and save progress if needed)
         fn_process_block(block);
      }
}

@w0xlt

w0xlt commented Feb 21, 2026

Copy link
Copy Markdown
Contributor

Concept ACK

@Eunovo

Eunovo commented Feb 26, 2026

Copy link
Copy Markdown
Contributor Author

I like the PR conceptually but I think it would be nice to first improve the current scanning code structure, then land the parallelization feature. The current code mixes a lot responsibilities. Similar to what you did in 6335316, but into a separate PR so we can first land some good building blocks for this to happen.

I moved the test change into #34667 and the ScanForWalletTransactions refactor into #34681.
I'll be putting this PR in draft while those PRs are open.

@Eunovo Eunovo marked this pull request as draft February 26, 2026 16:22
Kino1994 pushed a commit to Kino1994/bitcoin-full-history that referenced this pull request Jun 28, 2026
…orrectly updated during scanning

eb00707 test/wallet: ensure FastWalletRescanFilter is updated during scanning (Novo)

Pull request description:

  Part of bitcoin/bitcoin#34400

  On master, the `wallet_fast_rescan.py` test will pass even if the fast rescan filters are not updated during wallet rescan. This PR improves the `wallet_fast_rescan.py` test so that this no longer happens. Testers can verify by applying this diff
  ```diff
  diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
  index 63dab29..f7d6c5f84a 100644
  --- a/src/wallet/wallet.cpp
  +++ b/src/wallet/wallet.cpp
  @@ -1898,7 +1898,7 @@ CWallet::ScanResult CWallet::ScanForWalletTransactions(const uint256& start_bloc

           bool fetch_block{true};
           if (fast_rescan_filter) {
  -            fast_rescan_filter->UpdateIfNeeded();
  +            // fast_rescan_filter->UpdateIfNeeded();
               auto matches_block{fast_rescan_filter->MatchesBlock(block_hash)};
               if (matches_block.has_value()) {
                   if (*matches_block) {

  ```
  and running the `wallet_fast_rescan.py` test. The test will pass on master but fail on this PR.

ACKs for top commit:
  achow101:
    ACK eb00707
  Bortlesboat:
    re-ACK eb00707
  rkrux:
    tACK eb00707
  ismaelsadeeq:
    Tested ACK eb00707

Tree-SHA512: bcd66db0be6604b084230fa3d3aa8d99f5a1a679a7a92592a5e8962abbcf35a14fb6883f25c9e8e6c4799893b774b1c6766876587417f35c4190562ef5ad39fb
BigcoinBGC pushed a commit to BigcoinBGC/bigcoin that referenced this pull request Jun 30, 2026
…orrectly updated during scanning

16d0cb3 test/wallet: ensure FastWalletRescanFilter is updated during scanning (Novo)

Pull request description:

  Part of bitcoin/bitcoin#34400

  On master, the `wallet_fast_rescan.py` test will pass even if the fast rescan filters are not updated during wallet rescan. This PR improves the `wallet_fast_rescan.py` test so that this no longer happens. Testers can verify by applying this diff
  ```diff
  diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
  index 63dab29..f7d6c5f84a 100644
  --- a/src/wallet/wallet.cpp
  +++ b/src/wallet/wallet.cpp
  @@ -1898,7 +1898,7 @@ CWallet::ScanResult CWallet::ScanForWalletTransactions(const uint256& start_bloc

           bool fetch_block{true};
           if (fast_rescan_filter) {
  -            fast_rescan_filter->UpdateIfNeeded();
  +            // fast_rescan_filter->UpdateIfNeeded();
               auto matches_block{fast_rescan_filter->MatchesBlock(block_hash)};
               if (matches_block.has_value()) {
                   if (*matches_block) {

  ```
  and running the `wallet_fast_rescan.py` test. The test will pass on master but fail on this PR.

ACKs for top commit:
  achow101:
    ACK 16d0cb3
  Bortlesboat:
    re-ACK 16d0cb3
  rkrux:
    tACK 16d0cb3
  ismaelsadeeq:
    Tested ACK 16d0cb3

Tree-SHA512: bcd66db0be6604b084230fa3d3aa8d99f5a1a679a7a92592a5e8962abbcf35a14fb6883f25c9e8e6c4799893b774b1c6766876587417f35c4190562ef5ad39fb
Eunovo added 10 commits July 13, 2026 12:19
Add unit tests locking in currently untested rescan behavior, so that
the upcoming ChainScanner refactor can be reviewed against them:

- a reorged-out block that the block filter does not match is skipped
  and the scan ends successfully at the reorg point; a reorged-out
  block that must be inspected fails the scan
- rescan reservation lifecycle: single reservation at a time, RAII
  release, the with_passphrase flag, and idle accessor values
- bounded scans stop exactly at max_height, including a single-block
  range (progress start == end)
- a tip extension while the scan is running is picked up instead of
  stopping at the height the scan started with
- save_progress=false must not touch the wallet's best block record
- RescanFromTime moves the returned timestamp past unreadable (pruned)
  blocks and returns it unchanged when nothing needs scanning
- missing block filters must not cause blocks to be skipped
- loading a wallet that is behind the chain tip rescans from its
  recorded best block (inclusive), the path loadwallet takes
Move scan state atomics (abort, scanning, passphrase, start time,
progress) into ChainScanner and expose it via Scanner(). All
callers use Scanner().Scan() directly.

The newly added `m_scanner` is an incomplete type so CWallet's
constructor and destructor is moved into wallet.cpp where
the type is complete.

This change introduces a new circular dependency of the form
"wallet/scan -> wallet/wallet -> wallet/scan" which is added to
`EXPECTED_CIRCULAR_DEPENDENCIES`.
Callers now reach this via Scanner().ScanFromTime() rather than
a CWallet member function, keeping all scan logic in ChainScanner.
Dequeue the current block at the top of the Scan loop and extract the
lookup of its chain position and the queueing of its active-chain
successor into QueueNextBlock.

Since the current block is now dequeued at the top of the loop, update
progress_current there as well so the reported progress keeps referring
to the block being processed, as before.
This commit refactors the block filtering logic from ShouldFetchBlock
into a new ReadNextBlocks method that returns the blocks that are ready
to be scanned. This prepares the code for pipelined and parallel block
filter checking while keeping the current single-threaded, one block at
a time behaviour.

State for a single Scan() call lives in a ScanContext that is local to
Scan() and passed through the helpers. The QueueNextBlock helper is
absorbed into the new ReadNextBlock, its only caller: consuming the
pending block, recording whether it is still in the active chain, and
queueing its successor are one operation. Read blocks carry the
still-active flag, so a reorged-out block that must be inspected fails
the scan while a filter-skipped one stays skipped.

Progress is still updated and logged for filter-skipped ranges from the
scan loop, keeping the previous per-block reporting cadence.
This parameter will be used in a future commit to determine
the number of threads to use for parallel fast-rescan.

8 threads is chosen as a reasonable MAX_WALLETPAR,
benchmarks show 8x improvement on fast-rescan with 8 threads.

The member lives with the other public wallet options so that
tests can configure it directly.
Eunovo added 4 commits July 14, 2026 15:17
This commit implements parallel block filter checking. ParallelFilterChecker
owns the pipeline: the scan loop pushes read blocks into its queue, the
executor submits batches of checks to its thread pool, and TryPop()
pops the front block together with its verdict, in block order.

Each task checks a span of up to FILTER_TASK_SPAN blocks; batching
amortizes the scheduling cost of a task, which may otherwise rival
the cost of the checks themselves. In-flight work is capped at
WorkersCount()*2 span tasks so a scan that ends early does not leave
workers checking blocks that get discarded, and the queue is bounded by
MAX_BLOCKQUEUE_SIZE to limit the block hashes held in memory. When the
pipeline is saturated the main thread helps with queued checks and then
blocks on the oldest verdict instead of spinning.

Synchronization:
- Operations requiring cs_wallet (GetLastBlockHeight, SyncTransaction)
  remain on the main thread since cs_wallet is a RecursiveMutex and
  Scan is called from AttachChain which locks cs_wallet
- Workers only read the wallet's filter set; the set is only updated
  after a keypool top-up (FastWalletRescanFilter::NeedsUpdate), and
  FilterExecutor::Reset() first drains every submitted check and
  discards undelivered verdicts, which were computed against the old
  scripts (documented on FastWalletRescanFilter)
Pause the submission of filter checks at interval boundaries so that
ReadNextBlocks returns and the scan loop can log progress and give
ScanBlock() a chance to save progress. The scan loop resets the
interval for skipped ranges as well as scanned blocks, so a paused
pipeline always resumes.
Run the filter-dependent scan tests with parallel
filter checking (-walletpar 1 and 4).

The wallet loading rescan test also runs on the parallel path now, with
a log hook asserting that the multi-threaded variant actually engaged:
this covers a rescan that starts from a mid-chain block with cs_wallet
held.
The wallet now uses  parallel scan by default, so existing functional tests now exercise the parallel scan logic.
`wallet_fast_rescan` is updated to test both serial and parallel fast scan.
@Eunovo

Eunovo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

I've made some improvements which have increased the speedup from 5x at 8 threads to 8x at 8 threads. 8x speedup seems to be the peak for now. The results have been updated in the PR description, #34400 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants