Skip to content

Commit 6113a69

Browse files
authored
Merge pull request #30 from gosuda/perf/avx2-merkle
perf(consensus): batch Merkle hashing with AVX2
2 parents d8450ea + 9dc1281 commit 6113a69

9 files changed

Lines changed: 1368 additions & 68 deletions

File tree

CONCEPTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Parsing each block exactly once with `bitcoinkernel::Block::new` (wrapped as `Ke
4848
### Parallel granularity (per-item cost rule)
4949
Whether a fan-out pays is decided by per-item work against dispatch cost, not by how parallelizable the loop looks. Measured both directions on the same apply path: script checks (~100 µs per input) wanted *more* parallelism, and lowering `MIN_PARALLEL_SCRIPT_CHECKS` from 16 to 4 bought 1.15×; UTXO lookups (~500 ns) wanted *none*, and deleting two rayon fan-outs bought 1.07× and 1.11×. Merkle nodes (~2.6 µs) sit in between: Rayon task fan-out over scalar nodes measured neutral-to-worse (SIMD multi-buffer hashing is a different lever because it reduces cost per group rather than changing task granularity). A threshold has an interior optimum in both directions — below 4 the script threshold turns back up, and pool width peaks at 32 then degrades at 64. Always gate on **elapsed**, never on the stage being targeted: parallel prepare makes `script_prepare` 30% faster and the whole run 4% slower by contending with the script-verify pool. See `docs/solutions/performance-issues/processing-bound-sync-performance-evolution.md`.
5050

51+
The AVX2 Merkle result pins the distinction. Reusing prepared txids and hashing eight independent 64-byte parent pairs in SIMD lanes cut the matched fjall replay from 56.517s to 48.020s (1.177×), while scalar-library swaps and Rayon folds had failed. SIMD paid because it reduced the cost of a homogeneous batch without scheduling more tasks. The same candidate passed the RocksDB and redb gates at 1.171× and 1.112×.
52+
5153
### Matched-harness comparison
5254
The requirement that a cross-node benchmark match every input that is not the thing under test — block source, validation posture, CPU pinning, and time of measurement — before any ratio is quoted. Each mismatch found in this repo moved the headline materially: Core's reference was months stale (67s → re-derived 59.6s); bitcoin-rs fetched blocks over REST from a live `bitcoind` while Core read local `blk*.dat`, which cost ~35s of harness *and* contended for CPU (121.9s → 84.6s once `--blocks-file` matched it); and GoCoin skips script verification below its default `LastTrustedBlock` of #940000, so it must be compared either against an assume-valid bitcoin-rs run or with that asymmetry stated. Interleave both nodes back-to-back on an idle host and quote paired medians; comparing your best run against someone else's old run is not a measurement.
5355

@@ -59,6 +61,8 @@ must therefore match the production allocator and report RSS with both time
5961
axes. See
6062
`docs/solutions/performance/allocator-parity-changes-wall-not-cpu.md`.
6163

64+
The final prepared-txid plus AVX2 Merkle panel follows this rule: three candidate and three Core runs were interleaved on CPU set `0-31`, with a 30-second cooldown, identical local blocks 0→150k, and full validation. The medians were 49.356s versus 64.914s wall and 390.542s versus 481.092s CPU, so bitcoin-rs led by 1.315× wall and 1.232× CPU. All three storage backends reached the same tip and UTXO commitments. See `docs/benchmarks/data/end-to-end-sync/avx2-merkle-custody-v1.json`.
65+
6266
### Script-flag exceptions (BIP16Exception)
6367
The historical blocks Bitcoin Core hardcodes in `consensus.script_flag_exceptions` (chainparams) to be validated under a reduced script-verification flag set, because they contain spends valid under the rules in force at the time but invalid under a later-enforced flag. As of Core v29: mainnet block 170060 (`…ac4f9c22`, the BIP16/P2SH exception) and 692261 (`…e1e395ad`, the Taproot exception); testnet3 block 394; none on testnet4/signet/regtest. The two **P2SH waivers** (170060, 394) are reproduced explicitly by `Network::is_bip16_p2sh_exception` (keyed by block hash, mainnet/testnet3 only); missing them rejects canonical blocks and wedges full-validation sync past the assume-valid height. The **692261 Taproot override** needs no rs exception: Core's override only strips TAPROOT (which Core defaults on for all blocks), and rs already height-gates taproot (`is_taproot_active`, 709632 > 692261) so it never sets TAPROOT there — its computed flags already match Core's effective set. Compare *effective* flag sets, not raw overrides. See `docs/solutions/architecture-patterns/p2sh-flag-must-honor-core-script-flag-exceptions.md`.
6468

@@ -78,6 +82,8 @@ P2P result above remains valid for its network regime; it cannot be carried
7882
into the local replay regime. See
7983
`docs/solutions/performance/allocator-parity-changes-wall-not-cpu.md`.
8084

85+
The final AVX2 panel adds the same proof after the Merkle change: bitcoin-rs beat Core by 1.315× wall and 1.232× CPU while using 1.042× its peak RSS. The CPU result rules out a wall-only win bought by extra parallel work; the kernel batches eight hashes in SIMD lanes inside one task.
86+
8187
### Global rayon pool cap
8288
The process-wide rayon pool is capped at `GLOBAL_RAYON_THREADS` (4) by `cap_global_thread_pool` in `crates/node/src/run.rs`, called at the top of `run`. rayon otherwise sizes that pool at one worker per core, and because it leaves those workers unnamed they inherit the process name — which is why per-thread CPU attribution first blamed the async runtime. The pool runs only short coarse jobs (block txid hashing, shard commits) while `SCRIPT_VERIFY_POOL` separately holds up to 32 threads, so an uncapped global pool oversubscribes a many-core host and its workers spin for work that is not there. Capping it cut a loopback P2P sync to 150k from 75.6s wall / 314.4s CPU to 64.4s / 162.4s across three interleaved pairs — **both axes at once**, so it is not a wall-for-CPU trade. With the `MIN_PARALLEL_SCRIPT_CHECKS` correction stacked on top, that sync finally lands at 62.8s / 90.1s against Core's 45.9s / 67.8s. The width sweep is flat from 2 to 8; the full-verification replay is insensitive at every width because script verification dominates it and runs in its own pool. Contrast `Parallel granularity (per-item cost rule)`, which is about *when* to fan out; this is about *how wide* the shared pool may be.
8389

crates/consensus/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ hashbrown.workspace = true
4040
name = "verify_tx"
4141
harness = false
4242

43+
[[bench]]
44+
name = "merkle"
45+
harness = false
46+
4347
[[example]]
4448
name = "kernel_verify_spike"
4549
required-features = ["kernel"]

crates/consensus/benches/merkle.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//! Merkle root computation benchmarks for the AVX2-capable reducer.
2+
// PERF: Criterion emits public harness items whose docs are irrelevant to the benchmark report.
3+
#![allow(missing_docs)]
4+
5+
use std::hint::black_box;
6+
7+
use bitcoin::consensus::Encodable as _;
8+
use bitcoin::hashes::Hash as _;
9+
use bitcoin::{TxMerkleNode, Txid};
10+
use bitcoin_rs_consensus::verify_block::block_merkle_root_matches_txids;
11+
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
12+
13+
fn make_txids(count: usize) -> Vec<Txid> {
14+
(0..count)
15+
.map(|i| {
16+
let mut bytes = [0u8; 32];
17+
let value = match u32::try_from(i) {
18+
Ok(value) => value,
19+
Err(error) => panic!("benchmark size must fit u32: {error}"),
20+
};
21+
bytes[0..4].copy_from_slice(&value.to_le_bytes());
22+
Txid::from_byte_array(bytes)
23+
})
24+
.collect()
25+
}
26+
27+
fn scalar_merkle(level: &mut Vec<Txid>) -> Option<(Txid, bool)> {
28+
if level.is_empty() {
29+
return None;
30+
}
31+
let mut mutated = false;
32+
while level.len() > 1 {
33+
mutated |= level.chunks_exact(2).any(|pair| pair[0] == pair[1]);
34+
let original_len = level.len();
35+
for parent in 0..original_len.div_ceil(2) {
36+
let left = level[2 * parent];
37+
let right = level[(2 * parent + 1).min(original_len - 1)];
38+
let mut engine = Txid::engine();
39+
assert!(
40+
left.consensus_encode(&mut engine).is_ok(),
41+
"in-memory hash engine write failed"
42+
);
43+
assert!(
44+
right.consensus_encode(&mut engine).is_ok(),
45+
"in-memory hash engine write failed"
46+
);
47+
level[parent] = Txid::from_engine(engine);
48+
}
49+
level.truncate(original_len.div_ceil(2));
50+
}
51+
Some((level[0], mutated))
52+
}
53+
54+
fn benchmark_root(input: &[Txid]) -> TxMerkleNode {
55+
match bitcoin::merkle_tree::calculate_root(input.iter().copied()) {
56+
Some(root) => TxMerkleNode::from(root),
57+
None => panic!("benchmark inputs must be nonempty"),
58+
}
59+
}
60+
61+
fn benchmark_block(merkle_root: TxMerkleNode) -> bitcoin::Block {
62+
bitcoin::Block {
63+
header: bitcoin::block::Header {
64+
version: bitcoin::block::Version::ONE,
65+
prev_blockhash: bitcoin::BlockHash::all_zeros(),
66+
merkle_root,
67+
time: 0,
68+
bits: bitcoin::CompactTarget::from_consensus(0),
69+
nonce: 0,
70+
},
71+
txdata: Vec::new(),
72+
}
73+
}
74+
75+
fn validate_benchmark_input(block: &bitcoin::Block, input: &[Txid]) {
76+
let mut candidate = input.to_vec();
77+
assert!(block_merkle_root_matches_txids(block, &mut candidate));
78+
79+
let mut scalar = input.to_vec();
80+
let expected = Txid::from_byte_array(block.header.merkle_root.to_byte_array());
81+
assert_eq!(scalar_merkle(&mut scalar), Some((expected, false)));
82+
}
83+
84+
fn merkle_tree(c: &mut Criterion) {
85+
let mut group = c.benchmark_group("merkle");
86+
for &leaf_count in &[1, 2, 15, 16, 17, 31, 32, 33] {
87+
let input = make_txids(leaf_count);
88+
let root = benchmark_root(&input);
89+
let block = benchmark_block(root);
90+
validate_benchmark_input(&block, &input);
91+
let mut scratch = input.clone();
92+
group.bench_function(BenchmarkId::new("avx2_dispatch_leaves", leaf_count), |b| {
93+
b.iter(|| {
94+
scratch.clone_from(&input);
95+
black_box(block_merkle_root_matches_txids(&block, &mut scratch));
96+
});
97+
});
98+
group.bench_function(BenchmarkId::new("scalar_leaves", leaf_count), |b| {
99+
b.iter(|| {
100+
scratch.clone_from(&input);
101+
black_box(scalar_merkle(&mut scratch));
102+
});
103+
});
104+
}
105+
for &parent_count in &[8, 64, 1024] {
106+
let leaf_count = parent_count * 2;
107+
let input = make_txids(leaf_count);
108+
let root = benchmark_root(&input);
109+
let block = benchmark_block(root);
110+
validate_benchmark_input(&block, &input);
111+
let mut scratch = input.clone();
112+
group.bench_function(
113+
BenchmarkId::new("avx2_dispatch_parents", parent_count),
114+
|b| {
115+
b.iter(|| {
116+
scratch.clone_from(&input);
117+
black_box(block_merkle_root_matches_txids(&block, &mut scratch));
118+
});
119+
},
120+
);
121+
group.bench_function(BenchmarkId::new("scalar_parents", parent_count), |b| {
122+
b.iter(|| {
123+
scratch.clone_from(&input);
124+
black_box(scalar_merkle(&mut scratch));
125+
});
126+
});
127+
}
128+
group.finish();
129+
}
130+
131+
criterion_group!(benches, merkle_tree);
132+
criterion_main!(benches);

crates/consensus/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ pub mod connect_block;
4242
pub mod kernel;
4343
/// Portable Rust validator.
4444
pub mod rust_path;
45+
/// Private AVX2 SHA256d64 kernel for Merkle hashing.
46+
mod sha256d64;
4547
/// Block rule checks.
4648
pub mod verify_block;
4749
/// Transaction rule checks.

0 commit comments

Comments
 (0)