diff --git a/Cargo.toml b/Cargo.toml index f48f6fc..44a72c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bdk_coin_select" -version = "0.4.1" +version = "0.5.0" edition = "2018" #rust-version = "1.54" homepage = "https://bitcoindevkit.org" @@ -24,3 +24,8 @@ std = [] rand = "0.9" proptest = "1.7" bitcoin = "0.32" +criterion = "0.5" + +[[bench]] +name = "coin_selector" +harness = false diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs new file mode 100644 index 0000000..c7496c0 --- /dev/null +++ b/benches/coin_selector.rs @@ -0,0 +1,95 @@ +//! 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. +//! +//! Run with `cargo bench`. Filter with `cargo bench -- `. + +use bdk_coin_select::{ + metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, + TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, +}; +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; +use std::hint::black_box; + +/// Deterministic synthetic pool of P2WPKH-shaped UTXOs. +/// +/// Values grow super-linearly so the pool resembles a real wallet's mix of +/// small/medium/large UTXOs rather than uniform values. +fn make_candidates(n: usize) -> Vec { + const P2WPKH_SAT_W: u64 = 107; + (0..n) + .map(|i| { + let i = i as u64; + let value = 1_000 + i * 137 + i * i; + Candidate { + value, + weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, + input_count: 1, + is_segwit: true, + } + }) + .collect() +} + +fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, ChangePolicy, FeeRate) { + let target_fr = FeeRate::from_sat_per_vb(2.0); + let long_term_fr = FeeRate::from_sat_per_vb(10.0); + let total: u64 = candidates.iter().map(|c| c.value).sum(); + let target = Target { + fee: TargetFee::from_feerate(target_fr), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), + }; + let change_policy = + ChangePolicy::min_value_and_waste(DrainWeights::TR_KEYSPEND, 294, target_fr, long_term_fr); + (target, change_policy, long_term_fr) +} + +fn bench_coin_selector_clone(c: &mut Criterion) { + let mut group = c.benchmark_group("clone"); + for &n in &[64usize, 256, 1024, 4096] { + 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); + } + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(selector.clone())); + }); + } + 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. + group.sample_size(20); + for &n in &[20usize, 50, 100, 200] { + let candidates = make_candidates(n); + let selector = CoinSelector::new(&candidates); + let (target, change_policy, long_term_feerate) = make_bnb_inputs(&candidates); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let metric = LowestFee { + target, + long_term_feerate, + change_policy, + }; + let _ = sel.run_bnb(metric, black_box(100_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee); +criterion_main!(benches); diff --git a/src/bitset.rs b/src/bitset.rs new file mode 100644 index 0000000..76471aa --- /dev/null +++ b/src/bitset.rs @@ -0,0 +1,375 @@ +use alloc::{vec, vec::Vec}; + +/// A compact bitset addressed by candidate index, used by +/// [`CoinSelector`](crate::CoinSelector) to track which candidates are +/// currently selected or banned. +/// +/// Bit `i` corresponds to the candidate at index `i` in the slice passed to +/// [`CoinSelector::new`](crate::CoinSelector::new). The capacity is fixed at +/// construction. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Bitset { + words: Vec, + bit_capacity: usize, +} + +impl Bitset { + pub(crate) fn with_capacity(bit_capacity: usize) -> Self { + Self { + words: vec![0; (bit_capacity + 63) / 64], + bit_capacity, + } + } + + /// Bit-addressable capacity (number of candidates the bitset can represent). + pub fn capacity(&self) -> usize { + self.bit_capacity + } + + /// Number of set bits. + pub fn len(&self) -> usize { + self.words.iter().map(|w| w.count_ones() as usize).sum() + } + + /// Whether no bits are set. + pub fn is_empty(&self) -> bool { + self.words.iter().all(|w| *w == 0) + } + + /// Whether bit at `index` is set. Out-of-range indices return `false`. + pub fn contains(&self, index: usize) -> bool { + index < self.bit_capacity && self.words[index / 64] & (1u64 << (index % 64)) != 0 + } + + /// Set bit at `index`. Returns `true` if the bit was previously unset. + pub(crate) fn insert(&mut self, index: usize) -> bool { + debug_assert!(index < self.bit_capacity); + let mask = 1u64 << (index % 64); + let w = &mut self.words[index / 64]; + let was_unset = *w & mask == 0; + *w |= mask; + was_unset + } + + /// Clear bit at `index`. Returns `true` if the bit was previously set. + pub(crate) fn remove(&mut self, index: usize) -> bool { + if index >= self.bit_capacity { + return false; + } + let mask = 1u64 << (index % 64); + let w = &mut self.words[index / 64]; + let was_set = *w & mask != 0; + *w &= !mask; + was_set + } + + /// Iterate over set bits in ascending index order. + pub fn iter(&self) -> BitsetIter<'_> { + BitsetIter { + words: &self.words, + front: 0, + back: self.bit_capacity, + remaining: self.len(), + } + } +} + +impl<'a> IntoIterator for &'a Bitset { + type Item = usize; + type IntoIter = BitsetIter<'a>; + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Iterator over the set bits of a [`Bitset`]. +#[derive(Clone, Debug)] +pub struct BitsetIter<'a> { + words: &'a [u64], + front: usize, + back: usize, + remaining: usize, +} + +impl<'a> Iterator for BitsetIter<'a> { + type Item = usize; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + while self.front < self.back { + let word_i = self.front / 64; + let bit_i = self.front % 64; + let word = self.words[word_i]; + let masked = word & !((1u64 << bit_i).wrapping_sub(1)); + if masked != 0 { + let bit = masked.trailing_zeros() as usize; + let idx = word_i * 64 + bit; + if idx >= self.back { + return None; + } + self.front = idx + 1; + self.remaining -= 1; + return Some(idx); + } + self.front = (word_i + 1) * 64; + } + None + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl<'a> ExactSizeIterator for BitsetIter<'a> {} + +impl<'a> DoubleEndedIterator for BitsetIter<'a> { + fn next_back(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + while self.front < self.back { + let bit_pos = self.back - 1; + let word_i = bit_pos / 64; + let last_bit = bit_pos % 64; + let word = self.words[word_i]; + // Keep only bits 0..=last_bit within this word. + let keep_mask = if last_bit == 63 { + u64::MAX + } else { + (1u64 << (last_bit + 1)) - 1 + }; + let masked = word & keep_mask; + if masked != 0 { + let bit = 63 - masked.leading_zeros() as usize; + let idx = word_i * 64 + bit; + if idx < self.front { + return None; + } + self.back = idx; + self.remaining -= 1; + return Some(idx); + } + self.back = word_i * 64; + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::collections::BTreeSet; + use proptest::prelude::*; + + #[test] + fn empty_bitset() { + let b = Bitset::with_capacity(0); + assert_eq!(b.capacity(), 0); + assert_eq!(b.len(), 0); + assert!(b.is_empty()); + assert!(!b.contains(0)); + assert!(!b.contains(usize::MAX)); + assert_eq!(b.iter().next(), None); + assert_eq!(b.iter().next_back(), None); + } + + #[test] + fn default_matches_zero_capacity() { + assert_eq!(Bitset::default(), Bitset::with_capacity(0)); + } + + #[test] + fn capacity_reported_verbatim() { + for cap in [1, 5, 63, 64, 65, 127, 128, 129, 1000] { + assert_eq!(Bitset::with_capacity(cap).capacity(), cap); + } + } + + #[test] + fn insert_returns_was_unset() { + let mut b = Bitset::with_capacity(10); + assert!(b.insert(3)); + assert!(!b.insert(3)); + assert!(b.contains(3)); + } + + #[test] + fn remove_returns_was_set() { + let mut b = Bitset::with_capacity(10); + b.insert(5); + assert!(b.remove(5)); + assert!(!b.remove(5)); + assert!(!b.contains(5)); + } + + #[test] + fn out_of_range_is_safe() { + let mut b = Bitset::with_capacity(10); + assert!(!b.contains(10)); + assert!(!b.contains(usize::MAX)); + assert!(!b.remove(10)); + assert!(!b.remove(usize::MAX)); + } + + #[test] + fn word_boundary_bits() { + let mut b = Bitset::with_capacity(192); + let expected = [0, 1, 62, 63, 64, 65, 126, 127, 128, 129, 190, 191]; + for &i in &expected { + assert!(b.insert(i)); + } + for &i in &expected { + assert!(b.contains(i)); + } + assert_eq!(b.len(), expected.len()); + assert_eq!( + b.iter().collect::>(), + expected.to_vec() + ); + assert_eq!( + b.iter().rev().collect::>(), + expected.iter().rev().copied().collect::>() + ); + } + + #[test] + fn non_word_aligned_capacity_last_bit() { + let mut b = Bitset::with_capacity(100); + assert!(b.insert(99)); + assert!(b.contains(99)); + assert!(!b.contains(100)); + assert_eq!(b.iter().collect::>(), [99]); + } + + #[test] + fn iter_size_hint_decrements() { + let mut b = Bitset::with_capacity(200); + for i in [0, 7, 63, 64, 199] { + b.insert(i); + } + let mut iter = b.iter(); + for expected in (1..=5).rev() { + assert_eq!(iter.size_hint(), (expected, Some(expected))); + assert_eq!(iter.len(), expected); + iter.next().unwrap(); + } + assert_eq!(iter.size_hint(), (0, Some(0))); + assert_eq!(iter.next(), None); + } + + #[test] + fn double_ended_meets_in_middle() { + let mut b = Bitset::with_capacity(200); + for i in [0, 5, 63, 64, 127, 128, 199] { + b.insert(i); + } + let mut it = b.iter(); + assert_eq!(it.next(), Some(0)); + assert_eq!(it.next_back(), Some(199)); + assert_eq!(it.next(), Some(5)); + assert_eq!(it.next_back(), Some(128)); + assert_eq!(it.next(), Some(63)); + assert_eq!(it.next_back(), Some(127)); + assert_eq!(it.next(), Some(64)); + assert_eq!(it.next(), None); + assert_eq!(it.next_back(), None); + } + + #[test] + fn double_ended_back_only_full_drain() { + let mut b = Bitset::with_capacity(200); + for i in [0, 5, 63, 64, 127, 128, 199] { + b.insert(i); + } + let mut out = alloc::vec::Vec::new(); + let mut it = b.iter(); + while let Some(v) = it.next_back() { + out.push(v); + } + assert_eq!(out, [199, 128, 127, 64, 63, 5, 0]); + } + + #[test] + fn equality_set_based() { + let mut a = Bitset::with_capacity(100); + let mut b = Bitset::with_capacity(100); + for i in [3, 50, 99] { + a.insert(i); + b.insert(i); + } + assert_eq!(a, b); + b.remove(50); + assert_ne!(a, b); + } + + #[test] + fn into_iterator_for_ref() { + let mut b = Bitset::with_capacity(10); + b.insert(2); + b.insert(7); + let v: alloc::vec::Vec = (&b).into_iter().collect(); + assert_eq!(v, [2, 7]); + } + + proptest! { + /// Any sequence of insert/remove/contains operations on Bitset must + /// produce identical results to the same sequence on BTreeSet. + #[test] + fn matches_btreeset_under_random_ops( + cap in 1usize..256, + ops in prop::collection::vec((any::(), 0usize..256), 0..200), + ) { + let mut bitset = Bitset::with_capacity(cap); + let mut model: BTreeSet = BTreeSet::new(); + for (is_insert, raw_idx) in ops { + let idx = raw_idx % cap; + if is_insert { + prop_assert_eq!(bitset.insert(idx), model.insert(idx)); + } else { + prop_assert_eq!(bitset.remove(idx), model.remove(&idx)); + } + prop_assert_eq!(bitset.contains(idx), model.contains(&idx)); + prop_assert_eq!(bitset.len(), model.len()); + prop_assert_eq!(bitset.is_empty(), model.is_empty()); + } + let bvec: alloc::vec::Vec = bitset.iter().collect(); + let mvec: alloc::vec::Vec = model.iter().copied().collect(); + prop_assert_eq!(&bvec, &mvec); + let brev: alloc::vec::Vec = bitset.iter().rev().collect(); + let mrev: alloc::vec::Vec = model.iter().rev().copied().collect(); + prop_assert_eq!(brev, mrev); + } + + /// Interleaved next() / next_back() must yield each set bit exactly + /// once, in the order dictated by the call pattern. + #[test] + fn double_ended_interleaved_matches_model( + cap in 1usize..256, + bits in prop::collection::vec(0usize..256, 0..100), + front_first in prop::collection::vec(any::(), 0..200), + ) { + let mut bitset = Bitset::with_capacity(cap); + let mut model: BTreeSet = BTreeSet::new(); + for raw in bits { + let i = raw % cap; + bitset.insert(i); + model.insert(i); + } + let mut it = bitset.iter(); + let mut model_vec: alloc::vec::Vec = model.iter().copied().collect(); + for take_front in front_first { + let model_pick = if take_front { + if model_vec.is_empty() { None } else { Some(model_vec.remove(0)) } + } else { + model_vec.pop() + }; + let bitset_pick = if take_front { it.next() } else { it.next_back() }; + prop_assert_eq!(bitset_pick, model_pick); + if bitset_pick.is_none() { break; } + } + } + } +} diff --git a/src/coin_selector.rs b/src/coin_selector.rs index d6cdd7b..eb7ba1e 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -1,8 +1,8 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; -use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; -use alloc::{borrow::Cow, collections::BTreeSet}; +use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; +use alloc::{sync::Arc, vec::Vec}; /// [`CoinSelector`] selects/deselects coins from a set of canididate coins. /// @@ -14,9 +14,9 @@ use alloc::{borrow::Cow, collections::BTreeSet}; #[derive(Debug, Clone)] pub struct CoinSelector<'a> { candidates: &'a [Candidate], - selected: Cow<'a, BTreeSet>, - banned: Cow<'a, BTreeSet>, - candidate_order: Cow<'a, [usize]>, + selected: Bitset, + banned: Bitset, + candidate_order: Arc>, } impl<'a> CoinSelector<'a> { @@ -34,9 +34,9 @@ impl<'a> CoinSelector<'a> { pub fn new(candidates: &'a [Candidate]) -> Self { Self { candidates, - selected: Cow::Owned(Default::default()), - banned: Cow::Owned(Default::default()), - candidate_order: Cow::Owned((0..candidates.len()).collect()), + selected: Bitset::with_capacity(candidates.len()), + banned: Bitset::with_capacity(candidates.len()), + candidate_order: Arc::new((0..candidates.len()).collect::>()), } } @@ -59,21 +59,21 @@ impl<'a> CoinSelector<'a> { /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` /// slice passed into [`CoinSelector::new`]. pub fn deselect(&mut self, index: usize) -> bool { - self.selected.to_mut().remove(&index) + self.selected.remove(index) } /// Convienince method to pick elements of a slice by the indexes that are currently selected. /// Obviously the slice must represent the inputs ordered in the same way as when they were /// passed to `Candidates::new`. pub fn apply_selection(&self, candidates: &'a [T]) -> impl Iterator + '_ { - self.selected.iter().map(move |i| &candidates[*i]) + self.selected.iter().map(move |i| &candidates[i]) } /// Select the input at `index`. `index` refers to its position in the original `candidates` /// slice passed into [`CoinSelector::new`]. pub fn select(&mut self, index: usize) -> bool { assert!(index < self.candidates.len()); - self.selected.to_mut().insert(index) + self.selected.insert(index) } /// Select the next unselected candidate in the sorted order fo the candidates. @@ -95,20 +95,20 @@ impl<'a> CoinSelector<'a> { /// [`unselected`]: Self::unselected /// [`unselected_indices`]: Self::unselected_indices pub fn ban(&mut self, index: usize) { - self.banned.to_mut().insert(index); + self.banned.insert(index); } /// Gets the list of inputs that have been banned by [`ban`]. /// /// [`ban`]: Self::ban - pub fn banned(&self) -> &BTreeSet { + pub fn banned(&self) -> &Bitset { &self.banned } /// Is the input at `index` selected. `index` refers to its position in the original /// `candidates` slice passed into [`CoinSelector::new`]. pub fn is_selected(&self, index: usize) -> bool { - self.selected.contains(&index) + self.selected.contains(index) } /// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output). @@ -158,7 +158,7 @@ impl<'a> CoinSelector<'a> { pub fn selected_value(&self) -> u64 { self.selected .iter() - .map(|&index| self.candidates[index].value) + .map(|index| self.candidates[index].value) .sum() } @@ -326,9 +326,9 @@ impl<'a> CoinSelector<'a> { where F: FnMut((usize, Candidate), (usize, Candidate)) -> core::cmp::Ordering, { - let order = self.candidate_order.to_mut(); let candidates = &self.candidates; - order.sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) + Arc::make_mut(&mut self.candidate_order) + .sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) } /// Sorts the candidates by the key function. @@ -394,7 +394,7 @@ impl<'a> CoinSelector<'a> { ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { self.selected .iter() - .map(move |&index| (index, self.candidates[index])) + .map(move |index| (index, self.candidates[index])) } /// The unselected candidates with their index. @@ -408,7 +408,7 @@ impl<'a> CoinSelector<'a> { } /// The indices of the selelcted candidates. - pub fn selected_indices(&self) -> &BTreeSet { + pub fn selected_indices(&self) -> &Bitset { &self.selected } @@ -420,8 +420,8 @@ impl<'a> CoinSelector<'a> { pub fn unselected_indices(&self) -> impl DoubleEndedIterator + '_ { self.candidate_order .iter() - .filter(move |index| !(self.selected.contains(index) || self.banned.contains(index))) .copied() + .filter(move |&index| !(self.selected.contains(index) || self.banned.contains(index))) } /// Whether there are any unselected candidates left. @@ -509,14 +509,15 @@ impl<'a> CoinSelector<'a> { /// /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. pub fn select_all_effective(&mut self, feerate: FeeRate) { - for cand_index in self.candidate_order.iter() { + for i in 0..self.candidate_order.len() { + let cand_index = self.candidate_order[i]; if self.selected.contains(cand_index) || self.banned.contains(cand_index) - || self.candidates[*cand_index].effective_value(feerate) <= 0.0 + || self.candidates[cand_index].effective_value(feerate) <= 0.0 { continue; } - self.selected.to_mut().insert(*cand_index); + self.selected.insert(cand_index); } } @@ -601,7 +602,7 @@ impl core::fmt::Display for CoinSelector<'_> { write!(f, "{}", i)?; if self.is_selected(i) { write!(f, "✔")?; - } else if self.banned().contains(&i) { + } else if self.banned().contains(i) { write!(f, "✘")? } else { write!(f, "☐")?; diff --git a/src/lib.rs b/src/lib.rs index ac237b4..34c86ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,8 @@ extern crate alloc; #[macro_use] extern crate std; +mod bitset; +pub use bitset::*; mod coin_selector; pub mod float; pub use coin_selector::*;