Skip to content

Commit 7524a0d

Browse files
evanlinjinclaude
andcommitted
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) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 067552f commit 7524a0d

1 file changed

Lines changed: 136 additions & 22 deletions

File tree

benches/coin_selector.rs

Lines changed: 136 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
//! Benchmarks for `CoinSelector`.
22
//!
3-
//! Two groups:
4-
//! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset`
5-
//! was introduced to make cheap.
6-
//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a
7-
//! deterministic synthetic pool using the `LowestFee` metric.
3+
//! Groups:
4+
//! - `new`: cost of `CoinSelector::new(candidates)` — allocations grow with
5+
//! the candidate pool size. Bounds the cost of standing up a selector.
6+
//! - `clone`: cost of `CoinSelector::clone()`. The per-branch cost of BnB
7+
//! exploration is dominated by this.
8+
//! - `compute_view`: cost of `CoinSelector::compute_view()` — walks the
9+
//! selected bitset to build the cached aggregates. Scales with |selected|.
10+
//! - `run_bnb_lowest_fee`: end-to-end BnB solution-finding on a deterministic
11+
//! synthetic pool using the `LowestFee` metric, at sizes where the search
12+
//! converges to a solution within the round cap.
13+
//! - `run_bnb_lowest_fee_exhaust_cap`: the same search at sizes where
14+
//! best-first exploration does NOT complete any target-meeting selection
15+
//! within the cap — every sample runs exactly `MAX_ROUNDS` rounds of
16+
//! frontier expansion (`bound()` + branch cloning), which is precisely the
17+
//! hot path the delta-aware cache optimizes. BnB's search space is
18+
//! exponential in pool size, so sizes stay moderate (BnB at 10M candidates
19+
//! would take eons; real callers pre-filter / pre-group).
20+
//!
21+
//! Pool sizes target the spectrum from wallets (~1k UTXOs) to exchanges
22+
//! (~10M UTXOs). At the high end, this allocates hundreds of MB — adjust the
23+
//! `LARGE_N` list if your machine can't fit.
824
//!
925
//! Run with `cargo bench`. Filter with `cargo bench -- <pattern>`.
1026
@@ -15,6 +31,14 @@ use bdk_coin_select::{
1531
use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
1632
use std::hint::black_box;
1733

34+
/// Pool sizes for the O(n)-ish operations (new, clone, compute_view).
35+
///
36+
/// 1_024 ~ typical wallet, 1_048_576 ~ small exchange, 10_000_000 ~ very large
37+
/// exchange. The 10M case allocates ~320MB just for the `Candidate` slice and
38+
/// ~80MB for the selector's `candidate_order`; comment out if running on a
39+
/// memory-constrained host.
40+
const LARGE_N: &[usize] = &[64, 1_024, 16_384, 262_144, 1_048_576, 10_000_000];
41+
1842
/// Deterministic synthetic pool of P2WPKH-shaped UTXOs.
1943
///
2044
/// Values grow super-linearly so the pool resembles a real wallet's mix of
@@ -24,7 +48,7 @@ fn make_candidates(n: usize) -> Vec<Candidate> {
2448
(0..n)
2549
.map(|i| {
2650
let i = i as u64;
27-
let value = 1_000 + i * 137 + i * i;
51+
let value = 1_000 + i.wrapping_mul(137).wrapping_add(i.wrapping_mul(i));
2852
Candidate {
2953
value,
3054
weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W,
@@ -46,40 +70,110 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) {
4670
(target, long_term_fr)
4771
}
4872

73+
/// Number of selected candidates to use as a representative "sparse"
74+
/// selection (real wallets/exchanges typically select 1–100 UTXOs even from a
75+
/// huge pool).
76+
const SPARSE_SELECTED: usize = 100;
77+
78+
fn select_sparse(selector: &mut CoinSelector<'_>, n: usize) {
79+
let count = SPARSE_SELECTED.min(n);
80+
if count == 0 {
81+
return;
82+
}
83+
let stride = (n / count).max(1);
84+
for i in (0..n).step_by(stride).take(count) {
85+
selector.select(i);
86+
}
87+
}
88+
89+
fn bench_new(c: &mut Criterion) {
90+
let mut group = c.benchmark_group("new");
91+
group.sample_size(20);
92+
for &n in LARGE_N {
93+
let candidates = make_candidates(n);
94+
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
95+
b.iter(|| black_box(CoinSelector::new(&candidates)));
96+
});
97+
}
98+
group.finish();
99+
}
100+
49101
fn bench_coin_selector_clone(c: &mut Criterion) {
50102
let mut group = c.benchmark_group("clone");
51-
for &n in &[64usize, 256, 1024, 4096] {
103+
group.sample_size(20);
104+
for &n in LARGE_N {
52105
let candidates = make_candidates(n);
53106
let mut selector = CoinSelector::new(&candidates);
54-
// Select ~10% of candidates so `selected` is non-trivial to copy.
55-
for i in (0..n).step_by(10) {
56-
selector.select(i);
57-
}
107+
select_sparse(&mut selector, n);
58108
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
59109
b.iter(|| black_box(selector.clone()));
60110
});
61111
}
62112
group.finish();
63113
}
64114

65-
fn bench_run_bnb_lowest_fee(c: &mut Criterion) {
66-
let mut group = c.benchmark_group("run_bnb_lowest_fee");
67-
// Cap iterations so the largest case fits in a benchmark sample.
115+
fn bench_compute_view(c: &mut Criterion) {
116+
let mut group = c.benchmark_group("compute_view");
68117
group.sample_size(20);
69-
for &n in &[20usize, 50, 100, 200] {
118+
for &n in LARGE_N {
119+
let candidates = make_candidates(n);
120+
let mut selector = CoinSelector::new(&candidates);
121+
select_sparse(&mut selector, n);
122+
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
123+
b.iter(|| {
124+
let view = selector.compute_view();
125+
black_box(view.selected_value())
126+
});
127+
});
128+
}
129+
group.finish();
130+
}
131+
132+
/// Round cap for the BnB benches. Bounds the per-sample cost; at the
133+
/// `exhaust_cap` sizes every sample runs exactly this many rounds.
134+
const MAX_ROUNDS: usize = 100_000;
135+
136+
fn bnb_lowest_fee_metric(long_term_feerate: FeeRate) -> LowestFee {
137+
LowestFee {
138+
long_term_feerate,
139+
dust_relay_feerate: FeeRate::from_sat_per_vb(1.0),
140+
drain_weights: DrainWeights::TR_KEYSPEND,
141+
}
142+
}
143+
144+
fn bench_run_bnb_lowest_fee_sizes(
145+
c: &mut Criterion,
146+
group_name: &str,
147+
sizes: &[usize],
148+
expect_solution: bool,
149+
) {
150+
let mut group = c.benchmark_group(group_name);
151+
group.sample_size(10);
152+
for &n in sizes {
70153
let candidates = make_candidates(n);
71154
let selector = CoinSelector::new(&candidates);
72155
let (target, long_term_feerate) = make_bnb_inputs(&candidates);
156+
157+
// Pin what this group measures: if search dynamics change (metric,
158+
// bound tightness, candidate distribution), a size silently flipping
159+
// between the solution-finding and cap-exhaustion paths would corrupt
160+
// cross-version comparisons — fail loudly instead.
161+
let found = selector
162+
.clone()
163+
.run_bnb(target, bnb_lowest_fee_metric(long_term_feerate), MAX_ROUNDS)
164+
.is_ok();
165+
assert_eq!(
166+
found, expect_solution,
167+
"{}/{}: expected run_bnb solution-found == {}",
168+
group_name, n, expect_solution,
169+
);
170+
73171
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
74172
b.iter_batched(
75173
|| selector.clone(),
76174
|mut sel| {
77-
let metric = LowestFee {
78-
long_term_feerate,
79-
dust_relay_feerate: FeeRate::from_sat_per_vb(1.0),
80-
drain_weights: DrainWeights::TR_KEYSPEND,
81-
};
82-
let _ = sel.run_bnb(target, metric, black_box(100_000));
175+
let metric = bnb_lowest_fee_metric(long_term_feerate);
176+
let _ = sel.run_bnb(target, metric, black_box(MAX_ROUNDS));
83177
sel
84178
},
85179
BatchSize::SmallInput,
@@ -89,5 +183,25 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) {
89183
group.finish();
90184
}
91185

92-
criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee);
186+
fn bench_run_bnb_lowest_fee(c: &mut Criterion) {
187+
bench_run_bnb_lowest_fee_sizes(c, "run_bnb_lowest_fee", &[20, 50, 100], true);
188+
}
189+
190+
fn bench_run_bnb_lowest_fee_exhaust_cap(c: &mut Criterion) {
191+
bench_run_bnb_lowest_fee_sizes(
192+
c,
193+
"run_bnb_lowest_fee_exhaust_cap",
194+
&[200, 500, 1000],
195+
false,
196+
);
197+
}
198+
199+
criterion_group!(
200+
benches,
201+
bench_new,
202+
bench_coin_selector_clone,
203+
bench_compute_view,
204+
bench_run_bnb_lowest_fee,
205+
bench_run_bnb_lowest_fee_exhaust_cap
206+
);
93207
criterion_main!(benches);

0 commit comments

Comments
 (0)