Skip to content

Commit 1106f14

Browse files
committed
perf(utxo): shrink the record payload 21.7% and make lookups faster than v4
The attribution in the previous commit put the UTXO set at 77.4% of process RSS and the tip projection at 83% of the 16 GiB G14 budget on the UTXO path alone, before txindex and blockfilterindex. This is the encoding work that margin justified. v5 keeps the record header and replaces the per-output layout: txid(32) || output_count(4) || legacy_inline_len(1) || widths(1) || vout_dir : one fixed-width little-endian entry per output || len_dir : one fixed-width payload length per output || payloads : varint(amount) [|| raw amount] || varint(height<<1|coinbase) || script Three transforms do the shrinking, all per-output with no cross-output invariant to violate: Core's `CTxOutCompressor` amount transform, `height` and `coinbase` packed into one varint, and directory widths that are the narrowest the record needs. The script length is not stored — the script is whatever remains of its payload, so the length directory pays for itself. Measured: **11.75 bytes per output, 21.7% of the payload**, which is 14.8% of process RSS and about 1.97 GiB at tip. Hoisting `height` into the record header would save 3 bytes more and is deliberately not done: it needs "every output of a record shares one height", and BIP30's duplicate coinbase txids are exactly where that might not hold. The directories are the load-bearing part, and they exist because the first draft was wrong. That draft was a flat varint frame per output. It hit the size target and lost badly on speed: operation v4 flat v5 directory v5 get_miss (shard lookup) 705 ns 3.42 µs 300 ns get_last 728 ns 3.43 µs 617 ns get_middle 384 ns 1.67 µs 342 ns spend_fanout_64 18.5 µs 39.9 µs 21.3 µs spend_fanout_64_noop 86.7 µs 115.2 µs 77.1 µs Two mistakes produced that, and neither was visible until the benchmark was reshaped around the operation that actually dominates: 1. The benchmark timed whole-record encode/decode. The hot read is `find_output(vout)` — every spent input resolves through `Shard::get`, `get_entry` or `get_meta`, and all three land there. Whole-record decode is the snapshot and rescan path, which is rare by comparison. 2. v4 gets lazy field skipping for free and a flat varint layout cannot. Every v4 field sits at a constant offset, so when only `vout` is read the optimizer deletes the loads for the rest. In a flat layout each varint's length locates the next field, so the reads are a serial dependency chain, and finding output `i` walks the bytes of outputs `0..i`, scripts included. The directories remove exactly that: a lookup scans one dense fixed-width array and sums a second, touching ~2 bytes per output instead of ~35. Nine of the twelve `utxo_commit` lookup arms now beat v4; the three that do not are the `_first` cases, 10 ns apart in absolute terms. Encoding is 1.6-2.4x slower, because the directory widths are a property of the whole record so nothing can be written until every payload length is known. At block scale that is commit p95 +3% (`existing`), +8% (`uniform`) and +21% (`concentrated`). The G14 budget is 50 ms and the worst case measured is 2.57 ms. Checked by: - `tests/record_codec_equivalence.rs`, 7 tests. v4 is retained as the oracle; equality is per field over every decoded `OneUtxoOut`, in order, since comparing encoded bytes is meaningless when the layouts differ by design. Size is asserted as a property, not a spot check. - `non_canonical_v5_spellings_are_rejected` covers every second spelling the two layouts introduce: a non-minimal varint, the amount escape used for a value the compact form already covers, and a directory wider than the record needs. `UtxoRecord` compares by bytes, so two spellings of one record is a correctness bug. - `find_output_decompresses_at_most_the_amount_it_returns` asserts the work rather than the time: one amount decompression for a hit, none for a miss, none for `max_vout`. A wall-clock assertion in a test suite is a flake generator; counting the expensive operation is the same claim made deterministically. The `utxo_commit` arms cannot be paired in one run since only one codec is compiled in, so the v4 comparison was taken A-B-A across a stash, with the two v5 runs agreeing to 0.1-2.9%.
1 parent 0b07e13 commit 1106f14

7 files changed

Lines changed: 1852 additions & 63 deletions

File tree

