Skip to content

perf: batch merkleize levels and speed up SHA-256 pair hashing - #40

Draft
MegaRedHand wants to merge 1 commit into
mainfrom
perf/batched-sha256-htr
Draft

MegaRedHand wants to merge 1 commit into
mainfrom
perf/batched-sha256-htr

Conversation

@MegaRedHand

Copy link
Copy Markdown
Contributor

Makes hash_tree_root about 1.3x faster on BeaconState on x86_64 with the default backend, and about 1.7x faster with a new opt-in hashtree feature. Roots are unchanged: all consensus spec tests pass with both backends.

What changes

  • Batched levels: new Sha256Hasher::hash_pairs(&self, pairs, out) hashes many 64-byte pairs per call. merkleize hands it each tree level in one batch, and hash_nodes routes single pairs through it. It has a default implementation that calls hash once per pair, so existing Sha256Hasher implementations keep working.
  • No copy of level 0: merkleize hashes the first level straight from the input chunks instead of copying them first.
  • sha2 0.10 → 0.11: Sha2Hasher hashes a pair by calling sha2's compression function directly on the 64-byte message plus a constant padding block, skipping the Digest buffering. sha2 0.11 also detects the ARMv8 SHA-256 instructions at runtime; 0.10 only used them with its asm feature. x86 already got SHA-NI at runtime with 0.10.
  • New hashtree feature (libssz-merkle/hashtree, off by default): batches go to the hashtree assembly library through hashtree-rs 0.2.6. It is specialized for 64-byte inputs and hashes several pairs at once (SHA-NI, AVX-512, AVX2, ARMv8 SHA2, NEON). It needs a C toolchain and make at build time. CI tests it as a new entry in the test matrix.
  • Docs: docs/technical-design.md describes the batched levels and the hashtree option.

No public API is removed or renamed.

Tests

  • New tests in crates/ssz-merkle/src/sha256.rs:
    • the pair hash is compared with sha2::Sha256::digest;
    • hash_pairs is checked against it for every batch size from 0 to 33 pairs, which covers every SIMD remainder in hashtree;
    • a mismatched output length panics.
  • On x86_64, the workspace tests and the consensus spec tests pass with the default backend and with --features libssz-merkle/hashtree.
  • I haven't run this branch on ARM. CI will cover the rest.

Benchmarks (x86_64)

AMD Ryzen 9 9950X3D (Zen 5, SHA-NI), Linux 6.12, rustc 1.95.0, bench profile, criterion medians. Every bench was pinned to one core with taskset -c 4. main is 958a90f.

The full suite ran once per variant, then interleaved rounds repeated a subset. Those agreed within about 2% on every row above 100 ns. The Lighthouse and ssz_rs rows don't use libssz's hashing and act as a control.

Brackets: speedup vs main (higher is faster).

benchmark main this PR this PR + hashtree
merkle/hash_nodes/single 53.23 ns 64.27 ns (0.83x) 57.81 ns (0.92x)
merkle/mix_in_length/single 67.26 ns 63.65 ns (1.06x) 57.37 ns (1.17x)
hash_tree_root/consensus_containers/checkpoint 77.21 ns 65.30 ns (1.18x) 58.56 ns (1.32x)
hash_tree_root/containers/validator 496.06 ns 393.15 ns (1.26x) 298.56 ns (1.66x)
hash_tree_root/list_validator/10000 5.51 ms 4.29 ms (1.28x) 3.28 ms (1.68x)
hash_tree_root/beacon_state/16384 14.50 ms 11.20 ms (1.29x) 8.63 ms (1.68x)
hash_tree_root/beacon_state/300000 174.02 ms 135.91 ms (1.28x) 104.04 ms (1.67x)
diff/htr/beacon_state/libssz/1000000 569.14 ms 445.94 ms (1.28x) 340.35 ms (1.67x)
hash_tree_root/list_u64/100000 1.62 ms 1.05 ms (1.54x) 792.71 µs (2.04x)
merkle/merkleize/4096 218.85 µs 157.61 µs (1.39x) 115.82 µs (1.89x)
merkle/merkleize_with_limit/chunks/65536_of_262144 3.52 ms 2.53 ms (1.39x) 1.86 ms (1.89x)
control: diff/htr/beacon_state/lighthouse/1000000 842.09 ms 843.77 ms 843.83 ms
control: diff/htr/beacon_state/ssz_rs/1000000 960.19 ms 959.13 ms 964.57 ms
no hashing: merkle/pack/32768 1.59 µs 1.59 µs 1.60 µs
  • This PR vs main: 1.05–1.54x faster on every benchmark that hashes, except a lone hash_nodes (below). It is about 1.3x faster on BeaconState and validators and up to 1.54x on large u64 lists. Small objects that only hash a few pairs gain least (1.05x on unions/0).
  • With hashtree: 1.16–2.04x faster, and about 1.7x on BeaconState.
  • merkle/hash_nodes/single is the one regression: 53 → 64 ns, and I haven't found why. It reproduced in three passes. A standalone probe on the same machine doesn't show it: the new pair hash takes 40 ns for independent hashes and 63 ns for chained ones, against 52–69 ns and 66–70 ns for sha2 0.10's Digest. The other one-hash benchmarks got faster (mix_in_length/single 67 → 64 ns, checkpoint 77 → 65 ns).
  • ARM is not benchmarked. The sha2 0.11 bump should matter most there, because it enables the ARMv8 SHA-256 instructions. On an Apple M3, enabling them on main through sha2 0.10's asm feature made hash_tree_root about 5x faster (see feat!: use Keccak-256 instead of SHA-256 for hash_tree_root (experiment) #39).

The same runs also compared Keccak-256 (#39).

All 91 benchmarks (x86_64)

merkle/merkleize/1 doesn't hash, but it runs about 4x faster with this change.

benchmark main this PR this PR + hashtree this PR vs main hashtree vs main
diff/htr/beacon_state/libssz/100000 61.49 ms 47.72 ms 36.28 ms 1.29x 1.69x
diff/htr/beacon_state/libssz/1000000 569.14 ms 445.94 ms 340.35 ms 1.28x 1.67x
diff/htr/beacon_state/libssz/16384 14.08 ms 10.80 ms 8.18 ms 1.30x 1.72x
diff/htr/beacon_state/libssz/300000 173.81 ms 136.30 ms 103.59 ms 1.28x 1.68x
diff/htr/beacon_state/lighthouse/100000 91.33 ms 91.22 ms 91.47 ms 1.00x 1.00x
diff/htr/beacon_state/lighthouse/1000000 842.09 ms 843.77 ms 843.83 ms 1.00x 1.00x
diff/htr/beacon_state/lighthouse/16384 21.66 ms 21.57 ms 21.67 ms 1.00x 1.00x
diff/htr/beacon_state/lighthouse/300000 259.06 ms 258.09 ms 258.11 ms 1.00x 1.00x
diff/htr/beacon_state/ssz_rs/100000 139.27 ms 141.35 ms 141.61 ms 0.99x 0.98x
diff/htr/beacon_state/ssz_rs/1000000 960.19 ms 959.13 ms 964.57 ms 1.00x 1.00x
diff/htr/beacon_state/ssz_rs/16384 63.78 ms 63.41 ms 63.23 ms 1.01x 1.01x
diff/htr/beacon_state/ssz_rs/300000 318.77 ms 321.28 ms 320.73 ms 0.99x 0.99x
diff/htr/consensus_containers/libssz/attestation_data 505.20 ns 411.88 ns 340.71 ns 1.23x 1.48x
diff/htr/consensus_containers/libssz/checkpoint 76.98 ns 65.57 ns 58.92 ns 1.17x 1.31x
diff/htr/consensus_containers/libssz/eth1_data 196.46 ns 167.63 ns 145.68 ns 1.17x 1.35x
diff/htr/consensus_containers/libssz/fork 197.25 ns 168.91 ns 145.71 ns 1.17x 1.35x
diff/htr/libssz/bool 2.26 ns 2.26 ns 2.26 ns 1.00x 1.00x
diff/htr/libssz/bytes32 2.89 ns 2.88 ns 2.88 ns 1.00x 1.00x
diff/htr/libssz/u64 2.44 ns 2.44 ns 2.44 ns 1.00x 1.00x
diff/htr/lighthouse/bool 2.17 ns 2.17 ns 2.18 ns 1.00x 1.00x
diff/htr/lighthouse/bytes32 2.88 ns 2.89 ns 2.88 ns 1.00x 1.00x
diff/htr/lighthouse/u64 2.18 ns 2.17 ns 2.18 ns 1.00x 1.00x
diff/htr/ssz_rs/bool 2.31 ns 2.32 ns 2.32 ns 1.00x 1.00x
diff/htr/ssz_rs/bytes32 57.31 ns 51.31 ns 51.00 ns 1.12x 1.12x
diff/htr/ssz_rs/u64 32.78 ns 32.88 ns 32.42 ns 1.00x 1.01x
hash_tree_root/beacon_state/100000 62.80 ms 47.65 ms 36.47 ms 1.32x 1.72x
hash_tree_root/beacon_state/16384 14.50 ms 11.20 ms 8.63 ms 1.29x 1.68x
hash_tree_root/beacon_state/300000 174.02 ms 135.91 ms 104.04 ms 1.28x 1.67x
hash_tree_root/bitfields/bitlist/2048 489.43 ns 397.90 ns 302.78 ns 1.23x 1.62x
hash_tree_root/bitfields/bitlist/4096 928.81 ns 720.37 ns 542.77 ns 1.29x 1.71x
hash_tree_root/bitfields/bitlist/64 83.72 ns 73.19 ns 67.35 ns 1.14x 1.24x
hash_tree_root/bitfields/bitvector/4096 864.59 ns 659.92 ns 488.63 ns 1.31x 1.77x
hash_tree_root/bitfields/bitvector/512 83.80 ns 74.06 ns 64.99 ns 1.13x 1.29x
hash_tree_root/bitfields/bitvector/64 22.35 ns 12.75 ns 12.98 ns 1.75x 1.72x
hash_tree_root/consensus_containers/attestation_data 509.21 ns 409.44 ns 339.26 ns 1.24x 1.50x
hash_tree_root/consensus_containers/checkpoint 77.21 ns 65.30 ns 58.56 ns 1.18x 1.32x
hash_tree_root/consensus_containers/eth1_data 197.99 ns 167.93 ns 146.59 ns 1.18x 1.35x
hash_tree_root/consensus_containers/fork 198.37 ns 168.90 ns 147.20 ns 1.17x 1.35x
hash_tree_root/consensus_containers/pending_attestation 1.19 µs 967.26 ns 762.28 ns 1.23x 1.56x
hash_tree_root/containers/beacon_block_header 356.47 ns 289.90 ns 227.09 ns 1.23x 1.57x
hash_tree_root/containers/validator 496.06 ns 393.15 ns 298.56 ns 1.26x 1.66x
hash_tree_root/list_u64/100 2.70 µs 2.15 µs 1.87 µs 1.25x 1.45x
hash_tree_root/list_u64/1000 15.31 µs 11.54 µs 8.89 µs 1.33x 1.72x
hash_tree_root/list_u64/100000 1.62 ms 1.05 ms 792.71 µs 1.54x 2.04x
hash_tree_root/list_validator/100 56.18 µs 43.93 µs 33.58 µs 1.28x 1.67x
hash_tree_root/list_validator/1000 552.33 µs 430.44 µs 327.21 µs 1.28x 1.69x
hash_tree_root/list_validator/10000 5.51 ms 4.29 ms 3.28 ms 1.28x 1.68x
hash_tree_root/nested_container/10 6.22 µs 4.91 µs 3.79 µs 1.27x 1.64x
hash_tree_root/nested_container/100 55.59 µs 43.81 µs 33.43 µs 1.27x 1.66x
hash_tree_root/nested_container/1000 548.98 µs 431.38 µs 328.62 µs 1.27x 1.67x
hash_tree_root/primitives/bool 2.27 ns 2.27 ns 2.26 ns 1.00x 1.00x
hash_tree_root/primitives/bytes32 8.10 ns 8.11 ns 8.09 ns 1.00x 1.00x
hash_tree_root/primitives/u128 2.38 ns 2.38 ns 2.38 ns 1.00x 1.00x
hash_tree_root/primitives/u16 2.55 ns 2.55 ns 2.55 ns 1.00x 1.00x
hash_tree_root/primitives/u32 2.44 ns 2.43 ns 2.43 ns 1.00x 1.00x
hash_tree_root/primitives/u64 2.44 ns 2.44 ns 2.44 ns 1.00x 1.00x
hash_tree_root/primitives/u8 2.52 ns 2.51 ns 2.51 ns 1.00x 1.00x
hash_tree_root/unions/0 67.58 ns 64.20 ns 57.42 ns 1.05x 1.18x
hash_tree_root/unions/1 67.24 ns 63.89 ns 57.75 ns 1.05x 1.16x
hash_tree_root/unions/2 424.92 ns 351.66 ns 283.64 ns 1.21x 1.50x
hash_tree_root/unions/3 777.97 ns 670.78 ns 579.00 ns 1.16x 1.34x
hash_tree_root/variable_container/100 2.37 µs 1.89 µs 1.57 µs 1.26x 1.51x
hash_tree_root/variable_container/1000 17.20 µs 13.13 µs 10.11 µs 1.31x 1.70x
hash_tree_root/variable_container/10000 164.65 µs 122.02 µs 93.35 µs 1.35x 1.76x
hash_tree_root/vec_u64/100 1.78 µs 1.37 µs 1.10 µs 1.30x 1.62x
hash_tree_root/vec_u64/1000 14.61 µs 10.88 µs 8.32 µs 1.34x 1.76x
hash_tree_root/vec_u64/100000 1.58 ms 1.05 ms 789.53 µs 1.50x 2.00x
hash_tree_root/vector_bytes32/100 5.97 µs 4.42 µs 3.41 µs 1.35x 1.75x
hash_tree_root/vector_bytes32/1000 56.60 µs 41.48 µs 31.22 µs 1.36x 1.81x
merkle/hash_nodes/single 53.23 ns 64.27 ns 57.81 ns 0.83x 0.92x
merkle/merkleize/1 12.64 ns 2.95 ns 3.12 ns 4.29x 4.05x
merkle/merkleize/1024 54.70 µs 39.50 µs 28.97 µs 1.38x 1.89x
merkle/merkleize/16 832.76 ns 624.71 ns 456.50 ns 1.33x 1.82x
merkle/merkleize/256 13.65 µs 9.94 µs 7.28 µs 1.37x 1.88x
merkle/merkleize/4 194.53 ns 155.60 ns 110.65 ns 1.25x 1.76x
merkle/merkleize/4096 218.85 µs 157.61 µs 115.82 µs 1.39x 1.89x
merkle/merkleize/64 3.42 µs 2.48 µs 1.81 µs 1.38x 1.89x
merkle/merkleize_with_limit/chunks/1024_of_1048576 55.39 µs 40.13 µs 29.47 µs 1.38x 1.88x
merkle/merkleize_with_limit/chunks/256_of_4096 13.92 µs 10.17 µs 7.48 µs 1.37x 1.86x
merkle/merkleize_with_limit/chunks/64_of_1024 3.68 µs 2.72 µs 2.02 µs 1.35x 1.83x
merkle/merkleize_with_limit/chunks/65536_of_262144 3.52 ms 2.53 ms 1.86 ms 1.39x 1.89x
merkle/merkleize_with_limit/chunks/65537_of_262144 3.52 ms 2.53 ms 1.86 ms 1.39x 1.89x
merkle/mix_in_length/single 67.26 ns 63.65 ns 57.37 ns 1.06x 1.17x
merkle/pack/1024 61.56 ns 61.43 ns 61.95 ns 1.00x 0.99x
merkle/pack/256 18.34 ns 18.30 ns 18.37 ns 1.00x 1.00x
merkle/pack/32 10.98 ns 10.96 ns 10.95 ns 1.00x 1.00x
merkle/pack/32768 1.59 µs 1.59 µs 1.60 µs 1.00x 1.00x
merkle/pack_bits/2048 18.07 ns 18.36 ns 18.12 ns 0.98x 1.00x
merkle/pack_bits/512 12.31 ns 12.33 ns 12.26 ns 1.00x 1.00x
merkle/pack_bits/64 11.33 ns 11.27 ns 11.28 ns 1.01x 1.00x
merkle/pack_bits/8192 61.82 ns 61.77 ns 61.71 ns 1.00x 1.00x

- `Sha256Hasher::hash_pairs` hashes many 64-byte pairs per call. It has a
  default implementation, so existing hashers keep working unchanged.
  `merkleize` hands each tree level to it in one batch, and `hash_nodes`
  routes single pairs through it.
- `merkleize` hashes level 0 straight from the input chunks instead of
  copying them first.
- `Sha2Hasher` moves to `sha2` 0.11, which selects ARMv8 SHA2 instructions
  at runtime (0.10 only used them with its `asm` feature), and hashes pairs
  by calling the compression function directly on the message plus a
  constant padding block.
- New opt-in `hashtree` feature: pairs are hashed by the hashtree assembly
  library (SHA-NI, AVX-512/AVX2 multi-buffer, ARMv8 SHA2, NEON). Requires a
  C toolchain; CI tests it as a separate matrix entry.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant