Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`, `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<M>` 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.
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
99 changes: 84 additions & 15 deletions benches/coin_selector.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
//! 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 throughput on a deterministic
//! synthetic pool using the `LowestFee` metric. BnB's search space is
//! exponential in pool size, so this caps at moderate `n` (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 -- <pattern>`.

Expand All @@ -15,6 +25,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
Expand All @@ -24,7 +42,7 @@ fn make_candidates(n: usize) -> Vec<Candidate> {
(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,
Expand All @@ -46,27 +64,72 @@ 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()));
});
}
group.finish();
}

fn bench_compute_view(c: &mut Criterion) {
let mut group = c.benchmark_group("compute_view");
group.sample_size(20);
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();
}

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] {
// BnB's search space is exponential. Capping `max_rounds` at 100k bounds
// the per-sample cost, but for large `n` we hit that cap and the work
// per round itself scales with the depth of the search tree.
group.sample_size(10);
for &n in &[20usize, 50, 100, 200, 500, 1000] {
let candidates = make_candidates(n);
let selector = CoinSelector::new(&candidates);
let (target, long_term_feerate) = make_bnb_inputs(&candidates);
Expand All @@ -89,5 +152,11 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) {
group.finish();
}

criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee);
criterion_group!(
benches,
bench_new,
bench_coin_selector_clone,
bench_compute_view,
bench_run_bnb_lowest_fee
);
criterion_main!(benches);
Loading
Loading