crates/utxo/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,7 @@ mimalloc.workspace = true
5757
[[bench]]
5858
name = "utxo_commit"
5959
harness = false
60+
61+
[[bench]]
62+
name = "record_codec"
63+
harness = false
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
//! Paired benchmark for the v4 and v5 `UtxoRecord` payload codecs.
2+
//!
3+
//! This set carries a third acceptance criterion beyond equivalence and speed:
4+
//! **bytes**. v5 exists because a mainnet attribution run put the UTXO set at
5+
//! 77.4% of process RSS (`docs/benchmarks/utxo-memory.md`), so a codec that is
6+
//! lossless and faster but not smaller has missed. Every group therefore sets
7+
//! Criterion's throughput to the encoded payload size, and the harness prints
8+
//! the size table before measuring.
9+
//!
10+
//! Both arms run over one fixture in one group, so the reported spread is the
11+
//! change and not rebuild drift against a stored baseline.
12+
// PERF: Criterion emits public harness items whose docs are irrelevant here.
13+
#![allow(missing_docs)]
14+
// A fixture that fails to encode has no meaningful degraded mode.
15+
#![allow(clippy::expect_used)]
16+
17+
use std::hint::black_box;
18+
19+
use bitcoin_rs_primitives::Hash256;
20+
use bitcoin_rs_utxo::{OneUtxoOut, RecordCodec};
21+
use criterion::{Criterion, Throughput, criterion_group, criterion_main};
22+
23+
/// Live outputs per record measured on a real chainstate at height 412,732.
24+
/// The txid amortizes over this, so it is the number the payload size is most
25+
/// sensitive to.
26+
const MEASURED_OUTPUTS_PER_RECORD: usize = 4;
27+
28+
fn txid() -> Hash256 {
29+
Hash256::from_le_bytes(&[0x3c; 32])
30+
}
31+
32+
/// Mainnet script mix by share of the UTXO set: P2WPKH 22 B, P2PKH 25 B,
33+
/// P2SH 23 B, P2TR 34 B.
34+
fn script(index: usize) -> Vec<u8> {
35+
let len = match index % 4 {
36+
0 => 22,
37+
1 => 25,
38+
2 => 23,
39+
_ => 34,
40+
};
41+
let tag = u8::try_from(index % 251).unwrap_or(0);
42+
core::iter::repeat_n(tag, len).collect()
43+
}
44+
45+
/// Owned outputs shaped like a real record: small vouts, heights in the
46+
/// 800k range, and amounts that are mostly round numbers of satoshis.
47+
fn outputs(count: usize) -> Vec<(u32, u64, Vec<u8>, bool, u32)> {
48+
(0..count)
49+
.map(|index| {
50+
let value = if index % 3 == 0 {
51+
// Round: what Core's amount transform exists for.
52+
u64::try_from(index + 1).unwrap_or(1) * 10_000_000
53+
} else {
54+
// Not round: costs one extra bit and no more.
55+
u64::try_from(index).unwrap_or(0) * 7_919 + 54_321
56+
};
57+
(
58+
u32::try_from(index).unwrap_or(0),
59+
value,
60+
script(index),
61+
index == 0,
62+
800_000 + u32::try_from(index).unwrap_or(0),
63+
)
64+
})
65+
.collect()
66+
}
67+
68+
fn views(owned: &[(u32, u64, Vec<u8>, bool, u32)]) -> Vec<OneUtxoOut<'_>> {
69+
owned
70+
.iter()
71+
.map(|(vout, value, script, coinbase, height)| OneUtxoOut {
72+
vout: *vout,
73+
value: *value,
74+
script_pubkey: script,
75+
coinbase: *coinbase,
76+
height: *height,
77+
})
78+
.collect()
79+
}
80+
81+
fn bench_codec(c: &mut Criterion, count: usize) {
82+
let owned = outputs(count);
83+
let views = views(&owned);
84+
85+
let encoded_v4 = RecordCodec::encode_v4(txid(), &views).expect("v4 encodes");
86+
let encoded_v5 = RecordCodec::encode_v5(txid(), &views).expect("v5 encodes");
87+
88+
// The size result, printed rather than merely measured: Criterion reports
89+
// time, and time is not what this change is for.
90+
let saved = encoded_v4.len().saturating_sub(encoded_v5.len());
91+
println!(
92+
"record_codec/outputs_{count}: v4 {} B, v5 {} B, saved {} B ({:.2} B/output, {:.1}%)",
93+
encoded_v4.len(),
94+
encoded_v5.len(),
95+
saved,
96+
f64::from(u32::try_from(saved).unwrap_or(0)) / f64::from(u32::try_from(count).unwrap_or(1)),
97+
100.0 * f64::from(u32::try_from(saved).unwrap_or(0))
98+
/ f64::from(u32::try_from(encoded_v4.len()).unwrap_or(1)),
99+
);
100+
101+
let mut group = c.benchmark_group(format!("record_codec/encode/outputs_{count}"));
102+
group.throughput(Throughput::Bytes(
103+
u64::try_from(encoded_v4.len()).unwrap_or(0),
104+
));
105+
group.bench_function("before_v4", |b| {
106+
b.iter(|| black_box(RecordCodec::encode_v4(txid(), black_box(&views)).expect("encodes")));
107+
});
108+
group.throughput(Throughput::Bytes(
109+
u64::try_from(encoded_v5.len()).unwrap_or(0),
110+
));
111+
group.bench_function("after_v5", |b| {
112+
b.iter(|| black_box(RecordCodec::encode_v5(txid(), black_box(&views)).expect("encodes")));
113+
});
114+
group.finish();
115+
116+
let mut group = c.benchmark_group(format!("record_codec/decode_all/outputs_{count}"));
117+
group.throughput(Throughput::Bytes(
118+
u64::try_from(encoded_v4.len()).unwrap_or(0),
119+
));
120+
group.bench_function("before_v4", |b| {
121+
b.iter(|| black_box(RecordCodec::decode_v4(black_box(&encoded_v4)).expect("decodes")));
122+
});
123+
group.throughput(Throughput::Bytes(
124+
u64::try_from(encoded_v5.len()).unwrap_or(0),
125+
));
126+
group.bench_function("after_v5", |b| {
127+
b.iter(|| black_box(RecordCodec::decode_v5(black_box(&encoded_v5)).expect("decodes")));
128+
});
129+
group.finish();
130+
131+
// The operation that actually dominates: every spent input resolves one
132+
// output by vout through `Shard::get`/`get_entry`/`get_meta`. Decoding a
133+
// whole record is the snapshot and rescan path, which is rare by
134+
// comparison, so a codec judged only on `decode_all` is judged on the wrong
135+
// thing.
136+
//
137+
// `hit_last` is the worst case (the whole record is walked first) and
138+
// `miss` is the shape a spend takes when the record still holds other live
139+
// outputs.
140+
let last = u32::try_from(count.saturating_sub(1)).unwrap_or(0);
141+
for (label, needle) in [("hit_first", 0), ("hit_last", last), ("miss", u32::MAX)] {
142+
let mut group =
143+
c.benchmark_group(format!("record_codec/find_output/{label}/outputs_{count}"));
144+
group.bench_function("before_v4", |b| {
145+
b.iter(|| {
146+
black_box(RecordCodec::find_v4(black_box(&encoded_v4), black_box(needle)).ok())
147+
});
148+
});
149+
group.bench_function("after_v5", |b| {
150+
b.iter(|| {
151+
black_box(RecordCodec::find_v5(black_box(&encoded_v5), black_box(needle)).ok())
152+
});
153+
});
154+
group.finish();
155+
}
156+
}
157+
158+
fn record_codec(c: &mut Criterion) {
159+
// 1 output is the single-output record, MEASURED_OUTPUTS_PER_RECORD the
160+
// chainstate average, 16 the tail that amortizes the txid best.
161+
for count in [1, MEASURED_OUTPUTS_PER_RECORD, 16] {
162+
bench_codec(c, count);
163+
}
164+
}
165+
166+
criterion_group!(benches, record_codec);
167+
criterion_main!(benches);

0 commit comments

Comments
 (0)