Skip to content

Commit a52499b

Browse files
evanlinjinclaude
andcommitted
bench: add lowest_fee_bounds for V1 vs V2 comparison
Adds a Criterion bench that runs both bound versions side-by-side on a synthetic pool (mix of normal UTXOs + low-value dust to exercise the "go changeless via neg-ev" branch). Prints round-count comparison at the start and times each version. In this synthetic, V1 and V2 produce identical round counts — the tightening case (`amount_above_change_threshold > |ev_benchmark|`) doesn't fire because the dust candidates all have similar |ev|/value ratios, so the single-benchmark estimate happens to coincide with the LP-optimal cost. The proptest is what validates V2 is correct on more diverse pools. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent df23640 commit a52499b

2 files changed

Lines changed: 374 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ criterion = "0.5"
2929
[[bench]]
3030
name = "coin_selector"
3131
harness = false
32+
33+
[[bench]]
34+
name = "lowest_fee_bounds"
35+
harness = false

benches/lowest_fee_bounds.rs

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
//! Benchmark comparing `LowestFee` bound versions:
2+
//! - V1: the previous bound — single-benchmark estimate (uses only the lowest-`value_pwu`
3+
//! unselected candidate to estimate the cost of going changeless when `cs` has change).
4+
//! - V2: the LP-relaxed knapsack bound — sorts all negative-`ev` unselected candidates by
5+
//! `|ev|/value` descending and fractionally fills until the deficit is covered. Tighter
6+
//! when the single benchmark candidate's `|ev|` is smaller than the amount-above-threshold.
7+
8+
use bdk_coin_select::{
9+
float::Ordf32, metrics::LowestFee, BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain,
10+
DrainWeights, FeeRate, Target, TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT,
11+
TXOUT_BASE_WEIGHT,
12+
};
13+
use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
14+
use std::hint::black_box;
15+
16+
// ---------------------------------------------------------------------------
17+
// V1: previous bound. Single-benchmark "extra value to extinguish change" estimate.
18+
// ---------------------------------------------------------------------------
19+
20+
#[derive(Clone, Copy)]
21+
struct LowestFeeV1 {
22+
target: Target,
23+
long_term_feerate: FeeRate,
24+
change_policy: ChangePolicy,
25+
}
26+
27+
impl BnbMetric for LowestFeeV1 {
28+
fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
29+
if !cs.is_target_met(self.target) {
30+
return None;
31+
}
32+
let drain = cs.drain(self.target, self.change_policy);
33+
let fee_for_the_tx = cs.fee(self.target.value(), drain.value);
34+
assert!(fee_for_the_tx >= 0);
35+
let spend_fee = drain.weights.spend_fee(self.long_term_feerate);
36+
Some(Ordf32((fee_for_the_tx as u64 + spend_fee) as f32))
37+
}
38+
39+
fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32> {
40+
if cs.is_target_met(self.target) {
41+
let current_score = self.score(cs).unwrap();
42+
let drain_value = cs.drain_value(self.target, self.change_policy);
43+
if let Some(drain_value) = drain_value {
44+
// V1: single-benchmark candidate (the last unselected = lowest value_pwu).
45+
let amount_above_change_threshold = drain_value - self.change_policy.min_value;
46+
if let Some((_, low_sats_per_wu_candidate)) = cs.unselected().next_back() {
47+
let ev = low_sats_per_wu_candidate.effective_value(self.target.fee.rate);
48+
if ev < -0.0 {
49+
let value_per_negative_effective_value =
50+
low_sats_per_wu_candidate.value as f32 / ev.abs();
51+
let extra_value_needed_to_get_rid_of_change = amount_above_change_threshold
52+
as f32
53+
* value_per_negative_effective_value;
54+
let cost_of_getting_rid_of_change =
55+
extra_value_needed_to_get_rid_of_change + drain_value as f32;
56+
let cost_of_change = self.change_policy.drain_weights.waste(
57+
self.target.fee.rate,
58+
self.long_term_feerate,
59+
self.target.outputs.n_outputs,
60+
);
61+
let best_score_without_change = Ordf32(
62+
current_score.0 + cost_of_getting_rid_of_change - cost_of_change,
63+
);
64+
if best_score_without_change < current_score {
65+
return Some(best_score_without_change);
66+
}
67+
}
68+
}
69+
} else {
70+
let cost_of_adding_change = self.change_policy.drain_weights.waste(
71+
self.target.fee.rate,
72+
self.long_term_feerate,
73+
self.target.outputs.n_outputs,
74+
);
75+
let cost_of_no_change = cs.excess(self.target, Drain::NONE);
76+
let best_score_with_change =
77+
Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change);
78+
if best_score_with_change < current_score {
79+
return Some(best_score_with_change);
80+
}
81+
}
82+
return Some(current_score);
83+
}
84+
// Target not met — same resize trick as the current LowestFee, inlined for the
85+
// benchmark (we don't share with the in-src `resize_bound` helper to keep V1 fully
86+
// self-contained).
87+
let (mut cs, resize_index, to_resize) = cs
88+
.clone()
89+
.select_iter()
90+
.find(|(c, _, _)| c.is_target_met(self.target))?;
91+
if cs.excess(self.target, Drain::NONE) == 0 {
92+
return Some(self.score(&cs).unwrap());
93+
}
94+
cs.deselect(resize_index);
95+
let mut scale = 0.0_f32;
96+
let rate_excess = cs.rate_excess_wu(self.target, Drain::NONE) as f32;
97+
if rate_excess < 0.0 {
98+
let r = rate_excess.abs();
99+
let ev = to_resize.effective_value(self.target.fee.rate);
100+
if ev > 0.0 {
101+
scale = scale.max(r / ev);
102+
} else {
103+
return None;
104+
}
105+
}
106+
if let Some(replace) = self.target.fee.replace {
107+
let r = cs.replacement_excess_wu(self.target, Drain::NONE) as f32;
108+
if r < 0.0 {
109+
let ev = to_resize.effective_value(replace.incremental_relay_feerate);
110+
if ev > 0.0 {
111+
scale = scale.max(r.abs() / ev);
112+
} else {
113+
return None;
114+
}
115+
}
116+
}
117+
let abs_excess = cs.absolute_excess(self.target, Drain::NONE) as f32;
118+
if abs_excess < 0.0 {
119+
if to_resize.value > 0 {
120+
scale = scale.max(abs_excess.abs() / to_resize.value as f32);
121+
} else {
122+
return None;
123+
}
124+
}
125+
let ideal_fee = scale * to_resize.value as f32 + cs.selected_value() as f32
126+
- self.target.value() as f32;
127+
Some(Ordf32(ideal_fee.max(0.0)))
128+
}
129+
130+
fn requires_ordering_by_descending_value_pwu(&self) -> bool {
131+
true
132+
}
133+
}
134+
135+
// ---------------------------------------------------------------------------
136+
// Pool / scenarios.
137+
// ---------------------------------------------------------------------------
138+
139+
/// Diverse synthetic pool: bulk of normal-sized UTXOs plus a sprinkling of low-value
140+
/// "dust" UTXOs that will have negative `ev` at the test feerates. The dust ones are what
141+
/// trigger the "go changeless by adding negative-ev inputs" branch of `LowestFee::bound`.
142+
fn make_candidates(n: usize) -> Vec<Candidate> {
143+
const P2WPKH_SAT_W: u64 = 107;
144+
let dust_every = 4; // every 4th candidate is dust
145+
(0..n)
146+
.map(|i| {
147+
let i_u = i as u64;
148+
let (value, weight) = if i % dust_every == 0 {
149+
// Dust: small value, larger weight. value=200, weight ~= 400 -> value_pwu=0.5.
150+
// At feerate 10 vb (= 2.5 sat/wu), ev = 200 - 400*2.5 = -800 (negative).
151+
(200 + (i_u % 50), TXIN_BASE_WEIGHT + 250 + (i_u % 50))
152+
} else {
153+
// Normal: scaled up. value range ~1k-70k.
154+
(
155+
1_000 + i_u * 137 + i_u * i_u,
156+
TXIN_BASE_WEIGHT + P2WPKH_SAT_W,
157+
)
158+
};
159+
Candidate {
160+
value,
161+
weight,
162+
input_count: 1,
163+
is_segwit: true,
164+
}
165+
})
166+
.collect()
167+
}
168+
169+
#[derive(Clone, Copy)]
170+
struct Scenario {
171+
name: &'static str,
172+
target_feerate_vb: f32,
173+
long_term_feerate_vb: f32,
174+
target_fraction: f32, // fraction of total candidate value as target
175+
}
176+
177+
const SCENARIOS: &[Scenario] = &[
178+
Scenario {
179+
name: "rate_pos_half",
180+
target_feerate_vb: 10.0,
181+
long_term_feerate_vb: 2.0,
182+
target_fraction: 0.5,
183+
},
184+
Scenario {
185+
name: "rate_neg_half",
186+
target_feerate_vb: 2.0,
187+
long_term_feerate_vb: 10.0,
188+
target_fraction: 0.5,
189+
},
190+
// Small-target scenarios are where the V1 single-benchmark estimate is most likely to
191+
// diverge from V2 — the "amount above change threshold" is large relative to a single
192+
// candidate's `|ev|`.
193+
Scenario {
194+
name: "rate_pos_small",
195+
target_feerate_vb: 10.0,
196+
long_term_feerate_vb: 2.0,
197+
target_fraction: 0.05,
198+
},
199+
Scenario {
200+
name: "rate_neg_small",
201+
target_feerate_vb: 2.0,
202+
long_term_feerate_vb: 10.0,
203+
target_fraction: 0.05,
204+
},
205+
];
206+
207+
fn make_inputs(candidates: &[Candidate], s: Scenario) -> (Target, ChangePolicy, FeeRate) {
208+
let target_fr = FeeRate::from_sat_per_vb(s.target_feerate_vb);
209+
let long_term_fr = FeeRate::from_sat_per_vb(s.long_term_feerate_vb);
210+
let total: u64 = candidates.iter().map(|c| c.value).sum();
211+
let target_value = ((total as f32) * s.target_fraction) as u64;
212+
let target = Target {
213+
fee: TargetFee::from_feerate(target_fr),
214+
outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, target_value)]),
215+
};
216+
let change_policy =
217+
ChangePolicy::min_value_and_waste(DrainWeights::TR_KEYSPEND, 294, target_fr, long_term_fr);
218+
(target, change_policy, long_term_fr)
219+
}
220+
221+
struct RunResult {
222+
best: Option<f32>,
223+
rounds: usize,
224+
completed: bool,
225+
}
226+
227+
fn run_and_count<M: BnbMetric>(cs: &CoinSelector<'_>, metric: M, max_rounds: usize) -> RunResult {
228+
let mut rounds = 0usize;
229+
let mut best: Option<f32> = None;
230+
let mut iter = cs.bnb_solutions(metric);
231+
while rounds < max_rounds {
232+
match iter.next() {
233+
Some(step) => {
234+
rounds += 1;
235+
if let Some((_, score)) = step {
236+
best = Some(score.0);
237+
}
238+
}
239+
None => {
240+
return RunResult {
241+
best,
242+
rounds,
243+
completed: true,
244+
};
245+
}
246+
}
247+
}
248+
RunResult {
249+
best,
250+
rounds,
251+
completed: false,
252+
}
253+
}
254+
255+
fn fmt_rounds(r: &RunResult) -> String {
256+
if r.completed {
257+
format!("{}", r.rounds)
258+
} else {
259+
format!("{} (cap)", r.rounds)
260+
}
261+
}
262+
263+
fn print_round_report() {
264+
const MAX_ROUNDS: usize = 2_000_000;
265+
println!();
266+
println!(
267+
"=== LowestFee bound version comparison (cap {}) ===",
268+
MAX_ROUNDS
269+
);
270+
println!(
271+
"{:>16} {:>5} {:>14} {:>14}",
272+
"scenario", "n", "v1_rounds", "v2_rounds"
273+
);
274+
for &n in &[20usize, 50, 100, 200] {
275+
let candidates = make_candidates(n);
276+
for &s in SCENARIOS {
277+
let (target, change_policy, long_term_feerate) = make_inputs(&candidates, s);
278+
let cs = CoinSelector::new(&candidates);
279+
280+
let r1 = run_and_count(
281+
&cs,
282+
LowestFeeV1 {
283+
target,
284+
long_term_feerate,
285+
change_policy,
286+
},
287+
MAX_ROUNDS,
288+
);
289+
let r2 = run_and_count(
290+
&cs,
291+
LowestFee {
292+
target,
293+
long_term_feerate,
294+
change_policy,
295+
},
296+
MAX_ROUNDS,
297+
);
298+
299+
if r1.completed && r2.completed {
300+
assert_eq!(
301+
r1.best, r2.best,
302+
"V1 and V2 disagree at n={n} scenario={}",
303+
s.name
304+
);
305+
}
306+
307+
println!(
308+
"{:>16} {:>5} {:>14} {:>14}",
309+
s.name,
310+
n,
311+
fmt_rounds(&r1),
312+
fmt_rounds(&r2),
313+
);
314+
}
315+
}
316+
println!();
317+
}
318+
319+
fn bench_versions(c: &mut Criterion) {
320+
print_round_report();
321+
322+
let mut group = c.benchmark_group("lowest_fee_bounds");
323+
group.sample_size(10);
324+
325+
for &n in &[50usize, 100] {
326+
let candidates = make_candidates(n);
327+
for &s in SCENARIOS {
328+
let (target, change_policy, long_term_feerate) = make_inputs(&candidates, s);
329+
let selector = CoinSelector::new(&candidates);
330+
331+
let id_v1 = BenchmarkId::new(format!("v1/{}", s.name), n);
332+
group.bench_with_input(id_v1, &n, |b, _| {
333+
b.iter_batched(
334+
|| selector.clone(),
335+
|mut sel| {
336+
let m = LowestFeeV1 {
337+
target,
338+
long_term_feerate,
339+
change_policy,
340+
};
341+
let _ = sel.run_bnb(m, black_box(2_000_000));
342+
sel
343+
},
344+
BatchSize::SmallInput,
345+
);
346+
});
347+
348+
let id_v2 = BenchmarkId::new(format!("v2/{}", s.name), n);
349+
group.bench_with_input(id_v2, &n, |b, _| {
350+
b.iter_batched(
351+
|| selector.clone(),
352+
|mut sel| {
353+
let m = LowestFee {
354+
target,
355+
long_term_feerate,
356+
change_policy,
357+
};
358+
let _ = sel.run_bnb(m, black_box(2_000_000));
359+
sel
360+
},
361+
BatchSize::SmallInput,
362+
);
363+
});
364+
}
365+
}
366+
group.finish();
367+
}
368+
369+
criterion_group!(benches, bench_versions);
370+
criterion_main!(benches);

0 commit comments

Comments
 (0)