Skip to content

Commit 5a78f96

Browse files
rabbitson87claude
andcommitted
perf(mempool): order the priority index instead of re-sorting it per insert
`ParetoFront::insert` held its keys in a flat vector and, on every insert, did a linear `remove` followed by `sort_by` over the whole index. Filling an index of n entries was O(n^2 log n). That is not an idle path. `Mempool::insert_entry` — the path that accepts a transaction from a peer — calls `recompute_all_metadata`, which discards the priority index and re-inserts every entry, so the quadratic cost was paid once per accepted transaction by anyone able to put transactions in the mempool. Measured across one Criterion run over one fixture, both arms in process: 1,000 entries 2.064 ms -> 151.0 us 13.7x 4,000 entries 45.65 ms -> 626.2 us 72.9x 16,000 entries 464.7 ms -> 3.046 ms 152.6x 50,000 entries 4.497 s -> 10.45 ms 430.4x Measured exponent 1.97 for the old arm across that span, i.e. n^2; the new arm takes 69x longer over 50x the entries against the 78x n log n predicts. The replacement is an ordered set keyed by the priority comparison plus a map from entry id to the key currently indexed for it. The map is not redundant: removals arrive as an id while the set is keyed by priority, so without it a removal would have to search the set and the linear scan would come back. The ordering's final tiebreak on entry id was cosmetic before and is now load-bearing. The index is a *set of keys*, so two entries whose keys compared equal would collapse into one and a transaction would silently leave the priority index. THIS DOES NOT CLOSE THE QUADRATIC. `recompute_all_metadata` still walks every entry per insert, so `Mempool::insert_entry` measures an exponent of 2.17 *with this change applied* — 2.572 ms at 200 transactions against 1.057 s at 3,200. docs/benchmarks/mempool-pareto.md reports that rather than claiming a fix, and names the follow-up. The flat-vector index is retained whole as `SortedParetoFront`: the oracle the equivalence tests compare against and the benchmark's `before` arm. Four mutations, all killed on their named tests. The audit found a defect in the tests themselves. The oracle originally shared `ParetoKey`'s `Ord` with the replacement, which made it worthless: under a reversed-ordering mutation both implementations agreed with each other and both equivalence tests stayed green while the index was ordered backwards. The oracle now keeps its own verbatim copy of the comparison and the same mutation kills all three ordering tests. An oracle that shares code with the implementation cannot disagree with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent aa5b3f1 commit 5a78f96

7 files changed

Lines changed: 539 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mempool/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ tracing.workspace = true
3535
[dev-dependencies]
3636
proptest.workspace = true
3737
serde_json.workspace = true
38+
39+
criterion.workspace = true
40+
41+
[[bench]]
42+
name = "pareto"
43+
harness = false

