Skip to content

perf: port Bitcoin Core's incumbent-free branch-and-bound prunes - #69

Draft
evanlinjin wants to merge 15 commits into
bitcoindevkit:masterfrom
evanlinjin:experiment/core-bnb-prunes
Draft

perf: port Bitcoin Core's incumbent-free branch-and-bound prunes#69
evanlinjin wants to merge 15 commits into
bitcoindevkit:masterfrom
evanlinjin:experiment/core-bnb-prunes

Conversation

@evanlinjin

Copy link
Copy Markdown
Member

Important

Stacked on #64. This branch is two commits on top of that PR's head (7e965cf), and this repo doesn't carry #64's branch, so the diff below currently includes all of #64. Review after #64 merges, at which point this reduces to the two commits listed here. Draft until then.

Bitcoin Core's SelectCoinsBnB prunes a branch on four conditions, and three of them need no incumbent. Ours has exactly one prune, bound < best, which does nothing until a solution exists. For LowestFeeChangeless that is the whole problem: score returns None whenever change would be worthwhile, so the greedy descent lands on funded-but-changeful selections that score nothing, best stays None, and every node with a Some bound passes. Nothing is pruned until a changeless selection is found, which is the hard part.

This ports the two of Core's cuts that carry over.

86f64ec — hard-prune on a lookahead over the undecided candidates

Core's curr_value + curr_available_value < selection_target, as a running total the selection cache maintains rather than an O(n) rescan per node. The relaxation credits still-reachable ancestor surplus by relaxing to ancestor_bump_lower_bound instead of assuming monotonic funding, so unlike the existing None prunes it stays on when there are unconfirmed ancestors.

fb5a021 — cut changeless branches that have overshot their window

Core's defining cut, curr_value > selection_target + cost_of_change. The ceiling comes straight off LowestFee::drain_value, which is what already decides whether a selection counts as changeless here.

Two things stop it porting directly, and both are checked rather than assumed:

A candidate that costs more than it brings can be exactly what a changeless selection needs. Core's caller filters non-positive-effective-value coins from the pool and SelectCoinsBnB asserts the result; we cannot. Given c0 = (value 1000, weight 611), c1 = (value 35977, weight 372) and a target of 33719 at 9.716 sat/vb, {c1} alone overshoots and wants change, so it does not score — and it is the negative effective-value c0 that burns 484 sats of excess and lands the pair inside the changeless window, at the brute-force optimum. Core can filter because nothing in its objective wants such a coin; this metric does. So the cut runs only where the cache confirms every undecided candidate raises the excess. Covered by a_candidate_not_worth_selecting_can_still_be_needed.

Adding a candidate does not always raise the excess by its own effective value. Our weight model has count-dependent terms Core's GetSelectionAmount() has no equivalent of — the input-count varint grows, the first segwit input adds the witness header, and every legacy input in a segwit transaction serializes an empty witness — so max_future_input_overhead widens the ceiling by every weight unit the serialization could still gain. Covered by mixed_serialization_overhead_does_not_prune_exact_solution.

Unconfirmed ancestors and a Target::max_weight switch the cut off, both because they give a descendant a way back into the window. That is conservative and marked ponytail: in the source with the upgrade path: the ancestor gate wants an upper bound on the bump a descendant can still take on, mirroring the floor ancestor_bump_lower_bound already tracks.

Measured

coinselect-benchmark bench.py compare-revs, 126 fixture/track pairs against 7e965cf, 100,000-node budget:

value
solutions lost 0
solutions gained 0
selections changed 0
scores changed 0
peak RSS flat

Node counts on the kernel (LowestFeeChangeless) track, every fixture that finishes inside budget:

subsidizing_ancestry_20    886 ->   137  (6.5x)
nested_ancestry_20         200 ->    30  (6.7x)
smoke                      121 ->    42  (2.9x)
shared_ancestry_20         369 ->   225  (1.6x)
high_feerate_20             43 ->    32  (1.3x)
private_ancestry_20        207 ->   182  (1.1x)
no_ancestry_100           3211 ->  2827  (1.1x)
no_ancestry_50            4222 ->  3992  (1.1x)
nested_ancestry_50        3582 ->  3456  (1.0x)
...                       24/24 fixtures improved

Cheaper everywhere the search can finish; no help on the 11 fixtures that exhaust the budget, which are the ancestry families the window cut currently switches itself off for. The matrix aggregate therefore reads flat — the capped fixtures dominate the node total.

Attribution across the two commits is clean: 86f64ec does nearly all of it, including on the ancestry families. fb5a021 fires only on no_ancestry_*, exactly as its three gates predict.

Notes

  • No public API change. An earlier revision of this branch added a BnbMetric::ignores_candidates_not_worth_selecting opt-in so a metric could take Core's pool filter; it changed no fixture in the matrix and the one metric that most looks like it wants it must answer false, so it was dropped.
  • Both commits pass cargo fmt/check/clippy/doc/test and --no-default-features independently (bisect-checked).

🤖 Generated with Claude Code

https://claude.ai/code/session_019qxj8PzFNeqeKw7XkuzvK3

evanlinjin and others added 15 commits August 14, 2026 04:52
…y_count

Fixes CoinSelector::input_weight undercounting candidates that group multiple legacy inputs in a segwit transaction (where each legacy input serializes a 1 WU empty witness). Tracking segwit and legacy input counts separately also allows a single Candidate to mix legacy and segwit inputs.
…legacy

Replaces the boolean is_segwit parameter in Candidate::new with explicit new_segwit and new_legacy constructors. Clarifies in doc comments that satisfaction_weight is the additional weight required beyond TXIN_BASE_WEIGHT (which already accounts for a 1-byte scriptSigLen).
…call

A selector was built for one target and evaluated against it throughout,
but every method took the target as a parameter, so nothing stopped
`cs.excess(target_a, drain)` being followed by `cs.is_funded(target_b)`.
The correctness arguments in the metrics are all stated at a fixed target
-- `LowestFee::bound`'s proof that a changeless superset always costs
more, `Changeless::change_unavoidable`'s assumption that the drain
decision is monotone in the excess -- and were held together by
convention rather than by types.

`CoinSelector::new` now takes the target and owns it. Twenty signatures
*lose* a parameter rather than gaining one: fifteen public methods
(`excess`, `implied_fee`, `is_funded`, `drain`, `select_until_target_met`,
the four `*_excess`, ...), plus `bnb_solutions` and `run_bnb`, plus all
three `BnbMetric` methods.

The crate had already reached this conclusion one layer down: `BnbIter`
stored the target as a field, took it once in `BnbIter::new`, and then
re-passed it into `metric.score` and `metric.bound` at every node. That
field and the re-threading are both gone.

This is a breaking change, and it reaches `BnbMetric`, so metrics
implemented outside this crate need their signatures updated:

    fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
    fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
    fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain;

`CoinSelector::target()` exposes the target for metrics that need to read
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the fixed target, candidates, and optional ancestor graph into one
immutable problem object. CoinSelector now borrows that object, keeping
all calculations tied to the same inputs and allowing ancestry metadata
to remain separate from Candidate.

Provide new_no_ancestors for prebuilt candidates and new for constructing
candidates from input groups and their unconfirmed transaction graph.
Selecting an unconfirmed coin means paying to bump its ancestors. The
feerate obligation includes the shortfall of the union of ancestors the
selected candidates drag in (each charged once; weight and fee netted;
saturates at 0).

Score is still the child fee — the bump is already inside it. With
ancestors, LowestFee falls back to a loose but admissible fee floor;
tightening is a follow-up. BnB only batch-bans look-alikes with the same
drags_in; Changeless disables its prune when ancestors are present.
Precompute ancestors reachable through exactly one candidate as summed
private packages. Keep bitset de-duplication only for ancestors shared by
multiple candidates, preserving exact union accounting while reducing the
common-path work in every fee calculation.

Add Criterion coverage for private and shared ancestry at 20, 50, and 100
candidates, plus exhaustive regressions for the optimized representation.
For funded nodes, subtract the ancestor surplus still reachable by a
descendant. For unfunded nodes, derive a minimum added child weight from
independent fractional relaxations of the target-rate, absolute-fee, and
RBF constraints, then evaluate the fee floor at that weight.

