diff --git a/CONCEPTS.md b/CONCEPTS.md index 9642278d..52b7b868 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -282,3 +282,12 @@ A Criterion group holding the before and after implementations of one change ove ### Resolution-time sampling Recording a statistic when its outcome is known rather than when the subject arrives. The fee estimator counted a transaction against every confirmation target the moment it entered, so a fresh arrival was already a failure at every target and a burst silenced the estimator before anything had missed a deadline. It also broke the decay: the denominator had been decaying since entry while a confirmation arrived undecayed, reporting 81 successes in 100 as roughly 85%. Sampling numerator and denominator together at the moment a target resolves fixes both, because they then decay from the same block. The counterpart rule is that a subject leaving for an unrelated reason is untracked without being sampled: an eviction says something about the mempool, not about whether the transaction would have confirmed. + +### Directory-layout record +A record that keeps its per-item lookup keys and item lengths in fixed-width arrays in front of the items, rather than inline with them. `UtxoRecord` v5 is `txid || output_count || legacy_inline_len || widths || vout_dir || len_dir || payloads`, where each directory entry is the narrowest little-endian width the record needs. The reason is random access: the hot read is `find_output(vout)` — every spent input resolves through `Shard::get`/`get_entry`/`get_meta` — and in a flat variable-length layout each field's length is what locates the next one, so finding output `i` walks the bytes of outputs `0..i`, scripts included. That was measured at 4.4-4.9x slower than the fixed-width v4 it replaced. With directories a lookup scans one dense byte array and sums a second, touching about two bytes per output instead of thirty-five. The two layouts cross over near 64 outputs: below it v5's fixed setup dominates and it is about 3 ns slower at the measured mainnet average of 3.626 outputs per record, above it the scan dominates and v5 wins by up to 1.58x. Storing lengths rather than script lengths is what makes the directory free — the script is whatever remains of its payload, so no length is stored twice. See `docs/benchmarks/utxo-memory.md`. + +### Canonical record spelling +The rule that one logical record has exactly one byte string. Fixed-width fields give this away for free; variable-width encodings must enforce it, and `UtxoRecord` compares and hashes by bytes, so a second spelling makes equal records unequal. v5 needs three rules to keep it: a varint must be minimal, because `[0x80, 0x00]` also decodes to zero; a directory width must be the narrowest that fits, because a wider one describes the same record; and the compact amount form and the escape must be exact complements, so the compact form may encode only amounts the escape refuses and the escape refuses only amounts the compact form covers. The last of these is also a safety rule rather than a tidiness one: `read_varint` hands `decompress_amount` whatever a record contains and `validate_encoded` runs it over every output loaded from a snapshot, and the transform multiplies by up to a billion, so an unbounded input panics a debug build and wraps silently in a release one. `decompress_accepts_exactly_the_encoder_image` states the whole rule as one property over every `u64`. + +### Work-count assertion +Asserting how much of an expensive operation a code path performs, instead of how long it takes. A wall-clock assertion in a test suite is a flake generator, and an assertion that a function merely returns something passes for a stub. `find_output_decompresses_at_most_the_amount_it_returns` counts `decompress_amount` calls behind a `cfg(test)` thread-local and requires one for a hit, none for a miss and none for `max_vout`, at any record size — which is the algorithmic claim the layout rests on, stated deterministically. The counterpart is the case a count cannot make: where the claim really is about elapsed time, the assertion belongs in a paired-arm benchmark, not a test. diff --git a/DEVIATIONS.md b/DEVIATIONS.md index 20a48228..6b34fa8a 100644 --- a/DEVIATIONS.md +++ b/DEVIATIONS.md @@ -305,3 +305,64 @@ position while keeping the rest. See the *All-or-scan position fallback* concept in `CONCEPTS.md`. The residual accepted: a stale offset landing exactly on a transaction boundary whose transaction also matches, while a different transaction in that block matches too. + +## §9 — UTXO record payload encoding, and the arena PLAN.md specified + +`PLAN.md` design principle 8 specifies a `bumpalo::Bump` arena per shard for +UTXO record storage. The shipped implementation deviated earlier to one heap +allocation per record via `ThinRecordBuf`; this section records why the arena +is now **rejected on measurement** rather than merely deferred, and what +replaced the record encoding instead. + +### The arena is rejected, not pending + +The arena's stated purpose was the per-record allocation overhead and the +fragmentation expected from tens of millions of small allocations. Both were +measured before any work started (`docs/benchmarks/utxo-memory.md`): + +- Allocation header plus slack is **2.2 bytes per output** on a real mainnet + chainstate at height 412,732 (55.1 B payload against 57.3 accounted). +- Fragmentation is **5%** after churning twice the whole set, and the curve is + flattening rather than climbing. Uniform small allocations are the case a + size-class allocator handles well. + +An arena removes an overhead that measurement puts at a few percent, at the cost +of a self-referential per-shard structure (`self_cell!` over a pinned `Bump`) +plus the round-robin `defrag_one_shard` PLAN.md Task 5 Step 7 also specifies. +Do not start it without new evidence; the two numbers above are the evidence +against it. + +### The record payload is v5, not the v4 layout + +The same measurement found the UTXO set is **77.4% of process RSS**, which is +where the encoding work went instead. Per-output metadata was a fixed 19 bytes +(`vout(4) || value(8) || height(4) || coinbase(1) || script_len(2)`); it is now +Core's `CTxOutCompressor` amount transform, `height` and `coinbase` packed into +one varint, and two fixed-width directories in front of the payloads. Measured +saving **11.75 bytes per output, 21.7% of the payload**, about 1.97 GiB at tip. + +Three things about this are deviations worth naming: + +- **A flat varint layout was built first and rejected.** It hit the size target + and lost 4.4-4.9x on `find_output`, the hot read. See the *Directory-layout + record* concept. +- **`height` is not hoisted into the record header**, which would save three + bytes more. It needs "every output of a record shares one height" to hold, and + BIP30's duplicate coinbase txids are exactly where it might not. +- **The snapshot disk format is unchanged.** `PLAN.md`'s successor step called + for a v5 file format; disk size is not a G14 budget item (the budgets are tip + RSS and Electrum p95), so the invariant that step really protects was covered + instead by a golden vector generated from a v4 build — + `crates/utxo/tests/snapshot_v4_golden.rs`. `hash_serialized_3` and the MuHash + trailer are computed over decoded consensus values, never over the in-memory + encoding, and that is asserted in both directions plus as a load/store fixed + point. + +### Revert criterion + +v5 costs about 3 ns per lookup at the measured mainnet average and 3-21% on +block commit p95, against budgets with roughly twenty times the headroom. It +buys 12 points of the 16 GiB tip-RSS budget. **If G14 tip RSS measures well +under budget — say below 10 GiB — this complexity is not earning its keep and +reverting is the right call.** v4 remains in the tree as the equivalence oracle +and the benchmark's `before` arm, so a revert is a revert, not a rewrite. diff --git a/crates/node/src/checkpoint.rs b/crates/node/src/checkpoint.rs index 7b450520..ab5b7fb6 100644 --- a/crates/node/src/checkpoint.rs +++ b/crates/node/src/checkpoint.rs @@ -815,6 +815,72 @@ fn checkpoint_best_tip_id( Ok(applied_id) } +/// Logs and gauges what the UTXO set holds in memory, against process RSS. +/// +/// Runs on the checkpoint path because a checkpoint already walks every record +/// to serialize the snapshot, so a second pointer-only walk is cheap beside it, +/// and because a checkpoint is the only moment the set is guaranteed stable. +/// +/// The residual between `accounted` and RSS is the point: the set can only +/// account for its own allocations, while the G14 budget is written against the +/// process. See `docs/benchmarks/utxo-memory.md`. +fn report_utxo_memory(utxo: &UtxoSet, height: u32) { + // Clippy suggests a method reference here; it does not compile, because + // `with_stable_view` needs a closure general over the view's lifetime. + #[allow(clippy::redundant_closure_for_method_calls)] + let report = utxo.with_stable_view(|view| view.memory_report()); + let accounted = report.accounted_bytes(); + let rss = crate::metrics::process_rss_bytes(); + + metrics::gauge!("node.utxo.records").set(metric_count(report.records)); + metrics::gauge!("node.utxo.outputs").set(metric_count(report.outputs)); + metrics::gauge!("node.utxo.record_payload_bytes") + .set(metric_count(report.record_payload_bytes)); + metrics::gauge!("node.utxo.record_allocation_bytes") + .set(metric_count(report.record_allocation_bytes)); + metrics::gauge!("node.utxo.table_bytes").set(metric_count(report.table_bytes)); + metrics::gauge!("node.utxo.accounted_bytes").set(metric_count(accounted)); + if let Some(rss) = rss { + metrics::gauge!("node.process.rss_bytes").set(metric_count_u64(rss)); + } + + tracing::info!( + height, + records = report.records, + outputs = report.outputs, + record_payload_bytes = report.record_payload_bytes, + record_allocation_bytes = report.record_allocation_bytes, + table_bytes = report.table_bytes, + accounted_bytes = accounted, + // Plain numbers, not `?rss`: Debug on an `Option` emits "Some(123)", + // which every downstream parser then has to strip. + rss_bytes = rss.unwrap_or_default(), + rss_known = rss.is_some(), + unaccounted_bytes = rss + .map(|rss| rss.saturating_sub(accounted.try_into().unwrap_or(u64::MAX))) + .unwrap_or_default(), + "utxo memory attribution" + ); +} + +#[expect( + clippy::as_conversions, + clippy::cast_precision_loss, + reason = "gauge values are f64 by the metrics crate's contract" +)] +fn metric_count(value: usize) -> f64 { + value as f64 +} + +#[expect( + clippy::as_conversions, + clippy::cast_precision_loss, + reason = "gauge values are f64 by the metrics crate's contract" +)] +fn metric_count_u64(value: u64) -> f64 { + value as f64 +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn write_checkpoint_inner( data_dir: &Dir, @@ -875,6 +941,8 @@ fn write_checkpoint_inner( let (utxo_bytes, utxo_sha256) = utxo_writer.finish(); sync_file(&utxo_file, failpoint, CheckpointFailpoint::UtxoSync)?; + report_utxo_memory(utxo, applied_tip.height); + let listener_stats = coin_stats.snapshot(); if listener_stats.height != applied_tip.height { return Err(CheckpointError::Invalid(format!( diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index 3bf2d0b5..5208c3ad 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -170,6 +170,69 @@ impl HistogramFn for HistogramHandle { } } +/// Resident set size of this process in bytes, or `None` where the platform +/// cannot report it. +/// +/// Used by the memory-attribution reporting on the checkpoint path. The G14 +/// budget is written against RSS, and the UTXO set can only account for its own +/// allocations, so the residual between the two is the number that says whether +/// an encoding change is worth making. +#[must_use] +pub fn process_rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + parse_proc_status_rss(&std::fs::read_to_string("/proc/self/status").ok()?) + } + #[cfg(not(target_os = "linux"))] + { + // No `/proc`; shell out rather than take a platform-specific dependency + // for one number read once per checkpoint. + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + parse_ps_rss(&String::from_utf8(output.stdout).ok()?) + } +} + +/// Extracts `VmRSS` from `/proc/{pid}/status` content, in bytes. +/// +/// Split out from the read so it is testable off Linux. Without this the parser +/// is `cfg`-compiled out of every run on a macOS development host and first +/// executes in production, on the one path whose output the memory attribution +/// divides by. +/// +/// The kernel reports `VmRSS:` in kibibytes with variable leading whitespace, +/// and the field is absent for a kernel thread. +// Unreachable off Linux, and deliberately still compiled there: the point of +// splitting it out is that its tests run everywhere, which they cannot do if +// the function is `cfg`-ed away with its caller. +#[cfg_attr( + not(target_os = "linux"), + allow(dead_code, reason = "tested on every host, called only on Linux") +)] +fn parse_proc_status_rss(status: &str) -> Option { + status.lines().find_map(|line| { + line.strip_prefix("VmRSS:")? + .split_whitespace() + .next()? + .parse::() + .ok()? + .checked_mul(1024) + }) +} + +/// Extracts the kibibyte count `ps -o rss=` prints, in bytes. +// Mirror of the note on `parse_proc_status_rss`: unreachable on Linux, still +// compiled and still tested there. +#[cfg_attr( + target_os = "linux", + allow(dead_code, reason = "tested on every host, called only off Linux") +)] +fn parse_ps_rss(output: &str) -> Option { + output.trim().parse::().ok()?.checked_mul(1024) +} + /// Installs in-memory process metrics and returns its handle when configured. /// /// The workspace pins `metrics-exporter-prometheus` without its HTTP listener. @@ -228,6 +291,107 @@ mod tests { use super::*; + /// Real `/proc/{pid}/status` content, so the Linux parser is exercised on + /// every host rather than only wherever CI happens to run Linux. + /// + /// The field is kibibytes with variable leading whitespace, sits between + /// other `Vm*` keys that share its prefix shape, and is absent for a kernel + /// thread. + #[test] + fn proc_status_rss_is_parsed_from_the_kernel_format() { + const STATUS: &str = "\ +Name:\tbitcoin-rs +Umask:\t0022 +State:\tS (sleeping) +VmPeak:\t14680064 kB +VmSize:\t14680064 kB +VmLck:\t 0 kB +VmHWM:\t 3019751 kB +VmRSS:\t 2949952 kB +RssAnon:\t 2900000 kB +Threads:\t17 +"; + assert_eq!( + super::parse_proc_status_rss(STATUS), + Some(2_949_952 * 1024), + "VmRSS must be read in kibibytes and returned in bytes" + ); + + // `VmHWM` and `VmSize` share the prefix shape and must not be taken. + assert_ne!(super::parse_proc_status_rss(STATUS), Some(3_019_751 * 1024)); + + // A kernel thread has no `VmRSS` at all. + assert_eq!( + super::parse_proc_status_rss("Name:\tkthreadd\nThreads:\t1\n"), + None + ); + assert_eq!(super::parse_proc_status_rss(""), None); + assert_eq!( + super::parse_proc_status_rss("VmRSS:\tnot-a-number kB"), + None + ); + assert_eq!(super::parse_proc_status_rss("VmRSS:\t"), None); + } + + #[test] + fn ps_rss_output_is_parsed_in_kibibytes() { + assert_eq!(super::parse_ps_rss(" 2949952\n"), Some(2_949_952 * 1024)); + assert_eq!(super::parse_ps_rss(""), None); + assert_eq!(super::parse_ps_rss(" "), None); + assert_eq!(super::parse_ps_rss("garbage"), None); + // A value large enough to overflow the kibibyte conversion. + assert_eq!(super::parse_ps_rss(&u64::MAX.to_string()), None); + } + + /// The reading must track real resident memory, not merely return a number. + /// + /// Asserting only `is_some()` would pass for a stub returning a constant, + /// and this figure is what the memory-attribution reporting divides the + /// UTXO set against — a wrong denominator silently misprices every + /// encoding decision made from it. So the test allocates, touches every + /// page to make it resident rather than merely reserved, and requires the + /// reading to move. + /// + /// It is also the only coverage the Linux branch has: `/proc/self/status` + /// is `cfg`-compiled out on the development host, so this parser runs for + /// the first time wherever CI runs Linux. + #[test] + fn process_rss_bytes_tracks_a_real_allocation() { + const BALLAST_BYTES: u64 = 128 << 20; + const PAGE: usize = 4096; + + let Some(before) = process_rss_bytes() else { + // A platform with neither `/proc` nor `ps` is a legitimate `None`. + return; + }; + assert!( + before > (1 << 20), + "implausibly small RSS before allocating: {before} bytes" + ); + + let mut ballast = vec![0_u8; usize::try_from(BALLAST_BYTES).unwrap_or(0)]; + for page in ballast.chunks_mut(PAGE) { + if let Some(first) = page.first_mut() { + *first = 1; + } + } + + // `unwrap_or(0)` rather than `expect`: a `None` here fails the + // assertion below with the reading it produced, which says more than a + // panic message would. + let after = process_rss_bytes().unwrap_or(0); + assert!( + after >= before + (BALLAST_BYTES / 2), + "RSS did not track a {BALLAST_BYTES}-byte resident allocation: {before} -> {after}" + ); + + // Keep the ballast alive across the second reading. + assert_eq!( + u64::try_from(std::hint::black_box(&ballast).len()).unwrap_or(0), + BALLAST_BYTES + ); + } + #[test] fn install_metrics_returns_error_when_global_recorder_install_fails() { let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); diff --git a/crates/utxo/Cargo.toml b/crates/utxo/Cargo.toml index eb35aa8c..983d0869 100644 --- a/crates/utxo/Cargo.toml +++ b/crates/utxo/Cargo.toml @@ -57,3 +57,7 @@ mimalloc.workspace = true [[bench]] name = "utxo_commit" harness = false + +[[bench]] +name = "record_codec" +harness = false diff --git a/crates/utxo/benches/record_codec.rs b/crates/utxo/benches/record_codec.rs new file mode 100644 index 00000000..9f41a963 --- /dev/null +++ b/crates/utxo/benches/record_codec.rs @@ -0,0 +1,171 @@ +//! Paired benchmark for the v4 and v5 `UtxoRecord` payload codecs. +//! +//! This set carries a third acceptance criterion beyond equivalence and speed: +//! **bytes**. v5 exists because a mainnet attribution run put the UTXO set at +//! 77.4% of process RSS (`docs/benchmarks/utxo-memory.md`), so a codec that is +//! lossless and faster but not smaller has missed. Every group therefore sets +//! Criterion's throughput to the encoded payload size, and the harness prints +//! the size table before measuring. +//! +//! Both arms run over one fixture in one group, so the reported spread is the +//! change and not rebuild drift against a stored baseline. +// PERF: Criterion emits public harness items whose docs are irrelevant here. +#![allow(missing_docs)] +// A fixture that fails to encode has no meaningful degraded mode. +#![allow(clippy::expect_used)] + +use std::hint::black_box; + +use bitcoin_rs_primitives::Hash256; +use bitcoin_rs_utxo::{OneUtxoOut, RecordCodec}; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; + +/// Live outputs per record measured on a real chainstate at height 412,732. +/// The txid amortizes over this, so it is the number the payload size is most +/// sensitive to. +const MEASURED_OUTPUTS_PER_RECORD: usize = 4; + +fn txid() -> Hash256 { + Hash256::from_le_bytes(&[0x3c; 32]) +} + +/// Mainnet script mix by share of the UTXO set: P2WPKH 22 B, P2PKH 25 B, +/// P2SH 23 B, P2TR 34 B. +fn script(index: usize) -> Vec { + let len = match index % 4 { + 0 => 22, + 1 => 25, + 2 => 23, + _ => 34, + }; + let tag = u8::try_from(index % 251).unwrap_or(0); + core::iter::repeat_n(tag, len).collect() +} + +/// Owned outputs shaped like a real record: small vouts, heights in the +/// 800k range, and amounts that are mostly round numbers of satoshis. +fn outputs(count: usize) -> Vec<(u32, u64, Vec, bool, u32)> { + (0..count) + .map(|index| { + let value = if index % 3 == 0 { + // Round: what Core's amount transform exists for. + u64::try_from(index + 1).unwrap_or(1) * 10_000_000 + } else { + // Not round: costs one extra bit and no more. + u64::try_from(index).unwrap_or(0) * 7_919 + 54_321 + }; + ( + u32::try_from(index).unwrap_or(0), + value, + script(index), + index == 0, + 800_000 + u32::try_from(index).unwrap_or(0), + ) + }) + .collect() +} + +fn views(owned: &[(u32, u64, Vec, bool, u32)]) -> Vec> { + owned + .iter() + .map(|(vout, value, script, coinbase, height)| OneUtxoOut { + vout: *vout, + value: *value, + script_pubkey: script, + coinbase: *coinbase, + height: *height, + }) + .collect() +} + +fn bench_codec(c: &mut Criterion, count: usize) { + let owned = outputs(count); + let views = views(&owned); + + let encoded_v4 = RecordCodec::encode_v4(txid(), &views).expect("v4 encodes"); + let encoded_v5 = RecordCodec::encode_v5(txid(), &views).expect("v5 encodes"); + + // The size result, printed rather than merely measured: Criterion reports + // time, and time is not what this change is for. + let saved = encoded_v4.len().saturating_sub(encoded_v5.len()); + println!( + "record_codec/outputs_{count}: v4 {} B, v5 {} B, saved {} B ({:.2} B/output, {:.1}%)", + encoded_v4.len(), + encoded_v5.len(), + saved, + f64::from(u32::try_from(saved).unwrap_or(0)) / f64::from(u32::try_from(count).unwrap_or(1)), + 100.0 * f64::from(u32::try_from(saved).unwrap_or(0)) + / f64::from(u32::try_from(encoded_v4.len()).unwrap_or(1)), + ); + + let mut group = c.benchmark_group(format!("record_codec/encode/outputs_{count}")); + group.throughput(Throughput::Bytes( + u64::try_from(encoded_v4.len()).unwrap_or(0), + )); + group.bench_function("before_v4", |b| { + b.iter(|| black_box(RecordCodec::encode_v4(txid(), black_box(&views)).expect("encodes"))); + }); + group.throughput(Throughput::Bytes( + u64::try_from(encoded_v5.len()).unwrap_or(0), + )); + group.bench_function("after_v5", |b| { + b.iter(|| black_box(RecordCodec::encode_v5(txid(), black_box(&views)).expect("encodes"))); + }); + group.finish(); + + let mut group = c.benchmark_group(format!("record_codec/decode_all/outputs_{count}")); + group.throughput(Throughput::Bytes( + u64::try_from(encoded_v4.len()).unwrap_or(0), + )); + group.bench_function("before_v4", |b| { + b.iter(|| black_box(RecordCodec::decode_v4(black_box(&encoded_v4)).expect("decodes"))); + }); + group.throughput(Throughput::Bytes( + u64::try_from(encoded_v5.len()).unwrap_or(0), + )); + group.bench_function("after_v5", |b| { + b.iter(|| black_box(RecordCodec::decode_v5(black_box(&encoded_v5)).expect("decodes"))); + }); + group.finish(); + + // The operation that actually dominates: every spent input resolves one + // output by vout through `Shard::get`/`get_entry`/`get_meta`. Decoding a + // whole record is the snapshot and rescan path, which is rare by + // comparison, so a codec judged only on `decode_all` is judged on the wrong + // thing. + // + // `hit_last` is the worst case (the whole record is walked first) and + // `miss` is the shape a spend takes when the record still holds other live + // outputs. + let last = u32::try_from(count.saturating_sub(1)).unwrap_or(0); + for (label, needle) in [("hit_first", 0), ("hit_last", last), ("miss", u32::MAX)] { + let mut group = + c.benchmark_group(format!("record_codec/find_output/{label}/outputs_{count}")); + group.bench_function("before_v4", |b| { + b.iter(|| { + black_box(RecordCodec::find_v4(black_box(&encoded_v4), black_box(needle)).ok()) + }); + }); + group.bench_function("after_v5", |b| { + b.iter(|| { + black_box(RecordCodec::find_v5(black_box(&encoded_v5), black_box(needle)).ok()) + }); + }); + group.finish(); + } +} + +fn record_codec(c: &mut Criterion) { + // 1 is the single-output record, MEASURED_OUTPUTS_PER_RECORD the chainstate + // average, and 256 the batch-payout shape `utxo_commit`'s lookup arms use. + // The intermediate points exist because the two layouts cross over: v5 + // trades a fixed setup cost for a per-output scan that is far cheaper, so + // which one wins depends on how many outputs the record holds. Reporting + // only one size would let either arm look like the answer. + for count in [1, MEASURED_OUTPUTS_PER_RECORD, 16, 64, 256] { + bench_codec(c, count); + } +} + +criterion_group!(benches, record_codec); +criterion_main!(benches); diff --git a/crates/utxo/examples/snapshot_memory.rs b/crates/utxo/examples/snapshot_memory.rs new file mode 100644 index 00000000..e6a8f08a --- /dev/null +++ b/crates/utxo/examples/snapshot_memory.rs @@ -0,0 +1,169 @@ +//! Loads a real `utxo-v4.dat` checkpoint and reports what the set costs. +//! +//! The companion to `utxo_memory_attribution`, which builds a synthetic set from +//! an assumed script mix and an assumed outputs-per-record. This one reads a +//! chainstate a node actually produced, so the numbers carry no modelling +//! assumptions at all — and because the record codec is an internal encoding, +//! the *same* file loaded by two builds is a controlled A/B of the codec with +//! every other variable held fixed. +//! +//! It also re-checks correctness on that data. The checkpoint manifest records +//! the `MuHash` trailer's SHA-256, and the trailer is computed over decoded +//! consensus values rather than the in-memory encoding, so it must survive a +//! codec change untouched. Asserting that over 38 million real outputs is a +//! stronger statement than any fixture in the test suite makes. +//! +//! ```text +//! cargo run -p bitcoin-rs-utxo --example snapshot_memory --release -- \ +//! [expected-trailer-sha256] +//! ``` +// A measurement harness that cannot read its input has nothing to report. +#![allow(clippy::expect_used, clippy::print_stdout)] + +use std::io::BufReader; + +use bitcoin_rs_utxo::{hash_serialized_3, read_snapshot}; +use sha2::{Digest as _, Sha256}; + +/// Resident set size in bytes, or `None` where the platform cannot report it. +/// +/// Duplicated from `bitcoin-rs-node` rather than depended on: this crate must +/// not take a dependency on the node for an example, and the parser is four +/// lines. `crates/node/src/metrics.rs` is the version under test. +fn process_rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|line| { + line.strip_prefix("VmRSS:")? + .split_whitespace() + .next()? + .parse::() + .ok()? + .checked_mul(1024) + }) + } + #[cfg(not(target_os = "linux"))] + { + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + String::from_utf8(output.stdout) + .ok()? + .trim() + .parse::() + .ok()? + .checked_mul(1024) + } +} + +fn hex(bytes: &[u8]) -> String { + use core::fmt::Write as _; + bytes.iter().fold(String::new(), |mut out, byte| { + let _ = write!(out, "{byte:02x}"); + out + }) +} + +/// Bytes per output, for reporting only. +/// +/// `u32::try_from` would clip a byte total in the billions, so the widening +/// goes through `u64` and loses only mantissa precision, which is irrelevant at +/// two decimal places. +fn per(total: usize, outputs: usize) -> f64 { + if outputs == 0 { + return 0.0; + } + let total = + u32::try_from(total / 1_000).map_or_else(|_| f64::from(u32::MAX), f64::from) * 1_000.0; + let outputs = u32::try_from(outputs).map_or_else(|_| f64::from(u32::MAX), f64::from); + total / outputs +} + +fn main() { + let mut args = std::env::args().skip(1); + let path = args + .next() + .expect("usage: snapshot_memory [expected-trailer-sha256]"); + let expected_trailer = args.next(); + + let baseline = process_rss_bytes(); + + let file = std::fs::File::open(&path).expect("snapshot opens"); + let bytes_on_disk = file.metadata().map_or(0, |meta| meta.len()); + let started = std::time::Instant::now(); + let loaded = read_snapshot(&mut BufReader::new(file)).expect("snapshot loads"); + let load_elapsed = started.elapsed(); + + // `clippy::redundant_closure` suggests passing `UtxoSetView::memory_report` + // directly; that does not compile, because the higher-ranked lifetime on the + // view does not unify with a bare function item. + #[expect( + clippy::redundant_closure_for_method_calls, + reason = "the direct path does not compile" + )] + let report = loaded.set.with_stable_view(|view| view.memory_report()); + let rss = process_rss_bytes(); + + println!("snapshot {path}"); + println!("height {}", loaded.height); + println!("tip {}", loaded.tip_hash.to_string_be()); + println!("on disk {bytes_on_disk} B"); + println!("load {:.1} s", load_elapsed.as_secs_f64()); + println!(); + println!("records {}", report.records); + println!("outputs {}", report.outputs); + println!(); + println!( + "payload {:>14} B {:>6.2} B/output", + report.record_payload_bytes, + per(report.record_payload_bytes, report.outputs) + ); + println!( + "+ alloc header {:>14} B {:>6.2} B/output", + report.record_allocation_bytes, + per(report.record_allocation_bytes, report.outputs) + ); + println!( + "+ hash table {:>14} B {:>6.2} B/output", + report.accounted_bytes(), + per(report.accounted_bytes(), report.outputs) + ); + match (baseline, rss) { + (Some(before), Some(after)) => { + let delta = after.saturating_sub(before); + println!( + "process RSS {after:>14} B {:>6.2} B/output (delta over baseline {delta} B, {:.2} B/output)", + per(usize::try_from(after).unwrap_or(0), report.outputs), + per(usize::try_from(delta).unwrap_or(0), report.outputs) + ); + } + _ => println!("process RSS unavailable on this platform"), + } + + // Both of these are computed over decoded consensus values, never over the + // record encoding, so a codec change must leave them byte-identical. + // Checked here against 38 million real outputs rather than a fixture. + let hashed = std::time::Instant::now(); + let serialized = hash_serialized_3(&loaded.set).expect("hash_serialized_3"); + println!(); + println!( + "hash_serialized_3 {} ({:.1} s)", + hex(&serialized.to_le_bytes()), + hashed.elapsed().as_secs_f64() + ); + // The trailer is computed over consensus values, never over the record + // encoding, so a codec change must leave it byte-identical. Checked here + // against 38 million real outputs rather than a fixture. + let trailer_sha = hex(&Sha256::digest(loaded.muhash_trailer)); + println!(); + println!("muhash trailer sha256 {trailer_sha}"); + if let Some(expected) = expected_trailer { + assert_eq!( + trailer_sha, expected, + "the MuHash trailer changed; the record codec is not consensus-neutral" + ); + println!(" matches the checkpoint manifest"); + } +} diff --git a/crates/utxo/examples/utxo_memory_attribution.rs b/crates/utxo/examples/utxo_memory_attribution.rs new file mode 100644 index 00000000..f69c9af5 --- /dev/null +++ b/crates/utxo/examples/utxo_memory_attribution.rs @@ -0,0 +1,289 @@ +//! Attributes UTXO-set memory: what the set can account for, versus process RSS. +//! +//! Step 2.1 of the memory campaign, and deliberately measurement only. The +//! published tip-RSS evidence reached **13.83 GiB at height 645,804** and never +//! made the tip, against a G14 budget of 16 GiB. The record encoding alone does +//! not predict that figure, and the gap has never been attributed. Changing the +//! encoding before knowing where the bytes go would repeat a mistake this +//! project's own performance notes record twice: pricing a replacement against a +//! total that includes work which does not disappear. +//! +//! Run: +//! +//! ```text +//! cargo run -p bitcoin-rs-utxo --example utxo_memory_attribution --release -- [records] [churn_rounds] +//! ``` +//! +//! `churn_rounds` matters more than it looks. A monotonically inserted set never +//! frees anything, so it measures allocator size-class rounding and nothing else. +//! A real set has spent and re-created coins for every block of its history, and +//! that churn is where fragmentation comes from. Passing rounds > 0 spends a +//! slice of the set and refills it, repeatedly, holding the live count constant. +// A measurement tool: a failed fixture must abort loudly, not report a number. +#![allow(clippy::expect_used)] +#![allow(clippy::print_stdout)] + +use bitcoin::{Amount, ScriptBuf}; +use bitcoin_rs_primitives::{Hash256, OutPoint, TxOut}; +use bitcoin_rs_utxo::{BlockChanges, UtxoAdd, UtxoMemoryReport, UtxoSet}; + +/// Records per commit batch, so the set is built the way a node builds it. +const BATCH_RECORDS: usize = 20_000; +/// Default record count. Large enough that per-record costs dominate the +/// process baseline, small enough to finish on a laptop. +const DEFAULT_RECORDS: usize = 2_000_000; + +const fn next_u64(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + *state +} + +fn fill_bytes(seed: u64, out: &mut [u8]) { + let mut state = seed; + for chunk in out.chunks_mut(8) { + let draw = next_u64(&mut state).to_le_bytes(); + chunk.copy_from_slice(&draw[..chunk.len()]); + } +} + +/// Script shapes in roughly the proportion the mainnet UTXO set holds them. +/// +/// The exact mix matters less than the sizes: the encoding stores the script +/// verbatim, so bytes per output track these lengths directly. +fn script_for(index: u64) -> ScriptBuf { + let mut program = [0_u8; 32]; + fill_bytes(index, &mut program); + match index % 10 { + // P2WPKH, 22 bytes. + 0..=3 => { + let mut bytes = vec![0x00, 0x14]; + bytes.extend_from_slice(&program[..20]); + ScriptBuf::from_bytes(bytes) + } + // P2PKH, 25 bytes. + 4..=6 => { + let mut bytes = vec![0x76, 0xa9, 0x14]; + bytes.extend_from_slice(&program[..20]); + bytes.extend_from_slice(&[0x88, 0xac]); + ScriptBuf::from_bytes(bytes) + } + // P2SH, 23 bytes. + 7 => { + let mut bytes = vec![0xa9, 0x14]; + bytes.extend_from_slice(&program[..20]); + bytes.push(0x87); + ScriptBuf::from_bytes(bytes) + } + // P2TR, 34 bytes. + _ => { + let mut bytes = vec![0x51, 0x20]; + bytes.extend_from_slice(&program); + ScriptBuf::from_bytes(bytes) + } + } +} + +/// Live outputs per record, targeting a mean of `mean_x100 / 100`. +/// +/// This ratio is the single assumption the attribution is most sensitive to: +/// the 32-byte txid is stored once per record and amortizes over exactly this +/// many outputs. It is therefore taken from a real chainstate +/// (`gettxoutsetinfo` reports `txouts / transactions`) rather than guessed — +/// an earlier revision assumed 1.5 and a pruned mainnet sync measured 2.30 at +/// height 183k and 3.43 at 302k. +/// +/// Only the mean is reproduced, not the tail shape. The mean is what drives +/// amortization; the tail would additionally shift allocator size classes, +/// which the measured allocator overhead already folds in. +fn outputs_for(index: u64, mean_x100: u64) -> u32 { + let base = mean_x100 / 100; + let remainder = mean_x100 % 100; + let extra = u64::from(index % 100 < remainder); + let count = base + extra; + if count == 0 { + 1 + } else { + u32::try_from(count).unwrap_or(u32::MAX) + } +} + +/// Resident set size in bytes, or `None` where it cannot be read. +fn rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + for line in status.lines() { + if let Some(value) = line.strip_prefix("VmRSS:") { + let kib = value.split_whitespace().next()?.parse::().ok()?; + return kib.checked_mul(1024); + } + } + None + } + #[cfg(not(target_os = "linux"))] + { + // `ps` is the portable option here; this is a measurement tool, not a + // hot path, and it avoids a platform-specific dependency for one number. + let pid = std::process::id(); + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &pid.to_string()]) + .output() + .ok()?; + let kib = String::from_utf8(output.stdout) + .ok()? + .trim() + .parse::() + .ok()?; + kib.checked_mul(1024) + } +} + +#[expect( + clippy::as_conversions, + clippy::cast_precision_loss, + reason = "reporting megabytes to two decimals" +)] +fn mib(bytes: u64) -> f64 { + bytes as f64 / (1024.0 * 1024.0) +} + +#[expect( + clippy::as_conversions, + clippy::cast_precision_loss, + reason = "reporting bytes per output to one decimal" +)] +fn per(bytes: u64, outputs: u64) -> f64 { + bytes as f64 / outputs.max(1) as f64 +} + +/// Prints the attribution table. +fn report(report: &UtxoMemoryReport, delta: u64, churn_rounds: usize) { + let outputs = u64::try_from(report.outputs).unwrap_or(1).max(1); + let accounted = u64::try_from(report.accounted_bytes()).unwrap_or(0); + let payload = u64::try_from(report.record_payload_bytes).unwrap_or(0); + let allocation = u64::try_from(report.record_allocation_bytes).unwrap_or(0); + let tables = u64::try_from(report.table_bytes).unwrap_or(0); + + println!("churn rounds {churn_rounds}"); + println!("records {}", report.records); + println!("outputs {}", report.outputs); + println!("record payload {:>10.2} MiB", mib(payload)); + println!("record allocations {:>10.2} MiB", mib(allocation)); + println!("hash tables (estimated) {:>10.2} MiB", mib(tables)); + println!("accounted total {:>10.2} MiB", mib(accounted)); + println!("process RSS delta {:>10.2} MiB", mib(delta)); + println!( + "unaccounted {:>10.2} MiB", + mib(delta.saturating_sub(accounted)) + ); + println!(); + println!( + "bytes/output payload {:>6.1} allocation {:>6.1} accounted {:>6.1} RSS {:>6.1}", + per(payload, outputs), + per(allocation, outputs), + per(accounted, outputs), + per(delta, outputs), + ); + println!( + "RSS / accounted {:>10.3}x", + per(delta, accounted.max(1)) + ); +} + +fn main() { + let records: usize = std::env::args() + .nth(1) + .and_then(|arg| arg.parse().ok()) + .unwrap_or(DEFAULT_RECORDS); + let churn_rounds: usize = std::env::args() + .nth(2) + .and_then(|arg| arg.parse().ok()) + .unwrap_or(0); + // Mean live outputs per record, x100. Default matches the 3.43 measured on + // a pruned mainnet sync at height 302,740. + let mean_x100: u64 = std::env::args() + .nth(3) + .and_then(|arg| arg.parse().ok()) + .unwrap_or(343); + + let set = UtxoSet::new(); + let baseline = rss_bytes().expect("read RSS"); + + let mut index: u64 = 0; + let mut written = 0_usize; + while written < records { + let batch = BATCH_RECORDS.min(records - written); + let mut changes = BlockChanges::with_capacity(batch * 2, 0); + for _ in 0..batch { + let mut txid_bytes = [0_u8; 32]; + fill_bytes(index, &mut txid_bytes); + let txid = Hash256::from_le_bytes(&txid_bytes); + for vout in 0..outputs_for(index, mean_x100) { + changes.add(UtxoAdd { + outpoint: OutPoint::new(txid, vout), + txout: TxOut { + value: Amount::from_sat(1_000 + index % 100_000), + script_pubkey: script_for(index.wrapping_add(u64::from(vout))), + }, + coinbase: index.is_multiple_of(1_000), + height: u32::try_from(index % 800_000).unwrap_or(0), + }); + } + index += 1; + } + set.commit_block(&changes, &Hash256::from_le_bytes(&[0_u8; 32])) + .expect("commit batch"); + written += batch; + } + + // Churn: spend the oldest slice and create an equal number of fresh + // records, so the live count holds steady while the allocator sees the + // insert/free traffic a real chain produces. + let churn_slice = records / 10; + let mut spent_from: u64 = 0; + for _ in 0..churn_rounds { + let mut written_this_round = 0_usize; + while written_this_round < churn_slice { + let batch = BATCH_RECORDS.min(churn_slice - written_this_round); + let mut changes = BlockChanges::with_capacity(batch * 2, batch * 2); + for _ in 0..batch { + let mut old_bytes = [0_u8; 32]; + fill_bytes(spent_from, &mut old_bytes); + let old_txid = Hash256::from_le_bytes(&old_bytes); + for vout in 0..outputs_for(spent_from, mean_x100) { + changes.remove(OutPoint::new(old_txid, vout)); + } + spent_from += 1; + + let mut new_bytes = [0_u8; 32]; + fill_bytes(index, &mut new_bytes); + let new_txid = Hash256::from_le_bytes(&new_bytes); + for vout in 0..outputs_for(index, mean_x100) { + changes.add(UtxoAdd { + outpoint: OutPoint::new(new_txid, vout), + txout: TxOut { + value: Amount::from_sat(1_000 + index % 100_000), + script_pubkey: script_for(index.wrapping_add(u64::from(vout))), + }, + coinbase: false, + height: u32::try_from(index % 800_000).unwrap_or(0), + }); + } + index += 1; + } + set.commit_block(&changes, &Hash256::from_le_bytes(&[1_u8; 32])) + .expect("commit churn batch"); + written_this_round += batch; + } + } + + // Clippy suggests passing `UtxoSetView::memory_report` directly here; that + // does not compile, because `with_stable_view` needs a closure general over + // the view's lifetime and a method reference is not. + #[allow(clippy::redundant_closure_for_method_calls)] + let memory = set.with_stable_view(|view| view.memory_report()); + let after = rss_bytes().expect("read RSS"); + report(&memory, after.saturating_sub(baseline), churn_rounds); +} diff --git a/crates/utxo/proptest-regressions/compress.txt b/crates/utxo/proptest-regressions/compress.txt new file mode 100644 index 00000000..754bfe02 --- /dev/null +++ b/crates/utxo/proptest-regressions/compress.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 4e2ac50ef64e8005fb57c4087442a76f3afce04d99bd88960085967f87b78607 # shrinks to value = 2049638230412201671 +cc b5d65560bbc086aba36aa4e4932cc62072e7c017b83c25612b4ade02a28ad9a7 # shrinks to a = 0, b = 2049672486957746581 diff --git a/crates/utxo/src/compress.rs b/crates/utxo/src/compress.rs new file mode 100644 index 00000000..6f4af965 --- /dev/null +++ b/crates/utxo/src/compress.rs @@ -0,0 +1,419 @@ +//! Compact encodings for UTXO record fields. +//! +//! These shrink the in-memory record payload, which a mainnet attribution run +//! measured at 55.1 bytes per live output and 77.4% of process RSS +//! (`docs/benchmarks/utxo-memory.md`). They are an **internal storage** format: +//! nothing here is consensus-visible, and `hash_serialized_3` and the MuHash +//! trailer are computed over decoded consensus values, not over these bytes. +//! +//! Two encodings, chosen because both are pure per-output transforms with no +//! cross-output invariant to violate: +//! +//! * [`varint`] — 7 bits per byte with a continuation flag, so the `vout` and +//! script length that cost 4 and 2 fixed bytes cost one byte each for the +//! values almost every output actually has. +//! * [`compress_amount`] — Bitcoin Core's `CTxOutCompressor` amount transform, +//! which exploits how many amounts are round numbers of satoshis. + +use crate::UtxoError; + +/// Largest number of bytes a `u64` varint can occupy. +pub(crate) const VARINT_MAX_LEN: usize = 10; + +/// Writes `value` as a base-128 varint into `out` starting at `at`, returning +/// the offset just past it, or `None` when `out` is too short. +/// +/// Low 7 bits first, high bit set on every byte except the last. +/// +/// Exists so an encoder can lay several varints into one stack buffer and issue +/// a single copy into the record, rather than one bounds-checked push per +/// field. That difference measured 3.2x on a 16-output record. +#[inline] +pub(crate) fn write_varint_at(value: u64, out: &mut [u8], at: usize) -> Option { + let mut remaining = value; + let mut cursor = at; + loop { + let mut byte = u8::try_from(remaining & 0x7f).unwrap_or(0); + remaining >>= 7; + if remaining != 0 { + byte |= 0x80; + } + *out.get_mut(cursor)? = byte; + cursor += 1; + if remaining == 0 { + return Some(cursor); + } + } +} + +/// Bytes [`write_varint`] will produce for `value`. +/// +/// The record encoder allocates one exact-capacity buffer, so it must know the +/// payload size before writing a byte. Kept beside `write_varint` and pinned +/// against it for every width boundary — a disagreement here is a buffer that +/// is too small (a `CorruptRecord` on a valid output) or one carrying slack. +#[inline] +pub(crate) const fn varint_len(value: u64) -> usize { + let mut remaining = value >> 7; + let mut len = 1; + while remaining != 0 { + remaining >>= 7; + len += 1; + } + len +} + +/// Reads a base-128 varint at `offset`, returning the value and the next offset. +/// +/// Rejects a varint that runs off the end, that exceeds [`VARINT_MAX_LEN`] +/// bytes, whose final byte would overflow `u64`, or that is **not minimal**. A +/// record is decoded on every output read, so a malformed one must be an error +/// rather than a silently truncated value. +/// +/// Minimality is what keeps the encoding injective in both directions, and the +/// v4 layout it replaces got that for free from fixed-width fields: `[0x80, +/// 0x00]` also decodes to zero, so accepting it would let two distinct byte +/// strings describe one record. `UtxoRecord` compares and hashes by bytes, so +/// that is not a cosmetic property. +#[inline] +pub(crate) fn read_varint(bytes: &[u8], offset: usize) -> Result<(u64, usize), UtxoError> { + // Single-byte fast path. Every field this codec stores — vout, packed + // height, script length, and a compressed round amount — is one byte for + // the overwhelming majority of real outputs, so the general loop below is + // the exception, not the rule. + let first = *bytes.get(offset).ok_or(UtxoError::CorruptRecord)?; + if first & 0x80 == 0 { + // `get` succeeded, so `offset < bytes.len() <= isize::MAX`. + return Ok((u64::from(first), offset + 1)); + } + + let mut value: u64 = 0; + let mut shift = 0_u32; + let mut cursor = offset; + for _ in 0..VARINT_MAX_LEN { + let byte = *bytes.get(cursor).ok_or(UtxoError::CorruptRecord)?; + cursor += 1; + let payload = u64::from(byte & 0x7f); + // The tenth byte of a `u64` varint carries only one significant bit. + if shift >= 64 || (shift == 63 && payload > 1) { + return Err(UtxoError::CorruptRecord); + } + value |= payload << shift; + if byte & 0x80 == 0 { + // A continuation that contributes nothing is a longer spelling of a + // shorter varint. + if shift > 0 && payload == 0 { + return Err(UtxoError::CorruptRecord); + } + return Ok((value, cursor)); + } + shift += 7; + } + Err(UtxoError::CorruptRecord) +} + +/// Largest amount the compression is defined for: 21,000,000 BTC in satoshis. +/// +/// A consensus bound, not an arbitrary one — no UTXO can hold more. The +/// transform multiplies by 90, so it overflows `u64` above roughly 2e17; this +/// ceiling sits two orders of magnitude below that, and making the domain +/// explicit is better than a debug-only panic on a value that should be +/// impossible. +pub(crate) const MAX_COMPRESSIBLE_AMOUNT: u64 = 21_000_000 * 100_000_000; + +/// Bitcoin Core's `CTxOutCompressor` amount compression. +/// +/// Most amounts are round: a whole number of satoshis with a run of trailing +/// zeros. The transform factors out up to nine powers of ten and encodes the +/// exponent, so 1 BTC (100,000,000 sat) becomes a two-byte varint instead of +/// eight fixed bytes. Amounts that are not round cost one extra bit and are +/// still no worse than a plain varint. +/// +/// Ported for the same reason Core uses it, and paired with +/// [`decompress_amount`] under an exhaustive-boundary and property round trip: +/// an amount that does not survive the round trip is a silently wrong balance. +#[inline] +pub(crate) const fn compress_amount(amount: u64) -> Result { + if amount > MAX_COMPRESSIBLE_AMOUNT { + return Err(UtxoError::AmountOutOfRange { value: amount }); + } + if amount == 0 { + return Ok(0); + } + let mut n = amount; + let mut exponent = 0_u64; + while n.is_multiple_of(10) && exponent < 9 { + n /= 10; + exponent += 1; + } + if exponent < 9 { + let last_digit = n % 10; + n /= 10; + Ok(1 + (n * 9 + last_digit - 1) * 10 + exponent) + } else { + Ok(1 + (n - 1) * 10 + 9) + } +} + +// Counts `decompress_amount` calls so tests can assert *how much work* a record +// read does, instead of timing it. A wall-clock assertion in a test suite is a +// flake generator; this is the same claim made deterministically. Compiled only +// under `cfg(test)`, so production pays nothing. +#[cfg(test)] +thread_local! { + pub(crate) static DECOMPRESS_CALLS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// The powers of ten the transform can factor out, indexed by exponent. +/// +/// Replaces a `while` loop of up to nine dependent multiplies. Decoding an +/// amount is on the record read path, and that loop was most of its fixed cost. +const POW10: [u64; 10] = [ + 1, + 10, + 100, + 1_000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, +]; + +/// Inverse of [`compress_amount`], or `None` when `compressed` is not something +/// [`compress_amount`] could have produced. +/// +/// The rejection is not defensive tidiness. `read_varint` will hand this any +/// `u64` a corrupt or hostile record contains, and the transform multiplies by +/// up to 10^9: `decompress_amount(u64::MAX)` is 2.05e22, which **panics in a +/// debug build** and wraps silently in a release one. `validate_encoded` +/// decodes every output of every record loaded from a snapshot, so that path is +/// reachable from a file on disk. +/// +/// Requiring the result back inside the compressible domain also completes the +/// canonicality rule: the compact form may encode only amounts the escape +/// refuses, and the escape refuses exactly the amounts the compact form +/// covers. Together they leave each amount exactly one spelling. +#[inline] +pub(crate) fn decompress_amount(compressed: u64) -> Option { + #[cfg(test)] + DECOMPRESS_CALLS.with(|calls| calls.set(calls.get() + 1)); + + if compressed == 0 { + return Some(0); + } + let x = compressed - 1; + let exponent = x % 10; + let mut n = x / 10; + if exponent < 9 { + let last_digit = n % 9; + n /= 9; + n = n.checked_mul(10)?.checked_add(last_digit + 1)?; + } else { + n = n.checked_add(1)?; + } + // `exponent` is `x % 10`, so the lookup is in range by construction. + let scale = POW10.get(usize::try_from(exponent).ok()?)?; + let value = n.checked_mul(*scale)?; + (value <= MAX_COMPRESSIBLE_AMOUNT).then_some(value) +} + +#[cfg(test)] +// A codec test that cannot decode its own output has failed; panicking names +// the offending value. +#[expect(clippy::expect_used, reason = "test assertions")] +mod tests { + use super::{ + MAX_COMPRESSIBLE_AMOUNT, VARINT_MAX_LEN, compress_amount, decompress_amount, read_varint, + varint_len, write_varint_at, + }; + + /// Test shim for the removed fixed-buffer writer: the encoder lays varints + /// into a shared buffer at an offset, so `write_varint_at` is the only + /// writer, and these tests want "encode one, from zero". + fn write_varint(value: u64, out: &mut [u8; VARINT_MAX_LEN]) -> usize { + write_varint_at(value, out, 0).expect("a u64 varint fits VARINT_MAX_LEN") + } + use proptest::prelude::*; + + fn varint_roundtrip(value: u64) -> usize { + let mut buf = [0_u8; VARINT_MAX_LEN]; + let len = write_varint(value, &mut buf); + let (decoded, next) = read_varint(&buf, 0).expect("varint decodes"); + assert_eq!(decoded, value, "varint round trip failed for {value}"); + assert_eq!( + next, len, + "varint consumed the wrong byte count for {value}" + ); + assert_eq!( + varint_len(value), + len, + "varint_len disagreed with write_varint for {value}" + ); + len + } + + #[test] + fn varint_round_trips_at_every_width_boundary() { + // One byte per 7 bits, so each boundary is where the width steps up. + for shift in 0..64_u32 { + let value = 1_u64 << shift; + varint_roundtrip(value); + varint_roundtrip(value - 1); + } + varint_roundtrip(0); + varint_roundtrip(u64::MAX); + } + + #[test] + fn varint_costs_one_byte_for_the_values_outputs_actually_have() { + // This is the entire point of the change: `vout` and script lengths are + // small, and were costing 4 and 2 fixed bytes. + for value in [0_u64, 1, 2, 41, 127] { + assert_eq!(varint_roundtrip(value), 1); + } + assert_eq!(varint_roundtrip(128), 2); + } + + #[test] + fn varint_rejects_a_truncated_or_overlong_encoding() { + // Continuation bit set with nothing after it. + assert!(read_varint(&[0x80], 0).is_err()); + assert!(read_varint(&[], 0).is_err()); + // Eleven continuation bytes cannot be a `u64`. + assert!(read_varint(&[0xff; 11], 0).is_err()); + // Ten bytes whose final byte carries more than the one remaining bit. + assert!( + read_varint( + &[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02], + 0 + ) + .is_err() + ); + } + + /// Two byte strings decoding to one value would break the record layer's + /// byte equality, which the fixed-width v4 fields gave for free. + #[test] + fn varint_rejects_a_non_minimal_encoding() { + // Longer spellings of 0, 1 and 128. + for bytes in [ + vec![0x80, 0x00], + vec![0x81, 0x00], + vec![0x80, 0x80, 0x00], + vec![0x80, 0x81, 0x00], + ] { + assert!( + read_varint(&bytes, 0).is_err(), + "non-minimal varint {bytes:?} was accepted" + ); + } + // The minimal spellings of the same values still decode. + assert_eq!(read_varint(&[0x00], 0).expect("zero decodes").0, 0); + assert_eq!(read_varint(&[0x01], 0).expect("one decodes").0, 1); + assert_eq!( + read_varint(&[0x80, 0x01], 0).expect("128 decodes").0, + 128, + "a trailing byte that does contribute must stay valid" + ); + } + + #[test] + fn compressed_amounts_round_trip_over_bitcoin_relevant_values() { + const COIN: u64 = 100_000_000; + let mut values = vec![ + 0, + 1, + MAX_COMPRESSIBLE_AMOUNT, + 21_000_000 * COIN, + 50 * COIN, + // The subsidy halvings, which is what most coinbase outputs are. + 25 * COIN, + 1_250_000_000, + 625_000_000, + ]; + // Every power of ten, and its neighbours, since the transform factors + // out powers of ten. + let mut power = 1_u64; + while let Some(next) = power.checked_mul(10) { + if power > MAX_COMPRESSIBLE_AMOUNT { + break; + } + values.extend([power, power + 1, power.saturating_sub(1)]); + power = next; + } + for value in values { + let compressed = compress_amount(value).expect("within the money supply"); + assert_eq!( + decompress_amount(compressed), + Some(value), + "amount round trip failed for {value}" + ); + } + } + + #[test] + fn an_amount_above_the_money_supply_errors_rather_than_overflowing() { + assert!(compress_amount(MAX_COMPRESSIBLE_AMOUNT).is_ok()); + assert!(compress_amount(MAX_COMPRESSIBLE_AMOUNT + 1).is_err()); + assert!(compress_amount(u64::MAX).is_err()); + } + + #[test] + fn a_round_amount_compresses_to_fewer_varint_bytes_than_it_costs_raw() { + // 1 BTC is the shape the transform exists for: eight raw bytes today. + let mut buf = [0_u8; VARINT_MAX_LEN]; + let compressed = compress_amount(100_000_000).expect("1 BTC is in range"); + let len = write_varint(compressed, &mut buf); + assert!( + len <= 2, + "1 BTC should compress to at most 2 bytes, got {len}" + ); + } + + proptest! { + #[test] + fn varint_round_trips(value in any::()) { + let mut buf = [0_u8; VARINT_MAX_LEN]; + let len = write_varint(value, &mut buf); + let (decoded, next) = read_varint(&buf, 0).expect("decodes"); + prop_assert_eq!(decoded, value); + prop_assert_eq!(next, len); + } + + #[test] + fn compressed_amounts_round_trip(value in 0..=MAX_COMPRESSIBLE_AMOUNT) { + let compressed = compress_amount(value).expect("in range"); + prop_assert_eq!(decompress_amount(compressed), Some(value)); + } + + /// No `u64` may panic the decoder, and every value it accepts must be + /// one the encoder could have produced. + /// + /// `read_varint` hands this whatever a corrupt or hostile record + /// contains, and `validate_encoded` runs it over every output of every + /// record loaded from a snapshot. The second half is the canonicality + /// rule: if some compressed value outside the encoder's image were + /// accepted, one amount would have two spellings. + #[test] + fn decompress_accepts_exactly_the_encoder_image(compressed in any::()) { + if let Some(value) = decompress_amount(compressed) { + prop_assert!(value <= MAX_COMPRESSIBLE_AMOUNT); + prop_assert_eq!(compress_amount(value).ok(), Some(compressed)); + } + } + + /// The compression must be injective over the range it will ever see, + /// or two distinct amounts would decode to one. + #[test] + fn compression_is_injective( + a in 0..=MAX_COMPRESSIBLE_AMOUNT, + b in 0..=MAX_COMPRESSIBLE_AMOUNT, + ) { + prop_assume!(a != b); + let (ca, cb) = (compress_amount(a).expect("in range"), compress_amount(b).expect("in range")); + prop_assert_ne!(ca, cb); + } + } +} diff --git a/crates/utxo/src/lib.rs b/crates/utxo/src/lib.rs index 2af98a25..396add09 100644 --- a/crates/utxo/src/lib.rs +++ b/crates/utxo/src/lib.rs @@ -7,6 +7,8 @@ #![forbid(unsafe_op_in_unsafe_fn)] +/// Compact encodings for UTXO record fields. +mod compress; /// UTXO hash-table key. pub mod key; /// Owned UTXO records. @@ -21,10 +23,10 @@ pub mod snapshot; pub mod undo_codec; pub use key::{UtxoBuildHasher, UtxoKey}; -pub use record::{OneUtxoOut, UtxoRecord}; +pub use record::{OneUtxoOut, RecordCodec, UtxoRecord}; pub use set::{ BlockChanges, ScannedUtxo, UndoBatch, UtxoAdd, UtxoChangeListener, UtxoError, UtxoInserted, - UtxoRemoved, UtxoScan, UtxoSet, UtxoSetView, + UtxoMemoryReport, UtxoRemoved, UtxoScan, UtxoSet, UtxoSetView, }; pub use shard::{LiveOutput, LiveOutputMeta}; pub use snapshot::{ diff --git a/crates/utxo/src/record.rs b/crates/utxo/src/record.rs index b1464011..e7fdc825 100644 --- a/crates/utxo/src/record.rs +++ b/crates/utxo/src/record.rs @@ -3,6 +3,7 @@ use core::slice; use std::alloc::{Layout, alloc, dealloc, handle_alloc_error}; use bitcoin_rs_primitives::Hash256; +use smallvec::SmallVec; use crate::{UtxoError, UtxoKey}; @@ -10,9 +11,147 @@ const TXID_LEN: usize = 32; const OUTPUT_COUNT_OFFSET: usize = TXID_LEN; const LEGACY_INLINE_LEN_OFFSET: usize = OUTPUT_COUNT_OFFSET + core::mem::size_of::(); const RECORD_HEADER_LEN: usize = LEGACY_INLINE_LEN_OFFSET + core::mem::size_of::(); -const OUTPUT_METADATA_LEN: usize = 19; +/// Fixed per-output metadata width of the retained v4 layout: +/// `vout(4) || value(8) || height(4) || coinbase(1) || script_len(2)`. +const OUTPUT_METADATA_LEN_V4: usize = 19; +/// Largest v5 payload prologue, for the encoder's stack buffer: 10 bytes for +/// the amount varint (or the escape sentinel), 8 for a raw escaped amount, and +/// 5 for the packed height. The script needs none — it is the rest. +const PAYLOAD_PROLOGUE_MAX_LEN: usize = crate::compress::VARINT_MAX_LEN + 8 + 5; + +/// Byte holding both directory widths, immediately after the shared header. +const WIDTHS_OFFSET: usize = RECORD_HEADER_LEN; +/// First byte of the `vout` directory. +const V5_BODY_OFFSET: usize = WIDTHS_OFFSET + 1; +/// Widest directory entry. `vout` is a `u32`; a payload is at most a 10-byte +/// amount, 8 escape bytes, a 5-byte height and a `u16`-ceilinged script. +const MAX_DIR_WIDTH: usize = 4; + +/// Smallest little-endian width that can hold `value`. +/// +/// Minimal by construction and validated on decode: a record encoded with a +/// wider directory than it needs would be a second spelling of itself, and +/// `UtxoRecord` compares by bytes. +const fn width_for(value: u64) -> usize { + if value <= 0xff { + 1 + } else if value <= 0xffff { + 2 + } else if value <= 0x00ff_ffff { + 3 + } else { + MAX_DIR_WIDTH + } +} + +/// Reads a `width`-byte little-endian directory entry. +fn read_width(bytes: &[u8], offset: usize, width: usize) -> Option { + let end = offset.checked_add(width)?; + let slice = bytes.get(offset..end)?; + let mut value = 0_u64; + for (index, byte) in slice.iter().enumerate() { + value |= u64::from(*byte) << (index * 8); + } + Some(value) +} + +/// Where each region of a v5 body begins, and how wide its directory entries +/// are. +/// +/// The whole point of the layout: every boundary here is `count * width` +/// arithmetic, so finding the directories costs no scanning, and a lookup by +/// `vout` touches one dense byte array instead of walking every output's +/// script. +#[derive(Copy, Clone)] +struct V5Layout { + count: usize, + vout_width: usize, + len_width: usize, + vout_dir: usize, + len_dir: usize, + payloads: usize, +} + +impl V5Layout { + fn new(count: usize, vout_width: usize, len_width: usize) -> Result { + let vout_dir = V5_BODY_OFFSET; + let len_dir = count + .checked_mul(vout_width) + .and_then(|span| vout_dir.checked_add(span)) + .ok_or(UtxoError::CorruptRecord)?; + let payloads = count + .checked_mul(len_width) + .and_then(|span| len_dir.checked_add(span)) + .ok_or(UtxoError::CorruptRecord)?; + Ok(Self { + count, + vout_width, + len_width, + vout_dir, + len_dir, + payloads, + }) + } + + fn read(bytes: &[u8], count: usize) -> Result { + let widths = *bytes.get(WIDTHS_OFFSET).ok_or(UtxoError::CorruptRecord)?; + let vout_width = usize::from(widths & 0x0f); + let len_width = usize::from(widths >> 4); + if !(1..=MAX_DIR_WIDTH).contains(&vout_width) || !(1..=MAX_DIR_WIDTH).contains(&len_width) { + return Err(UtxoError::CorruptRecord); + } + Self::new(count, vout_width, len_width) + } + + fn vout_at(&self, bytes: &[u8], index: usize) -> Result { + let offset = index + .checked_mul(self.vout_width) + .and_then(|span| self.vout_dir.checked_add(span)) + .ok_or(UtxoError::CorruptRecord)?; + let raw = read_width(bytes, offset, self.vout_width).ok_or(UtxoError::CorruptRecord)?; + u32::try_from(raw).map_err(|_| UtxoError::CorruptRecord) + } + + fn payload_len_at(&self, bytes: &[u8], index: usize) -> Result { + let offset = index + .checked_mul(self.len_width) + .and_then(|span| self.len_dir.checked_add(span)) + .ok_or(UtxoError::CorruptRecord)?; + let raw = read_width(bytes, offset, self.len_width).ok_or(UtxoError::CorruptRecord)?; + usize::try_from(raw).map_err(|_| UtxoError::CorruptRecord) + } +} + +/// Packs both directory widths into one byte. +fn pack_widths(vout_width: usize, len_width: usize) -> Result { + let vout = u8::try_from(vout_width).map_err(|_| UtxoError::CorruptRecord)?; + let len = u8::try_from(len_width).map_err(|_| UtxoError::CorruptRecord)?; + Ok((len << 4) | vout) +} const LEGACY_INLINE_CAPACITY: usize = 8; +/// Sentinel amount varint meaning "the next 8 bytes are a raw little-endian +/// value". +/// +/// [`compress_amount`] maps the whole money supply below 2^54, so `u64::MAX` is +/// unreachable as a compressed amount and is free as an escape. No +/// consensus-valid output can need it — but `Amount` is a plain `u64`, v4 +/// stored one losslessly, and a codec that started rejecting values its +/// predecessor accepted would not be an equivalent replacement. +const AMOUNT_ESCAPE: u64 = u64::MAX; + +/// Packs the two per-output facts that always travel together into one varint. +/// +/// `coinbase` occupies the low bit, so a height under 2^20 — every height +/// Bitcoin will reach for centuries — costs 3 bytes for both fields where v4 +/// spent 5. Kept per-output rather than hoisted into the record header, which +/// would save 3 more: hoisting needs "every output of a record shares one +/// height" to hold, and BIP30's duplicate coinbase txids are exactly the case +/// where it might not. +fn pack_height(height: u32, coinbase: bool) -> u64 { + (u64::from(height) << 1) | u64::from(coinbase) +} + /// One checked, zero-copy live output view inside a transaction-level record. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct OneUtxoOut<'a> { @@ -313,35 +452,44 @@ struct RecordHeader { } /// Iterator over checked output views from one validated record. +/// +/// Walks the directory by index and the payload region by a running cursor, so +/// a full scan stays O(1) per output even though a random lookup has to sum the +/// preceding payload lengths. pub(crate) struct UtxoOutputIter<'a> { bytes: &'a [u8], - cursor: usize, - remaining: usize, + layout: V5Layout, + index: usize, + payload: usize, } impl<'a> Iterator for UtxoOutputIter<'a> { type Item = OneUtxoOut<'a>; fn next(&mut self) -> Option { - if self.remaining == 0 { + if self.index >= self.layout.count { return None; } - let (output, next) = match decode_output(self.bytes, self.cursor) { - Ok(decoded) => decoded, - // `UtxoRecord` is validated at construction (`from_encoded` runs - // `validate_encoded`, which fully decodes every output) and its - // `bytes` field is private and immutable afterward; a decode - // failure here means the validated record was mutated in place, - // which is an unrecoverable internal corrupt state. - Err(error) => panic!("validated UTXO record output must remain decodable: {error:?}"), - }; - self.cursor = next; - self.remaining -= 1; + let (output, next) = + match decode_output_at(self.bytes, &self.layout, self.index, self.payload) { + Ok(decoded) => decoded, + // `UtxoRecord` is validated at construction (`from_encoded` runs + // `validate_encoded`, which fully decodes every output) and its + // `bytes` field is private and immutable afterward; a decode + // failure here means the validated record was mutated in place, + // which is an unrecoverable internal corrupt state. + Err(error) => { + panic!("validated UTXO record output must remain decodable: {error:?}") + } + }; + self.payload = next; + self.index += 1; Some(output) } fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) + let remaining = self.layout.count.saturating_sub(self.index); + (remaining, Some(remaining)) } } @@ -393,20 +541,49 @@ impl UtxoRecord { /// between construction and this read. The returned iterator still fails /// fast (panics) if an internal invariant is ever violated. pub(crate) fn outputs(&self) -> UtxoOutputIter<'_> { - let header = self.header(); + let bytes = self.buf.as_bytes(); + let layout = match self.layout() { + Ok(layout) => layout, + Err(error) => panic!("UtxoRecord is validated at construction: {error:?}"), + }; UtxoOutputIter { - bytes: self.buf.as_bytes(), - cursor: RECORD_HEADER_LEN, - remaining: header.output_count, + bytes, + payload: layout.payloads, + layout, + index: 0, } } + /// Finds one live output by `vout`, decoding only the one that matches. + /// + /// This is the hot read: every spent input resolves through + /// `Shard::get`/`get_entry`/`get_meta`, all three of which land here, so it + /// is the operation the record layout is designed around. + /// + /// The search touches only the `vout` directory — one dense, fixed-width + /// byte array — and then sums the payload lengths of the outputs before the + /// match. Neither scan reads a script. A flat variable-length layout was + /// built first and measured 4.4-4.9x slower here, because locating output + /// `i` meant walking the bytes of outputs `0..i`, scripts included. pub(crate) fn find_output(&self, vout: u32) -> Option> { - self.outputs().find(|output| output.vout == vout) + let bytes = self.buf.as_bytes(); + let layout = self.layout().ok()?; + let index = (0..layout.count) + .find(|index| layout.vout_at(bytes, *index).is_ok_and(|c| c == vout))?; + let payload = payload_offset(bytes, &layout, index).ok()?; + match decode_output_at(bytes, &layout, index, payload) { + Ok((output, _)) => Some(output), + Err(error) => panic!("validated UTXO record output must remain decodable: {error:?}"), + } } + /// Highest live `vout`, read from the directory alone. pub(crate) fn max_vout(&self) -> Option { - self.outputs().map(|output| output.vout).max() + let bytes = self.buf.as_bytes(); + let layout = self.layout().ok()?; + (0..layout.count) + .filter_map(|index| layout.vout_at(bytes, index).ok()) + .max() } /// Stages an entire coalesced add run without changing this record, @@ -520,7 +697,15 @@ impl UtxoRecord { } /// Increasing-unique append-copy fast path. Returns `None` when appending - /// would reorder the legacy partition bytes, so the caller must rebuild. + /// would reorder the legacy partition bytes, or when the directories would + /// have to widen, so the caller must rebuild. + /// + /// Appending is a splice of three regions rather than one, because the + /// directories sit in front of the payloads. Every surviving output is + /// still copied as bytes and never re-encoded, which is the point of the + /// path; what it gives up is the case where a new `vout` or a longer + /// payload needs a wider directory entry, since that rewrites entries the + /// copy would otherwise preserve. fn append_unique_run(&self, additions: &[OutputParts<'_>]) -> Result, UtxoError> { let header = self.header(); let appends_at_end = header.output_count == header.legacy_inline_len @@ -528,6 +713,8 @@ impl UtxoRecord { if !appends_at_end { return Ok(None); } + let old = self.layout()?; + let bytes = self.buf.as_bytes(); let new_count = header @@ -543,30 +730,57 @@ impl UtxoRecord { let legacy_inline_len_u8 = u8::try_from(legacy_inline_len).map_err(|_| UtxoError::CorruptRecord)?; - let mut additions_len = 0usize; + // Widths must stay exactly as they are: narrower would be non-minimal + // for the outputs already encoded, wider would mean rewriting every + // existing directory entry. + let mut additions_len = 0_usize; for addition in additions { + let payload_len = addition.payload_len()?; + if width_for(u64::from(addition.vout)) > old.vout_width + || width_for(u64::try_from(payload_len).unwrap_or(u64::MAX)) > old.len_width + { + return Ok(None); + } additions_len = additions_len - .checked_add(addition.encoded_len()?) + .checked_add(payload_len) .ok_or(UtxoError::RecordTooLarge { len: additions_len })?; } - let payload_len = self - .buf - .as_bytes() + + let dir_growth = additions + .len() + .checked_mul(old.vout_width + old.len_width) + .ok_or(UtxoError::RecordTooLarge { + len: additions.len(), + })?; + let payload_len = bytes .len() .checked_add(additions_len) + .and_then(|len| len.checked_add(dir_growth)) .ok_or(UtxoError::RecordTooLarge { len: additions_len })?; if payload_len > usize::try_from(isize::MAX).unwrap_or(usize::MAX) { return Err(UtxoError::RecordTooLarge { len: payload_len }); } + let region = |from: usize, to: usize| bytes.get(from..to).ok_or(UtxoError::CorruptRecord); let mut buf = ThinRecordBuf::with_capacity(payload_len)?; let mut writer = RecordWriter::new(&mut buf); writer.push(&header.txid.to_le_bytes())?; writer.push(&output_count.to_le_bytes())?; writer.push(&[legacy_inline_len_u8])?; - writer.push(&self.buf.as_bytes()[RECORD_HEADER_LEN..])?; + writer.push(&[pack_widths(old.vout_width, old.len_width)?])?; + + writer.push(region(old.vout_dir, old.len_dir)?)?; + for addition in additions { + push_dir_entry(&mut writer, u64::from(addition.vout), old.vout_width)?; + } + writer.push(region(old.len_dir, old.payloads)?)?; + for addition in additions { + let len = u64::try_from(addition.payload_len()?).unwrap_or(u64::MAX); + push_dir_entry(&mut writer, len, old.len_width)?; + } + writer.push(region(old.payloads, bytes.len())?)?; for addition in additions { - write_output(&mut writer, addition)?; + write_payload(&mut writer, addition)?; } writer.finish()?; debug_assert_eq!(buf.as_bytes().len(), payload_len); @@ -716,6 +930,22 @@ impl UtxoRecord { self.buf.as_bytes() } + /// Bytes this record holds from the allocator: header plus buffer capacity. + pub(crate) fn allocation_bytes(&self) -> usize { + THIN_HEADER_LEN.saturating_add(self.buf.capacity()) + } + + /// Live encoded payload length, excluding the allocation header and any + /// spare capacity. + pub(crate) fn payload_bytes(&self) -> usize { + self.buf.len() + } + + /// Directory widths and region offsets of this record's v5 body. + fn layout(&self) -> Result { + V5Layout::read(self.buf.as_bytes(), self.header().output_count) + } + fn header(&self) -> RecordHeader { match decode_header(self.buf.as_bytes()) { Ok(header) => header, @@ -820,11 +1050,37 @@ impl<'a> OutputParts<'a> { ) } - /// Validated encoded size (19-byte metadata + script) of this output. - fn encoded_len(&self) -> Result { + /// Validated v5 payload size of this output, excluding its two directory + /// entries. + /// + /// Must agree with [`write_payload`] exactly: the record buffer is + /// allocated at this size, the writer bounds-checks every push, and the + /// length directory records it. An undercount turns a valid output into + /// `CorruptRecord`; an overcount leaves slack in a structure whose whole + /// point is to be small. `encoded_len_matches_the_bytes_written` pins the + /// two together. + /// + /// The script length is not stored: the script is whatever remains of the + /// payload, so the directory entry pays for itself. + fn payload_len(&self) -> Result { + use crate::compress::varint_len; + + let script_len = self.script.len(); + u16::try_from(script_len).map_err(|_| UtxoError::ScriptTooLarge { len: script_len })?; + let (amount, escaped) = amount_parts(self.value); + let prologue = varint_len(amount) + + usize::from(escaped) * core::mem::size_of::() + + varint_len(pack_height(self.height, self.coinbase)); + prologue + .checked_add(script_len) + .ok_or(UtxoError::RecordTooLarge { len: script_len }) + } + + /// Validated v4 encoded size (19-byte metadata + script). Oracle only. + fn encoded_len_v4(&self) -> Result { let script_len = self.script.len(); u16::try_from(script_len).map_err(|_| UtxoError::ScriptTooLarge { len: script_len })?; - OUTPUT_METADATA_LEN + OUTPUT_METADATA_LEN_V4 .checked_add(script_len) .ok_or(UtxoError::RecordTooLarge { len: script_len }) } @@ -914,9 +1170,24 @@ fn additions_are_strictly_increasing(previous: Option, additions: &[OutputP true } +/// Appends one `width`-byte little-endian directory entry. +fn push_dir_entry( + writer: &mut RecordWriter<'_>, + value: u64, + width: usize, +) -> Result<(), UtxoError> { + let bytes = value.to_le_bytes(); + writer.push(bytes.get(..width).ok_or(UtxoError::CorruptRecord)?) +} + /// Encodes a canonical record payload into one exact-capacity buffer. Every /// script must be `<= u16::MAX`; existing outputs satisfy this by construction /// and additions are prevalidated here. +/// +/// Layout: `header || widths || vout_dir || len_dir || payloads`. The +/// directories are fixed width — the narrowest that holds the record's largest +/// `vout` and largest payload — so a lookup indexes straight into them instead +/// of walking output frames. fn encode_record( txid: Hash256, legacy_inline_len: usize, @@ -928,10 +1199,77 @@ fn encode_record( return Err(UtxoError::CorruptRecord); } + // One pass for the sizes: the directory widths are a property of the whole + // record, so nothing can be written until every payload length is known. + let mut payload_lens: SmallVec<[u32; 16]> = SmallVec::with_capacity(outputs.len()); + let mut payload_total = 0_usize; + let mut max_vout = 0_u64; + let mut max_len = 0_u64; + for output in outputs { + let len = output.payload_len()?; + payload_total = payload_total + .checked_add(len) + .ok_or(UtxoError::RecordTooLarge { len: payload_total })?; + payload_lens.push(u32::try_from(len).map_err(|_| UtxoError::RecordTooLarge { len })?); + max_vout = max_vout.max(u64::from(output.vout)); + max_len = max_len.max(u64::try_from(len).unwrap_or(u64::MAX)); + } + let vout_width = width_for(max_vout); + let len_width = width_for(max_len); + let layout = V5Layout::new(outputs.len(), vout_width, len_width)?; + + let payload_len = layout + .payloads + .checked_add(payload_total) + .ok_or(UtxoError::RecordTooLarge { len: payload_total })?; + if payload_len > usize::try_from(isize::MAX).unwrap_or(usize::MAX) { + return Err(UtxoError::RecordTooLarge { len: payload_len }); + } + + let legacy_inline_len_u8 = + u8::try_from(legacy_inline_len).map_err(|_| UtxoError::CorruptRecord)?; + let mut buf = ThinRecordBuf::with_capacity(payload_len)?; + let mut writer = RecordWriter::new(&mut buf); + writer.push(&txid.to_le_bytes())?; + writer.push(&output_count.to_le_bytes())?; + writer.push(&[legacy_inline_len_u8])?; + writer.push(&[pack_widths(vout_width, len_width)?])?; + // One `push` per directory entry. Staging both directories in a + // `SmallVec` scratch and copying once was tried and measured *slower* — + // 505.7ns against 428.5ns to encode a 16-output record — because setting up + // the scratch costs more than the bounds checks it saves at one or two + // bytes per entry. + for output in outputs { + push_dir_entry(&mut writer, u64::from(output.vout), vout_width)?; + } + for len in &payload_lens { + push_dir_entry(&mut writer, u64::from(*len), len_width)?; + } + for output in outputs { + write_payload(&mut writer, output)?; + } + writer.finish()?; + debug_assert_eq!(buf.as_bytes().len(), payload_len); + Ok(buf) +} + +/// [`encode_record`] against the retained v4 output layout. Oracle and +/// benchmark arm only; nothing in the crate encodes v4 any more. +fn encode_record_v4( + txid: Hash256, + legacy_inline_len: usize, + outputs: &[OutputParts<'_>], +) -> Result { + let output_count = u32::try_from(outputs.len()) + .map_err(|_| UtxoError::RecordTooLarge { len: outputs.len() })?; + if legacy_inline_len > LEGACY_INLINE_CAPACITY || legacy_inline_len > outputs.len() { + return Err(UtxoError::CorruptRecord); + } + let mut payload_len = RECORD_HEADER_LEN; for output in outputs { payload_len = payload_len - .checked_add(output.encoded_len()?) + .checked_add(output.encoded_len_v4()?) .ok_or(UtxoError::RecordTooLarge { len: payload_len })?; } if payload_len > usize::try_from(isize::MAX).unwrap_or(usize::MAX) { @@ -946,24 +1284,81 @@ fn encode_record( writer.push(&output_count.to_le_bytes())?; writer.push(&[legacy_inline_len_u8])?; for output in outputs { - write_output(&mut writer, output)?; + write_output_v4(&mut writer, output)?; } writer.finish()?; debug_assert_eq!(buf.as_bytes().len(), payload_len); Ok(buf) } -/// Appends one output's canonical 19-byte metadata + script, validating the -/// script length fits the `u16` length field. -fn write_output(writer: &mut RecordWriter<'_>, output: &OutputParts<'_>) -> Result<(), UtxoError> { +/// The amount varint for `value`, and whether an 8-byte raw tail follows it. +fn amount_parts(value: u64) -> (u64, bool) { + match crate::compress::compress_amount(value) { + Ok(compressed) => (compressed, false), + Err(_) => (AMOUNT_ESCAPE, true), + } +} + +/// Appends one output's v5 payload: +/// `varint(amount) [|| raw amount] || varint(height << 1 | coinbase) || script`. +/// +/// `vout` and the payload length live in the directories, and the script length +/// is not stored at all — the script is the remainder of the payload. +/// +/// The `u16` script-length ceiling is kept from v4, so both codecs accept +/// exactly the same set of outputs and the equivalence between them is +/// unconditional. +fn write_payload(writer: &mut RecordWriter<'_>, output: &OutputParts<'_>) -> Result<(), UtxoError> { + use crate::compress::write_varint_at; + + let script_len = output.script.len(); + u16::try_from(script_len).map_err(|_| UtxoError::ScriptTooLarge { len: script_len })?; + let (amount, escaped) = amount_parts(output.value); + + // Laid into one stack buffer and copied once, mirroring v4. Issuing a + // bounds-checked `push` per field instead measured 3.2x slower to encode a + // 16-output record — the varints are cheap, the per-push overhead was not. + let mut prologue = [0_u8; PAYLOAD_PROLOGUE_MAX_LEN]; + let mut at = write_varint_at(amount, &mut prologue, 0).ok_or(UtxoError::CorruptRecord)?; + if escaped { + let end = at + .checked_add(core::mem::size_of::()) + .ok_or(UtxoError::CorruptRecord)?; + prologue + .get_mut(at..end) + .ok_or(UtxoError::CorruptRecord)? + .copy_from_slice(&output.value.to_le_bytes()); + at = end; + } + let at = write_varint_at( + pack_height(output.height, output.coinbase), + &mut prologue, + at, + ) + .ok_or(UtxoError::CorruptRecord)?; + + writer.push(prologue.get(..at).ok_or(UtxoError::CorruptRecord)?)?; + writer.push(output.script)?; + Ok(()) +} + +/// Appends one output in the retained v4 layout: a fixed 19-byte metadata block +/// plus the script. +/// +/// Not reachable from any live path — [`encode_record`] writes v5. It is the +/// equivalence oracle and the benchmark's `before` arm, and it is what proves +/// the replacement is both smaller and lossless. +fn write_output_v4( + writer: &mut RecordWriter<'_>, + output: &OutputParts<'_>, +) -> Result<(), UtxoError> { let script_len = u16::try_from(output.script.len()).map_err(|_| UtxoError::ScriptTooLarge { len: output.script.len(), })?; // Pack the canonical 19-byte metadata header (`vout || value || height || // coinbase || script_len`, all little-endian) into one stack array, then - // emit it followed by the script in a single two-push sequence. The range - // layout is byte-identical to the former six-segment push chain. - let mut meta = [0_u8; OUTPUT_METADATA_LEN]; + // emit it followed by the script in a single two-push sequence. + let mut meta = [0_u8; OUTPUT_METADATA_LEN_V4]; meta[0..4].copy_from_slice(&output.vout.to_le_bytes()); meta[4..12].copy_from_slice(&output.value.to_le_bytes()); meta[12..16].copy_from_slice(&output.height.to_le_bytes()); @@ -976,11 +1371,25 @@ fn write_output(writer: &mut RecordWriter<'_>, output: &OutputParts<'_>) -> Resu fn validate_encoded(bytes: &[u8]) -> Result { let header = decode_header(bytes)?; - let mut cursor = RECORD_HEADER_LEN; - for _ in 0..header.output_count { - let (_, next) = decode_output(bytes, cursor)?; + let layout = V5Layout::read(bytes, header.output_count)?; + + // Both directory widths must be the narrowest that fits, or the record + // would have a second, wider spelling of itself. `UtxoRecord` compares by + // bytes, so two spellings of one record is a correctness bug, not a + // cosmetic one. + let mut max_vout = 0_u64; + let mut max_len = 0_u64; + let mut cursor = layout.payloads; + for index in 0..layout.count { + max_vout = max_vout.max(u64::from(layout.vout_at(bytes, index)?)); + let len = layout.payload_len_at(bytes, index)?; + max_len = max_len.max(u64::try_from(len).unwrap_or(u64::MAX)); + let (_, next) = decode_output_at(bytes, &layout, index, cursor)?; cursor = next; } + if width_for(max_vout) != layout.vout_width || width_for(max_len) != layout.len_width { + return Err(UtxoError::CorruptRecord); + } if cursor != bytes.len() { return Err(UtxoError::CorruptRecord); } @@ -1009,9 +1418,93 @@ fn decode_header(bytes: &[u8]) -> Result { }) } -fn decode_output(bytes: &[u8], offset: usize) -> Result<(OneUtxoOut<'_>, usize), UtxoError> { +/// Byte offset of output `index`'s payload: the sum of every earlier payload +/// length. +/// +/// This is what a random lookup pays instead of walking frames. The additions +/// read a dense, fixed-width array with no data dependency between entries, +/// where walking frames chased each output's length through its own bytes and +/// jumped over its script. +fn payload_offset(bytes: &[u8], layout: &V5Layout, index: usize) -> Result { + let mut offset = layout.payloads; + for earlier in 0..index { + offset = offset + .checked_add(layout.payload_len_at(bytes, earlier)?) + .ok_or(UtxoError::CorruptRecord)?; + } + Ok(offset) +} + +/// Decodes output `index`, whose payload starts at `payload`. +/// +/// Returns the output and the offset just past its payload, so a sequential +/// walk never re-sums the length directory. +/// +/// Every rejection here exists to keep the encoding canonical, so that equal +/// records are byte-equal — a property v4's fixed-width fields gave for free +/// and one that `UtxoRecord`'s byte-wise `PartialEq` depends on. +fn decode_output_at<'a>( + bytes: &'a [u8], + layout: &V5Layout, + index: usize, + payload: usize, +) -> Result<(OneUtxoOut<'a>, usize), UtxoError> { + let vout = layout.vout_at(bytes, index)?; + let len = layout.payload_len_at(bytes, index)?; + let next = payload.checked_add(len).ok_or(UtxoError::CorruptRecord)?; + let body = bytes.get(payload..next).ok_or(UtxoError::CorruptRecord)?; + + let (amount, cursor) = crate::compress::read_varint(body, 0)?; + let (value, cursor) = if amount == AMOUNT_ESCAPE { + let end = cursor + .checked_add(core::mem::size_of::()) + .ok_or(UtxoError::CorruptRecord)?; + let raw: [u8; 8] = body + .get(cursor..end) + .ok_or(UtxoError::CorruptRecord)? + .try_into() + .map_err(|_| UtxoError::CorruptRecord)?; + let value = u64::from_le_bytes(raw); + // An escaped value that would have compressed is a second spelling of + // an amount the compact form already covers. + if value <= crate::compress::MAX_COMPRESSIBLE_AMOUNT { + return Err(UtxoError::CorruptRecord); + } + (value, end) + } else { + ( + crate::compress::decompress_amount(amount).ok_or(UtxoError::CorruptRecord)?, + cursor, + ) + }; + + let (packed, cursor) = crate::compress::read_varint(body, cursor)?; + let height = u32::try_from(packed >> 1).map_err(|_| UtxoError::CorruptRecord)?; + let coinbase = packed & 1 == 1; + + // Whatever is left of the payload is the script, so no length is stored. + // The v4 ceiling is still enforced: a record a v4 build could not have + // written must not decode here either. + let script_pubkey = body.get(cursor..).ok_or(UtxoError::CorruptRecord)?; + u16::try_from(script_pubkey.len()).map_err(|_| UtxoError::CorruptRecord)?; + + Ok(( + OneUtxoOut { + vout, + value, + script_pubkey, + coinbase, + height, + }, + next, + )) +} + +/// Decodes one output in the retained v4 layout. Oracle and benchmark arm only; +/// see [`write_output_v4`]. +fn decode_output_v4(bytes: &[u8], offset: usize) -> Result<(OneUtxoOut<'_>, usize), UtxoError> { let metadata_end = offset - .checked_add(OUTPUT_METADATA_LEN) + .checked_add(OUTPUT_METADATA_LEN_V4) .ok_or(UtxoError::CorruptRecord)?; let metadata = bytes .get(offset..metadata_end) @@ -1063,6 +1556,125 @@ fn read_u64(bytes: &[u8], offset: usize) -> Option { ])) } +/// Both record payload codecs, side by side, for the equivalence test and the +/// paired benchmark. +/// +/// The codec itself is `pub(crate)` and stays that way; this is the smallest +/// surface that lets an out-of-crate test drive v4 and v5 over identical inputs +/// and compare bytes as well as fields. Inputs and outputs are +/// [`OneUtxoOut`], which is the type the rest of the crate reads records +/// through, so nothing here is a test-only shape. +#[doc(hidden)] +pub struct RecordCodec; + +#[doc(hidden)] +impl RecordCodec { + /// Encodes a whole record payload with the current v5 output layout. + pub fn encode_v5(txid: Hash256, outputs: &[OneUtxoOut<'_>]) -> Result, UtxoError> { + let parts = view_parts(outputs); + let inline = parts.len().min(LEGACY_INLINE_CAPACITY); + Ok(encode_record(txid, inline, &parts)?.as_bytes().to_vec()) + } + + /// Encodes the same payload with the retained v4 output layout. + pub fn encode_v4(txid: Hash256, outputs: &[OneUtxoOut<'_>]) -> Result, UtxoError> { + let parts = view_parts(outputs); + let inline = parts.len().min(LEGACY_INLINE_CAPACITY); + Ok(encode_record_v4(txid, inline, &parts)?.as_bytes().to_vec()) + } + + /// Decodes every output of a v5 payload, borrowing scripts from `bytes`. + pub fn decode_v5(bytes: &[u8]) -> Result>, UtxoError> { + let header = decode_header(bytes)?; + let layout = V5Layout::read(bytes, header.output_count)?; + let mut outputs = Vec::with_capacity(layout.count); + let mut payload = layout.payloads; + for index in 0..layout.count { + let (output, next) = decode_output_at(bytes, &layout, index, payload)?; + outputs.push(output); + payload = next; + } + if payload != bytes.len() { + return Err(UtxoError::CorruptRecord); + } + Ok(outputs) + } + + /// Decodes every output of a v4 payload, borrowing scripts from `bytes`. + pub fn decode_v4(bytes: &[u8]) -> Result>, UtxoError> { + let header = decode_header(bytes)?; + let mut outputs = Vec::with_capacity(header.output_count); + let mut cursor = RECORD_HEADER_LEN; + for _ in 0..header.output_count { + let (output, next) = decode_output_v4(bytes, cursor)?; + outputs.push(output); + cursor = next; + } + if cursor != bytes.len() { + return Err(UtxoError::CorruptRecord); + } + Ok(outputs) + } + + /// Finds one output in a v5 payload by `vout`, decoding only the match. + /// + /// The hot read: `Shard::get`, `get_entry` and `get_meta` all resolve a + /// spent input through this shape, so it is the operation a codec change + /// has to be judged on. Mirrors [`UtxoRecord::find_output`] exactly. + pub fn find_v5(bytes: &[u8], vout: u32) -> Result>, UtxoError> { + let header = decode_header(bytes)?; + let layout = V5Layout::read(bytes, header.output_count)?; + for index in 0..layout.count { + if layout.vout_at(bytes, index)? == vout { + let payload = payload_offset(bytes, &layout, index)?; + return decode_output_at(bytes, &layout, index, payload) + .map(|(output, _)| Some(output)); + } + } + Ok(None) + } + + /// The same search over a v4 payload. + /// + /// Written as the naive full decode, because that is what the shipped code + /// did — and it is nonetheless the arm to beat. Every v4 field sits at a + /// constant offset, so when only `vout` is read the optimizer deletes the + /// loads for the rest: v4 gets lazy skipping for free from LLVM, without + /// anyone designing it. v5 cannot be given the same treatment, because each + /// varint's length is what locates the next field, so the reads are a + /// serial dependency chain that no optimizer can remove. + /// + /// That asymmetry is the real cost of the variable-length layout, and it + /// only shows up in a benchmark shaped like the hot path. + pub fn find_v4(bytes: &[u8], vout: u32) -> Result>, UtxoError> { + let header = decode_header(bytes)?; + let mut cursor = RECORD_HEADER_LEN; + for _ in 0..header.output_count { + let (output, next) = decode_output_v4(bytes, cursor)?; + if output.vout == vout { + return Ok(Some(output)); + } + cursor = next; + } + Ok(None) + } +} + +fn view_parts<'a>(outputs: &[OneUtxoOut<'a>]) -> Vec> { + outputs + .iter() + .map(|output| { + OutputParts::new( + output.vout, + output.value, + output.script_pubkey, + output.coinbase, + output.height, + ) + }) + .collect() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct OwnedUtxoOut { pub(crate) vout: u32, @@ -1210,8 +1822,21 @@ mod tests { model: &LegacyArrayVecModel, ) -> Result<(), UtxoError> { assert_eq!(record.output_count(), model.output_count()); + + // The model serializes v4 independently of the crate's codec, which is + // what makes it an oracle for ordering and for the inline/overflow + // partition. The record is v5, so the comparison runs the record's own + // descriptors — and its own inline length, which is not always + // `min(count, 8)` — back through the retained v4 encoder. Byte + // equality then still means "same outputs, same order, same partition" + // without the test having to reimplement the varint layout. let expected_bytes = model.encode(txid)?; - assert_eq!(record.encoded_bytes(), expected_bytes.as_slice()); + let actual_v4 = encode_record_v4( + txid, + record.header().legacy_inline_len, + &record.output_parts(), + )?; + assert_eq!(actual_v4.as_bytes(), expected_bytes.as_slice()); let actual_outputs = record .outputs() @@ -1266,9 +1891,19 @@ mod tests { u32::MAX, )], )?; + // Deliberately the worst case for v5: `u32::MAX` in both the vout and + // the height costs 5 varint bytes each, where a real output pays 1 and + // 3. Even here v5 is 15 metadata+script bytes against v4's 21. + // varint(u32::MAX) = 5, varint(compress(42)) = 2, + // varint(u32::MAX << 1 | 1) = 5, varint(2) = 1, script = 2 assert_eq!( record.encoded_bytes().len(), - RECORD_HEADER_LEN + OUTPUT_METADATA_LEN + 2 + RECORD_HEADER_LEN + 15, + "v5 output layout changed" + ); + assert!( + record.encoded_bytes().len() < RECORD_HEADER_LEN + OUTPUT_METADATA_LEN_V4 + 2, + "v5 must not be larger than v4 even on its worst-case input" ); let output = record.outputs().next().ok_or(UtxoError::CorruptRecord)?; assert_eq!(output.vout, u32::MAX); @@ -1279,6 +1914,60 @@ mod tests { Ok(()) } + /// The buffer is allocated at `encoded_len` and the writer bounds-checks + /// every push, so an undercount rejects a valid output and an overcount + /// leaves slack in the structure this whole change exists to shrink. + #[test] + fn encoded_len_matches_the_bytes_written() -> Result<(), UtxoError> { + let script = vec![0x51; 300]; + let cases = [ + OwnedUtxoOut::new(0, 0, Vec::new(), false, 0), + OwnedUtxoOut::new(1, 1, vec![0x51], false, 1), + OwnedUtxoOut::new(127, 100_000_000, vec![0x00; 22], true, 840_000), + OwnedUtxoOut::new(128, 2_099_999_999_999_999, script.clone(), false, 1_048_576), + // Above the money supply: takes the escape, which is the only case + // where v5 is larger than v4. + OwnedUtxoOut::new(u32::MAX, u64::MAX, script, true, u32::MAX), + ]; + for case in cases { + let payload = OutputParts::from_owned(&case).payload_len()?; + let vout_width = width_for(u64::from(case.vout)); + let len_width = width_for(u64::try_from(payload).unwrap_or(u64::MAX)); + // header || widths || one vout entry || one length entry || payload + let expected = RECORD_HEADER_LEN + 1 + vout_width + len_width + payload; + let record = UtxoRecord::from_owned_outputs(Hash256::default(), &[case])?; + assert_eq!( + record.encoded_bytes().len(), + expected, + "payload_len disagreed with write_payload" + ); + // Exact-capacity buffer: no slack survives the encode. + assert_eq!(record.buf.capacity(), record.buf.len()); + } + Ok(()) + } + + /// An amount above the money supply cannot occur in a consensus-valid + /// block, but v4 stored one losslessly and so must v5. + #[test] + fn an_amount_above_the_money_supply_survives_the_escape() -> Result<(), UtxoError> { + for value in [ + crate::compress::MAX_COMPRESSIBLE_AMOUNT + 1, + u64::MAX / 2, + u64::MAX, + ] { + let record = UtxoRecord::from_owned_outputs( + Hash256::default(), + &[OwnedUtxoOut::new(3, value, vec![0x51], false, 7)], + )?; + let output = record.outputs().next().ok_or(UtxoError::CorruptRecord)?; + assert_eq!(output.value, value, "escaped amount did not round trip"); + assert_eq!(output.vout, 3); + assert_eq!(output.height, 7); + } + Ok(()) + } + #[test] fn malformed_encoded_boundaries_are_rejected() -> Result<(), UtxoError> { let record = @@ -1286,7 +1975,7 @@ mod tests { let encoded = record.encoded_bytes(); let truncated_metadata = encoded - .get(..RECORD_HEADER_LEN + OUTPUT_METADATA_LEN - 1) + .get(..RECORD_HEADER_LEN + 2) .ok_or(UtxoError::CorruptRecord)? .to_vec(); assert!(matches!( @@ -1324,13 +2013,101 @@ mod tests { Err(UtxoError::CorruptRecord) )); - let mut invalid_bool = encoded.to_vec(); - let bool_byte = invalid_bool - .get_mut(RECORD_HEADER_LEN + 16) - .ok_or(UtxoError::CorruptRecord)?; - *bool_byte = 2; + Ok(()) + } + + /// A corrupt record must not be able to panic the decoder. + #[test] + fn an_absurd_compressed_amount_is_rejected_rather_than_overflowing() -> Result<(), UtxoError> { + // `varint(u64::MAX - 1)`: ten bytes, and not the escape sentinel, so it + // reaches the amount transform. + let mut payload = vec![0xFE_u8]; + payload.extend_from_slice(&[0xFF; 8]); + payload.push(0x01); + payload.extend_from_slice(&[0x02, 0x51, 0xAC]); + + let mut bytes = Hash256::default().to_le_bytes().to_vec(); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + bytes.push(1); + bytes.push(0x11); + bytes.push(0x00); + bytes.push(u8::try_from(payload.len()).unwrap_or(0)); + bytes.extend_from_slice(&payload); + + assert!(matches!( + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&bytes)?), + Err(UtxoError::CorruptRecord) + )); + Ok(()) + } + + /// v5 has no invalid bool byte — `coinbase` is one bit of a varint, so + /// every value is meaningful. What it has instead is several ways to spell + /// one output, and all of them must be refused: `UtxoRecord` compares by + /// bytes, so a second spelling makes equal records unequal. + #[test] + fn non_canonical_v5_spellings_are_rejected() -> Result<(), UtxoError> { + // Assembles a one-output record: `header || widths || vout_dir || + // len_dir || payload`. + fn record(vout_width: usize, len_width: usize, vout: u64, payload: &[u8]) -> Vec { + let mut bytes = Hash256::default().to_le_bytes().to_vec(); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + bytes.push(1); + let widths = + (u8::try_from(len_width).unwrap_or(1) << 4) | u8::try_from(vout_width).unwrap_or(1); + bytes.push(widths); + bytes.extend_from_slice(&vout.to_le_bytes()[..vout_width]); + let len = u64::try_from(payload.len()).unwrap_or(0); + bytes.extend_from_slice(&len.to_le_bytes()[..len_width]); + bytes.extend_from_slice(payload); + bytes + } + + // `vout 0, value 1, height 1, not coinbase, script 0x51 0xAC`. The + // payload is `varint(compress(1)) || varint(1 << 1) || script`; the + // script length is not stored, so the script is simply the remainder. + let canonical = record(1, 1, 0, &[0x01, 0x02, 0x51, 0xAC]); + assert!( + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&canonical)?).is_ok(), + "the canonical spelling must decode" + ); + + // The amount as a two-byte spelling of one. + let non_minimal = record(1, 1, 0, &[0x81, 0x00, 0x02, 0x51, 0xAC]); + assert!(matches!( + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&non_minimal)?), + Err(UtxoError::CorruptRecord) + )); + + // The escape used for a value the compact form already covers. + let mut escaped = [0xFF_u8; 9].to_vec(); + escaped.push(0x01); + escaped.extend_from_slice(&1_u64.to_le_bytes()); + escaped.extend_from_slice(&[0x02, 0x51, 0xAC]); assert!(matches!( - UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&invalid_bool)?), + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&record(1, 1, 0, &escaped))?), + Err(UtxoError::CorruptRecord) + )); + + // Directories wider than this record needs. This spelling is what the + // fixed-width layout introduced, and the reason the widths are + // validated on decode rather than merely read. + for (vout_width, len_width) in [(2, 1), (1, 2), (4, 4)] { + let wide = record(vout_width, len_width, 0, &[0x01, 0x02, 0x51, 0xAC]); + assert!( + matches!( + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&wide)?), + Err(UtxoError::CorruptRecord) + ), + "an over-wide {vout_width}/{len_width} directory was accepted" + ); + } + + // A script longer than the `u16` ceiling v4 could express. + let mut oversize = vec![0x01, 0x02]; + oversize.extend_from_slice(&vec![0x51; 65_536]); + assert!(matches!( + UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&record(1, 4, 0, &oversize))?), Err(UtxoError::CorruptRecord) )); Ok(()) @@ -1443,6 +2220,68 @@ mod tests { Ok(()) } + /// The speed half of this refactor set, asserted deterministically. + /// + /// `find_output` is the hot read — every spent input lands there — and what + /// made the first v5 draft slower than v4 was decoding every output it + /// rejected. Timing that in a test would flake; counting the expensive + /// operation does not. One decompression for a hit, none for a miss, no + /// matter how many outputs the record holds. + #[test] + fn find_output_decompresses_at_most_the_amount_it_returns() -> Result<(), UtxoError> { + let outputs: Vec = (0..24_u32) + .map(|index| { + OwnedUtxoOut::new( + index, + u64::from(index + 1) * 10_000_000, + vec![0x51; 22], + false, + 800_000 + index, + ) + }) + .collect(); + let record = UtxoRecord::from_owned_outputs(Hash256::default(), &outputs)?; + + let calls = |f: &dyn Fn()| { + crate::compress::DECOMPRESS_CALLS.with(|c| c.set(0)); + f(); + crate::compress::DECOMPRESS_CALLS.with(core::cell::Cell::get) + }; + + // The last output: the whole record is walked before it matches. + assert_eq!( + calls(&|| { + assert!(record.find_output(23).is_some()); + }), + 1, + "a hit must decompress only the amount it returns" + ); + // A miss walks every output and must decompress none of them. + assert_eq!( + calls(&|| { + assert!(record.find_output(999).is_none()); + }), + 0, + "a miss must not decompress anything" + ); + // `max_vout` reads only vouts. + assert_eq!( + calls(&|| { + assert_eq!(record.max_vout(), Some(23)); + }), + 0, + "max_vout must not decompress anything" + ); + // The full scan is the one read that legitimately pays per output. + assert_eq!( + calls(&|| { + assert_eq!(record.outputs().count(), 24); + }), + 24 + ); + Ok(()) + } + #[test] fn thin_owner_exact_constructor_has_no_slack() -> Result<(), UtxoError> { let record = diff --git a/crates/utxo/src/set.rs b/crates/utxo/src/set.rs index c2032ce7..fedecbef 100644 --- a/crates/utxo/src/set.rs +++ b/crates/utxo/src/set.rs @@ -111,6 +111,16 @@ pub enum UtxoError { /// Snapshot full txid does not match the stored key prefix. #[error("snapshot txid prefix does not match record key prefix")] SnapshotTxidPrefixMismatch, + /// An output value exceeds the largest amount any UTXO can hold. + /// + /// Unreachable through consensus, which caps the money supply. It exists so + /// a corrupt or synthetic value fails loudly instead of overflowing the + /// amount compression. + #[error("output value {value} exceeds the maximum money supply")] + AmountOutOfRange { + /// Offending value in satoshis. + value: u64, + }, } /// Receives UTXO mutations committed to durable shard state. @@ -687,6 +697,34 @@ pub struct UtxoSet { listener: Option>, } +/// Byte-level accounting of what a UTXO set holds in memory. +/// +/// Every field is what the set can account for itself. What it cannot see — +/// allocator size-class rounding, fragmentation, and per-allocation metadata — +/// is exactly the residual against process RSS, which is the point. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct UtxoMemoryReport { + /// Transaction-level records held. + pub records: usize, + /// Live outputs across those records. + pub outputs: usize, + /// Sum of every record's heap allocation: header plus buffer capacity. + pub record_allocation_bytes: usize, + /// Sum of every record's live encoded payload, excluding header and slack. + pub record_payload_bytes: usize, + /// Estimated hash-table backing store across all shards. + pub table_bytes: usize, +} + +impl UtxoMemoryReport { + /// Everything the set can attribute: record allocations plus table storage. + #[must_use] + pub const fn accounted_bytes(&self) -> usize { + self.record_allocation_bytes + .saturating_add(self.table_bytes) + } +} + /// Read guard for a stable whole-set UTXO view. pub struct UtxoSetView<'a> { set: &'a UtxoSet, @@ -712,6 +750,30 @@ impl UtxoSetView<'_> { self.set.shards.iter().map(Shard::record_count).sum() } + /// Accounts for what this set holds in memory, shard by shard. + /// + /// Exists to attribute process RSS rather than to guess at it. The set is + /// fully memory-resident with no eviction tier, and the published + /// 13.83 GiB at height 645,804 is far above what the record encoding alone + /// predicts, so the gap between `record_allocation_bytes + table_bytes` and + /// actual RSS is the number that decides whether an encoding change is worth + /// making at all. + /// + /// Walks every record in every shard: O(records), for measurement only. + #[must_use] + pub fn memory_report(&self) -> UtxoMemoryReport { + let mut report = UtxoMemoryReport::default(); + for shard in &self.set.shards { + let (allocation, payload) = shard.allocation_and_payload_bytes(); + report.records += shard.record_count(); + report.outputs += shard.output_count(); + report.record_allocation_bytes += allocation; + report.record_payload_bytes += payload; + report.table_bytes += shard.table_bytes(); + } + report + } + /// Computes Bitcoin Core's `hash_serialized_3` commitment for this stable view. pub fn hash_serialized_3(&self) -> Result { crate::snapshot::hash_serialized_3_stable(self) diff --git a/crates/utxo/src/shard.rs b/crates/utxo/src/shard.rs index 9cbe4bae..0900bd93 100644 --- a/crates/utxo/src/shard.rs +++ b/crates/utxo/src/shard.rs @@ -34,6 +34,38 @@ impl ShardTable { pub(crate) fn output_count(&self) -> usize { self.table.iter().map(UtxoRecord::output_count).sum() } + + /// Sums every record's heap allocation and live payload. + /// + /// The allocation is what the process actually holds — `AllocHeader` plus + /// the buffer's capacity — while the payload is the encoded bytes inside it. + /// The two differ wherever a record was grown and kept slack, and the gap + /// between the allocation total and process RSS is what allocator size + /// classes and fragmentation cost. + pub(crate) fn allocation_and_payload_bytes(&self) -> (usize, usize) { + self.table.iter().fold((0, 0), |(alloc, payload), record| { + ( + alloc + record.allocation_bytes(), + payload + record.payload_bytes(), + ) + }) + } + + /// Estimated bytes held by the hash table itself, excluding record payloads. + /// + /// `hashbrown` exposes usable capacity, not bucket count, so this + /// reconstructs the layout: buckets are a power of two above + /// `capacity / 0.875`, and each carries one `UtxoRecord` (a pointer) plus one + /// control byte. An estimate by construction — treat it as the right order of + /// magnitude, not an exact figure. + pub(crate) fn table_bytes(&self) -> usize { + let capacity = self.table.capacity(); + if capacity == 0 { + return 0; + } + let buckets = capacity.saturating_mul(8).div_ceil(7).next_power_of_two(); + buckets.saturating_mul(core::mem::size_of::() + 1) + } } /// One live UTXO output with the metadata consensus consumers need. @@ -203,6 +235,16 @@ impl Shard { table.output_count() } + pub(crate) fn allocation_and_payload_bytes(&self) -> (usize, usize) { + let table = self.inner.read(); + table.allocation_and_payload_bytes() + } + + pub(crate) fn table_bytes(&self) -> usize { + let table = self.inner.read(); + table.table_bytes() + } + pub(crate) fn insert_owned_record( &self, key: UtxoKey, diff --git a/crates/utxo/tests/fixtures/utxo-v4-golden.dat b/crates/utxo/tests/fixtures/utxo-v4-golden.dat new file mode 100644 index 00000000..1e6f57ff Binary files /dev/null and b/crates/utxo/tests/fixtures/utxo-v4-golden.dat differ diff --git a/crates/utxo/tests/record_codec_equivalence.rs b/crates/utxo/tests/record_codec_equivalence.rs new file mode 100644 index 00000000..6ea58188 --- /dev/null +++ b/crates/utxo/tests/record_codec_equivalence.rs @@ -0,0 +1,323 @@ +//! Equivalence between the v4 and v5 `UtxoRecord` payload codecs. +//! +//! v5 exists to make the record smaller — a mainnet attribution run put the +//! UTXO set at 77.4% of process RSS (`docs/benchmarks/utxo-memory.md`) — so this +//! file carries a **third** assertion beyond the usual pair. Equivalence and +//! speed are not enough: a v5 codec that is lossless and faster but not smaller +//! has missed the point, so size is asserted here too. +//! +//! Equivalence is **per field**, over every decoded output, in order. Comparing +//! encoded bytes would be meaningless: the two layouts are supposed to differ. +// A codec test that cannot encode or decode its own fixtures has failed, and +// panicking names the offending case. +#![allow(clippy::expect_used)] + +use bitcoin_rs_primitives::Hash256; +use bitcoin_rs_utxo::{OneUtxoOut, RecordCodec}; +use proptest::prelude::*; + +/// Owned form of one output, since `OneUtxoOut` borrows its script. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Out { + vout: u32, + value: u64, + script: Vec, + coinbase: bool, + height: u32, +} + +impl Out { + fn view(&self) -> OneUtxoOut<'_> { + OneUtxoOut { + vout: self.vout, + value: self.value, + script_pubkey: &self.script, + coinbase: self.coinbase, + height: self.height, + } + } +} + +fn views(outputs: &[Out]) -> Vec> { + outputs.iter().map(Out::view).collect() +} + +fn txid() -> Hash256 { + Hash256::from_le_bytes(&[0x3c; 32]) +} + +/// Encoded payload sizes for one output set, under each codec. +struct Sizes { + v4: usize, + v5: usize, +} + +/// Asserts both codecs round-trip `outputs` to identical fields in identical +/// order, and returns what each cost in bytes. +/// +/// Checking v4 against the input as well as against v5 matters: an oracle that +/// is only ever compared to the thing it is checking can be wrong in the same +/// direction and prove nothing. +fn assert_equivalent(outputs: &[Out]) -> Sizes { + let views = views(outputs); + let encoded_v4 = RecordCodec::encode_v4(txid(), &views).expect("v4 encodes"); + let encoded_v5 = RecordCodec::encode_v5(txid(), &views).expect("v5 encodes"); + + let decoded_v4 = RecordCodec::decode_v4(&encoded_v4).expect("v4 decodes"); + let decoded_v5 = RecordCodec::decode_v5(&encoded_v5).expect("v5 decodes"); + + assert_eq!( + decoded_v4.len(), + outputs.len(), + "v4 lost or invented an output" + ); + assert_eq!( + decoded_v5.len(), + outputs.len(), + "v5 lost or invented an output" + ); + + for (index, ((source, v4), v5)) in outputs.iter().zip(&decoded_v4).zip(&decoded_v5).enumerate() + { + let expected = source.view(); + for (label, got_v4, got_v5) in [ + ("vout", u64::from(v4.vout), u64::from(v5.vout)), + ("value", v4.value, v5.value), + ("height", u64::from(v4.height), u64::from(v5.height)), + ("coinbase", u64::from(v4.coinbase), u64::from(v5.coinbase)), + ] { + let want = match label { + "vout" => u64::from(expected.vout), + "value" => expected.value, + "height" => u64::from(expected.height), + _ => u64::from(expected.coinbase), + }; + assert_eq!(got_v4, want, "v4 {label} wrong at output {index}"); + assert_eq!(got_v5, want, "v5 {label} wrong at output {index}"); + } + assert_eq!( + v4.script_pubkey, expected.script_pubkey, + "v4 script wrong at output {index}" + ); + assert_eq!( + v5.script_pubkey, expected.script_pubkey, + "v5 script wrong at output {index}" + ); + } + + Sizes { + v4: encoded_v4.len(), + v5: encoded_v5.len(), + } +} + +/// A 22-byte P2WPKH-shaped script, the most common output on mainnet. +fn p2wpkh(tag: u8) -> Vec { + let mut script = vec![0x00, 0x14]; + script.extend(core::iter::repeat_n(tag, 20)); + script +} + +#[test] +fn the_adversarial_field_values_survive_both_codecs() { + let cases = [ + // Zero everywhere. + Out { + vout: 0, + value: 0, + script: Vec::new(), + coinbase: false, + height: 0, + }, + // Saturated everywhere: `u64::MAX` takes v5's amount escape. + Out { + vout: u32::MAX, + value: u64::MAX, + script: vec![0xAB; usize::from(u16::MAX)], + coinbase: true, + height: u32::MAX, + }, + // Exactly the money supply, and one satoshi past it: the boundary + // between the compact amount and the escape. + Out { + vout: 1, + value: 21_000_000 * 100_000_000, + script: p2wpkh(0x11), + coinbase: false, + height: 1, + }, + Out { + vout: 2, + value: 21_000_000 * 100_000_000 + 1, + script: p2wpkh(0x22), + coinbase: true, + height: 2, + }, + // Past `LEGACY_INLINE_CAPACITY` and past the vout bitmap. + Out { + vout: 65, + value: 50 * 100_000_000, + script: p2wpkh(0x33), + coinbase: true, + height: 210_000, + }, + // A non-standard script that no compression scheme should shrink. + Out { + vout: 3, + value: 1, + script: vec![0x6a, 0x4c, 0xFF], + coinbase: false, + height: 840_000, + }, + ]; + + // Each on its own, so a failure names one case... + for case in &cases { + assert_equivalent(core::slice::from_ref(case)); + } + // ...and all together, which is the only way the >8-output overflow + // partition and the cursor walking from one output to the next are covered. + assert_equivalent(&cases); +} + +#[test] +fn a_record_with_more_outputs_than_the_inline_partition_holds_round_trips() { + let outputs: Vec = (0..40_u32) + .map(|index| Out { + vout: index, + value: u64::from(index) * 100_000, + script: p2wpkh(u8::try_from(index).unwrap_or(0)), + coinbase: index == 0, + height: 700_000 + index, + }) + .collect(); + let sizes = assert_equivalent(&outputs); + assert!( + sizes.v5 < sizes.v4, + "v5 must be smaller on a mainnet-shaped record: {} vs {}", + sizes.v5, + sizes.v4 + ); +} + +/// The measured saving on the output shape mainnet actually holds. +/// +/// `docs/benchmarks/utxo-memory.md` projects the tip RSS from a per-output byte +/// count, so this is the number that projection rests on. Asserting a floor +/// rather than an exact figure: the point is that the saving is real and large, +/// and pinning it exactly would break on any future encoding change that is +/// still an improvement. +#[test] +fn v5_saves_at_least_eight_bytes_per_mainnet_shaped_output() { + let outputs: Vec = (0..64_u32) + .map(|index| Out { + vout: index, + // Round amounts, which is what the Core transform exists for. + value: u64::from(index + 1) * 10_000_000, + script: p2wpkh(u8::try_from(index).unwrap_or(0)), + coinbase: false, + height: 800_000 + index, + }) + .collect(); + let sizes = assert_equivalent(&outputs); + + let saved = sizes.v4 - sizes.v5; + let per_output = saved / outputs.len(); + assert!( + per_output >= 8, + "expected >= 8 bytes saved per output, got {per_output} ({} -> {})", + sizes.v4, + sizes.v5 + ); +} + +/// v5 is not smaller on every conceivable input, and the exception is worth a +/// test rather than a footnote. +/// +/// A script over 16,383 bytes needs a 3-byte varint length where v4 spent a +/// fixed 2, so v5 costs one byte more. It is reachable — an oversized +/// `scriptPubKey` is unspendable but still enters the UTXO set — and it is +/// irrelevant: one byte against a script of at least 16 KB. +#[test] +fn an_oversized_script_is_the_one_shape_v5_does_not_shrink() { + let outputs = [Out { + vout: 0, + value: 1, + script: vec![0x51; 20_000], + coinbase: false, + height: 1, + }]; + let sizes = assert_equivalent(&outputs); + assert!( + sizes.v5 <= sizes.v4 + 1, + "v5 overhead on an oversized script grew beyond one byte: {} vs {}", + sizes.v5, + sizes.v4 + ); +} + +prop_compose! { + /// Unconstrained field values: the codec must be total over everything its + /// callers can construct, not just over consensus-valid outputs. + fn any_output()( + vout in any::(), + value in any::(), + script in prop::collection::vec(any::(), 0..80), + coinbase in any::(), + height in any::(), + ) -> Out { + Out { vout, value, script, coinbase, height } + } +} + +prop_compose! { + /// Mainnet-shaped: small vouts, plausible heights, standard script sizes, + /// amounts inside the money supply. + fn mainnet_output()( + vout in 0..16_u32, + value in 0..=21_000_000_u64 * 100_000_000, + script_len in prop::sample::select(vec![22_usize, 23, 25, 34]), + coinbase in any::(), + height in 0..1_000_000_u32, + )( + vout in Just(vout), + value in Just(value), + script in prop::collection::vec(any::(), script_len..=script_len), + coinbase in Just(coinbase), + height in Just(height), + ) -> Out { + Out { vout, value, script, coinbase, height } + } +} + +proptest! { + #[test] + fn both_codecs_round_trip_every_field(outputs in prop::collection::vec(any_output(), 0..12)) { + assert_equivalent(&outputs); + } + + /// Size is the whole reason v5 exists, so it is a property, not a spot + /// check. + #[test] + fn v5_is_never_larger_on_a_mainnet_shaped_record( + outputs in prop::collection::vec(mainnet_output(), 1..12), + ) { + let sizes = assert_equivalent(&outputs); + prop_assert!( + sizes.v5 < sizes.v4, + "v5 {} was not smaller than v4 {}", + sizes.v5, + sizes.v4 + ); + } + + /// Two distinct output sets must not encode to the same bytes, or a lookup + /// could return another transaction's coin. + #[test] + fn v5_encoding_is_injective(a in any_output(), b in any_output()) { + prop_assume!(a != b); + let encoded_a = RecordCodec::encode_v5(txid(), &views(&[a])).expect("encodes"); + let encoded_b = RecordCodec::encode_v5(txid(), &views(&[b])).expect("encodes"); + prop_assert_ne!(encoded_a, encoded_b); + } +} diff --git a/crates/utxo/tests/snapshot_v4_golden.rs b/crates/utxo/tests/snapshot_v4_golden.rs new file mode 100644 index 00000000..abee7252 --- /dev/null +++ b/crates/utxo/tests/snapshot_v4_golden.rs @@ -0,0 +1,188 @@ +//! Cross-version snapshot fidelity: a file written by the **previous** record +//! codec must still load to the same consensus values, and the current build +//! must still write the same bytes for the same logical set. +//! +//! `snapshot_roundtrip.rs` writes and reads in one process, so it passes even +//! if both sides change together — which is precisely the failure a record +//! layout change can cause. `fixtures/utxo-v4-golden.dat` was generated by a +//! build of the v4 codec and is committed as bytes, so the assertion here has +//! a fixed point outside this build. +//! +//! `hash_serialized_3` and the `MuHash` trailer are computed over decoded +//! consensus values, never over the in-memory encoding. That is the invariant +//! the whole record-layout change rests on, and it is the one asserted here. +// A golden-vector test that cannot read its own fixture has failed; panicking +// names the step that broke. +#![allow(clippy::expect_used)] + +use std::io::Cursor; + +use bitcoin::{Amount, ScriptBuf}; +use bitcoin_rs_primitives::{Hash256, OutPoint, TxOut}; +use bitcoin_rs_utxo::{ + BlockChanges, UtxoAdd, UtxoSet, hash_serialized_3, read_snapshot, write_snapshot, +}; + +/// A v4-codec build's snapshot of [`golden_fixture`]. +const GOLDEN: &[u8] = include_bytes!("fixtures/utxo-v4-golden.dat"); + +/// `hash_serialized_3` of that set, as the v4 build computed it. +const GOLDEN_HASH_HEX: &str = "020e5a59271f9db60e11102c4262702747676e868e80e7837b6e6d5fb05213ff"; + +const GOLDEN_OUTPUTS: usize = 433; +const GOLDEN_TIP_HEIGHT: u32 = 412_732; + +fn txid(seed: u64) -> Hash256 { + let mut bytes = [0_u8; 32]; + bytes[..8].copy_from_slice(&seed.to_le_bytes()); + bytes[8..16].copy_from_slice(&seed.rotate_left(23).to_le_bytes()); + bytes[16..24].copy_from_slice(&seed.wrapping_mul(0x94d0_49bb_1331_11eb).to_le_bytes()); + bytes[24..32].copy_from_slice(&seed.wrapping_add(0x0123_4567_89ab_cdef).to_le_bytes()); + Hash256::from_le_bytes(&bytes) +} + +fn hex(bytes: &[u8]) -> String { + use core::fmt::Write as _; + bytes.iter().fold(String::new(), |mut out, byte| { + let _ = write!(out, "{byte:02x}"); + out + }) +} + +/// Rebuilds the exact set the golden vector was generated from. +/// +/// Kept verbatim beside the fixture so its provenance is in the tree rather +/// than in a commit message: this function *is* the generator. A deliberately +/// awkward mix — every field width the record codec can choose between, and +/// every partition boundary it can straddle. +fn golden_fixture() -> UtxoSet { + let set = UtxoSet::new(); + let mut changes = BlockChanges::default(); + + for seed in 0_u64..120 { + for vout in 0..=(seed % 6) { + let script: Vec = match seed % 4 { + 0 => [&[0x00, 0x14][..], &[0xAB; 20][..]].concat(), + 1 => [&[0x76, 0xa9, 0x14][..], &[0xCD; 20][..], &[0x88, 0xac][..]].concat(), + 2 => [&[0xa9, 0x14][..], &[0xEF; 20][..], &[0x87][..]].concat(), + _ => [&[0x51, 0x20][..], &[0x12; 32][..]].concat(), + }; + // Round amounts, awkward amounts, zero and the money supply: the + // cases Core's amount transform treats differently. + let value = match seed % 5 { + 0 => 0, + 1 => 50 * 100_000_000, + 2 => 1, + 3 => 21_000_000 * 100_000_000, + _ => seed.wrapping_mul(7_919).wrapping_add(54_321), + }; + changes.add(UtxoAdd::new( + OutPoint::new(txid(seed), u32::try_from(vout).unwrap_or(0)), + TxOut { + value: Amount::from_sat(value), + script_pubkey: ScriptBuf::from_bytes(script), + }, + seed % 11 == 0, + u32::try_from(seed).unwrap_or(0) * 137, + )); + } + } + + // Past the inline partition and past the vout bitmap, so overflow ordering + // is pinned too. + let wide = txid(900_001); + for vout in [0_u32, 1, 7, 8, 9, 63, 64, 65, 1_000, 70_000, u32::MAX] { + changes.add(UtxoAdd::new( + OutPoint::new(wide, vout), + TxOut { + value: Amount::from_sat(u64::from(vout) + 1), + script_pubkey: ScriptBuf::from_bytes(vec![0x51; 22]), + }, + false, + 840_000, + )); + } + + // The two ends of the script length range. + changes.add(UtxoAdd::new( + OutPoint::new(txid(900_002), 0), + TxOut { + value: Amount::from_sat(7), + script_pubkey: ScriptBuf::from_bytes(Vec::new()), + }, + true, + 1, + )); + changes.add(UtxoAdd::new( + OutPoint::new(txid(900_003), 0), + TxOut { + value: Amount::from_sat(u64::MAX), + script_pubkey: ScriptBuf::from_bytes(vec![0xAB; 2_048]), + }, + false, + u32::MAX >> 1, + )); + + set.commit_block(&changes, &txid(1_234_567)) + .expect("fixture commits"); + set +} + +/// The load direction: v4 bytes must still decode to the same consensus values. +#[test] +fn a_v4_snapshot_loads_to_the_hash_and_trailer_it_was_written_with() { + let loaded = read_snapshot(&mut Cursor::new(GOLDEN)).expect("v4 snapshot loads"); + + assert_eq!(loaded.height, GOLDEN_TIP_HEIGHT); + assert_eq!(loaded.tip_hash, txid(4_242)); + assert_eq!(loaded.set.len(), GOLDEN_OUTPUTS, "output count drifted"); + assert_eq!( + loaded.muhash_trailer, [0_u8; 384], + "the MuHash trailer must survive the load byte for byte" + ); + assert_eq!( + hex(&hash_serialized_3(&loaded.set).expect("hash").to_le_bytes()), + GOLDEN_HASH_HEX, + "a v4 snapshot no longer hashes to what the v4 build computed" + ); +} + +/// The write direction: the same logical set must still serialize identically. +/// +/// Stronger than the load test, because the snapshot writer emits records and +/// their outputs in the order the record layout stores them. A pure reordering +/// would show up here and nowhere else — `hash_serialized_3` sorts by +/// `(txid, vout)` before hashing, so it cannot see one. +#[test] +fn the_current_build_writes_the_bytes_the_v4_build_wrote() { + let set = golden_fixture(); + let mut bytes = Vec::new(); + write_snapshot(&set, &txid(4_242), GOLDEN_TIP_HEIGHT, &mut bytes).expect("write"); + + assert_eq!( + bytes.len(), + GOLDEN.len(), + "snapshot length changed: {} against the v4 build's {}", + bytes.len(), + GOLDEN.len() + ); + assert!( + bytes == GOLDEN, + "snapshot bytes diverged from the v4 build at offset {:?}", + bytes.iter().zip(GOLDEN).position(|(a, b)| a != b) + ); +} + +/// Round-tripping the golden file through this build must be a fixed point. +#[test] +fn reloading_and_rewriting_a_v4_snapshot_reproduces_it_exactly() { + let loaded = read_snapshot(&mut Cursor::new(GOLDEN)).expect("v4 snapshot loads"); + let mut rewritten = Vec::new(); + write_snapshot(&loaded.set, &loaded.tip_hash, loaded.height, &mut rewritten).expect("rewrite"); + + assert!( + rewritten == GOLDEN, + "a load/store cycle is not a fixed point; first difference at {:?}", + rewritten.iter().zip(GOLDEN).position(|(a, b)| a != b) + ); +} diff --git a/docs/benchmarks/utxo-memory.md b/docs/benchmarks/utxo-memory.md new file mode 100644 index 00000000..63080dac --- /dev/null +++ b/docs/benchmarks/utxo-memory.md @@ -0,0 +1,422 @@ +# UTXO set memory attribution + +Step 2.1 of the memory campaign: measurement only, no encoding change. It exists +to decide whether the encoding and allocation work planned after it is worth +doing at all. + +The question. `UtxoSet` is fully memory-resident across 256 shards with no +eviction tier, and the published tip-RSS evidence reached **13.83 GiB at height +645,804** without making the tip, against a G14 budget of 16 GiB. Nothing had +ever attributed that figure. The record constants predict roughly 5 GB at that +height, leaving ~9 GiB unexplained, and the plan named allocator fragmentation +across tens of millions of small allocations as the leading suspect. + +Harness: `crates/utxo/examples/utxo_memory_attribution.rs`, backed by +`UtxoSetView::memory_report()`. Synthetic set with a mainnet-shaped script mix +(P2WPKH 22 B, P2PKH 25 B, P2SH 23 B, P2TR 34 B) and 1.5 live outputs per record. +System allocator, Apple Silicon. + +``` +cargo run -p bitcoin-rs-utxo --example utxo_memory_attribution --release -- [records] [churn_rounds] +``` + +## What a UTXO costs + +Bytes per live output, constant across every size measured: + +| Layer | Bytes/output | Share | +|---|---:|---:| +| Record payload | 69.6 | 63% | +| ...plus allocation header and slack | 74.9 | 68% | +| ...plus hash-table backing store | 87.5 | 79% | +| Process RSS, 200% churn | 110.5 | 100% | + +The absolute RSS-to-accounted ratio falls with scale as the fixed process +baseline amortizes — 1.431x at 750k outputs, 1.207x at 3M, 1.163x at 6M — so the +honest figure is the **marginal** cost between the 3M and 6M points, which +removes the baseline entirely: **97.96 bytes of RSS per output against 87.5 +accounted, a 1.12x allocator overhead.** + +## Where the payload goes + +The 69.6 bytes of payload per output decompose as: + +| Item | Bytes/output | +|---|---:| +| Record header amortized (37 B over 1.5 outputs) | 24.7 | +| ...of which the 32-byte txid alone | 21.3 | +| Per-output metadata (`vout`, value, height, coinbase, script_len) | 19.0 | +| Script bytes | 25.9 | + +**The single largest item is the txid**, at 21.3 bytes per output, because a +record averages only 1.5 live outputs to amortize it over. It is also the one +item that cannot be compressed: the 8-byte key prefix is lossy and the full txid +is what makes a lookup exact. + +## Fragmentation is not the answer + +The plan's leading hypothesis was allocator fragmentation from tens of millions +of small allocations. Tested directly by spending the oldest tenth of the set and +refilling it, repeatedly, holding the live count constant: + +| Churn | RSS bytes/output | RSS / accounted | +|---|---:|---:| +| None (monotonic insert) | 105.7 | 1.207x | +| 50% of the set replaced | 108.3 | 1.237x | +| 200% of the set replaced | 110.5 | 1.263x | + +Churning twice the whole set costs **5% more RSS**, and the curve is flattening, +not climbing. Uniform small allocations are the case a size-class allocator +handles well. **The hypothesis is refuted at this scale on this allocator** — +with two caveats worth keeping: production links mimalloc rather than the system +allocator, and 645,804 blocks is far more churn than twenty rounds. + +## Measured on a real chainstate + +A pruned mainnet sync to **height 412,732** (38,145,360 outputs across +10,519,335 records) settled the assumptions above. Taken from the checkpoint +path, so sync has stopped and the subsystems have drained — in-flight samples +swing between 1.1 and 3.2 GB and measure block staging, not the set. + +| Layer | Bytes/output | Total | +|---|---:|---:| +| Record payload | 55.1 | 1.96 GiB | +| ...plus allocation header and slack | 57.3 | | +| ...plus hash-table backing store | 61.2 | 2.18 GiB | +| **Process RSS** | **79.2** | **2.81 GiB** | + +**The UTXO set is 77.4% of process RSS**, not the ~44% an in-flight sample +suggested. The remaining 0.64 GiB is fjall, CoinStats, the block-record log and +the runtime. + +An earlier revision attributed 450 MB of that residual to the configured +`dbcache`. That was wrong: `Config::dbcache_mb` is parsed but **never reaches a +backend constructor** — `NodeStorage::open` does not pass it, fjall takes builder +defaults and RocksDB a fixed 256 MiB block cache. Issue #51 tracks it. The +residual is therefore not a configured, bounded component the way that claim +implied, and it has not been attributed. + +**The synthetic harness is validated.** Re-run at the measured 3.626 outputs per +record it predicts 54.6 B/output of payload against 55.1 measured (0.9% apart) +and 62.0 accounted against 61.2 (1.3%). The v4 snapshot on disk is 57.3 +B/output, a third independent path agreeing. + +**Outputs per record was the assumption that mattered, and it was wrong.** The +first pass assumed 1.5; the real trajectory is 2.296 at height 183k, 3.427 at +302k, 4.056 at 390k (the 2015 UTXO-spam era) and 3.626 at 412k. It has not +converged, and the tip value is still unknown. + +## Verdict + +| | Tip projection, 180M outputs | Share of the 16 GiB budget | +|---|---:|---:| +| Today (v4) | **13.28 GiB** | **83%** | +| With the v5 codec, measured on a real chainstate | **11.35 GiB** | **71%** | +| Projected before building it | 9.61 GiB | 60% | + +**The projection was optimistic and the measured figure is what stands.** It +assumed 17 B/output from four changes; three of them shipped and deliver a +measured **11.49 B/output of RSS** on a real 38,145,360-output chainstate, which +is 14.5% of process RSS and about **1.93 GiB at tip**. (An earlier revision of +this line quoted 11.75 from the synthetic bench; the real chainstate came in 5.1% +under it.) The fourth — hoisting `height` into the record header, worth roughly 3 +B/output — was not attempted, because it needs an invariant BIP30 duplicate +txids may break. Core-style script compression, the other 3 B/output, is +untouched. + +At 83% of budget on the UTXO path alone — before `txindex` and +`blockfilterindex`, which the G14 budget requires — that 12-point margin is +worth having, but it does not by itself settle the gate. + +**Step 2.2 is justified and done. Step 2.4 is not**: fragmentation measured 5% +after churning twice the whole set. + +### When to revert v5 + +Stated now, while the numbers are in front of us, so that whoever reads this +later does not have to reconstruct the trade. + +v5 costs about **3 ns per lookup** at the measured mainnet average and **3-21% +on block commit p95**, against budgets with roughly twenty times the headroom. +It buys **12 points of the 16 GiB tip-RSS budget**. That is a good trade only +while tip RSS is actually near the budget — and the budget has never been +measured. The 13.83 GiB figure everything here is projected from comes from +*excluded* evidence at height 645,804, on a run that never made the tip. + +**If G14 tip RSS measures well under budget — say below 10 GiB — this +complexity is not earning its keep and reverting is the right call.** v4 is +retained in the tree as the equivalence oracle and the benchmark's `before` +arm, so a revert is a revert rather than a rewrite. + +The second thing that would change the answer is outputs per record. The +projection holds it at 3.626; it has not converged (2.296 at height 183k, 4.056 +at 390k), and it is the number the result is most sensitive to, because the +32-byte txid amortizes over it directly. + +The projection holds outputs per record at 3.626, which has not converged and +remains the number the result is most sensitive to. + +## Step 2.2: the v5 record codec + +**Result: 11.75 bytes saved per output (21.7% of the payload), for a lookup cost +of about 3 ns on a typical record and a lookup *win* on a fat one.** + +> **Correction, 2026-08-16.** This section first said "lookups are faster than +> v4 rather than slower", quoting the `utxo_commit` arms. Those arms use a +> **256-output** record, and the two layouts cross over well above the record +> size mainnet actually has. The measured average is 3.626 outputs per record, +> where v5 is slower. The claim was true of the fixture and false of the +> workload, which is the more useful thing to be right about. + +### Measured on the real chainstate, not the synthetic bench + +Everything above about size came from a synthetic fixture. The same 2.03 GiB +`utxo-v4.dat` a real pruned sync produced at height 412,732 — 10,519,335 records +and **38,145,360 outputs** — was then loaded by a v4 build and a v5 build. +Same file, same machine, only the codec differs +(`crates/utxo/examples/snapshot_memory.rs`). + +| Layer | v4 | v5 | Saved | +|---|---:|---:|---:| +| Record payload | 55.08 B/output | 43.90 B/output | **11.18** | +| ...plus allocation header and slack | 57.28 | 46.11 | 11.17 | +| ...plus hash-table backing store | 61.24 | 50.07 | 11.17 | +| **Process RSS** | **65.57** | **54.08** | **11.49** | + +**The synthetic bench was 5.1% optimistic.** It predicted 11.75 B/output of +payload; the real chainstate gives **11.18**. The figure to quote is 11.18. + +Two things corroborate the instrument. The v4 payload here is 55.08 B/output +against the 55.1 the original attribution run measured by a completely different +route, and the RSS saving (11.49) is slightly *larger* than the payload saving +(11.17), which is what an allocator returning whole size classes should do. + +**And it is consensus-neutral on real data.** Both builds produce the identical +`hash_serialized_3` — +`438e59e4c0400b89cd06a5bb3623234a299ba5cf600043fb298ab345c328edfb` — and a +`MuHash` trailer whose SHA-256 matches the one the checkpoint manifest recorded +when the v4 node wrote it. That is the golden-vector assertion from +`crates/utxo/tests/snapshot_v4_golden.rs`, restated over 38 million real outputs +instead of 433 fixture ones. + +What this does **not** measure is full-node tip RSS: it loads the UTXO set alone, +with no fjall, CoinStats, block-record log or runtime alongside it. The G14 gate +still needs a synced tip node with `txindex` and `blockfilterindex`. + +``` +cargo run -p bitcoin-rs-utxo --example snapshot_memory --release -- \ + /chainstate-checkpoints/gen-*/utxo-v4.dat [manifest-trailer-sha256] +``` + +### Where the two layouts cross over + +`find_output`, by record size, `before_v4` against `after_v5`: + +| outputs | `miss` | | | `hit_last` | | | +|---|---:|---:|---:|---:|---:|---:| +| | v4 | v5 | ratio | v4 | v5 | ratio | +| 1 | 2.6 ns | 7.6 ns | 0.35x | 2.5 ns | 15.6 ns | 0.16x | +| **3.626 (measured mainnet average)** | 3.9 ns | 6.9 ns | 0.57x | 3.6 ns | 17.1 ns | 0.21x | +| 16 | 16.6 ns | 20.7 ns | 0.80x | 17.1 ns | 39.7 ns | 0.43x | +| 64 | 108.4 ns | 78.7 ns | 1.38x | 126.8 ns | 135.4 ns | 0.94x | +| 256 | 462.1 ns | 294.1 ns | 1.57x | 476.7 ns | 492.5 ns | 0.97x | + +v5 pays a fixed ~11 ns to read the directory header and decode the matched +payload, and then scans at a fraction of v4's per-output cost. Below roughly 64 +outputs the fixed cost dominates; above it the scan does. `hit_first` never +crosses, because v4's best case is a single constant-offset read the optimizer +reduces to almost nothing. + +Replacing the amount transform's `while` loop of up to nine dependent multiplies +with a power-of-ten lookup took that fixed cost from 12.5 ns to 11.3 ns — 1.1x, +real but small. It was worth doing for a different reason (see below); the +remaining fixed cost is the directory read and building the returned view, not +the arithmetic. + +**In absolute terms the loss is 3 ns per lookup at the mainnet average.** At +~4,000 spent inputs per block that is 12 µs against a 50 ms commit budget — +0.02% — bought with 21.7% of the record payload. The win concentrates on batch +payouts, the records where a lookup was expensive in the first place. + +The `utxo_commit` arms measure 705 ns -> 300 ns (2.35x) on their 256-output +fixture, against this harness's 472 ns -> 299 ns (1.58x) for the same shape. +The v5 figures agree; the v4 ones do not, because `utxo_commit` drives the real +`UtxoRecord::find_output` while this harness reimplements the v4 search as a +direct loop that the optimizer handles better. **The microbenchmark is the +conservative number** and the one quoted above. + +v5 keeps v4's 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 encodings do the shrinking, all pure per-output transforms 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 at all — the +script is whatever remains of its payload, so the length directory pays for +itself. + +Hoisting `height` into the record header would save 3 bytes more and is **not +done**: it needs "every output of a record shares one height" to hold, and +BIP30's duplicate coinbase txids are exactly where it might not. + +### Rejected first draft: flat varints + +The first v5 was a flat frame per output — +`varint(vout) || varint(amount) || varint(packed_height) || varint(script_len) || script` +— with no directories. It hit the size target and **failed on speed**, and the +way it failed is the part worth keeping. + +| Operation | v4 | flat v5 | directory v5 | +|---|---:|---:|---:| +| `get_miss` (real 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_listener` | 86.7 µs | 115.2 µs | **77.1 µs** | + +Those `same_txid_lookup` arms hold **256 outputs in one record**, which is the +far side of the crossover above. Read them as the fat-record case, not as the +typical one. + +Two mistakes produced that 4.4-4.9x, and neither was visible until the benchmark +was reshaped: + +1. **The benchmark measured the wrong operation.** It timed whole-record + `encode`/`decode`. The operation that dominates is `find_output(vout)` — + every spent input resolves through `Shard::get`/`get_entry`/`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 v5 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 variable-length layout each varint's length is + what locates the next field, so the reads are a serial dependency chain no + optimizer can remove — and locating output `i` meant walking the bytes of + outputs `0..i`, scripts included. + +The directories fix exactly that: a lookup scans one dense fixed-width array and +sums a second, touching ~2 bytes per output instead of ~35. It is why `get_miss` +ends up **2.3x faster than v4**, not merely level with it. + +### A corrupt record could panic the decoder + +Found while looking at why `find_output` has a fixed cost, and worth more than +the answer to that question. + +`decompress_amount` finished with a `while` loop multiplying by ten up to nine +times. `read_varint` hands it whatever a record contains, and +`validate_encoded` runs it over every output of every record loaded from a +snapshot — so a file on disk could reach it with an arbitrary `u64`. +`decompress_amount(u64::MAX)` is 2.05e22: **a panic in a debug build, a silent +wrap in a release one.** Reproduced before fixing, as +`an_absurd_compressed_amount_is_rejected_rather_than_overflowing`, which failed +with `attempt to multiply with overflow`. + +The fix returns `Option` and requires the decompressed value back inside the +compressible domain, which also closes a canonicality hole that was open until +now: the compact form may encode only amounts the escape refuses, and the escape +refuses exactly the amounts the compact form covers, so each amount has one +spelling and no other. + +`decompress_accepts_exactly_the_encoder_image` states that as a property over +every `u64`: whatever the decoder accepts must round-trip back to the same +compressed value through the encoder. It does not merely check for absence of +panics — it pins the accepted set to the encoder's image exactly. + +### What it costs + +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 shows up as commit p95 rising 3% (`existing`), 8% +(`uniform`) and 21% (`concentrated`, which puts all 10,000 entries in one +shard). Against the G14 budget of 50 ms this is not close to binding: the worst +case measured is 2.57 ms. + +One encode "optimization" was tried and **rejected by measurement** — staging +both directories in a `SmallVec` scratch and copying once, instead of one push +per entry. It measured 505.7 ns against 428.5 ns on a 16-output record: setting +up the scratch costs more than the bounds checks it saves at one or two bytes +per entry. + +### How it is checked + +- `crates/utxo/tests/record_codec_equivalence.rs`, 7 tests. v4 is retained as + the oracle and both codecs run over the same inputs; equality is **per field** + over every decoded `OneUtxoOut`, in order, because comparing encoded bytes + would be meaningless when the layouts are supposed to differ. Size is asserted + as a property, not a spot check. +- `non_canonical_v5_spellings_are_rejected` covers what the variable-length and + fixed-width layouts each introduced: 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 a second spelling of one + record is a correctness bug. +- `find_output_decompresses_at_most_the_amount_it_returns` asserts the *work*, + not the time: one amount decompression for a hit, none for a miss, none for + `max_vout`, no matter how many outputs the record holds. A wall-clock + assertion in a test suite is a flake generator; counting the expensive + operation is the same claim made deterministically. + +Reproduce: + +``` +cargo bench -p bitcoin-rs-utxo --bench record_codec +cargo bench -p bitcoin-rs-utxo --bench utxo_commit -- "lookup|spend_fanout_64" +``` + +The `utxo_commit` arms cannot be paired in one run — 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%. That drift bounds the rebuild effect well below every +ratio quoted above. + +## Superseded: the pre-measurement sizing + +The section below was written from the synthetic harness alone, assuming 1.5 +outputs per record. It concluded encoding work was worth ~7% of process RSS and +recommended against starting it. Both inputs were wrong. Kept because the +reasoning error is the instructive part: it priced a change against a component +size that had never been measured. + +Extrapolating 110 bytes/output to the ~67M outputs live at height 645,804 gives +**about 6.9 GiB** — roughly **half** of the observed 13.83 GiB. The UTXO set is +not where the other ~7 GiB is. Candidates never yet measured: fjall/RocksDB +memtables and block caches, CoinStats MuHash state, the `Vec` log, +and the sync staging budgets. + +Sizing the planned encoding work against the measured layout: + +| Planned change | Saving | Share of UTXO RSS | +|---|---:|---:| +| Hoist `height` + `coinbase` to the record header | 5 B/output | 4.5% | +| Compressed amounts (8 B -> ~3 B) | 5 B/output | 4.5% | +| Varint `vout` and `script_len` (6 B -> ~2 B) | 4 B/output | 3.6% | +| Core-style script compression | ~3 B/output | 2.7% | +| **Total** | **~17 B/output** | **~15%** | + +Fifteen percent of a component that is itself about half of process RSS is +**roughly 7% of the number the G14 budget is written against**. The arena work in +Step 2.4 targets the 8-byte allocation header plus the fragmentation measured +above — around 10% of UTXO RSS, or ~5% of process RSS — and it is the largest and +riskiest item in the plan. + +**Recommendation: do not start Step 2.2 or 2.4 on this evidence.** Neither is +wrong, both are small, and the half of the problem that has never been measured +is larger than everything they can win together. Attribute the non-UTXO half +first. + +## Caveats + +- **1.5 live outputs per record is an assumption**, and it is the one the result + is most sensitive to: the 32-byte txid amortizes over it directly. At 1.2 + outputs per record the txid share rises to 26.7 B/output; at 2.0 it falls to + 16 B/output. A real distribution should be taken from a synced node before the + encoding table above is used to make a decision. +- The script mix is representative, not measured from chainstate. +- System allocator, not the mimalloc production links. +- Whether the 13.83 GiB run had `txindex` and `blockfilterindex` enabled is not + recorded, so the ~7 GiB residual cannot yet be split between index structures + and everything else. diff --git a/docs/solutions/best-practices/benchmark-the-operation-the-workload-performs-not-the-one-the-api-exposes.md b/docs/solutions/best-practices/benchmark-the-operation-the-workload-performs-not-the-one-the-api-exposes.md new file mode 100644 index 00000000..a3ef04ba --- /dev/null +++ b/docs/solutions/best-practices/benchmark-the-operation-the-workload-performs-not-the-one-the-api-exposes.md @@ -0,0 +1,115 @@ +--- +title: Benchmark the operation the workload performs, not the one the API exposes — a codec that won on encode/decode lost 4.9x on the call the node actually makes +date: 2026-08-17 +category: docs/solutions/best-practices +module: performance measurement / UTXO record codec (crates/utxo) +problem_type: best_practice +component: tooling +severity: high +applies_when: + - "Benchmarking a data-structure or codec change behind an accessor API" + - "Choosing between a fixed-width and a variable-length in-memory layout" + - "Quoting a speedup ratio measured on one fixture size" +related_components: + - development_workflow + - testing_framework +tags: + - benchmark + - paired-arm + - codec + - measurement-discipline +--- + +## What happened + +The UTXO record payload was re-encoded to save memory. The refactor set required +a paired benchmark, and it had one: `encode` and `decode` of a whole record, v4 +against v5, both arms in one Criterion group over one fixture. v5 was slower on +both, by 1.9-3.2x, and two rounds of optimization went into closing that gap. + +Both the benchmark and the optimization were aimed at the wrong thing. + +The operation the node performs is **`find_output(vout)`** — every spent input +resolves one output by index 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. Reshaped around `find_output`, the same v5 codec +measured **4.4-4.9x slower**, not 1.9x. The change was far worse than the +harness had been reporting, and no amount of tuning the encode path would have +found it. + +## Why the wrong benchmark looked reasonable + +`encode` and `decode` are what the codec module exposes. They are the natural +unit to benchmark if you are looking at the codec. `find_output` lives one layer +up, in the record type, and reads like a convenience wrapper over the decoder — +which is exactly what it was, and exactly why it was slow: it decoded every +output it rejected. + +The tell was available and was not read: the codec is `pub(crate)`, and the only +callers outside its own tests were three `Shard` methods, all doing the same +by-index lookup. **The call sites were the benchmark specification.** + +## The second-order finding + +Once `find_output` was benchmarked, the cause was not what it appeared: + +> Every v4 field sits at a constant offset, so when only `vout` is read the +> optimizer deletes the loads for the rest. v4 got lazy field skipping for free +> from LLVM, without anyone designing it. A variable-length layout cannot be +> given the same treatment, because each field's length is what locates the +> next, so the reads are a serial dependency chain no optimizer can remove. + +The fixed-width arm was not merely faster — it was benefiting from an +optimization the variable-length arm is structurally unable to receive. That is +a property of the layout choice, not of the implementation, and it is invisible +in a benchmark that consumes every field. + +The fix was a layout change (fixed-width directories in front of the payloads), +not a tuning pass. + +## One fixture size hides a crossover in both directions + +The corrected harness ran 1, 4 and 16 outputs per record. The real-workload +benchmark, `utxo_commit`, used a fixture with **256**. They disagreed, and both +were quoted as if general: + +| outputs | `find_output/miss` v4 | v5 | ratio | +|---:|---:|---:|---:| +| 1 | 2.6 ns | 7.6 ns | 0.35x | +| 3.626 (measured mainnet average) | 3.9 ns | 6.9 ns | 0.57x | +| 16 | 16.6 ns | 20.7 ns | 0.80x | +| 64 | 108.4 ns | 78.7 ns | **1.38x** | +| 256 | 462.1 ns | 294.1 ns | **1.57x** | + +Benchmarking only small sizes hid the crossover one way; quoting only the large +fixture hid it the other. The claim shipped as "makes lookups faster than v4", +which was **true of the fixture and false of the workload** — the measured +mainnet average is 3.626 outputs per record, where v5 is slower. + +## Rules + +1. **Enumerate the call sites before writing the harness.** For a `pub(crate)` + API that is a grep, and it is the specification. Benchmark what calls it, not + what it exports. +2. **Bracket the workload's real parameter, and put the workload's own value in + the table.** One fixture size cannot show a crossover, and a crossover is the + normal outcome when a change trades fixed cost for per-item cost. +3. **Suspect the arm that looks impossibly fast.** v4's best case measured 2.1 ns + and did not move with record size; that was the optimizer eliminating work, + which is real but tells you the arms are not doing comparable work. +4. **When two harnesses disagree, publish the conservative one and say why.** + `utxo_commit` reported 2.35x and the microbenchmark 1.58x on the same shape, + because the microbenchmark reimplements the `before` arm as a direct loop the + optimizer handles better — so its v4 arm is *faster than the shipped v4*. The + smaller number is the one that survives scrutiny. + +## Related + +- `docs/solutions/best-practices/small-window-benchmarks-do-not-predict-at-scale-throughput.md` + — the same failure in the size dimension rather than the operation dimension. +- `docs/solutions/best-practices/criterion-bench-trust-rebuild-drift-baselines-allocator.md` + — why both arms belong in one group in one run. +- `docs/benchmarks/utxo-memory.md` — the campaign this came from, including the + correction notice. +- The *Directory-layout record* and *Work-count assertion* entries in + `CONCEPTS.md`.