crates/mempool/benches/pareto.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Mempool priority-index refactor-set benchmark.
2+
//!
3+
//! Both arms run over one identical fixture in one process, so the before/after
4+
//! ratio comes from a single run and cannot be confounded by the rebuild and
5+
//! baseline drift recorded in
6+
//! `docs/solutions/best-practices/criterion-bench-trust-rebuild-drift-baselines-allocator.md`.
7+
//!
8+
//! `before_sorted` is `SortedParetoFront`, the flat vector that did a linear
9+
//! `remove` and a full `sort_by` on every insert. `after_ordered` is
10+
//! `ParetoFront`, the ordered set that replaced it.
11+
//!
12+
//! `mempool_insert_entry` is the end-to-end path an attacker actually drives:
13+
//! `Mempool::insert_entry` calls `recompute_all_metadata`, which rebuilds the
14+
//! whole priority index for every accepted transaction. It is benchmarked at
15+
//! smaller sizes than the index arms because that outer rebuild is quadratic
16+
//! *independently* of the index — which is the point of measuring it separately.
17+
// PERF: Criterion emits public harness items whose docs are irrelevant here.
18+
#![allow(missing_docs)]
19+
// A fixture that fails to build has no meaningful degraded mode: a fill that
20+
// silently indexed nothing would be timed as a win.
21+
#![allow(clippy::expect_used)]
22+
23+
use std::hint::black_box;
24+
use std::sync::Arc;
25+
26+
use bitcoin::hashes::Hash as _;
27+
use bitcoin::{
28+
Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, absolute,
29+
transaction,
30+
};
31+
use bitcoin_rs_mempool::{Mempool, MempoolEntry, MempoolLimits, ParetoFront, SortedParetoFront};
32+
use criterion::{Criterion, criterion_group, criterion_main};
33+
34+
/// Index fill sizes. The largest is far below a Core-default mempool (~10^5
35+
/// transactions at `-maxmempool=300MB`); the quadratic arm cannot be measured
36+
/// there in reasonable time, which is itself the finding.
37+
const FILL_SIZES: [usize; 4] = [1_000, 4_000, 16_000, 50_000];
38+
39+
/// End-to-end sizes, an order of magnitude smaller: `insert_entry` rebuilds the
40+
/// entire index per transaction, so its cost is the index fill cost multiplied
41+
/// by the number of transactions.
42+
const POOL_SIZES: [usize; 3] = [200, 800, 3_200];
43+
44+
fn spread_fee(seed: u64) -> u64 {
45+
// Not monotonic in the seed: an index fed entries already in priority order
46+
// never has to reorder anything, and would benchmark the best case only.
47+
(seed.wrapping_mul(2_654_435_761) % 100_000).saturating_add(1)
48+
}
49+
50+
fn distinct_tx(seed: u64) -> Transaction {
51+
let mut previous = [0_u8; 32];
52+
previous[..8].copy_from_slice(&seed.to_le_bytes());
53+
Transaction {
54+
version: transaction::Version::TWO,
55+
lock_time: absolute::LockTime::ZERO,
56+
input: vec![TxIn {
57+
// Distinct prevouts: entries that conflict would be rejected rather
58+
// than accepted, and the fill would measure the rejection path.
59+
previous_output: OutPoint::new(Txid::from_byte_array(previous), 0),
60+
script_sig: ScriptBuf::new(),
61+
sequence: Sequence::MAX,
62+
witness: Witness::new(),
63+
}],
64+
output: vec![TxOut {
65+
value: Amount::from_sat(10_000),
66+
script_pubkey: ScriptBuf::from_bytes(seed.to_le_bytes().to_vec()),
67+
}],
68+
}
69+
}
70+
71+
fn entry(seed: u64) -> MempoolEntry {
72+
MempoolEntry::new(Arc::new(distinct_tx(seed)), 200, spread_fee(seed), seed, 0)
73+
}
74+
75+
fn bench_index_fill(c: &mut Criterion) {
76+
let mut group = c.benchmark_group("mempool_pareto");
77+
group.sample_size(10);
78+
79+
for size in FILL_SIZES {
80+
let entries = (0..size as u64).map(entry).collect::<Vec<_>>();
81+
82+
// Prove both arms index the same fixture before timing either. An arm
83+
// that dropped entries would be timed as a spectacular, meaningless win.
84+
let mut check_before = SortedParetoFront::new();
85+
let mut check_after = ParetoFront::new();
86+
for (index, item) in entries.iter().enumerate().take(1_000) {
87+
let id = u32::try_from(index).expect("fixture id fits u32");
88+
check_before.insert(id, item);
89+
check_after.insert(id, item);
90+
}
91+
assert_eq!(
92+
check_before.top_n(check_before.len()).collect::<Vec<_>>(),
93+
check_after.top_n(check_after.len()).collect::<Vec<_>>(),
94+
"the arms order differently; the benchmark would be meaningless"
95+
);
96+
97+
group.bench_function(format!("before_sorted/fill/{size}"), |b| {
98+
b.iter(|| {
99+
let mut front = SortedParetoFront::new();
100+
for (index, item) in entries.iter().enumerate() {
101+
front.insert(u32::try_from(index).unwrap_or(u32::MAX), item);
102+
}
103+
black_box(front.len())
104+
});
105+
});
106+
group.bench_function(format!("after_ordered/fill/{size}"), |b| {
107+
b.iter(|| {
108+
let mut front = ParetoFront::new();
109+
for (index, item) in entries.iter().enumerate() {
110+
front.insert(u32::try_from(index).unwrap_or(u32::MAX), item);
111+
}
112+
black_box(front.len())
113+
});
114+
});
115+
}
116+
117+
group.finish();
118+
}
119+
120+
fn bench_mempool_fill(c: &mut Criterion) {
121+
let mut group = c.benchmark_group("mempool_insert_entry");
122+
group.sample_size(10);
123+
124+
for size in POOL_SIZES {
125+
let entries = (0..size as u64).map(entry).collect::<Vec<_>>();
126+
group.bench_function(format!("fill/{size}"), |b| {
127+
b.iter(|| {
128+
let mut pool = Mempool::new(MempoolLimits::default());
129+
for item in &entries {
130+
let _ = pool.insert_entry(item.clone());
131+
}
132+
black_box(pool.len())
133+
});
134+
});
135+
}
136+
137+
group.finish();
138+
}
139+
140+
criterion_group! {
141+
name = benches;
142+
config = Criterion::default();
143+
targets = bench_index_fill, bench_mempool_fill
144+
}
145+
criterion_main!(benches);