Candidate ancestry is deliberately represented only by the global bump
lower bound: package surplus can absorb a later private deficit, so a
per-candidate ancestor cost is not admissible. Keep infeasibility prunes
off because ancestor funding is non-monotone.

Add regressions for package subsidy, absolute/RBF double counting, and
large-float cancellation, plus the existing exhaustive proptests.
Maintain aggregate selection state per branch and expose it through SelectionView so metric evaluation avoids repeatedly walking selected candidates. Track each branch's candidate cursor to skip repeated scans, and extend benchmarks across wallet- and exchange-scale pools.
Keep SelectionView's hypothetical updates set-like and synchronize
ancestor reachability when branches exclude candidates. Remove unsound
funding and changeless assumptions exposed by non-monotone ancestor debt,
and preserve conservative fee rounding in the bound.

Add regressions for public view updates, exclusion transitions, weight
caps, mixed serialization overhead, and floating-point edge cases.
Separate deterministic solution-finding cases from larger pools expected
to exhaust the fixed round cap. Assert each fixture's expected search
outcome before measuring it so benchmark comparisons cannot silently time
different paths.
Store private ancestor totals directly and allocate shared reference
tracking only when the problem actually has shared ancestry. Preserve an
explicit precision allowance for large floating-point ancestor fees so the
smaller cache does not tighten the admissible bound.
Replace generic metric composition with a changeless metric that reuses
LowestFee's funding, weight-cap, dust, and change decisions. Add a
monotone selected-value bound for pools up to 24 candidates while retaining
LowestFee's ordering for larger pools to avoid finite-round starvation.

Cover the constrained objective with exhaustive and serialization-edge
regressions, and document the migration from Changeless and tuple metrics.
Port Bitcoin Core's `SelectCoinsBnB` lookahead. Core keeps a running
`curr_available_value` over the coins it has not decided on yet and
backtracks as soon as that total cannot close the gap to the target; the
cut needs no incumbent, so it fires from the very first descent. We had
the same idea only in `LowestFee::bound`'s no-ancestor path, as an O(n)
rescan that ran after the relaxation had already been set up, and not at
all when the problem has ancestors.

`SelectionCache` now carries the value and weight of the undecided
candidates worth selecting, maintained by the same add/sub/ban/unban hooks
that already track reachable ancestor surplus, so the test is O(1).

Two one-sided relaxations keep it from pruning a branch that holds a
solution: only candidates with positive standalone effective value count
toward the total, and the current ancestor bump is swapped for
`ancestor_bump_lower_bound`, which holds for the whole subtree. That
second one is what lets the prune run with ancestors present, where
funding is not monotone and "select everything and it is still unfunded"
would have been an unsound claim.
Port Bitcoin Core's defining `SelectCoinsBnB` cut: it backtracks on
`curr_value > selection_target + cost_of_change`, and because that window
is a constant known before the search starts, the cut needs no incumbent.
`LowestFeeChangeless` had no equivalent, so with `best` still `None` — and
it stays `None` until a changeless selection is found, which is the hard
part — nothing was pruned at all. The ceiling comes straight off
`LowestFee::drain_value`, which is what decides whether a selection counts
as changeless here.

Two things stop it porting directly, and both are checked rather than
assumed:

Adding a candidate does not always raise the excess. Its own weight is not
the whole cost — the input-count varint grows, the first segwit input adds
the witness header, and every legacy input in a segwit transaction
serializes an empty witness — so `max_future_input_overhead` widens the
ceiling by every weight unit the serialization could still gain.

More importantly, a candidate that costs more than it brings is exactly
what a changeless selection may need. Given
`c0 = (1000, 611)`, `c1 = (35977, 372)`, target 33719 at 9.716 sat/vb,
`{c1}` alone overshoots and wants change, and it is the *negative*
effective-value `c0` that burns 484 sats of excess and lands the pair
inside the window at the brute-force optimum of 3258. So this metric
cannot lean on a pre-filtered pool the way Core does — Core can filter
because nothing in its objective wants such a coin — and the cut instead
runs only where the cache confirms every undecided candidate raises the
excess. Unconfirmed ancestors and a `max_weight` cap switch it off for the
same reason: both give a descendant a way back into the window.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant