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`, `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<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
158 changes: 136 additions & 22 deletions benches/coin_selector.rs
Original file line number Diff line number Diff line change
@@ -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 -- <pattern>`.

Expand All @@ -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
Expand All @@ -24,7 +48,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,40 +70,110 @@ 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_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,
Expand All @@ -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);
Loading
Loading