crates/mempool/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub mod standardness;
2424

2525
pub use entry::{EntryId, MempoolEntry};
2626
pub use eviction::evict_lowest_fee_packages;
27-
pub use pareto::ParetoFront;
27+
pub use pareto::{ParetoFront, SortedParetoFront};
2828
pub use policy::{MempoolLimits, PolicyError};
2929
pub use pool::{Mempool, MempoolError, MempoolStats, ScriptHash};
3030
pub use rbf::{RbfError, ReplacementCandidate, ReplacementPlan};

crates/mempool/src/pareto.rs

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1+
use alloc::collections::{BTreeMap, BTreeSet};
2+
13
use tinyvec::TinyVec;
24

35
use crate::{EntryId, MempoolEntry};
46

57
/// Priority index ordered by fee rate, ancestor fee rate, then age.
8+
///
9+
/// Ordering lives in [`ParetoKey`]'s [`Ord`], and the set is kept in that order
10+
/// rather than re-sorted. Insertion and removal are both `O(log n)`.
11+
///
12+
/// The previous implementation held the keys in a flat vector, and every
13+
/// `insert` did a linear `remove` followed by a full `sort_by`. Filling a
14+
/// mempool was therefore quadratic — 4.92 ms at 1,000 entries against 4.57 s at
15+
/// 50,000, a measured exponent of 2.05 — and the cost is paid on the path that
16+
/// accepts transactions from peers, so it was reachable by anyone who could fill
17+
/// the mempool. [`SortedParetoFront`] keeps that implementation as the oracle
18+
/// these tests compare against and as the benchmark's `before` arm.
619
#[derive(Clone, Debug, Default)]
720
pub struct ParetoFront {
8-
entries: TinyVec<[ParetoKey; 256]>,
21+
/// Keys in priority order.
22+
order: BTreeSet<ParetoKey>,
23+
/// The key currently indexed for each entry.
24+
///
25+
/// A removal is given an id, and the ordered set is keyed by priority, so
26+
/// without this a removal would have to search the set to find what to
27+
/// remove — which is the linear scan this type exists to avoid.
28+
keys: BTreeMap<EntryId, ParetoKey>,
929
}
1030

1131
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -16,7 +36,111 @@ struct ParetoKey {
1636
time: u64,
1737
}
1838

39+
impl Ord for ParetoKey {
40+
/// Highest fee rate first, then highest ancestor fee rate, then oldest.
41+
///
42+
/// The final tiebreak on `id` is what makes this a *total* order, and that
43+
/// is load-bearing rather than cosmetic: the ordered set stores keys, so two
44+
/// entries whose keys compared `Equal` would collapse into one and an entry
45+
/// would silently vanish from the mempool's priority index. Entry ids are
46+
/// unique, so no two distinct entries can compare equal.
47+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
48+
other
49+
.fee_rate
50+
.cmp(&self.fee_rate)
51+
.then_with(|| other.ancestor_fee_rate.cmp(&self.ancestor_fee_rate))
52+
.then_with(|| self.time.cmp(&other.time))
53+
.then_with(|| self.id.cmp(&other.id))
54+
}
55+
}
56+
57+
impl PartialOrd for ParetoKey {
58+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
59+
Some(self.cmp(other))
60+
}
61+
}
62+
63+
impl ParetoKey {
64+
fn new(id: EntryId, entry: &MempoolEntry) -> Self {
65+
Self {
66+
id,
67+
fee_rate: entry.fee_rate,
68+
ancestor_fee_rate: entry.ancestor_fee_rate(),
69+
time: entry.time,
70+
}
71+
}
72+
}
73+
1974
impl ParetoFront {
75+
/// Creates an empty priority index.
76+
#[must_use]
77+
pub fn new() -> Self {
78+
Self {
79+
order: BTreeSet::new(),
80+
keys: BTreeMap::new(),
81+
}
82+
}
83+
84+
/// Inserts or replaces an entry in priority order.
85+
///
86+
/// Replacement is not a special case for the caller but is one here: an
87+
/// entry whose ancestor fee rate changed has a different key, so the stale
88+
/// key must leave the ordered set or the entry would be indexed twice.
89+
pub fn insert(&mut self, id: EntryId, entry: &MempoolEntry) {
90+
let key = ParetoKey::new(id, entry);
91+
if let Some(previous) = self.keys.insert(id, key) {
92+
let _ = self.order.remove(&previous);
93+
}
94+
let _ = self.order.insert(key);
95+
}
96+
97+
/// Removes an entry from the priority index.
98+
pub fn remove(&mut self, id: EntryId) -> bool {
99+
let Some(key) = self.keys.remove(&id) else {
100+
return false;
101+
};
102+
self.order.remove(&key)
103+
}
104+
105+
/// Returns the highest-priority `n` entry identifiers.
106+
pub fn top_n(&self, n: usize) -> impl Iterator<Item = EntryId> + '_ {
107+
self.order.iter().take(n).map(|key| key.id)
108+
}
109+
110+
/// Returns `true` if the front is empty.
111+
#[must_use]
112+
pub fn is_empty(&self) -> bool {
113+
self.order.is_empty()
114+
}
115+
116+
/// Returns the number of indexed entries.
117+
#[must_use]
118+
pub fn len(&self) -> usize {
119+
self.order.len()
120+
}
121+
}
122+
123+
/// The flat-vector priority index [`ParetoFront`] replaced.
124+
///
125+
/// Retained deliberately, not left behind: it is the oracle the equivalence
126+
/// tests compare the replacement against, and the `before` arm of
127+
/// `benches/pareto.rs`. Both arms have to run in one process over one fixture
128+
/// for the ratio to mean anything, which they cannot do if this is deleted.
129+
///
130+
/// Nothing in the node uses it. It is quadratic to fill, which is the entire
131+
/// reason it was replaced.
132+
///
133+
/// It keeps its own copy of the comparison rather than borrowing
134+
/// [`ParetoKey`]'s [`Ord`]. Sharing it looked tidier and made the oracle
135+
/// worthless: a mutation that reversed the ordering left both implementations
136+
/// agreeing with each other, so the equivalence tests stayed green while the
137+
/// index was ordered backwards. An oracle has to be able to disagree.
138+
#[derive(Clone, Debug, Default)]
139+
pub struct SortedParetoFront {
140+
entries: TinyVec<[ParetoKey; 256]>,
141+
}
142+
143+
impl SortedParetoFront {
20144
/// Creates an empty priority index.
21145
#[must_use]
22146
pub fn new() -> Self {
@@ -28,21 +152,16 @@ impl ParetoFront {
28152
/// Inserts or replaces an entry in priority order.
29153
pub fn insert(&mut self, id: EntryId, entry: &MempoolEntry) {
30154
self.remove(id);
31-
self.entries.push(ParetoKey {
32-
id,
33-
fee_rate: entry.fee_rate,
34-
ancestor_fee_rate: entry.ancestor_fee_rate(),
35-
time: entry.time,
36-
});
37-
self.entries.sort_by(compare_keys);
155+
self.entries.push(ParetoKey::new(id, entry));
156+
self.entries.sort_by(legacy_compare_keys);
38157
}
39158

40159
/// Removes an entry from the priority index.
41160
pub fn remove(&mut self, id: EntryId) -> bool {
42161
let Some(index) = self.entries.iter().position(|entry| entry.id == id) else {
43162
return false;
44163
};
45-
self.entries.remove(index);
164+
let _ = self.entries.remove(index);
46165
true
47166
}
48167

@@ -64,7 +183,11 @@ impl ParetoFront {
64183
}
65184
}
66185

67-
fn compare_keys(left: &ParetoKey, right: &ParetoKey) -> core::cmp::Ordering {
186+
/// The ordering the flat-vector index sorted by, kept verbatim.
187+
///
188+
/// Deliberately a duplicate of [`ParetoKey`]'s [`Ord`] rather than a call to it.
189+
/// See [`SortedParetoFront`].
190+
fn legacy_compare_keys(left: &ParetoKey, right: &ParetoKey) -> core::cmp::Ordering {
68191
right
69192
.fee_rate
70193
.cmp(&left.fee_rate)

0 commit comments

Comments
 (0)