validation: prefetch blocks while connecting #36000

pull l0rinc wants to merge 6 commits into bitcoin:master from l0rinc:l0rinc/block-read-ahead changing 9 files +191 −9
  1. l0rinc commented at 10:31 PM on August 17, 2026: contributor

    Problem: Block reading and deserialization happen immediately before block connection, so their mostly I/O-bound work does not overlap CPU-bound validation.

    This is a follow-up to #35295, which parallelized input prevout fetching during block connection.

    Fix: Add a Chainstate-owned block fetcher that reads later blocks from disk while the current block is connected. Start with synchronous 1-block read-ahead, then move reads to an eagerly started 2-worker pool that persists across activations. Keep blocks provided by the caller on the existing direct path and maintain a sliding queue of up to 4 disk reads. During reorgs, start reading the first sibling while disconnecting the old tip.

    This PR deliberately uses 2 readers and a fixed queue size of 4. Current measurements favor 2 readers with queue sizes of 4 or 8, so this uses the smaller queue. The worker count and queue size can be made configurable later if further measurements justify tuning them.

    Credit: The idea comes from bitcoindev1337, who had already implemented a similar design and reported comparable results.

    <details><summary>Benchmark results</summary>

    1d6656c6b0 refactor: prepare block fetcher wiring
    3547915bfb doc: add block read-ahead release note
    
    2026-09-02 | reindex-chainstate | 964469 blocks | dbcache 2000 | i7-hdd | x86_64 | Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz | 8 threads | 62Gi RAM | HDD
    
    Benchmark 1: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
      Time (abs ≡):        30017.350 s               [User: 44516.820 s, System: 1392.820 s]
    
    Benchmark 2: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
      Time (abs ≡):        19609.006 s               [User: 46710.915 s, System: 1466.050 s]
    
    Relative speed comparison
            1.53          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
            1.00          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
    
    2026-09-02 | reindex-chainstate | 964469 blocks | dbcache 2000 | i9-ssd | x86_64 | Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz | 16 threads | 62Gi RAM | SSD
    
    Benchmark 1: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
      Time (mean ± σ):     14954.582 s ± 47.740 s    [User: 41200.553 s, System: 1293.087 s]
      Range (min … max):   14920.825 s … 14988.339 s    2 runs
     
    Benchmark 2: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
      Time (mean ± σ):     9499.382 s ± 26.187 s    [User: 42840.137 s, System: 1291.391 s]
      Range (min … max):   9480.865 s … 9517.899 s    2 runs
     
    Relative speed comparison
            1.57 ±  0.01  COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
            1.00          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
    
    2026-09-02 | reindex-chainstate | 964469 blocks | dbcache 2000 | rpi5-16-2 | aarch64 | Cortex-A76 | 4 threads | 15Gi RAM | SSD
    
    Benchmark 1: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
      Time (abs ≡):        37266.870 s               [User: 77596.462 s, System: 5504.890 s]
     
    Benchmark 2: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
      Time (abs ≡):        31224.887 s               [User: 76833.950 s, System: 5281.869 s]
     
    Relative speed comparison
            1.19          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
            1.00          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -reindex-chainstate -assumevalid=00000000000000000000ccebd6d74d9194d8dcdc1d177c478e094bfad51ba5ac -blocksonly -disablewallet -connect=0 -listen=0 -dnsseed=0 -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
    
    2026-09-02 | IBD | 964469 blocks | dbcache 2000 | ssd-ryzen | x86_64 | AMD Ryzen 7 3700X 8-Core Processor | 16 threads | 62Gi RAM | ext4 | SSD
    
    Benchmark 1: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -blocksonly -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
      Time (mean ± σ):     18325.931 s ± 713.652 s    [User: 40928.980 s, System: 4110.338 s]
      Range (min … max):   17821.303 s … 18830.559 s    2 runs
    
    Benchmark 2: COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -blocksonly -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
      Time (mean ± σ):     16192.131 s ± 232.698 s    [User: 40349.710 s, System: 2982.493 s]
      Range (min … max):   16027.589 s … 16356.674 s    2 runs
    
    Relative speed comparison
            1.13 ±  0.05  COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -blocksonly -printtoconsole=0 (COMMIT = 1d6656c6b024578db910f45f4a131f18d75a2ed0)
            1.00          COMPILER=gcc ./build/bin/bitcoind -datadir=/mnt/my_storage/BitcoinData -stopatheight=964469 -dbcache=2000 -blocksonly -printtoconsole=0 (COMMIT = 3547915bfb240f280188954c7c5b60ec90145ac5)
    

    </details>

  2. DrahtBot added the label Validation on Aug 17, 2026
  3. DrahtBot commented at 10:31 PM on August 17, 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/36000.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK andrewtoth

    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:

    • #36066 (validation: Separate check-only version of ConnectBlock by optout21)
    • #35675 (mining: add block template manager by ismaelsadeeq)
    • #35646 (RFC: Separate out runtime errors from BlockValidationState using util::Expected by yuvicc)
    • #35071 (Reindex: save progress to continue after interruption by pinheadmz)
    • #33854 (fix assumevalid is ignored during reindex by Eunovo)
    • #30342 (kernel, logging: Pass Logger instances to kernel objects by ryanofsky)
    • #28690 (build: Introduce internal kernel library by sedited)

    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-->

  4. fanquake commented at 10:24 AM on August 18, 2026: member

    Note that this spams blockread.* thread start/exit debug logs to the point that rate-limiting kicks:

    2026-08-18T10:20:43Z blockread.02 thread exit
    2026-08-18T10:20:43Z blockread.03 thread exit
    2026-08-18T10:20:43Z blockread.01 thread exit
    [*] 2026-08-18T10:20:43Z [warning] Excessive logging detected from ./util/thread.cpp:19 (TraceThread): >1048576 bytes logged during the last time window of 3600s. Suppressing logging to disk from this source location until time window resets. Console logging unaffected. Last log entry.
    [*] 2026-08-18T10:20:43Z blockread.00 thread start
    [*] 2026-08-18T10:20:43Z UpdateTip: new best=00000000000002376bd10d0e9734df8894b2628dce1aacb9f86169dfd890eb0f height=198324 version=0x00000001 log2_work=68.682795 tx=6990764 date='2012-09-11T15:33:52Z' progress=0.005005 cache=336.1MiB(2535259txo)
    
  5. in src/validation.cpp:3411 in b2f43075c1
    3406 | @@ -3357,6 +3407,9 @@ bool Chainstate::ActivateBestChain(BlockValidationState& state, std::shared_ptr<
    3407 |          return Assume(false);
    3408 |      }
    3409 |  
    3410 | +    // Persists across cs_main scopes for use by each activation step.
    3411 | +    BlockFetcher fetcher{m_blockman, m_chainman.m_options.block_fetch_parallelism};
    


    andrewtoth commented at 6:18 PM on August 18, 2026:

    This is creating a new ThreadPool on each invocation of ActivateBestChain. This is what's causing the logs in #36000 (comment). The BlockFetcher or at least a shared pointer to a ThreadPool that can be passed to it should be owned by the Chainstate, so it can keep the fetcher threads alive throughout IBD.

    This works fine for -reindex-chainstate, because it is one long ActivateBestChain call. But for IBD this gets called many times.


    l0rinc commented at 6:38 PM on August 18, 2026:

    Yes, thanks @fanquake and @andrewtoth, I also just noticed that BlockFetcher was indeed recreated on every ActivateBestChain() call: I’ve pushed a fix that keeps it alive on Chainstate, and will remeasure IBD performance. Added you both as coauthors, thanks for the tests!

    I’ve also reduced the default queue size from 4 to 2, since the results so far indicate that 4 isn’t worthwhile and single threaded for now for simplicity - proper HDD measurements may change this again later: <img width="235" height="201" alt="image" src="https://github.com/user-attachments/assets/3225e3ad-a924-46bb-89b4-6124cb4862b9" />

    Also removed configurability to make the patch even simpler - we can add it back later if needed.

  6. l0rinc force-pushed on Aug 19, 2026
  7. l0rinc force-pushed on Aug 19, 2026
  8. in src/validation.cpp:3262 in 01d895b4c1


    andrewtoth commented at 2:29 PM on August 23, 2026:

    We can make this useful for reorgs here by checking if the pindexFork is not our current tip, then clearing the prefetch queue and start fetching from the fork index. We would then be fetching the sibling while we read the current tip synchronously in DisconnectTip.


    l0rinc commented at 6:58 PM on August 26, 2026:

    Thanks, implemented. Before disconnecting the current tip, activation now clears followups for the previous candidate and fills the queue from the first sibling. This overlaps that read with DisconnectTip().

  9. in src/validation.cpp:3227 in 01d895b4c1 outdated
    3222 | +        auto block{m_pending[0].get()};
    3223 | +        m_pending.pop_front();
    3224 | +        return block && block->GetHash() == hash ? block : nullptr;
    3225 | +    }
    3226 | +
    3227 | +    void Prefetch(const CBlockIndex& index_most_work, int next_height) EXCLUSIVE_LOCKS_REQUIRED(::cs_main)
    


    andrewtoth commented at 2:30 PM on August 23, 2026:

    Should we just pass in height here, and internally start at height + 1? We are adding the + 1 to callsites, so this would probably be cleaner to handle it in here instead.

        void Prefetch(const CBlockIndex& index_most_work, int height) EXCLUSIVE_LOCKS_REQUIRED(::cs_main)
    

    l0rinc commented at 4:57 AM on August 25, 2026:

    I think next_height is clearer here because the argument is the first height to enqueue: there's no "current" height here, it doesn't make sense to give an unrelated block height to the fetcher. But the names should be consolidated (pending vs queued vs followups vs provided vs saved), I kept renaming these and they don't match anymore.

  10. andrewtoth commented at 2:38 PM on August 23, 2026: contributor

    Concept ACK.

    The benchmarks look promising. I am still trying to reproduce the benchmarks locally, but my usual IBD from a single local peer does not produce any speedup. This makes sense because all blocks will be downloaded in-order from the single peer, so the next block to connect will always be the one in memory.

    I am trying to simulate out-of-order IBD by having my local node bind to 10 different ports and then -connect=ing to each of the ports. I am still working on getting consistent results with this, but will report back when I get this working properly.

    A reindex-chainstate should show the highest theoretical speedup, since every block will be read ahead in-order.

    This will also not show any improvement for connecting new blocks to tip at steady-state, since the new block will always be the one in memory. However, I think we can modify this to improve reorgs at steady-state. We can prefetch the blocks on the new fork that will be connected, while we read the blocks to disconnect.

  11. in src/validation.cpp:3230 in 01d895b4c1 outdated
    3225 | +    }
    3226 | +
    3227 | +    void Prefetch(const CBlockIndex& index_most_work, int next_height) EXCLUSIVE_LOCKS_REQUIRED(::cs_main)
    3228 | +    {
    3229 | +        AssertLockHeld(::cs_main);
    3230 | +        if (!m_pending.empty()) return;
    


    andrewtoth commented at 6:41 PM on August 24, 2026:

    I think this method might be more efficient as a sliding window rather than just adding all pending tasks only when the validation thread has emptied the queue. The current way has the worker thread go idle once it hits the max queue size and waits until validation catches up, whereas we can make sure the worker thread is always either working or bumping up against the max queue size.

            while (m_pending.size() < QUEUE_SIZE) {
                auto* next{index_most_work.GetAncestor(next_height + static_cast<int>(m_pending.size()))};
                if (!next || !Enqueue(*next)) break;
            }
    

    l0rinc commented at 4:43 AM on August 25, 2026:

    Thanks, implemented. FillQueue() (renamed) now starts from the number of queued followups and fills every missing slot.

    I originally waited for the queue to empty so adjacent reads would be submitted together (from the same file probably), which I thought might improve file-cache locality, but your measurements indicate that's not the case, so I'll remeasure. Instead of the while loop above (which might be infinite if m_pending doesn't get updated in the loop) I used a bounded loop.

  12. l0rinc force-pushed on Aug 25, 2026
  13. andrewtoth commented at 4:30 PM on August 25, 2026: contributor

    While I figure out how to do out-of-order IBD, I can share reindex-chainstate benchmarks I did on my laptop. I tried variants with 2 threads and queue depth of 4, as well as the sliding-window change with queue depth of 2 and 4. The results are very impressive! The sliding-window with 2 depth had the best time.

    Done on an i9-14900HX, default dbcache, stopatheight=961000.

    Reindex Time Speedup
    master 2h 33m 27s 1.00x
    PR 1h 56m 19s 1.32x
    sliding-window 1 thread, queue of 4 1h 52m 19s 1.37x
    PR 2 threads, queue of 4 1h 51m 58s 1.37x
    sliding-window 1 thread, queue of 2 1h 50m 44s 1.39x
  14. l0rinc force-pushed on Aug 26, 2026
  15. l0rinc commented at 7:39 PM on August 26, 2026: contributor

    Thanks for the review! I took all your suggestions (rebased separately to keep the diff focused).

    The provided block is already decoded and selected by ActivateBestChain(), so it now stays on that path instead of being stored in BlockFetcher. This simplified the fetcher state: it only owns followups now, while read_ahead_tip stops at the provided block's parent to avoid rereading it. Accordingly, m_pending/PopPending() became m_followups/PopFollowup(), Prefetch() became FillQueue(), and the new argument is provided_block.

    Also switched to a sliding 1-worker/depth-2 queue, added sibling prefetch during reorg disconnection, fixed the final followup being skipped when no block is provided, and removed futures before calling get(). feature_reindex.py now checks exact cache-hit counts, worker reuse, submitblock results, and reorg read-ahead.

    I'll experiment further with different threads and queue sizes and whether we can call the context-free checks here.

  16. in src/validation.cpp:3195 in b5e48454d5
    3190 | @@ -3201,19 +3191,92 @@ void Chainstate::PruneBlockIndexCandidates() {
    3191 |      assert(!setBlockIndexCandidates.empty());
    3192 |  }
    3193 |  
    3194 | +/** Supplies blocks to validation. Destruction waits for any queued reads. */
    3195 | +class Chainstate::BlockFetcher
    


    andrewtoth commented at 4:05 PM on August 28, 2026:

    What if we moved this out into a header in src/node/blockfetcher.h, and allowed passing the thread name in the constructor? Then with a simple commit on top (and shortening index thread names)

    <details><summary>Index patch</summary>

    diff --git a/src/index/base.cpp b/src/index/base.cpp
    index 5820448bb7..4225796fc0 100644
    --- a/src/index/base.cpp
    +++ b/src/index/base.cpp
    @@ -11,6 +11,7 @@
     #include <interfaces/types.h>
     #include <kernel/types.h>
     #include <node/abort.h>
    +#include <node/blockfetcher.h>
     #include <node/blockstorage.h>
     #include <node/context.h>
     #include <node/database_args.h>
    @@ -209,6 +210,7 @@ void BaseIndex::Sync()
     {
         const CBlockIndex* pindex = m_best_block_index.load();
         if (!m_synced) {
    +        node::BlockFetcher fetcher{m_chainstate->m_blockman, m_thread_name};
             auto last_log_time{NodeClock::now()};
             auto last_locator_write_time{last_log_time};
             while (true) {
    @@ -249,8 +251,14 @@ void BaseIndex::Sync()
                 }
                 pindex = pindex_next;
     
    -
    -            if (!ProcessBlock(pindex)) return; // error logged internally
    +            std::shared_ptr<const CBlock> loaded;
    +            WITH_LOCK(::cs_main, {
    +                loaded = fetcher.Load(pindex->GetBlockHash());
    +                if (const auto* tip{m_chainstate->m_chain.Tip()}) {
    +                    fetcher.FillQueue(*tip, pindex->nHeight + 1);
    +                }
    +            });
    +            if (!ProcessBlock(pindex, loaded.get())) return; // error logged internally
     
                 auto current_time{NodeClock::now()};
                 if (current_time - last_log_time >= SYNC_LOG_INTERVAL) {
    

    </details>

    we could have parallelism for all indexes. With this patch I measured speedups of 30% for txindex, 50% for blockfilterindex, and 6% for coinstatsindex. We could improve the latter two even more with an optional prefetch of the undo data as a follow-up.

    cc @furszy what do you think of this approach to parallelizing the indexing code? It would stack easily with #34489 for txospenderindex and txindex; it should be as simple as increasing the threadcount and queue depth of the blockfetcher (since each append would no longer be writing so the bottleneck would just be reading/deserializing blocks).


    l0rinc commented at 6:48 PM on August 28, 2026:

    Thanks, I like this approach. I moved BlockFetcher into src/node/blockfetcher.{h,cpp} in this push and added you as a co-author - although I initially forgot to add it to the Kernel build, and ReadBlock introduced a circular dependency. Since the extraction is useful independently of the index follow-up and makes the diff simpler, I also wired it into BaseIndex::Sync() locally to make sure the interface works. Your results make index prefetching worth doing, but I left the index wiring and configurable thread name out so we can finish the validation path in this PR first.

    One detail for the follow-up is thread naming: ThreadPool adds a .xx suffix, so the current blkfltbscidx, coinstatsidx, and txospenderidx names would exceed the limit accepted by ThreadRename() before it adds b-. Since we just shortened these names in this release, maybe we should leave room for the worker suffix now, even if index prefetching remains a follow-up, to avoid renaming the threads again later (assuming each index will get their own ThreadPool, which would kind of defeat the purpose of handling shared resources).

    And about the index parallelization work: this change is independent of batching index writes or parallelizing MuHash, so @furszy's ideas can still make sense on top. Before batching or adding more index concurrency, I think we should fix the concrete Stop()/Init() reader issue and account for pre-genesis sync and flush callbacks, stale-tip rewind after a disconnect, and blockfilter/coinstats crash-reorg recovery (I found several of these issues by repeatedly crashing during sync, and while some paths are theoretical and can wait, we should account for these states first).

    There is also a separate follow-up for context-free validation: the prefetched block is owned by the worker until its future is consumed, so CheckBlock() can safely populate its memoization flags there, and I left a TODO at that point.

    So I agree index block prefetching should be a follow-up, but before batching or parallelizing the indexes, I'd like to harden them to be more crash-safe.


    andrewtoth commented at 5:13 PM on August 31, 2026:

    I don't think there's a circular dependency. We don't need the ReadBlockFn (so we also don't need BlockFetcher to be wrapped in a std::unique_ptr):

    <details><summary>Patch</summary>

    diff --git a/src/node/blockfetcher.cpp b/src/node/blockfetcher.cpp
    index cc8c65e182..0528622606 100644
    --- a/src/node/blockfetcher.cpp
    +++ b/src/node/blockfetcher.cpp
    @@ -5,12 +5,11 @@
     #include <node/blockfetcher.h>
     
     #include <chain.h>
    -#include <flatfile.h>
     #include <kernel/cs_main.h>
    +#include <node/blockstorage.h>
     #include <primitives/block.h>
     #include <sync.h>
     #include <uint256.h>
    -#include <util/expected.h>
     #include <util/threadpool.h>
     
     #include <cstddef>
    @@ -31,9 +30,9 @@ std::shared_ptr<const CBlock> BlockFetcher::PopFollowup()
     bool BlockFetcher::Enqueue(const CBlockIndex& index)
     {
         if (m_pool.WorkersCount() == 0) m_pool.Start(WORKER_COUNT);
    -    auto followup{m_pool.Submit([&read_block = m_read_block, hash = index.GetBlockHash(), pos = index.GetBlockPos()]() -> std::shared_ptr<const CBlock> {
    +    auto followup{m_pool.Submit([&blockman = m_blockman, hash = index.GetBlockHash(), pos = index.GetBlockPos()]() -> std::shared_ptr<const CBlock> {
             auto block{std::make_shared<CBlock>()};
    -        if (!read_block(*block, pos, hash)) return nullptr;
    +        if (!blockman.ReadBlock(*block, pos, hash)) return nullptr;
             // TODO The block is owned by the worker until its future is consumed, so CheckBlock() may safely set its memoization flags.
             return block;
         })};
    diff --git a/src/node/blockfetcher.h b/src/node/blockfetcher.h
    index 88290a39af..40e489f328 100644
    --- a/src/node/blockfetcher.h
    +++ b/src/node/blockfetcher.h
    @@ -5,33 +5,30 @@
     #ifndef BITCOIN_NODE_BLOCKFETCHER_H
     #define BITCOIN_NODE_BLOCKFETCHER_H
     
    +#include <attributes.h>
     #include <kernel/cs_main.h>
     #include <sync.h>
     #include <util/threadpool.h>
     
     #include <cstdint>
     #include <deque>
    -#include <functional>
     #include <future>
     #include <memory>
    -#include <string>
    -#include <utility>
     
     class CBlock;
     class CBlockIndex;
    -struct FlatFilePos;
     class uint256;
     
     namespace node {
    +class BlockManager;
    +
     /** Supplies blocks to validation. Destruction waits for any queued reads. */
     class BlockFetcher
     {
    -    using ReadBlockFn = std::function<bool(CBlock&, const FlatFilePos&, const uint256&)>;
    -
         static constexpr uint32_t WORKER_COUNT{1};
         static constexpr uint32_t QUEUE_SIZE{2};
     
    -    const ReadBlockFn m_read_block;
    +    const BlockManager& m_blockman;
         ThreadPool m_pool{"blockread"};
         std::deque<std::future<std::shared_ptr<const CBlock>>> m_followups GUARDED_BY(::cs_main);
     
    @@ -40,7 +37,7 @@ class BlockFetcher
         bool Enqueue(const CBlockIndex& index) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
     
     public:
    -    explicit BlockFetcher(ReadBlockFn read_block) : m_read_block{std::move(read_block)} {}
    +    explicit BlockFetcher(const BlockManager& blockman LIFETIMEBOUND) : m_blockman{blockman} {}
     
         void Clear() EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
         std::shared_ptr<const CBlock> Load(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
    diff --git a/src/validation.cpp b/src/validation.cpp
    index abb2a612be..be006fdd0d 100644
    --- a/src/validation.cpp
    +++ b/src/validation.cpp
    @@ -29,7 +29,6 @@
     #include <kernel/types.h>
     #include <kernel/warning.h>
     #include <logging/timer.h>
    -#include <node/blockfetcher.h>
     #include <node/blockstorage.h>
     #include <node/utxo_snapshot.h>
     #include <policy/ephemeral_policy.h>
    @@ -1876,17 +1875,13 @@ Chainstate::Chainstate(
         BlockManager& blockman,
         ChainstateManager& chainman,
         std::optional<uint256> from_snapshot_blockhash)
    -    : m_block_fetcher{std::make_unique<node::BlockFetcher>([blockman = &blockman](CBlock& block, const FlatFilePos& pos, const uint256& hash) {
    -          return blockman->ReadBlock(block, pos, hash);
    -      })},
    +    : m_block_fetcher{blockman},
           m_mempool(mempool),
           m_blockman(blockman),
           m_chainman(chainman),
           m_assumeutxo(from_snapshot_blockhash ? Assumeutxo::UNVALIDATED : Assumeutxo::VALIDATED),
           m_from_snapshot_blockhash(from_snapshot_blockhash) {}
     
    -Chainstate::~Chainstate() = default;
    -
     fs::path Chainstate::StoragePath() const
     {
         fs::path path{m_chainman.m_options.datadir / "chainstate"};
    @@ -3222,8 +3217,8 @@ bool Chainstate::ActivateBestChainStep(BlockValidationState& state, CBlockIndex&
         const CBlockIndex* pindexFork = m_chain.FindFork(index_most_work);
         const CBlockIndex* read_ahead_tip{provided_block ? index_most_work.pprev : &index_most_work}; // Avoid rereading the provided block
         if (pindexFork && pindexFork != pindexOldTip) {
    -        m_block_fetcher->Clear();
    -        if (read_ahead_tip) m_block_fetcher->FillQueue(*read_ahead_tip, pindexFork->nHeight + 1);
    +        m_block_fetcher.Clear();
    +        if (read_ahead_tip) m_block_fetcher.FillQueue(*read_ahead_tip, pindexFork->nHeight + 1);
         }
     
         // Disconnect active blocks which are no longer in the best chain.
    @@ -3263,8 +3258,8 @@ bool Chainstate::ActivateBestChainStep(BlockValidationState& state, CBlockIndex&
     
             // Connect new blocks.
             for (CBlockIndex* pindexConnect : vpindexToConnect | std::views::reverse) {
    -            auto block_to_connect{provided_block && pindexConnect == &index_most_work ? provided_block : m_block_fetcher->Load(pindexConnect->GetBlockHash())};
    -            if (read_ahead_tip) m_block_fetcher->FillQueue(*read_ahead_tip, pindexConnect->nHeight + 1);
    +            auto block_to_connect{provided_block && pindexConnect == &index_most_work ? provided_block : m_block_fetcher.Load(pindexConnect->GetBlockHash())};
    +            if (read_ahead_tip) m_block_fetcher.FillQueue(*read_ahead_tip, pindexConnect->nHeight + 1);
                 if (!ConnectTip(state, pindexConnect, std::move(block_to_connect), connected_blocks, disconnectpool)) {
                     if (state.IsInvalid()) {
                         // The block violates a consensus rule.
    diff --git a/src/validation.h b/src/validation.h
    index 2c67d1aa32..2793ccb92b 100644
    --- a/src/validation.h
    +++ b/src/validation.h
    @@ -18,6 +18,7 @@
     #include <kernel/chainparams.h>
     #include <kernel/chainstatemanager_opts.h>
     #include <kernel/cs_main.h> // IWYU pragma: export
    +#include <node/blockfetcher.h>
     #include <node/blockstorage.h>
     #include <policy/feerate.h>
     #include <policy/packages.h>
    @@ -63,7 +64,6 @@ namespace kernel {
     struct ChainstateRole;
     } // namespace kernel
     namespace node {
    -class BlockFetcher;
     class SnapshotMetadata;
     } // namespace node
     namespace Consensus {
    @@ -562,7 +562,7 @@ protected:
         Mutex m_chainstate_mutex;
     
         //! Reads blocks ahead during chain activation.
    -    std::unique_ptr<node::BlockFetcher> m_block_fetcher;
    +    node::BlockFetcher m_block_fetcher;
     
         //! Optional mempool that is kept in sync with the chain.
         //! Only the active chainstate has a mempool.
    @@ -594,7 +594,6 @@ public:
             node::BlockManager& blockman,
             ChainstateManager& chainman,
             std::optional<uint256> from_snapshot_blockhash = std::nullopt);
    -    ~Chainstate();
     
         //! Return path to chainstate leveldb directory.
         fs::path StoragePath() const;
    

    </details>


    l0rinc commented at 9:43 PM on September 1, 2026:

    The full patch still introduces the exact cycle: node/blockfetcher -> node/blockstorage -> validation -> node/blockfetcher, can you please check it with lint-circular-dependencies.py?


    andrewtoth commented at 4:56 PM on September 3, 2026:

    Ahh I did not use the linter, I just compiled. Indeed, my patch fails lint. Thanks!

  17. l0rinc force-pushed on Aug 28, 2026
  18. l0rinc force-pushed on Aug 28, 2026
  19. DrahtBot added the label CI failed on Aug 28, 2026
  20. DrahtBot commented at 6:06 PM on August 28, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/33196171834/job/98933744258</sub> <sub>LLM reason (✨ experimental): CI failed because ctest hit a runtime symbol lookup error in test_kernel (libbitcoinkernel.so undefined symbol: node::BlockFetcher::FillQueue(...)).</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>

  21. l0rinc force-pushed on Aug 28, 2026
  22. DrahtBot removed the label CI failed on Aug 28, 2026
  23. in doc/release-notes-36000.md:1 in aaf3283a12 outdated
       0 | @@ -0,0 +1,6 @@
       1 | +Performance Improvements
    


    andrewtoth commented at 4:25 PM on August 31, 2026:

    Does this warrant a release note? It is covered already by "performance improvements" in every release note.


    l0rinc commented at 9:41 PM on September 1, 2026:

    I expect this to land in a future release, so for now I'd keep it

  24. in src/node/blockfetcher.cpp:1 in aaf3283a12
       0 | @@ -0,0 +1,60 @@
       1 | +// Copyright (c) 2026-present The Bitcoin Core developers
    


    andrewtoth commented at 4:25 PM on August 31, 2026:

    nit: we can remove the dates altogether. same for header file.

    // Copyright (c) The Bitcoin Core developers
    
  25. in src/node/blockfetcher.cpp:37 in aaf3283a12
      32 | +{
      33 | +    if (m_pool.WorkersCount() == 0) m_pool.Start(WORKER_COUNT);
      34 | +    auto followup{m_pool.Submit([&read_block = m_read_block, hash = index.GetBlockHash(), pos = index.GetBlockPos()]() -> std::shared_ptr<const CBlock> {
      35 | +        auto block{std::make_shared<CBlock>()};
      36 | +        if (!read_block(*block, pos, hash)) return nullptr;
      37 | +        // TODO The block is owned by the worker until its future is consumed, so CheckBlock() may safely set its memoization flags.
    


    andrewtoth commented at 4:28 PM on August 31, 2026:

    Not sure we should have this TODO here. I don't know if there is consensus for this yet. Changing the validation ordering is a different beast than just parallelizing reading a block from disk and deserializing.

  26. in src/node/blockfetcher.h:32 in aaf3283a12
      27 | +class BlockFetcher
      28 | +{
      29 | +    using ReadBlockFn = std::function<bool(CBlock&, const FlatFilePos&, const uint256&)>;
      30 | +
      31 | +    static constexpr uint32_t WORKER_COUNT{1};
      32 | +    static constexpr uint32_t QUEUE_SIZE{2};
    


    andrewtoth commented at 4:29 PM on August 31, 2026:

    Could we pass these as default parameters to the constructor, and call ThreadPool::Start on construction? That way we can inject different values. Why do we wait to start the threadpool later? IMO it's cleaner/more RAII to start in constructor.

  27. in src/node/blockfetcher.cpp:49 in aaf3283a12 outdated
      44 | +void BlockFetcher::Clear() { m_followups.clear(); }
      45 | +
      46 | +std::shared_ptr<const CBlock> BlockFetcher::Load(const uint256& hash)
      47 | +{
      48 | +    if (auto block{PopFollowup()}; block && block->GetHash() == hash) return block;
      49 | +    return nullptr;
    


    andrewtoth commented at 4:31 PM on August 31, 2026:

    We should call Clear here, since on a miss we have gotten out of sync of the fetching.

  28. andrewtoth commented at 4:41 PM on August 31, 2026: contributor

    Still working on out-of-order IBD benchmarks, but this is looking good. Left some minor suggestions.

  29. refactor: prepare block fetcher wiring
    `ActivateBestChain()` filters the caller-provided block to the selected most-work index before calling `ActivateBestChainStep()`.
    The step passes it to `ConnectTip()` only for the matching connection index.
    
    Extract both selections into `block_to_connect` so read-ahead can supply other connection indexes without changing the caller-provided block path.
    1d6656c6b0
  30. validation: add synchronous block prefetch
    `ConnectTip()` reads every block from disk except a caller-provided `index_most_work` block.
    
    Add a `Chainstate`-owned `BlockFetcher` that reads and retains the next disk-backed block before connecting the current block.
    Keep the caller-provided block on its existing path and stop read-ahead at its parent, avoiding a duplicate read.
    
    `Load()` returns a followup only when its hash matches the requested index. If no matching followup is available, `ConnectTip()` uses its existing synchronous load path.
    
    `feature_reindex.py` covers followup consumption and verifies that synchronous read-ahead starts no worker.
    
    Co-authored-by: Andrew Toth <andrewstoth@gmail.com>
    64b6d0d957
  31. validation: make block prefetch asynchronous
    Move the followup read onto a lazily started `ThreadPool` with 1 worker so disk I/O overlaps block connection.
    Keeping the fetcher in `Chainstate` lets later activation calls reuse the worker.
    
    Keep the block provided by the caller on the direct activation path. If an asynchronous followup is unavailable, `ConnectTip()` uses its existing synchronous load path.
    
    Extend reindex coverage to require worker startup and reuse across activation calls.
    
    Co-authored-by: bitcoindev1337
    Co-authored-by: Andrew Toth <andrewstoth@gmail.com>
    07693b8030
  32. validation: queue blocks for read-ahead
    A single followup leaves a worker idle after it finishes until validation consumes the result.
    
    Maintain a fixed queue of up to 4 reads on 2 workers. Fill missing slots after each block is consumed so the workers can continue reading while validation connects the current block.
    
    Current measurements favor 2 workers with queue depths of 4 or 8, so use the smaller depth.
    
    Co-authored-by: Andrew Toth <andrewstoth@gmail.com>
    02391ae5f6
  33. validation: prefetch blocks during reorgs
    Chain activation disconnects the current tip before it begins connecting blocks from a competing branch.
    
    Clear followups queued for the previous candidate and fill the queue from the first sibling before disconnection starts. This overlaps the sibling read with `DisconnectTip()`.
    
    Extend `feature_reindex.py` with a 2-block fork that verifies the first sibling is read ahead before the caller-provided tip is connected.
    
    Co-authored-by: Andrew Toth <andrewstoth@gmail.com>
    03351d3dc4
  34. doc: add block read-ahead release note
    Document the chain activation speedup.
    3547915bfb
  35. l0rinc force-pushed on Sep 1, 2026
  36. l0rinc commented at 9:53 PM on September 1, 2026: contributor

    Thanks, addressed most of your concerns, rebased and updated the defaults to 2 workers and queue size of 4 (current SSD and especially HDD measurements indicate that to be the optimum).

  37. l0rinc commented at 2:55 AM on September 2, 2026: contributor

    Took about a week, but the measurements are in and 2 threads and a queue of 4 blocks (already the current values of the PR) seem to be the sweet spot:

    <img width="2534" height="799" alt="image" src="https://github.com/user-attachments/assets/9ea194be-b614-49a9-b353-d40fcd842743" />

    If we add context-independent CheckBlock calls in a followup (to do partial validation before returning the blocks), the optimum shifts slightly to 4 threads and queue length of 8.

    <img width="2534" height="799" alt="image" src="https://github.com/user-attachments/assets/2c0b3bde-2318-4627-a8f7-0f99a38d1a36" />

    <img width="2534" height="802" alt="image" src="https://github.com/user-attachments/assets/96d20155-5f21-421a-bdca-8b6d6df3ceea" />

  38. in src/node/blockfetcher.cpp:33 in 3547915bfb
      28 | +    return followup.get();
      29 | +}
      30 | +
      31 | +bool BlockFetcher::Enqueue(const CBlockIndex& index)
      32 | +{
      33 | +    auto followup{m_pool.Submit([&read_block = m_read_block, hash = index.GetBlockHash(), pos = index.GetBlockPos()]() -> std::shared_ptr<const CBlock> {
    


    andrewtoth commented at 3:11 PM on September 3, 2026:

    Since we have multiple worker threads now, maybe we can take advantage of the multi-Submit overload whenever we have an empty m_followups? That would awaken all threads at once. This would happen on every ActivateBestChainStep call, so could show some improvement?

    Also, if we have multiple threads and queue depth, we might want to allow disabling this mechanism for low memory systems. Maybe worth it to just be an on/off toggle (that defaults to on)? Or maybe a configuration of threads only and the queue depth is 2*threads? Just trying to think of ways to make it simple instead of adding all kinds of configuration options.

  39. in doc/release-notes-36000.md:4 in 3547915bfb
       0 | @@ -0,0 +1,6 @@
       1 | +Performance Improvements
       2 | +------------------------
       3 | +
       4 | +- A background thread can now prefetch later blocks from disk while another
    


    andrewtoth commented at 6:32 PM on September 8, 2026:

    The release notes are stale. There are now multiple threads.

  40. in src/node/blockfetcher.h:26 in 3547915bfb
      21 | +class CBlockIndex;
      22 | +struct FlatFilePos;
      23 | +class uint256;
      24 | +
      25 | +namespace node {
      26 | +/** Supplies blocks to validation. Destruction waits for any queued reads. */
    


    andrewtoth commented at 6:37 PM on September 8, 2026:

    This comment is not very helpful IMO. Why would anyone care about destruction waiting on queued reads? Something like this would be better.

    /**
     * Reads and deserializes blocks in parallel starting from a requested block index.
     * Useful for improving performance when scanning blocks.
     **/
    
  41. in src/validation.cpp:3212 in 3547915bfb
    3209 |      AssertLockHeld(cs_main);
    3210 |      if (m_mempool) AssertLockHeld(m_mempool->cs);
    3211 |  
    3212 |      const CBlockIndex* pindexOldTip = m_chain.Tip();
    3213 |      const CBlockIndex* pindexFork = m_chain.FindFork(index_most_work);
    3214 | +    const CBlockIndex* read_ahead_tip{provided_block ? index_most_work.pprev : &index_most_work}; // Avoid rereading the provided block
    


    andrewtoth commented at 6:40 PM on September 8, 2026:

    This comment is a bit cryptic.

        // If we have a provided block, we don't need to read it so we can stop reading ahead at the previous block.
        const CBlockIndex* read_ahead_tip{provided_block ? index_most_work.pprev : &index_most_work};
    
  42. andrewtoth approved
  43. andrewtoth commented at 6:53 PM on September 8, 2026: contributor

    ACK 3547915bfb240f280188954c7c5b60ec90145ac5

    Managed to see speedup in IBD benchmarks by having one local node bind to 10 ports and the other connect to all 10. I did this and reindex-chainstate with an i7 and i5 machine. Nice speedups, especially for reindex-chainstate. This will also be good for the typical single block reorg during tip, and we can use it almost verbatim for speeding up our optional index syncing in a follow-up.

    Workload Machine Base elapsed PR elapsed Less elapsed time
    IBD i7 5h 39m 4h 53m 13.4%
    IBD i5 7h 12m 6h 56m 3.8%
    reindex-chainstate i7 3h 44m 2h 24m 35.8%
    reindex-chainstate i5 4h 40m 4h 04m 12.7%

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-09-09 07:56 UTC