refactor: avoid redundant input lookup in `CheckTxInputs` #35657

pull arejula27 wants to merge 3 commits into bitcoin:master from arejula27:checktxinputs-dedup-haveinputs changing 3 files +69 −7
  1. arejula27 commented at 11:35 AM on July 4, 2026: none

    Consensus::CheckTxInputs currently makes two UTXO lookups per input: first through a HaveInputs() pre-pass (which internally calls HaveCoin() for every input), and then through AccessCoin() on the same outpoints in the main validation loop.

    HaveInputs() has only a single caller (CheckTxInputs), and AccessCoin() already returns a reference to a spent sentinel Coin for any missing outpoint. This means the presence check can be folded into the existing loop without changing behavior.

    This PR removes the initial HaveInputs() pre-pass and instead checks coin.IsSpent() immediately after AccessCoin() returns. As a result, CheckTxInputs performs one lookup per input instead of two (2N → N, where N is the number of inputs) in the validation path shared by both ConnectBlock and mempool acceptance.

    HaveInputs() itself is not removed. It is still used by the coins-view fuzz harness, although this change leaves it with no callers in the validation hot path. Removing it would be a trivial follow-up.

    The only observable behavioral difference is which validation error is reported if one input is missing while another input in the same transaction fails for some unrelated reason (for example, coinbase immaturity).

    • In ConnectBlock, all such failures are ultimately reported as BLOCK_CONSENSUS, so the specific failure reason or ordering has no consensus impact.
    • In the mempool (MemPoolAccept::PreChecks), every input has already been checked with m_view.HaveCoin() before CheckTxInputs() is called. Missing inputs are handled there as orphan transactions, making the "missing input" path inside CheckTxInputs() unreachable during mempool validation.

    <details> <summary><strong>Benchmark</strong> (<code>bench_bitcoin -filter=CheckTxInputs</code>)</summary>

    The benchmark below is intentionally designed to measure the cost of the redundant lookup itself, rather than end-to-end block validation.

    A fresh CCoinsViewCache wrapping CoinsTip is constructed for each measured batch (coins are added to CoinsTip itself, not to the benchmarked view), so each pass actually crosses the view/base boundary instead of hitting an already-warmed local cache. This closely matches how a fresh per-block view behaves in production.

    BEFORE="d31c26fd1a53300a704bccbf12aae5b050c67ec3"
    AFTER="5021cd02fa503f0ea0dc741f7200eba8275a885d"
    
    RESULTS_FILE="$HOME/checktxinputs_bench_results.txt"
    : > "$RESULTS_FILE"
    
    echo "machine | $(hostname) | $(uname -m) | $(lscpu | grep 'Model name' | head -1 | cut -d: -f2 | xargs) | $(nproc) cores | $(free -h | awk '/^Mem:/{print $2}') RAM" | tee -a "$RESULTS_FILE"
    
    ORIG="$(git rev-parse --abbrev-ref HEAD)"
    
    for hash in $BEFORE $AFTER; do
      echo ">>> $hash"
      git checkout "$hash" >/dev/null 2>&1 &&
      cmake --build build -t bench_bitcoin &&
      (
        echo "--- ${hash:0:7} ---"
        ./build/bin/bench_bitcoin -filter=CheckTxInputs -min-time=3000
      ) 2>&1 | tee -a "$RESULTS_FILE"
    done
    
    git switch "$ORIG"
    

    </details>

    ns/tx tx/s err%
    before 1,154.31 866,316 0.4%
    after 985.84 1,014,367 0.5%

    ~14.6% faster. This is smaller than a naive 2N → N lookup-count argument might suggest, because both lookups are FetchCoin calls against the same already-resolved cache entry after the first one resolves it. The second lookup is just a cheap lookup in an already-warm cache, not a second trip to the backing store. The real saving is exactly one redundant cache lookup per input, which is what this benchmark isolates.

    As a sanity check, I also compared IBD over the same block range before and after this change and observed no measurable difference in synchronization time, which is expected: CheckTxInputs accounts for only a small fraction of ConnectBlock, where signature verification dominates.

    The optimization is therefore real but modest. Its value comes from removing a genuinely redundant lookup without changing consensus behavior, rather than from any meaningful IBD speedup.

    Historical context

    This pre-pass originally served a real purpose.

    Before April 2017, CheckTxInputs() used a per-txid CCoins cache where AccessCoins(txid) returned a raw pointer that could be NULL, and a single CCoins entry could contain both spent and unspent outputs. In that design, HaveInputs() prevented both null-pointer dereferences and spending an already-spent output within an otherwise live entry.

    On 2017-04-25, two commits changed this:

    • 0003911326 ("Introduce new per-txout CCoinsViewCache functions") introduced AccessCoin(), which returns a reference to a sentinel empty coin for missing outpoints.
    • f68cdfe92b ("Switch from per-tx to per-txout CCoinsViewCache methods in some places") updated CheckTxInputs() to use the new API.

    From that point onward, missing and already-spent outputs were both represented by coin.IsSpent() on the object returned by AccessCoin(), making the HaveInputs() pre-pass redundant. The validation loop has effectively remained unchanged since then, aside from the later scripted rename from IsPruned() to IsSpent() (589827975f).

    This PR simply removes that leftover redundancy.

    Edit: :When I opened this PR, I hadn't reviewed PR #35187, which modifies the same lines. While the primary goal of that PR is different, it also avoids the redundant lookup as a side effect.

    Although that PR was submitted earlier, it is significantly larger and includes more critical and potentially controversial changes (it has already received one concept NACK), so it may take longer to be merged.

    This PR is smaller, focused on a specific issue, and can be evaluated independently. It also doesn't block or conflict with the other PR, since #35187 can still be merged later on top of these changes without any issues.

  2. DrahtBot added the label Refactoring on Jul 4, 2026
  3. DrahtBot commented at 11:35 AM on July 4, 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/35657.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process. A summary of reviews will appear here.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #32317 (kernel: Separate UTXO set access from validation functions 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-->

    LLM Linter (✨ experimental)

    Possible typos and grammar issues:

    • AccessCoin returns a spent coinEmpty -> AccessCoin returns a spent coin [“coinEmpty” is not grammatical here and makes the sentence unclear.]

    <sup>2026-07-04 11:53:50</sup>

  4. bench: add CheckTxInputs benchmark d31c26fd1a
  5. arejula27 force-pushed on Jul 4, 2026
  6. arejula27 force-pushed on Jul 4, 2026
  7. consensus: avoid redundant input lookup in CheckTxInputs 5021cd02fa
  8. test: cover missing-input outpoint in CheckTxInputs
    Exercise the case where a transaction spends an outpoint that is
    entirely absent from the coins view. AccessCoin returns a spent
    placeholder Coin for it, so it must be rejected the same way as an
    already-spent input: bad-txns-inputs-missingorspent.
    5b02e4b4c2
  9. arejula27 force-pushed on Jul 4, 2026
  10. DrahtBot added the label CI failed on Jul 5, 2026
  11. DrahtBot commented at 5:36 PM on July 5, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/28705366932/job/85240720272</sub> <sub>LLM reason (✨ experimental): CI failed due to a Clang -Wthread-safety-analysis build error (calling chainstate.CoinsTip() without exclusive cs_main lock in bench/connectblock.cpp).</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>

  12. l0rinc commented at 6:17 PM on July 5, 2026: contributor

    I'm not necessarily against this change; we've argued this for years now, but as noted in the description, this changes invalid block reason reporting. Not sure how critical that would be (in https://github.com/l0rinc/bitcoin/pull/137 I deliberately tried avoiding that), but ultimately the effect is small, and after #35215 it is expected to be even smaller.

  13. arejula27 commented at 7:53 PM on July 5, 2026: none

    Thank you @l0rinc for your comments!!

    we've argued this for years now

    Sorry, I wasn't aware, I tried to do some research before doing the pr but it looks like I missed something. I am reading the code, profiling and running benchmarks to find things to optimize, but I am not finding that much. I would be glad if you could point me to any issue or problem that has already been discussed but no one is working on :D

    but as noted in the description, this changes invalid block reason reporting.

    Personally, I think the impact is minimal. I would say (correct me if I am wrong) the mismatch is only visible locally, in debug.log and in the reject-reason string returned to submitblock/getblocktemplate proposal callers. It also only happens in the specific scenario where a transaction in a block has a missing input and an earlier input with an unrelated failure. In that case, reporting either of the two reasons seems enough to me. There is no behavior change in the node's logic, only which of the two errors is reported changes (one before, another now), so the same amount of information is reported: a single reject reason, just as before.

    However im totally open to do any changes or explore a solution to that matter if it is really that relevant!

  14. l0rinc commented at 9:55 PM on July 5, 2026: contributor

    looks like I missed something

    Not necessarily, some similar discussion happened a long time ago e.g. #14837

    the algorithm checks more invariants at once and detects them in a different order than before, clients relying on an exact reject reason may be confused. Clients should already not rely on exact reject reasons, so I don't believe this has a ramification.

    profiling and running benchmarks to find things to optimize, but I am not finding that much

    We're working hard to make sure you don't find that much :p #35295 may enable new optimizations; you could help by reviewing it. Just know that consensus refactors are usually ignored if done by relatively new contributors.

    However im totally open to do any changes or explore a solution to that matter if it is really that relevant!

    As mentioned, I'm not against it, but I'm personally not prioritizing it (especially while #35295 is still open).


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