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

pull Eunovo wants to merge 15 commits into bitcoin:master from Eunovo:new-rescan changing 18 files +1451 −388
  1. Eunovo commented at 7:46 PM on January 24, 2026: 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

    • #34667 - modify the fast-rescan test to ensure that it fails when the filter is not updated properly.
    • #34681 - refactor CWallet::ScanForWalletTransactions to 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).

  2. DrahtBot commented at 7:46 PM on January 24, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

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

    <!--021abf342d371248e50ceaed478a90ca-->

    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 <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #bitcoin-core/gui/954 (Add dialog to select change output when bumping fee by pablomartin4btc)
    • #35752 (wallet: make encryption state updates atomic by l0rinc)
    • #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)
    • #34861 (wallet: Add importdescriptors interface by polespinasa)
    • #34681 (wallet: move rescan logic into ChainScanner and wallet/scan by Eunovo)
    • #33392 (wallet, rpc: add UTXO set check and incremental rescan to importdescriptors by musaHaruna)
    • #32857 (wallet: allow skipping script paths by Sjors)
    • #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)

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. DrahtBot added the label CI failed on Jan 24, 2026
  4. DrahtBot commented at 8:44 PM on January 24, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

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

    <details><summary>Hints</summary>

    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.

    </details>

  5. Eunovo force-pushed on Jan 25, 2026
  6. DrahtBot removed the label CI failed on Jan 25, 2026
  7. Eunovo renamed this:
    Parallel Fast Rescan (approx 5x speed up with 16 threads)
    wallet: parallel fast rescan (approx 5x speed up with 16 threads)
    on Jan 27, 2026
  8. DrahtBot added the label Wallet on Jan 27, 2026
  9. luke-jr commented at 4:50 AM on January 29, 2026: 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?

  10. Eunovo commented at 8:42 AM on January 29, 2026: contributor

    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.

  11. DrahtBot added the label Needs rebase on Feb 4, 2026
  12. in src/wallet/scan.cpp:203 in ef847e8bce outdated
     198 | +        // If m_max_blockqueue_size blocks have been filtered,
     199 | +        // stop reading more blocks for now, to give the
     200 | +        // main scanning loop a chance to update progress
     201 | +        // and erase some blocks from the queue.
     202 | +        if (m_continue && completed < m_max_blockqueue_size) m_continue = ReadBlockHash(result);
     203 | +        else if (!futures.empty()) thread_pool->ProcessTask();
    


    bvbfan commented at 8:59 AM on February 8, 2026:

    This slows down the scanning no? All workers already process submit task in its own WorkThread just randomly trying to acquire mutex from scanning thread is non sense to me.


    Eunovo commented at 11:40 AM on February 8, 2026:

    Are you referring to the ThreadPool::m_mutex? This mutex is not held during task processing. It is only briefly held to access the work queue. Calling ProcessTask() from the main thread does not slow down scanning; it gives the main thread work to do instead of wasting cycles waiting for results.


    bvbfan commented at 6:29 PM on February 9, 2026:

    Yep it's not held during task execution, but if main thread do a task, it cannot put new tasks to queue i.e. workers "fight" itself to read something and do nothing. The idea is main thread submit tasks faster than workers could finish to keep all of them busy otherwise there is no difference between 3 and 16 thread (~13 threads do nothing).


    Eunovo commented at 9:34 AM on February 10, 2026:

    The main thread intentionally submits only up to WORKERS_COUNT tasks before waiting, rather than continuously submitting. This allows it to pause and update filters whenever a payment is found, preventing unnecessary work on wallets with many transactions packed into a short block range.


    Eunovo commented at 12:59 PM on April 14, 2026:

    I have changed the implementation so that the main thread only joins task processing when it needs to clear the work queue (after a range of blocks to fetch and scan has been determined) before processing blocks. The number of blocks that can be submitted at once has been raised to 2 * WORKERS_COUNT, it is still kept low intentionally to reduce wasted work.

  13. Eunovo force-pushed on Feb 8, 2026
  14. DrahtBot removed the label Needs rebase on Feb 8, 2026
  15. Eunovo force-pushed on Feb 11, 2026
  16. Eunovo commented at 5:20 PM on February 11, 2026: contributor

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

  17. furszy commented at 8:43 PM on February 11, 2026: member

    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 633531614f69de49733642fd19cc9eba830fbdea, 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);
          }
    }
    
  18. w0xlt commented at 8:12 AM on February 21, 2026: contributor

    Concept ACK

  19. Eunovo force-pushed on Feb 26, 2026
  20. Eunovo commented at 4:21 PM on February 26, 2026: contributor

    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.

  21. Eunovo marked this as a draft on Feb 26, 2026
  22. ismaelsadeeq commented at 2:34 PM on March 13, 2026: member

    Concept ACK

    I attempted to reproduce the benchmarks for this But I did not use hyperfine, I used time

    System: AMD Ryzen 7 7700, 16 cores, 64GB RAM, mainnet, blocks 500000–900000, 3 runs averaged.

    Baseline (881ebc4730ad15bd26e3e32ee3c9ba9d6e05552d): single-threaded fast rescan, -walletpar has no effect — consistently ~283s regardless of thread count.

    Parallel scan (48154b87e2fb303ca0f3d46da29a3fbbf8758a06): fast rescan with threadpool parallelism enabled via -walletpar.

    num_threads baseline parallel speedup
    1 282.618s 288.214s 0.98x (slight overhead)
    2 282.618s 187.485s 1.51x
    4 282.618s 119.575s 2.37x
    8 282.618s 81.565s 3.47x
    16 282.618s 62.965s 4.53x
    • Speedup scales well up to 16 threads, going from ~283s down to ~63s — a 4.5x improvement.
    • Results are very consistent across runs (low variance), because the machine is bare metal and no other running processes apart from bitcoind are present during the benchmark runs.

    Steps to reproduce

    1. Restart bitcoind with -blockfilterindex=1 till it's done.
    2. create a new wallet test
    3. Stop the node
    4. Save the script below as bench_script.sh

    <details> <summary>script</summary>

    #!/usr/bin/env bash
    set -euo pipefail
    
    COMMITS=(881ebc4730ad15bd26e3e32ee3c9ba9d6e05552d 48154b87e2fb303ca0f3d46da29a3fbbf8758a06)
    THREADS=(1 2 4 8 16)
    RUNS=3
    WALLET_NAME="test"
    DATADIR="$HOME/.bitcoin"
    RESULTS_CSV="results.csv"
    RESULTS_MD="results.md"
    
    echo "commit,num_threads,run,seconds" > "$RESULTS_CSV"
    
    for commit in "${COMMITS[@]}"; do
      short="${commit:0:7}"
      git checkout "$commit"
      cmake --build build -j 20
    
      for num_threads in "${THREADS[@]}"; do
        echo "=== commit=$short num_threads=$num_threads ==="
    
        for run in $(seq 1 $RUNS); do
          echo "  run $run/$RUNS"
    
          # start node
          build/bin/bitcoind -blockfilterindex=1 -walletpar="$num_threads" -daemonwait
          build/bin/bitcoin-cli loadwallet "$WALLET_NAME"
    
          build/bin/bitcoin-cli rescanblockchain 500000 900000
    
          # parse timing from debug log: "Rescan completed in 284737ms"
          ms=$(grep "Rescan completed in" "$DATADIR/debug.log" | tail -1 | grep -oP '\d+(?=ms)')
          seconds=$(python3 -c "print(f'{$ms / 1000:.3f}')")
    
          echo "$short,$num_threads,$run,$seconds" >> "$RESULTS_CSV"
          echo "  -> ${seconds}s"
    
          build/bin/bitcoin-cli stop
          # wait for clean shutdown
          while build/bin/bitcoin-cli ping 2>/dev/null; do sleep 1; done
          sleep 5
        done
      done
    done
    
    # markdown table with averages
    python3 - "$RESULTS_CSV" "$RESULTS_MD" <<'EOF'
    import sys, csv
    from collections import defaultdict
    
    infile, outfile = sys.argv[1], sys.argv[2]
    
    rows = list(csv.DictReader(open(infile)))
    groups = defaultdict(list)
    for r in rows:
        groups[(r['commit'], r['num_threads'])].append(float(r['seconds']))
    
    with open(outfile, 'w') as f:
        f.write("| commit | num_threads | run1 | run2 | run3 | mean |\n")
        f.write("|--------|-------------|------|------|------|------|\n")
        for (commit, threads), times in sorted(groups.items()):
            mean = sum(times) / len(times)
            runs = " | ".join(f"{t:.3f}" for t in times)
            f.write(f"| {commit} | {threads} | {runs} | {mean:.3f} |\n")
    
    print(f"Written {outfile}")
    EOF
    
    echo "Done. Results in $RESULTS_CSV and $RESULTS_MD"
    

    </details>

    1. Make the script executable chmod +x bench_script.sh
    2. Execute the script ./bench_script.sh
    3. You can go a step further by using a top like btop https://github.com/aristocratos/btop to monitor the resource usage and how it will be well utilized when rescanning in parallel.

    The current steps to reproduce in the description are stale because the commit hashes have changed since your force pushes.

  23. in src/wallet/scan.cpp:191 in 48154b87e2
     195 | +        // Submit jobs to the threadpool in batches of at most `workers_count` size.
     196 | +        // This prevents over-submission: if we queued all jobs upfront and the filtered
     197 | +        // block range is smaller than expected, worker threads would process blocks
     198 | +        // that get discarded, wasting CPU cycles.
     199 | +        const size_t job_gap = workers_count - futures.size();
     200 | +        if (job_gap > 0 && i < m_blocks.size()) {
    


    rkrux commented at 2:03 PM on March 16, 2026:

    In 48154b87e2fb303ca0f3d46da29a3fbbf8758a06 "wallet: check blockfilters in parallel"

    These two conditions in this check seem redundant with the same two conditions in the following for loop.


    Eunovo commented at 1:43 PM on March 18, 2026:

    Fixed.

  24. in src/wallet/scan.cpp:194 in 48154b87e2
     198 | +        // that get discarded, wasting CPU cycles.
     199 | +        const size_t job_gap = workers_count - futures.size();
     200 | +        if (job_gap > 0 && i < m_blocks.size()) {
     201 | +            for (size_t j = 0; j < job_gap && i < m_blocks.size(); ++j, ++i) {
     202 | +                auto block = m_blocks[i];
     203 | +                futures.emplace_back(*thread_pool->Submit([&filter, block = std::move(block)]() {
    


    rkrux commented at 2:08 PM on March 16, 2026:

    In 48154b87e2fb303ca0f3d46da29a3fbbf8758a06 "wallet: check blockfilters in parallel"

    So it appears this is a flow where multiple tasks can be submitted in one go. There is an overload method of Submit that accepts a range of tasks & pushes all of them in the queue within one acquisition of queue lock, while notifying all the waiting workers. I think this workflow can be benefitted with this method, an untested code snippet is below because this branch is not rebased over master that contains the ranged overload.

    https://github.com/bitcoin/bitcoin/blob/ff7cdf633e375f151cccbcc78c7add161b3d29b8/src/util/threadpool.h#L199-L220

    diff --git a/src/wallet/scan.cpp b/src/wallet/scan.cpp
    index 6b776930f1..2295a242a1 100644
    --- a/src/wallet/scan.cpp
    +++ b/src/wallet/scan.cpp
    @@ -147,6 +147,20 @@ std::optional<std::pair<size_t, size_t>> ChainScanner::ReadNextBlocks(const std:
             return std::make_pair<size_t, size_t>(0, m_blocks.size());
         }
         filter->UpdateIfNeeded();
    +
    +    auto block_matcher = [&filter](uint256 block_hash) {
    +        const auto matches_block{filter->MatchesBlock(block_hash)};
    +        if (matches_block.has_value()) {
    +            if (*matches_block) {
    +                return FilterRes::FILTER_MATCH;
    +            } else {
    +                return FilterRes::FILTER_NO_MATCH;
    +            }
    +        } else {
    +            return FilterRes::FILTER_NO_FILTER;
    +        }
    +    }
    +
         auto* thread_pool = m_wallet.m_thread_pool;
         // ThreadPool pointer should never be null here
         // during normal operation because it should
    @@ -187,23 +201,13 @@ std::optional<std::pair<size_t, size_t>> ChainScanner::ReadNextBlocks(const std:
             // This prevents over-submission: if we queued all jobs upfront and the filtered
             // block range is smaller than expected, worker threads would process blocks
             // that get discarded, wasting CPU cycles.
    -        const size_t job_gap = workers_count - futures.size();
    -        if (job_gap > 0 && i < m_blocks.size()) {
    -            for (size_t j = 0; j < job_gap && i < m_blocks.size(); ++j, ++i) {
    -                auto block = m_blocks[i];
    -                futures.emplace_back(*thread_pool->Submit([&filter, block = std::move(block)]() {
    -                    const auto matches_block{filter->MatchesBlock(block.first)};
    -                    if (matches_block.has_value()) {
    -                        if (*matches_block) {
    -                            return FilterRes::FILTER_MATCH;
    -                        } else {
    -                            return FilterRes::FILTER_NO_MATCH;
    -                        }
    -                    } else {
    -                        return FilterRes::FILTER_NO_FILTER;
    -                    }
    -                }));
    +        auto to_submit_jobs_count = std::min(workers_count - futures.size(), m_blocks - i);
    +        if (to_submit_jobs_count) {
    +            std::vector<std::function<FilterRes(uint256)>> block_matchers;
    +            for (; i < to_submit_jobs_count; ++i) {
    +                block_matchers.emplace_back(block_matcher(m_blocks[i].first));
                 }
    +            futures.emplace_back(*thread_pool->Submit(std::move(block_matchers)));
             }
     
             // If m_max_blockqueue_size blocks have been filtered,
    
    

    Eunovo commented at 12:18 PM on March 18, 2026:

    I will check this when I rebase on master.


    Eunovo commented at 12:50 PM on April 14, 2026:

    Done.

  25. in src/wallet/scan.cpp:220 in 48154b87e2 outdated
     224 | +            if (next_block) m_blocks.emplace_back(*next_block);
     225 | +        }
     226 | +        else if (!futures.empty()) {
     227 | +            // Join work processing instead of waiting idly.
     228 | +            thread_pool->ProcessTask();
     229 | +        }
    


    rkrux commented at 2:19 PM on March 16, 2026:

    In 48154b8 "wallet: check blockfilters in parallel"

    I'm doubtful that putting the controller (non-worker) thread to process the threadpool tasks is helpful.

    This threadpool is shared across wallets. It could be the case that multiple RPCs of different wallets could be called simultaneously. ProcessTask picks the first item from a shared queue in the threadpool. Can't it happen that this controller thread picks up the task of another RPC of another wallet, thereby distorting results (from a RPC latency point of view) of this one?


    Eunovo commented at 12:05 PM on March 18, 2026:

    I'm doubtful that putting the controller (non-worker) thread to process the threadpool tasks is helpful.

    It is helpful. I had better results when I put the thread to work vs when I didn't.

    Can't it happen that this controller thread picks up the task of another RPC of another wallet, thereby distorting results (from a RPC latency point of view) of this one?

    True, this creates an argument to ditch the shared threadpool and create one at the begining of the scan process; the same way we initialise script threads in ConnectBlock


    rkrux commented at 12:16 PM on March 18, 2026:

    I was doubtful of the helpfulness of using processtask because of this cross-wallet/rpc operation.

    this creates an argument to ditch the shared threadpool and create one at the begining of the scan process

    Interesting, I had thought of not using the processtask function in the current thread pool setup as a way. But an intra-wallet scan operation specific threadpool seems like a good alternative to consider. I wll think of its implications.


    Eunovo commented at 12:54 PM on April 14, 2026:

    I have changed the implementation substantially. I have moved away from a shared ThreadPool to one that is initialized before scanning and discarded after.

    ProcessTask() is now only used when the main thread needs to complete the queued up jobs before the processing the selected blocks.

  26. in src/wallet/scan.cpp:183 in 48154b87e2
     187 | +            }
     188 |  
     189 | -    const auto& [block_hash, block_height] = m_blocks[0];
     190 | -    auto matches_block{filter->MatchesBlock(block_hash)};
     191 | +            if (!range.has_value()) range = std::make_pair(current_block_index, current_block_index + 1);
     192 | +            else range->second = current_block_index + 1;
    


    rkrux commented at 2:57 PM on March 16, 2026:

    In 48154b8 "wallet: check blockfilters in parallel"

    It appears that current_block_index + 1 is equal to completed due to a int current_block_index = completed - 1; above.


    Eunovo commented at 1:43 PM on March 18, 2026:

    Fixed.

  27. rkrux commented at 3:05 PM on March 16, 2026: contributor

    I've looked only at the 48154b8 "wallet: check blockfilters in parallel" commit partially.

  28. Eunovo force-pushed on Mar 18, 2026
  29. achow101 referenced this in commit 696b5457c5 on Mar 24, 2026
  30. DrahtBot added the label Needs rebase on Mar 24, 2026
  31. Eunovo force-pushed on Apr 6, 2026
  32. DrahtBot removed the label Needs rebase on Apr 6, 2026
  33. DrahtBot added the label CI failed on Apr 6, 2026
  34. Eunovo force-pushed on Apr 12, 2026
  35. Eunovo force-pushed on Apr 12, 2026
  36. Eunovo force-pushed on Apr 12, 2026
  37. Eunovo force-pushed on Apr 12, 2026
  38. Eunovo renamed this:
    wallet: parallel fast rescan (approx 5x speed up with 16 threads)
    wallet: parallel fast rescan (approx 5x speed up with 8 threads)
    on Apr 13, 2026
  39. Eunovo force-pushed on Apr 14, 2026
  40. DrahtBot removed the label CI failed on Apr 14, 2026
  41. l0rinc commented at 3:38 PM on April 20, 2026: contributor

    I have measured it on an Intel(R) Core(TM) i7-7700 CPU with 8 cores, 64Gi RAM and a HDD:

    hyperfine --show-output \     
    --export-markdown pr34400-results.md \     
    --export-json pr34400-results.json \     
    --sort command \      
    --runs 3 \     
    -L commit 68f030eef7b14d5ac6372a12864a813317ae0f4f,b62e9e739113fe58a86d23a00f8e9b1b561cd29f \     
    -L num_threads 1,2,3,4,5,6,7,8,9,16 \     
    --prepare 'git checkout -q {commit} && cmake -S . -B build && cmake --build build --parallel "$(nproc)" && build/bin/bitcoind -daemonwait -datadir=/mnt/my_storage/BitcoinData -connect=0 -listen=0 -dnsseed=0 -blockfilterindex=1 -wallet=bench_pr34400_20260416 -walletpar={num_threads} && build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 getwalletinfo > /dev/null' \     
    --conclude 'build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData stop && sleep 10' \
    'rescanblockchain 500000 900000'
    

    <img width="2084" height="740" alt="image" src="https://github.com/user-attachments/assets/968632a3-cdc4-478f-b1e3-f908a1d862b4" />

    Command Mean [s] Min [s] Max [s] Relative
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 1) 572.300 ± 64.726 534.060 647.032 3.42 ± 0.39
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 1) 545.611 ± 14.138 536.478 561.897 3.26 ± 0.10
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 2) 534.545 ± 0.569 534.055 535.168 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 2) 303.284 ± 6.724 299.207 311.045 1.81 ± 0.05
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 3) 534.895 ± 0.740 534.298 535.723 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 3) 222.130 ± 4.551 216.878 224.899 1.33 ± 0.03
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 4) 534.069 ± 0.546 533.447 534.473 3.19 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 4) 200.326 ± 2.305 197.771 202.248 1.20 ± 0.02
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 5) 534.699 ± 0.582 534.047 535.167 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 5) 186.884 ± 4.476 182.887 191.720 1.12 ± 0.03
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 6) 535.054 ± 0.270 534.819 535.349 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 6) 175.091 ± 0.531 174.640 175.677 1.05 ± 0.02
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 7) 534.670 ± 1.045 533.600 535.688 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 7) 167.224 ± 2.574 165.724 170.196 1.00
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 8) 535.010 ± 0.121 534.875 535.105 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 8) 172.263 ± 1.808 171.188 174.350 1.03 ± 0.02
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 9) 535.058 ± 0.383 534.660 535.425 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 9) 171.178 ± 0.138 171.053 171.326 1.02 ± 0.02
    rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 16) 535.238 ± 0.204 535.013 535.412 3.20 ± 0.05
    rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 16) 172.619 ± 2.656 171.043 175.685 1.03 ± 0.02

    <details><summary>Hyperfine relative speed comparison</summary>

    3.42 ±  0.39  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 1)
    3.26 ±  0.10  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 1)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 2)
    1.81 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 2)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 3)
    1.33 ±  0.03  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 3)
    3.19 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 4)
    1.20 ±  0.02  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 4)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 5)
    1.12 ±  0.03  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 5)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 6)
    1.05 ±  0.02  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 6)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 7)
    1.00          build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 7)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 8)
    1.03 ±  0.02  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 8)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 9)
    1.02 ±  0.02  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 9)
    3.20 ±  0.05  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = 68f030eef7b14d5ac6372a12864a813317ae0f4f, num_threads = 16)
    1.03 ±  0.02  build/bin/bitcoin-cli -rpcwait -datadir=/mnt/my_storage/BitcoinData -rpcwallet=bench_pr34400_20260416 rescanblockchain 500000 900000 (commit = b62e9e739113fe58a86d23a00f8e9b1b561cd29f, num_threads = 16)
    

    </details>

  42. DrahtBot added the label Needs rebase on May 13, 2026
  43. Kino1994 referenced this in commit f58aed3421 on Jun 28, 2026
  44. BigcoinBGC referenced this in commit 6d61c51229 on Jun 30, 2026
  45. wallet/tests: pin ScanForWalletTransactions behavior
    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
    217efa59c7
  46. wallet: introduce ChainScanner as a CWallet member
    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`.
    65d9e2fab9
  47. wallet/scan: move RescanFromTime to ChainScanner as ScanFromTime
    Callers now reach this via Scanner().ScanFromTime() rather than
    a CWallet member function, keeping all scan logic in ChainScanner.
    0f6591dc43
  48. wallet/scan: move WalletRescanReserver to scan files 9295cab7b7
  49. wallet/scan: extract block filter matching to ShouldFetchBlock 9eb81e1f73
  50. wallet/scan: extract block scanning logic to ScanBlock 6b6690e55a
  51. wallet/scan: extract QueueNextBlock
    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.
    86db854414
  52. wallet/scan: extract progress tracking helpers from `ChainScanner::Scan` b4953b1026
  53. wallet/scan: combine block iteration and filtering in ReadNextBlocks
    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.
    3ffb908812
  54. wallet: Add wallet parallel processing threads param
    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.
    33faeab50c
  55. Eunovo force-pushed on Jul 14, 2026
  56. DrahtBot added the label CI failed on Jul 14, 2026
  57. DrahtBot removed the label Needs rebase on Jul 14, 2026
  58. Eunovo force-pushed on Jul 14, 2026
  59. DrahtBot removed the label CI failed on Jul 14, 2026
  60. Eunovo renamed this:
    wallet: parallel fast rescan (approx 5x speed up with 8 threads)
    wallet: parallel fast rescan (approx 8x speed up with 8 threads)
    on Jul 15, 2026
  61. Eunovo commented at 6:43 PM on July 15, 2026: contributor

    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#issue-3851923967

  62. Eunovo force-pushed on Jul 21, 2026
  63. Eunovo force-pushed on Jul 21, 2026
  64. ismaelsadeeq commented at 12:53 PM on July 22, 2026: member

    I re-ran my script with the recent changes.

    Rescan of blocks 500000-900000 (mean of 3 runs).

    commit threads run1 run2 run3 mean (s) speedup
    b36c2d7 (master) 1 282.488 279.832 280.120 280.813 1.00×
    bf45a67 1 282.495 280.959 280.725 281.393 1.00×
    bf45a67 2 139.017 138.740 137.590 138.449 2.03×
    bf45a67 4 73.558 72.770 73.283 73.204 3.84×
    bf45a67 8 42.863 43.172 42.746 42.927 6.54×
    bf45a67 16 42.481 42.943 42.425 42.616 6.59×

    I got a 6× speedup relative to master.

  65. wallet/scan: check blockfilters in parallel
    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)
    4534de0811
  66. wallet/scan: patch parallel filter verdicts with top-up deltas
    A keypool top-up previously made the parallel checker discard every
    undelivered verdict and re-check the queued blocks against the updated
    filter set. Every scanned block triggers a top-up, so the in-flight
    window is discarded and re-checked once per block, and each re-check
    hashes the wallet's entire filter set.
    
    The filter set only ever grows, so undelivered verdicts don't need to
    be discarded: a MATCH computed against the old set stays valid, and a
    NO_MATCH can only be upgraded by the newly derived scripts. Have
    UpdateIfNeeded() return the scripts it adds and patch the undelivered
    NO_MATCH verdicts against just that delta, keeping every completed
    check. The re-checks are re-queued to the thread pool as span tasks,
    so they run in parallel like fresh checks instead of stalling the scan
    thread while the workers idle. Since the re-queued spans take the
    place of the collected ones in the in-flight window, the number of
    verdicts a top-up has to patch stays bounded by Submit()'s cap.
    1cfd6a3916
  67. wallet/scan: save and log scan progress every `INTERVAL_TIME`
    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 scanned blocks, skipped ranges, and empty batches, so a
    paused pipeline always resumes.
    eab65562b3
  68. wallet/tests: exercise parallel fast-rescan
    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.
    8027e1cef9
  69. tests: update wallet_fast_rescan to cover serial fast scan
    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.
    ae888e0dc9
  70. Eunovo force-pushed on Jul 22, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-01 07:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me