From 067552f51df25f7e1e92cab2c944c9729691eab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 2 Jul 2026 16:56:08 +0000 Subject: [PATCH 1/3] perf!: delta-aware BnbMetric via SelectionView/compute_view Squash of the two-commit perf series (cd1017a "delta-aware BnbMetric via SelectionView/SelectionCache" + 536a03a "replace duplicated CoinSelector read methods with compute_view()"), rebased onto the new BnbMetric API (target passed as a parameter; metric decides its own change output). Squashed because the first commit's intermediate state (src/selection_cache.rs, SelectionView-by-value) is fully superseded by the second (src/selection_view.rs, Cow-backed cache, &SelectionView, compute_view()); replaying both would mean resolving the same BnbMetric merge against master twice. The flamegraph on fix/better-memory showed ~65% of run_bnb_lowest_fee time was in cs.selected_value() (47.5%) and cs.input_weight() (17.3%) -- both O(|selected|) walks recomputed many times per branch via excess/is_target_met/drain. This makes the metric evaluator delta-aware. BnB maintains a SelectionCache (running aggregates over the selection: value_sum, weight_sum, input_count, segwit_count, candidate_count) per Branch; inclusion expansions call cache.add(c), which is O(1). The metric trait now takes a `&SelectionView<'_>` -- a handle over (&CoinSelector, Cow) -- with O(1) versions of every read method (selected_value, input_weight, excess, is_target_met, drain, ...). CoinSelector's ~15 duplicated O(|selected|) read methods collapse into a single `cs.compute_view()` entry point (fresh cache built once on demand); the BnB hot path borrows the per-branch cache instead (zero clone). Adapted to the master-side API changes: - score/bound/drain take `target: Target` as a parameter (metrics no longer store target); BnbIter threads its `target` through to the metric. - LowestFee owns the change decision (dust_relay_feerate + drain_weights, no change_policy) and implements `drain`; its delta-aware not-target-met bound loop is preserved. - Changeless wraps an inner metric, using &SelectionView + target. All lib, integration and doc tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + README.md | 10 +- src/bnb.rs | 113 ++++---- src/coin_selector.rs | 314 ++-------------------- src/drain.rs | 6 +- src/lib.rs | 5 +- src/metrics/changeless.rs | 26 +- src/metrics/lowest_fee.rs | 61 +++-- src/selection_view.rs | 534 ++++++++++++++++++++++++++++++++++++++ tests/bnb.rs | 42 +-- tests/common.rs | 55 ++-- tests/lowest_fee.rs | 6 +- tests/weight.rs | 19 +- 13 files changed, 742 insertions(+), 450 deletions(-) create mode 100644 src/selection_view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b6efaf7..1470daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- **Breaking:** The read-only query methods (`selected_value`, `input_weight`, `weight`, `excess` and its `rate_`/`absolute_`/`replacement_` variants, `is_target_met`/`is_target_met_with_drain`, `fee`, `implied_fee`, `implied_feerate`, `missing`, `drain_value`, `drain`, `effective_value`, `waste`, `is_selection_possible`) move off `CoinSelector` onto the new public `SelectionView`, obtained via `CoinSelector::compute_view()` (e.g. `cs.excess(..)` becomes `cs.compute_view().excess(..)`). `is_selection_possible` is renamed to `SelectionView::is_target_reachable`. `BnbMetric::{score, bound, drain}` now receive `&SelectionView` instead of `&CoinSelector`. During branch-and-bound the view's running aggregates are maintained incrementally (delta-aware), so metric evaluation is O(1) per query instead of O(|selected|) — a large speedup at scale. - **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. diff --git a/README.md b/README.md index 098a027..256adee 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,10 @@ let candidates = vec![ let mut coin_selector = CoinSelector::new(&candidates); coin_selector.select(0); -assert!(!coin_selector.is_target_met(target), "we didn't select enough"); -println!("we didn't select enough yet we're missing: {}", coin_selector.missing(target)); +assert!(!coin_selector.compute_view().is_target_met(target), "we didn't select enough"); +println!("we didn't select enough yet we're missing: {}", coin_selector.compute_view().missing(target)); coin_selector.select(1); -assert!(coin_selector.is_target_met(target), "we should have enough now"); +assert!(coin_selector.compute_view().is_target_met(target), "we should have enough now"); // Now we need to know if we need a change output to drain the excess if we overshot too much // @@ -67,7 +67,7 @@ assert!(coin_selector.is_target_met(target), "we should have enough now"); let drain_weights = DrainWeights::TR_KEYSPEND; // Our policy is to only add a change output if the value is over 1_000 sats let change_policy = ChangePolicy::min_value(drain_weights, 1_000); -let change = coin_selector.drain(target, change_policy); +let change = coin_selector.compute_view().drain(target, change_policy); if change.is_some() { println!("We need to add our change output to the transaction with {} value", change.value); } else { @@ -153,7 +153,7 @@ let change = match coin_selector.run_bnb(target, metric, 100_000) { // fall back to naive selection coin_selector.select_until_target_met(target).expect("a selection was impossible!"); // the metric still decides the change output for whatever we end up selecting - metric.drain(&coin_selector, target) + metric.drain(&coin_selector.compute_view(), target) } Ok((score, change)) => { println!("we found a solution with score {}", score); diff --git a/src/bnb.rs b/src/bnb.rs index 0498e5c..f494689 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,6 +1,6 @@ use core::cmp::Reverse; -use crate::{float::Ordf32, Drain, Target}; +use crate::{float::Ordf32, Drain, SelectionCache, SelectionView, Target}; use super::CoinSelector; use alloc::collections::BinaryHeap; @@ -38,7 +38,10 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { // branch.selector, // !branch.is_exclusion, // branch.lower_bound, - // self.metric.score(&branch.selector), + // self.metric.score( + // &SelectionView::with_cache(&branch.selector, &branch.cache), + // self.target, + // ), // ); return None; } @@ -48,14 +51,23 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { // branch.selector, // !branch.is_exclusion, // branch.lower_bound, - // self.metric.score(&branch.selector), + // self.metric.score( + // &SelectionView::with_cache(&branch.selector, &branch.cache), + // self.target, + // ), // ); - let selector = branch.selector; + let Branch { + selector, + cache, + is_exclusion, + .. + } = branch; let mut return_val = None; - if !branch.is_exclusion { - if let Some(score) = self.metric.score(&selector, self.target) { + if !is_exclusion { + let view = SelectionView::with_cache(&selector, &cache); + if let Some(score) = self.metric.score(&view, self.target) { let better = match self.best { Some(best_score) => score < best_score, None => true, @@ -67,7 +79,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { }; } - self.insert_new_branches(&selector); + self.insert_new_branches(&selector, &cache); Some(return_val.map(|score| (selector, score))) } } @@ -85,81 +97,60 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { selector.sort_candidates_by_descending_value_pwu(); } - iter.consider_adding_to_queue(&selector, false); + let cache = SelectionCache::from_selector(&selector); + iter.consider_adding_to_queue(&selector, &cache, false); iter } - fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs, self.target); + fn consider_adding_to_queue( + &mut self, + cs: &CoinSelector<'a>, + cache: &SelectionCache, + is_exclusion: bool, + ) { + let bound = self + .metric + .bound(&SelectionView::with_cache(cs, cache), self.target); if let Some(bound) = bound { let is_good_enough = match self.best { Some(best) => best > bound, None => true, }; if is_good_enough { - let branch = Branch { + self.queue.push(Branch { lower_bound: bound, selector: cs.clone(), + cache: cache.clone(), is_exclusion, - }; - /*println!( - "\t\t(PUSH) branch={} inclusion={} lb={:?} score={:?}", - branch.selector, - !branch.is_exclusion, - branch.lower_bound, - self.metric.score(&branch.selector), - );*/ - self.queue.push(branch); - } /* else { - println!( - "\t\t( REJ) branch={} inclusion={} lb={:?} score={:?}", - cs, - !is_exclusion, - bound, - self.metric.score(cs), - ); - }*/ - } /*else { - println!( - "\t\t(NO B) branch={} inclusion={} score={:?}", - cs, - !is_exclusion, - self.metric.score(cs), - ); - }*/ + }); + } + } } - fn insert_new_branches(&mut self, cs: &CoinSelector<'a>) { + fn insert_new_branches(&mut self, cs: &CoinSelector<'a>, cache: &SelectionCache) { let (next_index, next) = match cs.unselected().next() { Some(c) => c, None => return, // exhausted }; + // Inclusion branch: selecting `next_index` requires updating the cache. let mut inclusion_cs = cs.clone(); + let mut inclusion_cache = cache.clone(); inclusion_cs.select(next_index); - self.consider_adding_to_queue(&inclusion_cs, false); + inclusion_cache.add(next); + self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false); - // for the exclusion branch, we keep banning if candidates have the same weight and value - let mut is_first_ban = true; + // Exclusion branch: only bans, no selection change → cache unchanged. let mut exclusion_cs = cs.clone(); let to_ban = (next.value, next.weight); - for (next_index, next) in cs.unselected() { - if (next.value, next.weight) != to_ban { + for (ban_index, ban_cand) in cs.unselected() { + if (ban_cand.value, ban_cand.weight) != to_ban { break; } - let (_index, _candidate) = exclusion_cs - .candidates() - .find(|(i, _)| *i == next_index) - .expect("must have index since we are planning to ban it"); - if is_first_ban { - is_first_ban = false; - } /*else { - println!("banning: [{}] {:?}", _index, _candidate); - }*/ - exclusion_cs.ban(next_index); + exclusion_cs.ban(ban_index); } - self.consider_adding_to_queue(&exclusion_cs, true); + self.consider_adding_to_queue(&exclusion_cs, cache, true); } } @@ -167,6 +158,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { struct Branch<'a> { lower_bound: Ordf32, selector: CoinSelector<'a>, + cache: SelectionCache, is_exclusion: bool, } @@ -201,11 +193,18 @@ impl Eq for Branch<'_> {} /// A branch and bound metric where we minimize the [`Ordf32`] score. /// /// This is to be used as input for [`CoinSelector::run_bnb`] or [`CoinSelector::bnb_solutions`]. +/// +/// Both [`score`](Self::score) and [`bound`](Self::bound) receive a +/// [`SelectionView`]: a read-only handle over the [`CoinSelector`] whose +/// `&self` methods (`view.selected_value`, `view.input_weight`, `view.excess`, +/// `view.is_target_met`, `view.drain`, ...) are O(1) because the underlying +/// running aggregates are maintained incrementally as BnB explores branches. +/// Use these methods rather than recomputing aggregates yourself. pub trait BnbMetric { /// Get the score of a given selection for `target`. /// /// If this returns `None`, the selection is invalid. - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn score(&mut self, view: &SelectionView<'_>, target: Target) -> Option; /// Get the lower bound score using a heuristic for `target`. /// @@ -214,13 +213,13 @@ pub trait BnbMetric { /// /// If this returns `None`, the current branch and all descendant branches will not have valid /// solutions. - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn bound(&mut self, view: &SelectionView<'_>, target: Target) -> Option; /// The change output (a.k.a. drain) this metric decides on for the given selection and `target`, /// or [`Drain::NONE`] if it decides there should be no change. /// /// Call this on a branch-and-bound solution to get the change output the metric optimized against. - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain; + fn drain(&mut self, view: &SelectionView<'_>, target: Target) -> Drain; /// Returns whether the metric requies we order candidates by descending value per weight unit. fn requires_ordering_by_descending_value_pwu(&self) -> bool { diff --git a/src/coin_selector.rs b/src/coin_selector.rs index f1b439b..2161149 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -1,7 +1,7 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; -use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; +use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, FeeRate, SelectionView, Target}; use alloc::{sync::Arc, vec::Vec}; /// [`CoinSelector`] selects/deselects coins from a set of canididate coins. @@ -111,206 +111,23 @@ impl<'a> CoinSelector<'a> { self.selected.contains(index) } - /// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output). - /// Note this will respect [`ban`]ned candidates. - /// - /// This simply selects all effective inputs at the target's feerate and checks whether we have - /// enough value. - /// - /// [`ban`]: Self::ban - pub fn is_selection_possible(&self, target: Target) -> bool { - let mut test = self.clone(); - test.select_all_effective(target.fee.rate); - test.is_target_met(target) - } - /// Returns true if no candidates have been selected. pub fn is_empty(&self) -> bool { self.selected.is_empty() } - /// The weight of the inputs including the witness header and the varint for the number of - /// inputs. - pub fn input_weight(&self) -> u64 { - let is_segwit_tx = self.selected().any(|(_, wv)| wv.is_segwit); - let witness_header_extra_weight = is_segwit_tx as u64 * 2; - - let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::(); - let input_varint_weight = varint_size(input_count) * 4; - - let selected_weight: u64 = self - .selected() - .map(|(_, candidate)| { - let mut weight = candidate.weight; - if is_segwit_tx && !candidate.is_segwit { - // non-segwit candidates do not have the witness length field included in their - // weight field so we need to add 1 here if it's in a segwit tx. - weight += 1; - } - weight - }) - .sum(); - - input_varint_weight + selected_weight + witness_header_extra_weight - } - - /// Absolute value sum of all selected inputs. - pub fn selected_value(&self) -> u64 { - self.selected - .iter() - .map(|index| self.candidates[index].value) - .sum() - } - - /// Current weight of transaction implied by the selection. + /// Compute a [`SelectionView`] over the current selection. /// - /// If you don't have any drain outputs (only target outputs) just set drain_weights to - /// [`DrainWeights::NONE`]. - pub fn weight(&self, target_ouputs: TargetOutputs, drain_weight: DrainWeights) -> u64 { - TX_FIXED_FIELD_WEIGHT - + self.input_weight() - + target_ouputs.output_weight_with_drain(drain_weight) - } - - /// How much the current selection overshoots the value needed to achieve `target`. - /// - /// In order for the resulting transaction to be valid this must be 0 or above. If it's above 0 - /// this means the transaction will overpay for what it needs to reach `target`. - pub fn excess(&self, target: Target, drain: Drain) -> i64 { - self.rate_excess(target, drain) - .min(self.absolute_excess(target, drain)) - .min(self.replacement_excess(target, drain)) - } - - /// How much extra value needs to be selected to reach the target. - pub fn missing(&self, target: Target) -> u64 { - let excess = self.excess(target, Drain::NONE); - if excess < 0 { - excess.unsigned_abs() - } else { - 0 - } - } - - /// How much the current selection overshoots the value need to satisfy `target.fee.rate` and - /// `target.value` (while ignoring `target.fee.absolute`). - pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate(target, drain.weights) as i64 - } - - /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the - /// implied transaction's weight units directly without any conversion to vbytes. - pub fn rate_excess_wu(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 - } - - /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` - /// and `target.value` (while ignoring `target.fee.rate`). - pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - target.fee.absolute as i64 - } - - /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. - pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) - } - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee - /// is calculated using weight units directly without any conversion to vbytes. - pub fn replacement_excess_wu(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain.weights)) - } - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// The feerate the transaction would have if we were to use this selection of inputs to achieve - /// the `target`'s value and weight. It is essentially telling you what target feerate you currently have. - /// - /// Returns `None` if the feerate would be negative or infinity. - pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { - let numerator = - self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; - let denom = self.weight(target_outputs, drain.weights); - if numerator < 0 || denom == 0 { - return None; - } - Some(FeeRate::from_sat_per_wu(numerator as f32 / denom as f32)) - } - - /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. - /// - /// This compares the fee calculated from the target feerate with the fee calculated from the - /// [`Replace`] constraints and returns the larger of the two. - /// - /// `drain_weight` can be 0 to indicate no draining output. - pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { - let mut implied_fee = self - .implied_fee_from_feerate(target, drain_weights) - .max(target.fee.absolute); - - if let Some(replace) = target.fee.replace { - implied_fee = Ord::max( - implied_fee, - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)), - ); - } - - implied_fee - } - - fn implied_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target - .fee - .rate - .implied_fee(self.weight(target.outputs, drain_weights)) - } - - fn implied_fee_from_feerate_wu(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target - .fee - .rate - .implied_fee_wu(self.weight(target.outputs, drain_weights)) - } - - /// The actual fee the selection would pay if it was used in a transaction that had - /// `target_value` value for outputs and change output of `drain_value`. + /// The returned view scans the selected bitset once (O(n/64 + |selected|), + /// where n is the candidate count) to build its running aggregates, then + /// exposes O(1) accessors for `selected_value`, `input_weight`, `excess`, + /// `is_target_met`, `drain`, etc. /// - /// This can be negative when the selection is invalid (outputs are greater than inputs). - pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { - self.selected_value() as i64 - target_value as i64 - drain_value as i64 - } - - /// The value of the current selected inputs minus the fee needed to pay for the selected inputs - pub fn effective_value(&self, feerate: FeeRate) -> i64 { - self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 - } - - // /// Waste sum of all selected inputs. - fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { - self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) + /// Use this for ad-hoc queries on a selection. Inside a BnB metric you + /// already receive a view (whose aggregates are maintained incrementally + /// across branches); call this only outside that hot path. + pub fn compute_view(&self) -> SelectionView<'_> { + SelectionView::from_selector(self) } /// Sorts the candidates by the comparision function. @@ -355,39 +172,6 @@ impl<'a> CoinSelector<'a> { }); } - /// The waste created by the current selection as measured by the [waste metric]. - /// - /// You can pass in an `excess_discount` which must be between `0.0..1.0`. Passing in `1.0` gives you no discount - /// - /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection - pub fn waste( - &self, - target: Target, - long_term_feerate: FeeRate, - drain: Drain, - excess_discount: f32, - ) -> f32 { - debug_assert!((0.0..=1.0).contains(&excess_discount)); - let mut waste = self.input_waste(target.fee.rate, long_term_feerate); - - if drain.is_none() { - // We don't allow negative excess waste since negative excess just means you haven't - // satisified target yet in which case you probably shouldn't be calling this function. - let mut excess_waste = self.excess(target, drain).max(0) as f32; - // we allow caller to discount this waste depending on how wasteful excess actually is - // to them. - excess_waste *= excess_discount.clamp(0.0, 1.0); - waste += excess_waste; - } else { - waste += - drain - .weights - .waste(target.fee.rate, long_term_feerate, target.outputs.n_outputs); - } - - waste - } - /// The selected candidates with their index. pub fn selected( &self, @@ -429,22 +213,6 @@ impl<'a> CoinSelector<'a> { self.unselected_indices().next().is_none() } - /// Whether the constraints of `Target` have been met if we include a specific `drain` ouput. - /// - /// Note if [`is_target_met`] is true and the `drain` is produced from the [`drain`] method then - /// this method will also always be true. - /// - /// [`is_target_met`]: Self::is_target_met - /// [`drain`]: Self::drain - pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool { - self.excess(target, drain) >= 0 - } - - /// Whether the constraints of `Target` have been met. - pub fn is_target_met(&self, target: Target) -> bool { - self.is_target_met_with_drain(target, Drain::NONE) - } - /// Select all unselected candidates pub fn select_all(&mut self) { loop { @@ -454,57 +222,6 @@ impl<'a> CoinSelector<'a> { } } - /// The value of the change output should have to drain the excess value while maintaining the - /// constraints of `target` and respecting `change_policy`. - /// - /// If not change output should be added according to policy then it will return `None`. - pub fn drain_value(&self, target: Target, change_policy: ChangePolicy) -> Option { - let excess = self.excess( - target, - Drain { - weights: change_policy.drain_weights, - value: 0, - }, - ); - if excess > change_policy.min_value as i64 { - debug_assert_eq!( - self.is_target_met(target), - self.is_target_met_with_drain( - target, - Drain { - weights: change_policy.drain_weights, - value: excess as u64 - } - ), - "if the target is met without a drain it must be met after adding the drain" - ); - Some(excess as u64) - } else { - None - } - } - - /// Figures out whether the current selection should have a change output given the - /// `change_policy`. If it should not, then it will return [`Drain::NONE`]. The value of the - /// `Drain` will be the same as [`drain_value`]. - /// - /// If [`is_target_met`] returns true for this selection then [`is_target_met_with_drain`] will - /// also be true if you pass in the drain returned from this method. - /// - /// [`drain_value`]: Self::drain_value - /// [`is_target_met_with_drain`]: Self::is_target_met_with_drain - /// [`is_target_met`]: Self::is_target_met - #[must_use] - pub fn drain(&self, target: Target, change_policy: ChangePolicy) -> Drain { - match self.drain_value(target, change_policy) { - Some(value) => Drain { - weights: change_policy.drain_weights, - value, - }, - None => Drain::NONE, - } - } - /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. /// /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. @@ -525,9 +242,12 @@ impl<'a> CoinSelector<'a> { /// /// Returns an error if the target was unable to be met. pub fn select_until_target_met(&mut self, target: Target) -> Result<(), InsufficientFunds> { - self.select_until(|cs| cs.is_target_met(target)) + self.select_until(|cs| cs.compute_view().is_target_met(target)) .ok_or_else(|| InsufficientFunds { - missing: self.excess(target, Drain::NONE).unsigned_abs(), + missing: self + .compute_view() + .excess(target, Drain::NONE) + .unsigned_abs(), }) } @@ -590,7 +310,7 @@ impl<'a> CoinSelector<'a> { .flatten() .last(); let (selector, score) = best.ok_or(NoBnbSolution { max_rounds, rounds })?; - let drain = iter.metric.drain(&selector, target); + let drain = iter.metric.drain(&selector.compute_view(), target); *self = selector; Ok((score, drain)) } diff --git a/src/drain.rs b/src/drain.rs index 98067ef..35e02ff 100644 --- a/src/drain.rs +++ b/src/drain.rs @@ -70,10 +70,10 @@ impl DrainWeights { /// A drain (A.K.A. change) output. /// Technically it could represent multiple outputs. /// -/// This is returned from [`CoinSelector::drain`]. Note if `drain` returns a drain where `is_none()` -/// returns true then **no change should be added** to the transaction. +/// This is returned from [`SelectionView::drain`]. Note if `drain` returns a drain where +/// `is_none()` returns true then **no change should be added** to the transaction. /// -/// [`CoinSelector::drain`]: crate::CoinSelector::drain +/// [`SelectionView::drain`]: crate::SelectionView::drain #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub struct Drain { /// Weight of adding drain output and spending the drain output. diff --git a/src/lib.rs b/src/lib.rs index 34c86ad..531368c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,9 @@ pub use bitset::*; mod coin_selector; pub mod float; pub use coin_selector::*; +mod selection_view; +use selection_view::SelectionCache; +pub use selection_view::SelectionView; mod bnb; pub use bnb::*; @@ -59,7 +62,7 @@ pub const TR_KEYSPEND_TXIN_WEIGHT: u64 = TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFAC pub const TR_DUST_RELAY_MIN_VALUE: u64 = 330; /// Helper to calculate varint size. `v` is the value the varint represents. -const fn varint_size(v: usize) -> u64 { +pub(crate) const fn varint_size(v: usize) -> u64 { if v <= 0xfc { return 1; } diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index a9c9e32..d5d3892 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -1,4 +1,4 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; +use crate::{bnb::BnbMetric, float::Ordf32, Drain, SelectionView, Target}; /// Constrains an `inner` metric to only changeless solutions. /// @@ -26,50 +26,50 @@ impl Changeless { /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. /// /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu - fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool { - if self.0.drain(cs, target).is_none() { + fn change_unavoidable(&mut self, view: &SelectionView<'_>, target: Target) -> bool { + if self.0.drain(view, target).is_none() { return false; } - let mut least_excess = cs.clone(); - cs.unselected() + let mut least_excess = view.selector().clone(); + view.unselected() .rev() .take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0) .for_each(|(index, _)| { least_excess.select(index); }); - self.0.drain(&least_excess, target).is_some() + self.0.drain(&least_excess.compute_view(), target).is_some() } } impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _view: &SelectionView<'_>, _target: Target) -> Drain { // by definition a changeless selection never has a change output Drain::NONE } - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn score(&mut self, view: &SelectionView<'_>, target: Target) -> Option { // Reject selections that have change. We don't need an explicit target-met check: `inner` // returns `None` for invalid (e.g. not-target-met) selections. // // NOTE: for metrics whose `score` recomputes the drain (e.g. `LowestFee`), this evaluates // the drain decision twice per node. Sharing it would mean threading the drain into // `score`, which we avoid to keep metrics composable. - if self.0.drain(cs, target).is_some() { + if self.0.drain(view, target).is_some() { return None; } - self.0.score(cs, target) + self.0.score(view, target) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if self.change_unavoidable(cs, target) { + fn bound(&mut self, view: &SelectionView<'_>, target: Target) -> Option { + if self.change_unavoidable(view, target) { // every descendant has change, so no changeless solution is reachable None } else { // the changeless-constrained optimum is no better than the inner metric's unconstrained // optimum, so the inner bound is a valid lower bound - self.0.bound(cs, target) + self.0.bound(view, target) } } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 744bdd0..2682e7d 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -1,4 +1,4 @@ -use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate, Target}; +use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionView, Target}; /// Metric that aims to minimize transaction fees. The future fee for spending the change output is /// included in this calculation. @@ -27,10 +27,10 @@ pub struct LowestFee { impl LowestFee { /// The value the change output should have, or `None` if this selection should be changeless. - fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn drain_value(&self, view: &SelectionView<'_>, target: Target) -> Option { // The change output pays for its own weight, so the value we'd actually recover is the // excess remaining after accounting for that weight. - let excess_with_drain_weight = cs.excess( + let excess_with_drain_weight = view.excess( target, Drain { weights: self.drain_weights, @@ -58,22 +58,22 @@ impl LowestFee { } impl BnbMetric for LowestFee { - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain { - self.drain_value(cs, target) + fn drain(&mut self, view: &SelectionView<'_>, target: Target) -> Drain { + self.drain_value(view, target) .map_or(Drain::NONE, |value| Drain { weights: self.drain_weights, value, }) } - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if !cs.is_target_met(target) { + fn score(&mut self, view: &SelectionView<'_>, target: Target) -> Option { + if !view.is_target_met(target) { return None; } let long_term_fee = { - let drain = self.drain(cs, target); - let fee_for_the_tx = cs.fee(target.value(), drain.value); + let drain = self.drain(view, target); + let fee_for_the_tx = view.fee(target.value(), drain.value); assert!( fee_for_the_tx >= 0, "must not be called unless selection has met target: fee={}", @@ -88,9 +88,9 @@ impl BnbMetric for LowestFee { Some(Ordf32(long_term_fee as f32)) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if cs.is_target_met(target) { - let current_score = self.score(cs, target).unwrap(); + fn bound(&mut self, view: &SelectionView<'_>, target: Target) -> Option { + if view.is_target_met(target) { + let current_score = self.score(view, target).unwrap(); // `current_score` is already a valid lower bound for a selection that has change: a // descendant can never lower the fee by removing an existing (worthwhile) change @@ -111,7 +111,7 @@ impl BnbMetric for LowestFee { // `drain_value`, where `change_value` is `excess_with_drain_weight` and `spend_fee` is // `drain_spend_cost`). With `v >= 0` the difference is strictly positive: B always // costs more. - if self.drain_value(cs, target).is_none() { + if self.drain_value(view, target).is_none() { // But a descendant might *add* a change output that improves the metric. This // happens when the current selection is changeless only because the change would be // dust: a descendant with more excess could clear the dust threshold and recover @@ -121,7 +121,7 @@ impl BnbMetric for LowestFee { self.long_term_feerate, target.outputs.n_outputs, ); - let cost_of_no_change = cs.excess(target, Drain::NONE); + let cost_of_no_change = view.excess(target, Drain::NONE); let best_score_with_change = Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change); @@ -132,17 +132,26 @@ impl BnbMetric for LowestFee { Some(current_score) } else { - // Step 1: select everything up until the input that hits the target. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .find(|(cs, _, _)| cs.is_target_met(target))?; + // Step 1: account for additional candidates one at a time in the + // cache of a cloned view, until the target is met. We advance a + // single iterator over the original selector's unselected + // candidates rather than re-querying it. + let mut local = view.clone(); + let mut unselected = view.unselected(); + let to_resize = loop { + let (_idx, cand) = unselected.next()?; + local.add(cand); + if local.is_target_met(target) { + break cand; + } + }; // If this selection is already perfect, return its score directly. - if cs.excess(target, Drain::NONE) == 0 { - return Some(self.score(&cs, target).unwrap()); + if local.excess(target, Drain::NONE) == 0 { + return Some(self.score(&local, target).unwrap()); }; - cs.deselect(resize_index); + local.sub(to_resize); + let local_view = &local; // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do // this by imagining we had a perfect input that perfectly hit the target. The sats per @@ -161,7 +170,7 @@ impl BnbMetric for LowestFee { // // In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to // vbytes and so all fee calculations below are performed on weight units directly. - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; + let rate_excess = local_view.rate_excess_wu(target, Drain::NONE) as f32; let mut scale = Ordf32(0.0); if rate_excess < 0.0 { @@ -179,7 +188,7 @@ impl BnbMetric for LowestFee { // We can use the same approach for replacement we just have to use the // incremental_relay_feerate. if let Some(replace) = target.fee.replace { - let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; + let replace_excess = local_view.replacement_excess_wu(target, Drain::NONE) as f32; if replace_excess < 0.0 { let remaining_value_to_reach_feerate = replace_excess.abs(); let effective_value_of_resized_input = @@ -196,7 +205,7 @@ impl BnbMetric for LowestFee { // Handle absolute fee constraint. Unlike feerate and replacement, the // absolute fee is a fixed amount (not weight-proportional), so we just // need enough raw value to cover the gap. - let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32; + let absolute_excess = local_view.absolute_excess(target, Drain::NONE) as f32; if absolute_excess < 0.0 { let remaining = absolute_excess.abs(); if to_resize.value > 0 { @@ -209,7 +218,7 @@ impl BnbMetric for LowestFee { // `scale` could be 0 even if `is_target_met` is `false` due to the latter being based on // rounded-up vbytes. - let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32 + let ideal_fee = scale.0 * to_resize.value as f32 + local_view.selected_value() as f32 - target.value() as f32; assert!(ideal_fee >= 0.0); diff --git a/src/selection_view.rs b/src/selection_view.rs new file mode 100644 index 0000000..1ad3ecd --- /dev/null +++ b/src/selection_view.rs @@ -0,0 +1,534 @@ +//! [`SelectionView`] — a read-only, O(1)-queryable view over a +//! [`CoinSelector`]'s current selection. +//! +//! BnB maintains an internal cache of running aggregates so that +//! [`crate::BnbMetric`] implementations can answer queries like +//! "what's the selected value?" or "what's the selection's input weight?" +//! in O(1) rather than walking the bitset each time. Outside BnB, callers +//! get a view via [`CoinSelector::compute_view`]. + +use alloc::borrow::Cow; +use core::ops::Deref; + +#[allow(unused)] // needed for `f32::ceil` under no_std; std provides it inherently +use crate::float::FloatExt; +use crate::{ + varint_size, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, Target, + TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; + +/// Running aggregates over a selection. Internal to the crate — external +/// callers go through [`SelectionView`] via [`CoinSelector::compute_view`]. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct SelectionCache { + value_sum: u64, + weight_sum: u64, + input_count: usize, + segwit_count: usize, + candidate_count: usize, +} + +impl SelectionCache { + /// Build a cache by walking the selector's currently selected candidates. + /// + /// O(|selected|). After this, prefer [`add`](Self::add) / [`sub`](Self::sub) + /// to keep it in sync rather than rebuilding from scratch. + pub(crate) fn from_selector(cs: &CoinSelector<'_>) -> Self { + let mut c = Self::default(); + for (_, cand) in cs.selected() { + c.add(cand); + } + c + } + + /// Equivalent to `CoinSelector::input_weight`, computed in O(1) from the + /// cached aggregates. + fn input_weight(&self) -> u64 { + let is_segwit_tx = self.segwit_count > 0; + let witness_header_extra_weight = is_segwit_tx as u64 * 2; + let nonsegwit_count = self.candidate_count - self.segwit_count; + let segwit_adjust = if is_segwit_tx { + nonsegwit_count as u64 + } else { + 0 + }; + let input_varint_weight = varint_size(self.input_count) * 4; + input_varint_weight + self.weight_sum + segwit_adjust + witness_header_extra_weight + } + + /// Apply the effect of selecting `candidate`. + pub(crate) fn add(&mut self, candidate: Candidate) { + self.value_sum += candidate.value; + self.weight_sum += candidate.weight; + self.input_count += candidate.input_count; + if candidate.is_segwit { + self.segwit_count += 1; + } + self.candidate_count += 1; + } + + /// Apply the effect of deselecting `candidate`. + pub(crate) fn sub(&mut self, candidate: Candidate) { + self.value_sum -= candidate.value; + self.weight_sum -= candidate.weight; + self.input_count -= candidate.input_count; + if candidate.is_segwit { + self.segwit_count -= 1; + } + self.candidate_count -= 1; + } +} + +/// Read-only view of a [`CoinSelector`]'s current selection, with O(1) +/// accessors for `selected_value`, `input_weight`, `excess`, `is_target_met`, +/// `drain`, and friends. +/// +/// Obtained via [`CoinSelector::compute_view`] for ad-hoc queries, or as the +/// argument to [`BnbMetric::score`](crate::BnbMetric::score) / +/// [`BnbMetric::bound`](crate::BnbMetric::bound) in the BnB hot path. +/// +/// Methods on this type read pre-computed aggregates rather than walking the +/// selected bitset, so they are constant-time (except +/// [`is_target_reachable`](Self::is_target_reachable), which iterates the +/// unselected candidates). During branch-and-bound search, the cache is +/// maintained incrementally as branches are explored, which is what makes the +/// metric evaluator "delta-aware". +/// +/// `SelectionView` implements [`Deref`](Deref), so every +/// `&self` method of [`CoinSelector`] is reachable directly on the view. +/// Mutating methods take `&mut self` and are *not* reachable through `Deref`, +/// so the view stays read-only. +#[derive(Clone, Debug)] +pub struct SelectionView<'a> { + selector: &'a CoinSelector<'a>, + cache: Cow<'a, SelectionCache>, +} + +impl<'a> Deref for SelectionView<'a> { + type Target = CoinSelector<'a>; + fn deref(&self) -> &CoinSelector<'a> { + self.selector + } +} + +impl<'a> SelectionView<'a> { + /// Construct a view that borrows an externally maintained cache. Caller is + /// responsible for keeping the cache in sync with `selector`'s selection. + pub(crate) fn with_cache(selector: &'a CoinSelector<'a>, cache: &'a SelectionCache) -> Self { + Self { + selector, + cache: Cow::Borrowed(cache), + } + } + + /// Construct a view by building a fresh cache from the selector. Used by + /// [`CoinSelector::compute_view`]; O(|selected|) one-time cache build. + pub(crate) fn from_selector(selector: &'a CoinSelector<'a>) -> Self { + Self { + selector, + cache: Cow::Owned(SelectionCache::from_selector(selector)), + } + } + + /// Access the underlying [`CoinSelector`] reference for cases where the + /// `Deref` impl doesn't suffice (e.g. cloning the selector, or passing it + /// where a `&'a CoinSelector` with the original lifetime is required). + pub fn selector(&self) -> &'a CoinSelector<'a> { + self.selector + } + + fn cache(&self) -> &SelectionCache { + &self.cache + } + + /// Update the cached aggregates as if `cand` were selected, without + /// modifying the underlying [`CoinSelector`]. Useful for exploring + /// hypothetical extensions of the current selection. + /// + /// On a borrowed view this triggers a one-time cache clone (via + /// [`Cow::to_mut`](alloc::borrow::Cow::to_mut)); subsequent calls mutate + /// in place. The view's selector-iteration methods (e.g. `unselected()`) + /// still reflect the original selection, so advance a single iterator + /// across the loop rather than re-creating one per step. + pub fn add(&mut self, cand: Candidate) { + self.cache.to_mut().add(cand); + } + + /// Update the cached aggregates as if `cand` were deselected. See + /// [`add`](Self::add) for caveats. + pub fn sub(&mut self, cand: Candidate) { + self.cache.to_mut().sub(cand); + } + + /// Absolute value sum of all selected inputs. + pub fn selected_value(&self) -> u64 { + self.cache().value_sum + } + + /// The weight of the inputs including the witness header and the varint + /// for the number of inputs. + pub fn input_weight(&self) -> u64 { + self.cache().input_weight() + } + + /// Current weight of transaction implied by the selection. + /// + /// If you don't have any drain outputs (only target outputs) just set + /// `drain_weight` to [`DrainWeights::NONE`]. + pub fn weight(&self, target_outputs: TargetOutputs, drain_weight: DrainWeights) -> u64 { + TX_FIXED_FIELD_WEIGHT + + self.input_weight() + + target_outputs.output_weight_with_drain(drain_weight) + } + + /// How much the current selection overshoots the value need to satisfy + /// `target.fee.rate` and `target.value` (while ignoring + /// `target.fee.absolute`). + pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - target + .fee + .rate + .implied_fee(self.weight(target.outputs, drain.weights)) as i64 + } + + /// Same as [`rate_excess`](Self::rate_excess) except `target.fee.rate` is + /// applied to the implied transaction's weight units directly without any + /// conversion to vbytes. + pub fn rate_excess_wu(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - target + .fee + .rate + .implied_fee_wu(self.weight(target.outputs, drain.weights)) as i64 + } + + /// How much the current selection overshoots the value needed to satisfy + /// `target.fee.absolute` and `target.value` (while ignoring + /// `target.fee.rate`). + pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - target.fee.absolute as i64 + } + + /// How much the current selection overshoots the value needed to satisfy + /// RBF's rule 4. + pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { + let mut replacement_excess_needed = 0; + if let Some(replace) = target.fee.replace { + replacement_excess_needed = + replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) + } + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - replacement_excess_needed as i64 + } + + /// Same as [`replacement_excess`](Self::replacement_excess) except the + /// replacement fee is calculated using weight units directly without any + /// conversion to vbytes. + pub fn replacement_excess_wu(&self, target: Target, drain: Drain) -> i64 { + let mut replacement_excess_needed = 0; + if let Some(replace) = target.fee.replace { + replacement_excess_needed = + replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain.weights)) + } + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - replacement_excess_needed as i64 + } + + /// How much the current selection overshoots the value needed to achieve + /// `target`. + /// + /// In order for the resulting transaction to be valid this must be 0 or + /// above. If it's above 0 the transaction will overpay for what it needs + /// to reach `target`. + pub fn excess(&self, target: Target, drain: Drain) -> i64 { + self.rate_excess(target, drain) + .min(self.absolute_excess(target, drain)) + .min(self.replacement_excess(target, drain)) + } + + /// Whether the constraints of `target` have been met if we include a + /// specific `drain` output. + /// + /// If [`is_target_met`](Self::is_target_met) is true and `drain` is + /// produced by [`drain`](Self::drain) for this selection, this method will + /// also be true. + pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool { + self.excess(target, drain) >= 0 + } + + /// Whether the constraints of `target` have been met. + pub fn is_target_met(&self, target: Target) -> bool { + self.is_target_met_with_drain(target, Drain::NONE) + } + + /// Whether `target` could be met by selecting more effective candidates on + /// top of the current selection, respecting + /// [`banned`](CoinSelector::banned) candidates. + /// + /// Greedily accounts for every additional effective candidate at the + /// target's feerate in a cloned view's cache, then checks whether the + /// target is met. Unlike the other methods on this type, this is + /// O(|unselected|), not O(1). + pub fn is_target_reachable(&self, target: Target) -> bool { + let mut local = self.clone(); + let feerate = target.fee.rate; + for (_, cand) in self.unselected() { + if cand.effective_value(feerate) > 0.0 { + local.add(cand); + } + } + local.is_target_met(target) + } + + /// How much extra value needs to be selected to reach the target. + pub fn missing(&self, target: Target) -> u64 { + let excess = self.excess(target, Drain::NONE); + if excess < 0 { + excess.unsigned_abs() + } else { + 0 + } + } + + /// The actual fee the selection would pay if it was used in a transaction + /// with `target_value` value for outputs and a change output of + /// `drain_value`. + /// + /// Can be negative when the selection is invalid (outputs greater than + /// inputs). + pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { + self.selected_value() as i64 - target_value as i64 - drain_value as i64 + } + + /// The fee the current selection and `drain_weights` should pay to satisfy + /// `target.fee`. + /// + /// This compares the fee calculated from the target feerate with the fee + /// calculated from the [`Replace`](crate::Replace) constraints and returns + /// the larger of the two. + pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { + let tx_weight = self.weight(target.outputs, drain_weights); + let mut implied_fee = target + .fee + .rate + .implied_fee(tx_weight) + .max(target.fee.absolute); + if let Some(replace) = target.fee.replace { + implied_fee = Ord::max(implied_fee, replace.min_fee_to_do_replacement(tx_weight)); + } + implied_fee + } + + /// The feerate the transaction would have if we were to use this selection + /// of inputs to achieve the `target`'s value and weight. It is essentially + /// telling you what target feerate you currently have. + /// + /// Returns `None` if the feerate would be negative or infinity. + pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { + let numerator = + self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; + let denom = self.weight(target_outputs, drain.weights); + if numerator < 0 || denom == 0 { + return None; + } + Some(FeeRate::from_sat_per_wu(numerator as f32 / denom as f32)) + } + + /// The value of the current selected inputs minus the fee needed to pay + /// for them at `feerate`. + pub fn effective_value(&self, feerate: FeeRate) -> i64 { + self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 + } + + /// The value the change output should have to drain the excess while + /// maintaining the constraints of `target` and respecting `change_policy`. + /// + /// Returns `None` if no change output should be added according to the + /// policy. + pub fn drain_value(&self, target: Target, change_policy: ChangePolicy) -> Option { + let excess = self.excess( + target, + Drain { + weights: change_policy.drain_weights, + value: 0, + }, + ); + if excess > change_policy.min_value as i64 { + Some(excess as u64) + } else { + None + } + } + + /// Figures out whether the current selection should have a change output + /// given the `change_policy`. If it should not, returns [`Drain::NONE`]. + /// Otherwise the returned drain has the value of + /// [`drain_value`](Self::drain_value). + /// + /// If [`is_target_met`](Self::is_target_met) is true for this selection + /// then [`is_target_met_with_drain`](Self::is_target_met_with_drain) will + /// also be true with the drain returned here. + #[must_use] + pub fn drain(&self, target: Target, change_policy: ChangePolicy) -> Drain { + match self.drain_value(target, change_policy) { + Some(value) => Drain { + weights: change_policy.drain_weights, + value, + }, + None => Drain::NONE, + } + } + + /// The waste created by the current selection as measured by the + /// [waste metric]. + /// + /// `excess_discount` must be between `0.0..=1.0`; passing `1.0` gives no + /// discount. + /// + /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection + pub fn waste( + &self, + target: Target, + long_term_feerate: FeeRate, + drain: Drain, + excess_discount: f32, + ) -> f32 { + debug_assert!((0.0..=1.0).contains(&excess_discount)); + let mut waste = + self.input_weight() as f32 * (target.fee.rate.spwu() - long_term_feerate.spwu()); + + if drain.is_none() { + // We don't allow negative excess waste since negative excess just means you haven't + // satisified target yet in which case you probably shouldn't be calling this function. + let mut excess_waste = self.excess(target, drain).max(0) as f32; + // we allow caller to discount this waste depending on how wasteful excess actually is + // to them. + excess_waste *= excess_discount.clamp(0.0, 1.0); + waste += excess_waste; + } else { + waste += + drain + .weights + .waste(target.fee.rate, long_term_feerate, target.outputs.n_outputs); + } + + waste + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Candidate, CoinSelector, TXIN_BASE_WEIGHT}; + use proptest::prelude::*; + + /// The original O(n) `CoinSelector::input_weight` definition, kept verbatim as an + /// *independent* oracle for the cache's O(1) formula: `from_selector` is itself built on + /// [`SelectionCache::add`], so comparing the incremental cache against a rebuilt one alone + /// would validate the formula against itself. + fn input_weight_oracle(cs: &CoinSelector<'_>) -> u64 { + let is_segwit_tx = cs.selected().any(|(_, wv)| wv.is_segwit); + let witness_header_extra_weight = is_segwit_tx as u64 * 2; + + let input_count = cs.selected().map(|(_, wv)| wv.input_count).sum::(); + let input_varint_weight = varint_size(input_count) * 4; + + let selected_weight: u64 = cs + .selected() + .map(|(_, candidate)| { + let mut weight = candidate.weight; + if is_segwit_tx && !candidate.is_segwit { + // non-segwit candidates do not have the witness length field included in + // their weight field so we need to add 1 here if it's in a segwit tx. + weight += 1; + } + weight + }) + .sum(); + + input_varint_weight + selected_weight + witness_header_extra_weight + } + + fn synth_candidates(values: &[u64]) -> alloc::vec::Vec { + values + .iter() + .enumerate() + .map(|(i, &v)| Candidate { + value: v, + weight: TXIN_BASE_WEIGHT + 107, + input_count: 1 + (i % 3), + // Mix segwit and non-segwit to exercise the adjustment. + is_segwit: i % 2 == 0, + }) + .collect() + } + + #[test] + fn empty_cache_matches_empty_selector() { + let candidates: alloc::vec::Vec = alloc::vec::Vec::new(); + let cs = CoinSelector::new(&candidates); + let cache = SelectionCache::from_selector(&cs); + assert_eq!(cache.value_sum, cs.compute_view().selected_value()); + assert_eq!(cache.input_weight(), cs.compute_view().input_weight()); + } + + #[test] + fn add_matches_select() { + let candidates = synth_candidates(&[100, 200, 300, 400, 500]); + let mut cs = CoinSelector::new(&candidates); + let mut cache = SelectionCache::default(); + for i in [0, 2, 4] { + cs.select(i); + cache.add(candidates[i]); + assert_eq!(cache.value_sum, cs.compute_view().selected_value()); + assert_eq!(cache.input_weight(), cs.compute_view().input_weight()); + } + } + + proptest! { + /// Random sequence of select/deselect: incremental cache must match + /// `SelectionCache::from_selector` rebuilt from scratch. + #[test] + fn matches_from_scratch_under_random_ops( + values in prop::collection::vec(1u64..1_000_000, 1..32), + ops in prop::collection::vec((any::(), 0usize..32), 0..200), + ) { + let candidates = synth_candidates(&values); + let mut cs = CoinSelector::new(&candidates); + let mut cache = SelectionCache::default(); + for (is_select, raw_idx) in ops { + let idx = raw_idx % candidates.len(); + if is_select { + if cs.select(idx) { + cache.add(candidates[idx]); + } + } else if cs.deselect(idx) { + cache.sub(candidates[idx]); + } + let rebuilt = SelectionCache::from_selector(&cs); + prop_assert_eq!(&cache, &rebuilt); + prop_assert_eq!(cache.value_sum, cs.compute_view().selected_value()); + prop_assert_eq!(cache.input_weight(), cs.compute_view().input_weight()); + // Independent oracles: straight sums/walks over the selection, sharing no + // code with `SelectionCache`. + prop_assert_eq!( + cache.value_sum, + cs.selected().map(|(_, c)| c.value).sum::() + ); + prop_assert_eq!(cache.input_weight(), input_weight_oracle(&cs)); + } + } + } +} diff --git a/tests/bnb.rs b/tests/bnb.rs index 3e65022..10b14d1 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -1,6 +1,7 @@ mod common; use bdk_coin_select::{ - float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, Target, TargetFee, TargetOutputs, + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, SelectionView, Target, TargetFee, + TargetOutputs, }; #[macro_use] extern crate alloc; @@ -32,24 +33,24 @@ struct MinExcessThenWeight; const EXCESS_RATIO: f32 = 1_000_000_f32; impl BnbMetric for MinExcessThenWeight { - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let excess = cs.excess(target, Drain::NONE); + fn score(&mut self, view: &SelectionView<'_>, target: Target) -> Option { + let excess = view.excess(target, Drain::NONE); if excess < 0 { None } else { Some(Ordf32( - excess as f32 * EXCESS_RATIO + cs.input_weight() as f32, + excess as f32 * EXCESS_RATIO + view.input_weight() as f32, )) } } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let mut cs = cs.clone(); + fn bound(&mut self, view: &SelectionView<'_>, target: Target) -> Option { + let mut cs = view.selector().clone(); cs.select_until_target_met(target).ok()?; - Some(Ordf32(cs.input_weight() as f32)) + Some(Ordf32(cs.compute_view().input_weight() as f32)) } - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _view: &SelectionView<'_>, _target: Target) -> Drain { Drain::NONE } } @@ -71,7 +72,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let solution_weight = { let mut cs = CoinSelector::new(&solution); cs.select_all(); - cs.input_weight() + cs.compute_view().input_weight() }; let target_value = solution.iter().map(|c| c.value).sum(); @@ -103,8 +104,14 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .expect("it found a solution"); assert_eq!(rounds, 3194); - assert_eq!(best.input_weight(), solution_weight); - assert_eq!(best.selected_value(), target_value, "score={:?}", score); + let best_view = best.compute_view(); + assert_eq!(best_view.input_weight(), solution_weight); + assert_eq!( + best_view.selected_value(), + target_value, + "score={:?}", + score + ); } #[test] @@ -137,7 +144,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .expect("found a solution"); assert_eq!(rounds, 164); - let excess = sol.excess(target, Drain::NONE); + let excess = sol.compute_view().excess(target, Drain::NONE); assert_eq!(excess, 0); } @@ -157,8 +164,8 @@ proptest! { let solutions = cs.bnb_solutions(target, MinExcessThenWeight); match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { - Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target_value), - _ => prop_assert!(!cs.is_selection_possible(target)), + Some((_i, (sol, _score))) => assert!(sol.compute_view().selected_value() >= target_value), + _ => prop_assert!(!cs.compute_view().is_target_reachable(target)), } } @@ -176,7 +183,7 @@ proptest! { let solution_weight = { let mut cs = CoinSelector::new(&solution); cs.select_all(); - cs.input_weight() + cs.compute_view().input_weight() }; let target_value = solution.iter().map(|c| c.value).sum(); @@ -208,7 +215,8 @@ proptest! { .last() .expect("it found a solution"); - prop_assert!(best.input_weight() <= solution_weight); - prop_assert_eq!(best.selected_value(), target.value()); + let best_view = best.compute_view(); + prop_assert!(best_view.input_weight() <= solution_weight); + prop_assert_eq!(best_view.selected_value(), target.value()); } } diff --git a/tests/common.rs b/tests/common.rs index 3728c22..6feac61 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -55,7 +55,7 @@ where println!("\texhaustive search:"); let now = std::time::Instant::now(); let exp_result = exhaustive_search(&mut exp_selection, target, &mut metric); - let exp_change = metric.drain(&exp_selection, target); + let exp_change = metric.drain(&exp_selection.compute_view(), target); let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); println!( "\t\telapsed={:8}s result={}", @@ -64,14 +64,14 @@ where ); // bonus check: ensure replacement fee is respected if exp_result.is_some() { - let selected_value = exp_selection.selected_value(); - let drain = metric.drain(&exp_selection, target); + let view = exp_selection.compute_view(); + let selected_value = view.selected_value(); + let drain = metric.drain(&view, target); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(exp_selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement(view.weight(target.outputs, drain.weights)) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -81,7 +81,7 @@ where let now = std::time::Instant::now(); let mut bnb_metric = metric.clone(); let result = bnb_search(&mut selection, target, metric, usize::MAX); - let change = bnb_metric.drain(&selection, target); + let change = bnb_metric.drain(&selection.compute_view(), target); let result_str = result_string(&result, change); println!( "\t\telapsed={:8}s result={}", @@ -104,14 +104,14 @@ where ); // bonus check: ensure replacement fee is respected - let selected_value = selection.selected_value(); - let drain = bnb_metric.drain(&selection, target); + let view = selection.compute_view(); + let selected_value = view.selected_value(); + let drain = bnb_metric.drain(&view, target); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement(view.weight(target.outputs, drain.weights)) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -150,12 +150,12 @@ where print_candidates(¶ms, &init_cs); for (cs, _) in ExhaustiveIter::new(&init_cs).into_iter().flatten() { - if let Some(lb_score) = metric.bound(&cs, target) { + if let Some(lb_score) = metric.bound(&cs.compute_view(), target) { // This is the branch's lower bound. In other words, this is the BEST selection // possible (can overshoot) traversing down this branch. Let's check that! - if let Some(score) = metric.score(&cs, target) { - let has_change = metric.drain(&cs, target).is_some(); + if let Some(score) = metric.score(&cs.compute_view(), target) { + let has_change = metric.drain(&cs.compute_view(), target).is_some(); prop_assert!( score >= lb_score, "checking branch: selection={} score={} change={} lb={}", @@ -171,9 +171,12 @@ where .flatten() .filter(|(_, inc)| *inc) { - if let Some(descendant_score) = metric.score(&descendant_cs, target) { - let parent_has_change = metric.drain(&cs, target).is_some(); - let descendant_has_change = metric.drain(&descendant_cs, target).is_some(); + if let Some(descendant_score) = metric.score(&descendant_cs.compute_view(), target) + { + let parent_has_change = metric.drain(&cs.compute_view(), target).is_some(); + let descendant_has_change = metric + .drain(&descendant_cs.compute_view(), target) + .is_some(); prop_assert!( descendant_score >= lb_score, " @@ -183,7 +186,7 @@ where cs, parent_has_change, lb_score, - cs.is_target_met(target), + cs.compute_view().is_target_met(target), descendant_cs, descendant_has_change, descendant_score, @@ -350,7 +353,11 @@ where .enumerate() .inspect(|(i, _)| rounds = *i) .filter(|(_, (_, inclusion))| *inclusion) - .filter_map(|(_, (cs, _))| metric.score(&cs, target).map(|score| (cs, score))); + .filter_map(|(_, (cs, _))| { + metric + .score(&cs.compute_view(), target) + .map(|score| (cs, score)) + }); for (child_cs, score) in iter { match &mut best { @@ -460,11 +467,11 @@ pub fn compare_against_benchmarks( let cmp_benchmarks = cmp_benchmarks .into_iter() - .filter(|cs| cs.is_target_met(target)); - let sol_score = metric.score(&sol, target); + .filter(|cs| cs.compute_view().is_target_met(target)); + let sol_score = metric.score(&sol.compute_view(), target); for (_bench_id, mut bench) in cmp_benchmarks.enumerate() { - let bench_score = metric.score(&bench, target); + let bench_score = metric.score(&bench.compute_view(), target); if sol_score > bench_score { dbg!(_bench_id); println!("bnb solution: {}", sol); @@ -475,7 +482,7 @@ pub fn compare_against_benchmarks( } } None => { - prop_assert!(!cs.is_selection_possible(target)); + prop_assert!(!cs.compute_view().is_target_reachable(target)); } } @@ -495,8 +502,8 @@ fn randomly_satisfy_target<'a, R: rand::Rng>( let mut last_score: Option = None; while let Some(next) = cs.unselected_indices().choose(rng) { cs.select(next); - if cs.is_target_met(target) { - let curr_score = metric.score(&cs, target); + if cs.compute_view().is_target_met(target) { + let curr_score = metric.score(&cs.compute_view(), target); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { break; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index efbbd36..b1b5e52 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -87,7 +87,7 @@ proptest! { let mut cs = CoinSelector::new(&candidates); let metric = params.lowest_fee_metric(); - let is_impossible = !cs.is_selection_possible(params.target()); + let is_impossible = !cs.compute_view().is_target_reachable(params.target()); match common::bnb_search(&mut cs, params.target(), metric, params.n_candidates * 10) { Ok((score, rounds)) => { // the +1 is because the iterator will always try selecting nothing as a solution so we have @@ -225,7 +225,7 @@ fn does_not_create_change_below_spend_cost() { }; assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( - metric.drain(&cs, target).is_none(), + metric.drain(&cs.compute_view(), target).is_none(), "optimal selection must be changeless" ); @@ -238,7 +238,7 @@ fn does_not_create_change_below_spend_cost() { assert!( score <= metric - .score(&with_extra_input, target) + .score(&with_extra_input.compute_view(), target) .expect("target is met") ); } diff --git a/tests/weight.rs b/tests/weight.rs index 6a8dbb5..e212408 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -50,11 +50,14 @@ fn segwit_one_input_one_output() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -94,11 +97,14 @@ fn segwit_two_inputs_one_output() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -137,11 +143,14 @@ fn legacy_three_inputs() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), orig_weight.to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -195,7 +204,9 @@ fn legacy_three_inputs_one_segwit() { coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); } From 7524a0d91cd95bff07769bc6d69b89fb97046712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 18 May 2026 08:51:29 +0000 Subject: [PATCH 2/3] bench: extend pool sizes to wallet (1k) and exchange (10M) scale The original benches capped at 4k candidates for `clone` and 200 for `run_bnb_lowest_fee`. Real callers span a much wider range -- a typical wallet has ~1k UTXOs, a large exchange ~10M. For the O(n)-ish operations (`new`, `clone`, `compute_view`) extend the parameter list to 64 / 1k / 16k / 256k / 1M / 10M. These all scale roughly linearly: clone/64 51 ns clone/1024 52 ns clone/16384 260 ns clone/262144 2.0 us clone/1048576 6.3 us clone/10000000 98 us At 10M UTXOs the `Candidate` slice itself is ~320MB and the selector's `candidate_order` Vec is ~80MB -- commented as a heads-up for memory- constrained hosts. Add new groups: - `new`: cost of `CoinSelector::new(candidates)` -- allocations grow with pool size. - `compute_view`: cost of building a SelectionView. Scales with |selected| rather than |pool|; benched against a fixed sparse selection of ~100 candidates regardless of pool size, matching how wallets actually use selection. The BnB bench splits into two explicitly-named groups, because at n >= 200 best-first exploration does not complete any target-meeting selection within the round cap (run_bnb returns NoBnbSolution after exactly 100k rounds): - `run_bnb_lowest_fee` (n = 20/50/100): end-to-end solution finding. - `run_bnb_lowest_fee_exhaust_cap` (n = 200/500/1000): exactly MAX_ROUNDS rounds of frontier expansion (bound() + branch cloning) -- the hot path the delta-aware cache optimizes. Per-round cost grows roughly linearly: run_bnb_lowest_fee_exhaust_cap/200 193 ms run_bnb_lowest_fee_exhaust_cap/500 211 ms run_bnb_lowest_fee_exhaust_cap/1000 377 ms Each group asserts at startup that run_bnb's solution-found outcome matches what the group claims to measure, so a size silently flipping between the two paths (metric change, bound tightening) fails loudly instead of corrupting cross-version comparisons. 10M-scale BnB is intentionally not benchmarked: it's impractical at any finite round budget, and real callers pre-filter / pre-group at that scale. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Fable 5 --- benches/coin_selector.rs | 158 +++++++++++++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 22 deletions(-) diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 76a8da9..66d6dfb 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -1,10 +1,26 @@ //! Benchmarks for `CoinSelector`. //! -//! Two groups: -//! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` -//! was introduced to make cheap. -//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a -//! deterministic synthetic pool using the `LowestFee` metric. +//! Groups: +//! - `new`: cost of `CoinSelector::new(candidates)` — allocations grow with +//! the candidate pool size. Bounds the cost of standing up a selector. +//! - `clone`: cost of `CoinSelector::clone()`. The per-branch cost of BnB +//! exploration is dominated by this. +//! - `compute_view`: cost of `CoinSelector::compute_view()` — walks the +//! selected bitset to build the cached aggregates. Scales with |selected|. +//! - `run_bnb_lowest_fee`: end-to-end BnB solution-finding on a deterministic +//! synthetic pool using the `LowestFee` metric, at sizes where the search +//! converges to a solution within the round cap. +//! - `run_bnb_lowest_fee_exhaust_cap`: the same search at sizes where +//! best-first exploration does NOT complete any target-meeting selection +//! within the cap — every sample runs exactly `MAX_ROUNDS` rounds of +//! frontier expansion (`bound()` + branch cloning), which is precisely the +//! hot path the delta-aware cache optimizes. BnB's search space is +//! exponential in pool size, so sizes stay moderate (BnB at 10M candidates +//! would take eons; real callers pre-filter / pre-group). +//! +//! Pool sizes target the spectrum from wallets (~1k UTXOs) to exchanges +//! (~10M UTXOs). At the high end, this allocates hundreds of MB — adjust the +//! `LARGE_N` list if your machine can't fit. //! //! Run with `cargo bench`. Filter with `cargo bench -- `. @@ -15,6 +31,14 @@ use bdk_coin_select::{ use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; +/// Pool sizes for the O(n)-ish operations (new, clone, compute_view). +/// +/// 1_024 ~ typical wallet, 1_048_576 ~ small exchange, 10_000_000 ~ very large +/// exchange. The 10M case allocates ~320MB just for the `Candidate` slice and +/// ~80MB for the selector's `candidate_order`; comment out if running on a +/// memory-constrained host. +const LARGE_N: &[usize] = &[64, 1_024, 16_384, 262_144, 1_048_576, 10_000_000]; + /// Deterministic synthetic pool of P2WPKH-shaped UTXOs. /// /// Values grow super-linearly so the pool resembles a real wallet's mix of @@ -24,7 +48,7 @@ fn make_candidates(n: usize) -> Vec { (0..n) .map(|i| { let i = i as u64; - let value = 1_000 + i * 137 + i * i; + let value = 1_000 + i.wrapping_mul(137).wrapping_add(i.wrapping_mul(i)); Candidate { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, @@ -46,15 +70,41 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { (target, long_term_fr) } +/// Number of selected candidates to use as a representative "sparse" +/// selection (real wallets/exchanges typically select 1–100 UTXOs even from a +/// huge pool). +const SPARSE_SELECTED: usize = 100; + +fn select_sparse(selector: &mut CoinSelector<'_>, n: usize) { + let count = SPARSE_SELECTED.min(n); + if count == 0 { + return; + } + let stride = (n / count).max(1); + for i in (0..n).step_by(stride).take(count) { + selector.select(i); + } +} + +fn bench_new(c: &mut Criterion) { + let mut group = c.benchmark_group("new"); + group.sample_size(20); + for &n in LARGE_N { + let candidates = make_candidates(n); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(CoinSelector::new(&candidates))); + }); + } + group.finish(); +} + fn bench_coin_selector_clone(c: &mut Criterion) { let mut group = c.benchmark_group("clone"); - for &n in &[64usize, 256, 1024, 4096] { + group.sample_size(20); + for &n in LARGE_N { let candidates = make_candidates(n); let mut selector = CoinSelector::new(&candidates); - // Select ~10% of candidates so `selected` is non-trivial to copy. - for i in (0..n).step_by(10) { - selector.select(i); - } + select_sparse(&mut selector, n); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter(|| black_box(selector.clone())); }); @@ -62,24 +112,68 @@ fn bench_coin_selector_clone(c: &mut Criterion) { group.finish(); } -fn bench_run_bnb_lowest_fee(c: &mut Criterion) { - let mut group = c.benchmark_group("run_bnb_lowest_fee"); - // Cap iterations so the largest case fits in a benchmark sample. +fn bench_compute_view(c: &mut Criterion) { + let mut group = c.benchmark_group("compute_view"); group.sample_size(20); - for &n in &[20usize, 50, 100, 200] { + for &n in LARGE_N { + let candidates = make_candidates(n); + let mut selector = CoinSelector::new(&candidates); + select_sparse(&mut selector, n); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| { + let view = selector.compute_view(); + black_box(view.selected_value()) + }); + }); + } + group.finish(); +} + +/// Round cap for the BnB benches. Bounds the per-sample cost; at the +/// `exhaust_cap` sizes every sample runs exactly this many rounds. +const MAX_ROUNDS: usize = 100_000; + +fn bnb_lowest_fee_metric(long_term_feerate: FeeRate) -> LowestFee { + LowestFee { + long_term_feerate, + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + } +} + +fn bench_run_bnb_lowest_fee_sizes( + c: &mut Criterion, + group_name: &str, + sizes: &[usize], + expect_solution: bool, +) { + let mut group = c.benchmark_group(group_name); + group.sample_size(10); + for &n in sizes { let candidates = make_candidates(n); let selector = CoinSelector::new(&candidates); let (target, long_term_feerate) = make_bnb_inputs(&candidates); + + // Pin what this group measures: if search dynamics change (metric, + // bound tightness, candidate distribution), a size silently flipping + // between the solution-finding and cap-exhaustion paths would corrupt + // cross-version comparisons — fail loudly instead. + let found = selector + .clone() + .run_bnb(target, bnb_lowest_fee_metric(long_term_feerate), MAX_ROUNDS) + .is_ok(); + assert_eq!( + found, expect_solution, + "{}/{}: expected run_bnb solution-found == {}", + group_name, n, expect_solution, + ); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), |mut sel| { - let metric = LowestFee { - long_term_feerate, - dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), - drain_weights: DrainWeights::TR_KEYSPEND, - }; - let _ = sel.run_bnb(target, metric, black_box(100_000)); + let metric = bnb_lowest_fee_metric(long_term_feerate); + let _ = sel.run_bnb(target, metric, black_box(MAX_ROUNDS)); sel }, BatchSize::SmallInput, @@ -89,5 +183,25 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee); +fn bench_run_bnb_lowest_fee(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes(c, "run_bnb_lowest_fee", &[20, 50, 100], true); +} + +fn bench_run_bnb_lowest_fee_exhaust_cap(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes( + c, + "run_bnb_lowest_fee_exhaust_cap", + &[200, 500, 1000], + false, + ); +} + +criterion_group!( + benches, + bench_new, + bench_coin_selector_clone, + bench_compute_view, + bench_run_bnb_lowest_fee, + bench_run_bnb_lowest_fee_exhaust_cap +); criterion_main!(benches); From 07e2a36406c92b74715f875ec7532202e38e1e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 18 May 2026 09:08:33 +0000 Subject: [PATCH 3/3] perf: skip unselected scan in BnbIter via per-branch cursor The flamegraph after the delta-aware refactor showed ~32% of run_bnb time spent walking candidate_order and checking Bitset::contains(selected) || Bitset::contains(banned) per element, inlined into insert_new_branches's `cs.unselected().next()`. As BnB descends and more candidates get selected/banned, each .next() call scans further before finding the next viable candidate. But BnB never re-considers a position: each branch's exploration only moves forward in candidate_order. Inclusion advances by 1; exclusion advances past every consecutive same-(value, weight) candidate. So we can store a per-Branch cursor and avoid the scan entirely. Add `Branch::cursor: usize` (the position the branch will expand on next). The init branch starts at 0; insert_new_branches advances past any pre-selected/pre-banned positions on demand, then expands at the located cursor and hands children their new cursors directly. One subtlety: the exclusion branch's same-(value, weight) dedup run now walks raw candidate_order positions, where the old `unselected()` scan skipped already-decided candidates implicitly. The run must do the same explicitly -- skip pre-selected/pre-banned positions (advancing the cursor past them) rather than banning them or letting them end the run. Otherwise a caller-pre-selected candidate that duplicates an excluded one ends up simultaneously selected and banned in the selector that run_bnb hands back, and a pre-decided candidate inside a duplicate run fragments the equivalence class into redundant branches. Covered by `bnb_exclusion_dedup_skips_decided_candidates`. Bench (run_bnb_lowest_fee, n = pool size): n=20 166 us -> 147 us (11%) n=50 5.3 ms -> 4.5 ms (16%) n=100 12.6 ms -> 11.5 ms (9%) n=500 200 ms -> 171 ms (14%) n=1000 365 ms -> 248 ms (32%) (n >= 200 rows are the exhaust-cap group: fixed 100k rounds of frontier expansion, so they measure pure per-round cost.) Largest win at large n where the unselected scan was burning the most time. Flamegraph confirms the unselected-scan hot spot is gone; new top is LowestFee::bound itself (the metric's float math + lookahead). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Fable 5 --- Cargo.toml | 5 ++++ src/bnb.rs | 81 +++++++++++++++++++++++++++++++++++++++++----------- tests/bnb.rs | 45 +++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 44a72c1..6775856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,8 @@ criterion = "0.5" [[bench]] name = "coin_selector" harness = false + +# Enable debug symbols so profilers (perf, samply, flamegraph) can resolve +# function names. No runtime cost. +[profile.bench] +debug = true diff --git a/src/bnb.rs b/src/bnb.rs index f494689..6713c8d 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,3 +1,17 @@ +//! Branch-and-bound search. +//! +//! BnB explores a binary tree where each [`Branch`] expands into two +//! children: an *inclusion* child that selects the candidate at the +//! branch's `cursor`, and an *exclusion* child that bans it along with +//! any same-(value, weight) duplicates that immediately follow. +//! +//! Cursors only advance as we descend — inclusion's child has cursor +//! `parent + 1`; exclusion's jumps past the banned duplicates. That +//! invariant lets `insert_new_branches` skip directly to the next +//! undecided candidate without re-scanning `selected` / `banned`. The +//! only scanning happens to step past pre-selected / pre-banned positions +//! (rare, lazy). + use core::cmp::Reverse; use crate::{float::Ordf32, Drain, SelectionCache, SelectionView, Target}; @@ -61,6 +75,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { selector, cache, is_exclusion, + cursor, .. } = branch; @@ -79,7 +94,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { }; } - self.insert_new_branches(&selector, &cache); + self.insert_new_branches(&selector, &cache, cursor); Some(return_val.map(|score| (selector, score))) } } @@ -98,7 +113,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } let cache = SelectionCache::from_selector(&selector); - iter.consider_adding_to_queue(&selector, &cache, false); + iter.consider_adding_to_queue(&selector, &cache, false, 0); iter } @@ -108,6 +123,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { cs: &CoinSelector<'a>, cache: &SelectionCache, is_exclusion: bool, + cursor: usize, ) { let bound = self .metric @@ -123,34 +139,63 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { selector: cs.clone(), cache: cache.clone(), is_exclusion, + cursor, }); } } } - fn insert_new_branches(&mut self, cs: &CoinSelector<'a>, cache: &SelectionCache) { - let (next_index, next) = match cs.unselected().next() { - Some(c) => c, - None => return, // exhausted + fn insert_new_branches(&mut self, cs: &CoinSelector<'a>, cache: &SelectionCache, start: usize) { + // Find the position to expand on: at or after `start`, whichever is + // the first candidate that's neither selected nor banned. Usually + // this *is* `start` — the only reason to advance is to skip past + // pre-selected/pre-banned candidates (see module-level docs). + let mut iter = cs.candidates().skip(start); + let mut cursor = start; + let (here_idx, here_cand) = loop { + match iter.next() { + None => return, // no more candidates — this branch is a leaf + Some((idx, cand)) => { + if !cs.is_selected(idx) && !cs.banned().contains(idx) { + break (idx, cand); + } + cursor += 1; + } + } }; + // Past here, `iter` is positioned at `cursor + 1`. - // Inclusion branch: selecting `next_index` requires updating the cache. + // Inclusion: descendants explore "this candidate is selected". let mut inclusion_cs = cs.clone(); let mut inclusion_cache = cache.clone(); - inclusion_cs.select(next_index); - inclusion_cache.add(next); - self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false); + inclusion_cs.select(here_idx); + inclusion_cache.add(here_cand); + self.consider_adding_to_queue(&inclusion_cs, &inclusion_cache, false, cursor + 1); - // Exclusion branch: only bans, no selection change → cache unchanged. + // Exclusion: descendants explore "this candidate is *not* selected". + // Bans this and every consecutive same-(value, weight) candidate — + // they're equivalent choices, so we deduplicate by handling the + // entire equivalence class in one branch. The cursor jumps past + // all of them. let mut exclusion_cs = cs.clone(); - let to_ban = (next.value, next.weight); - for (ban_index, ban_cand) in cs.unselected() { - if (ban_cand.value, ban_cand.weight) != to_ban { + exclusion_cs.ban(here_idx); + let equiv = (here_cand.value, here_cand.weight); + let mut exclusion_cursor = cursor + 1; + for (idx, cand) in iter { + // Already-decided candidates (pre-selected or banned) must not be banned and must not + // end the equivalence run: the pre-cursor version scanned `unselected()`, which skips + // them entirely. The cursor may still advance past them — they're decided. + if cs.is_selected(idx) || cs.banned().contains(idx) { + exclusion_cursor += 1; + continue; + } + if (cand.value, cand.weight) != equiv { break; } - exclusion_cs.ban(ban_index); + exclusion_cs.ban(idx); + exclusion_cursor += 1; } - self.consider_adding_to_queue(&exclusion_cs, cache, true); + self.consider_adding_to_queue(&exclusion_cs, cache, true, exclusion_cursor); } } @@ -160,6 +205,10 @@ struct Branch<'a> { selector: CoinSelector<'a>, cache: SelectionCache, is_exclusion: bool, + /// Position in `candidate_order` of the candidate whose include / + /// exclude decision creates this branch's two children. See the + /// module-level "Cursor invariant" section. + cursor: usize, } impl Ord for Branch<'_> { diff --git a/tests/bnb.rs b/tests/bnb.rs index 10b14d1..2cb9756 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -148,6 +148,51 @@ fn bnb_finds_solution_if_possible_in_n_iter() { assert_eq!(excess, 0); } +#[test] +/// The exclusion branch's same-(value, weight) dedup run must skip already-decided candidates +/// instead of banning them (a pre-selected candidate must never end up banned) or letting them +/// end the run early. +/// +/// Regression test: candidate 1 is pre-selected by the caller and shares (value, weight) with +/// candidate 0. The optimal solution requires excluding candidate 0, and the dedup run following +/// that exclusion used to also ban the pre-selected candidate 1, contaminating the selector that +/// `run_bnb` hands back. +fn bnb_exclusion_dedup_skips_decided_candidates() { + let candidates = vec![ + Candidate::new(500, 100, false), // same (value, weight) as the pre-selected candidate + Candidate::new(500, 100, false), // pre-selected + Candidate::new(400, 100, false), + ]; + + let mut cs = CoinSelector::new(&candidates); + cs.select(1); + + let target = Target { + outputs: TargetOutputs { + value_sum: 900, + weight_sum: 0, + n_outputs: 1, + }, + fee: TargetFee::ZERO, + }; + + let _ = cs + .run_bnb(target, MinExcessThenWeight, 1_000) + .expect("must find solution"); + + // Optimal selection is {1, 2}: candidate 1 is mandatory and adding candidate 2 hits the + // target exactly, while any selection containing candidate 0 overshoots. + assert_eq!(cs.selected_indices().iter().collect::>(), vec![1, 2]); + // The returned selector must not have any candidate simultaneously selected and banned. + for (idx, _) in cs.selected() { + assert!( + !cs.banned().contains(idx), + "candidate {} is both selected and banned", + idx + ); + } +} + proptest! { #[test] fn bnb_always_finds_solution_if_possible(num_inputs in 1usize..18, target_value in 0u64..10_000) {