Repository navigation
feat!: use Keccak-256 instead of SHA-256 for hash_tree_root (experiment) - #39
MegaRedHand wants to merge 6 commits into
Conversation
Swap the `sha2` backend for `sha3::Keccak256` (legacy Keccak padding, as used by Ethereum's execution layer), including the build-time ZERO_HASHES table so zero-subtree elision stays consistent with `hash_nodes`. Breaking renames: - trait `Sha256Hasher` -> `MerkleHasher` - struct `Sha2Hasher` -> `KeccakHasher` - feature `sha2-backend` -> `keccak-backend` Roots are no longer consensus-compatible: the two unit tests pinned to SHA-256 reference roots are ignored, and spec-tests / differential fuzz targets against Lighthouse are expected to fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vghNkypyMGfhziz7iorub
- `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.
Keeps this branch's tree: Keccak-256 replaces the SHA-256 backend, and the next commit ports the level batching.
Port the level batching from perf/batched-sha256-htr to `MerkleHasher`: `merkleize` hands every full pair of a level to `hash_pairs` in one call, and `hash_nodes` goes through the same method. `KeccakHasher` moves to its own module and skips the general-purpose sponge: each 64-byte pair is one Keccak-f[1600] permutation of a pre-padded state. `hash_pairs` runs several permutations at once, chosen at runtime with `cpufeatures`: AVX-512 (8 pairs), AVX2 (4) or the ARMv8 SHA3 extension (2). Leftover pairs and other CPUs use the scalar permutation from the `keccak` crate. Each backend is tested against `sha3::Keccak256`.
AVX2: assemble each state row from 64-bit message words in registers instead of transposing through a stack buffer, whose 64-bit stores read back as one vector stalled on store forwarding. On Zen 5 a full 4-way batch drops from about 378 to 307 ns (94 to 77 ns per hash). AVX-512: one 8-way permutation (about 143 ns) already beats one scalar permutation (about 192 ns), so a leftover single pair uses it too.
With AVX-512 a single leftover pair now runs in SIMD, so the docs no longer say leftovers always use the scalar permutation. The AVX2 row assembly measured faster on Zen 5, but the cause is not established: the previous commit attributed it to store-forwarding stalls, while timings also shifted with unrelated code layout changes.
Updated x86_64 results: faster SHA-256 and SIMD KeccakThe description above compares
This PR is now built on top of Setup: same machine and method as the x86_64 section above: Ryzen 9 9950X3D (Zen 5, SHA-NI, AVX-512), Linux 6.12, rustc 1.95.0, SummaryBrackets: SHA-256 columns vs SHA-256
Keccak SIMD time divided by SHA-256 time (bold = Keccak is faster):
Takeaways
Caveats
CI on this PRBuild, tests, clippy, no_std and docs pass. Spec Tests and All 91 benchmarks (x86_64)The first three ratio columns are speedups (higher is faster). The last one is Keccak SIMD time divided by SHA-256 new time (below 1 means Keccak is faster).
|
Warning
Experimental. This makes
hash_tree_rootnot consensus-compatible: every root that involves hashing changes. It is a draft to measure the cost/benefit of Keccak-256 vs SHA-256, not to merge as-is.Result: SHA-256 is still faster on consensus data, even with SIMD Keccak. On x86_64 (Ryzen 9 9950X3D, SHA-NI, AVX-512), against the batched SHA-256 in
perf/batched-sha256-htr:BeaconState: Keccak-256 is 1.16–1.45x slower, and 1.51–1.90x slower than that branch'shashtreefeature. Validators and small containers are worse, up to 2.7x and 3.3x.merkleize, big lists and vectors ofu64orBytes32): Keccak-256 is up to 2x faster, and up to 1.45x faster thanhashtree.Full results and caveats: results comment.
What changes
This branch is built on top of
perf/batched-sha256-htr(merged in), which makesmerkleizehash a whole tree level per call through a newhash_pairsmethod. Until that branch lands, this diff also includes its batching changes. On top of that:sha2::Sha256→ Keccak-256 forhash_nodes,merkleizeandmix_in_*. This is the original Keccak padding used by Ethereum's execution layer, not NIST SHA3-256.crates/ssz-merkle/src/keccak256.rs: a 64-byte pair fits in one Keccak block, so each pair costs one Keccak-f[1600] permutation on a pre-padded state, skipping the sponge.hash_pairspicks a backend at runtime withcpufeatures:keccakcrate.MerkleHasher::hash, for input of any length, still usessha3::Keccak256.sha2is replaced bysha30.10,keccak0.1.6 andcpufeatures0.2.keccak'sasmfeature uses the ARMv8 SHA3 instructions through inline assembly, with no C toolchain.ZERO_HASHES: the build-time table incrates/ssz-merkle/build.rsis regenerated with Keccak-256, so zero-subtree elision inmerkleizestays consistent withhash_nodes.Sha256Hasher→MerkleHasher(also in thessz-deriveoutput)Sha2Hasher→KeccakHashersha2-backend→keccak-backenddocs/technical-design.mdis updated.docs/ssz-spec-summary.mdstill says SHA-256 because it describes the upstream spec.Tests
keccak_hasher_is_legacy_keccak256pinsKeccak256("") = c5d24601…5d85a470, which tells it apart from SHA3-256.sha3::Keccak256for every batch size from 0 to 41 pairs, and on all-zero pairs againstZERO_HASHES[1]. A backend the CPU lacks is skipped, so I ran the tests on a Zen 5 (AVX-512 and AVX2) and on an Apple M3 (SHA3).#[ignore]d with a reason rather than deleted:merkleize_progressive_matches_reference_roots(remerkleable) andmerkleize_progressive_matches_consensus_spec_vectors.Spec Tests(consensus vectors are SHA-256) andsmoke / diff_htr_container(compares container roots with Lighthouse and ssz_rs).diff_hash_tree_rootonly compares primitives, which don't hash, so it passes.maintoo:Cargo Audit, oncrypto-bigint(RUSTSEC-2026-0387).Results (x86_64)
AMD Ryzen 9 9950X3D (Zen 5, SHA-NI, AVX-512), Linux 6.12, rustc 1.95.0,
benchprofile, criterion medians, each bench pinned to one core.main: 958a90f.perf/batched-sha256-htr@ 72b2889, with and without itshashtreefeature.Brackets: SHA-256 columns vs SHA-256
main, Keccak SIMD vs Keccak old (higher is faster).mainhashtreemerkle/hash_nodes/singlemerkle/mix_in_length/singlehash_tree_root/consensus_containers/checkpointhash_tree_root/containers/validatorhash_tree_root/list_validator/10000hash_tree_root/beacon_state/16384hash_tree_root/beacon_state/300000diff/htr/beacon_state/libssz/1000000hash_tree_root/list_u64/100000merkle/merkleize/4096merkle/merkleize_with_limit/chunks/65536_of_262144diff/htr/beacon_state/lighthouse/1000000diff/htr/beacon_state/ssz_rs/1000000merkle/pack/32768Keccak SIMD time divided by SHA-256 time (bold = Keccak is faster):
mainhashtreemerkle/hash_nodes/singlemerkle/mix_in_length/singlehash_tree_root/consensus_containers/checkpointhash_tree_root/containers/validatorhash_tree_root/list_validator/10000hash_tree_root/beacon_state/16384hash_tree_root/beacon_state/300000diff/htr/beacon_state/libssz/1000000hash_tree_root/list_u64/100000merkle/merkleize/4096merkle/merkleize_with_limit/chunks/65536_of_262144All 91 benchmarks, noise checks and caveats are in the results comment. In short:
hash_nodesgot slower, for reasons not yet found.First version: one pair at a time through
sha3Measurements of 58d16bd on Apple M3 Pro and x86_64
These are the original measurements of this PR's first commit, which hashed one pair at a time through
sha3::Keccak256, againstmain@ 958a90f. Thesha20.11 bump suggested in the M3 section is done inperf/batched-sha256-htr, but that branch isn't benchmarked on ARM yet.Apple M3 Pro: SHA-256 (
main@ 958a90f) vs Keccak-256 (this branch)Setup: Apple M3 Pro, macOS, rustc 1.99.1,
benchprofile (opt-level=3, thin LTO), criterion 0.5. I rancargo bench -p ssz-bench --bench hash_tree_root --bench merkleplus--bench differential -- diff/htr, saving one baseline per side and comparing criterion medians. Encode/decode benches were skipped because they never hash.What this compares: on aarch64,
sha20.10 without itsasmfeature uses its software implementation, and so doessha3. So this is software SHA-256 vs software Keccak-256. On x86_64 with SHA-NI (e.g. GitHub runners),sha2picks hardware SHA-256 automatically, so the result there would likely be very different. See M3 hardware acceleration below.Noise: the machine had heavy background load during the runs. One burst inflated a contiguous block of the Keccak run (
beacon_state,hash_nodes,pack), including benches that never hash. Those rows (marked †) were re-run alternating SHA-256 → Keccak → SHA-256 → Keccak; both rounds agreed, and the table uses round 1. Thelighthouse/ssz_rsrows always use SHA-256, so they act as a control and show the run-to-run noise (0–7%).Summary
merkle/hash_nodes/single†merkle/mix_in_length/singlemerkle/merkleize/1024merkle/merkleize_with_limit/chunks/65536_of_262144hash_tree_root/containers/validatorhash_tree_root/list_validator/10000hash_tree_root/vec_u64/100000hash_tree_root/beacon_state/300000†diff/htr/beacon_state/libssz/1000000diff/htr/beacon_state/lighthouse/1000000diff/htr/beacon_state/ssz_rs/1000000merkle/pack/32768†hash_tree_root/primitives/u64Takeaway (software only): anything that hashes is consistently ~38–44% faster with Keccak-256, and benches that don't hash are unchanged. This fits the cost of one 64-byte
hash_nodesinput: SHA-256 runs two compressions (the data block plus a padding block), while Keccak-256 (136-byte rate) absorbs it in a single Keccak-f[1600] permutation.Note that libssz with soft SHA-256 is ~3x slower than Lighthouse's
tree_hashonbeacon_statehere (Lighthouse hashes throughethereum_hashing, which usesring's assembly SHA-256 on aarch64). Switching to soft Keccak narrows that to ~1.8x but doesn't close it.All 91 benchmarks
diff/htr/beacon_state/libssz/100000diff/htr/beacon_state/libssz/1000000diff/htr/beacon_state/libssz/16384diff/htr/beacon_state/libssz/300000diff/htr/beacon_state/lighthouse/100000diff/htr/beacon_state/lighthouse/1000000diff/htr/beacon_state/lighthouse/16384diff/htr/beacon_state/lighthouse/300000diff/htr/beacon_state/ssz_rs/100000diff/htr/beacon_state/ssz_rs/1000000diff/htr/beacon_state/ssz_rs/16384diff/htr/beacon_state/ssz_rs/300000diff/htr/consensus_containers/libssz/attestation_datadiff/htr/consensus_containers/libssz/checkpointdiff/htr/consensus_containers/libssz/eth1_datadiff/htr/consensus_containers/libssz/forkdiff/htr/libssz/booldiff/htr/libssz/bytes32diff/htr/libssz/u64diff/htr/lighthouse/booldiff/htr/lighthouse/bytes32diff/htr/lighthouse/u64diff/htr/ssz_rs/booldiff/htr/ssz_rs/bytes32diff/htr/ssz_rs/u64hash_tree_root/beacon_state/100000†hash_tree_root/beacon_state/16384†hash_tree_root/beacon_state/300000†hash_tree_root/bitfields/bitlist/2048hash_tree_root/bitfields/bitlist/4096hash_tree_root/bitfields/bitlist/64hash_tree_root/bitfields/bitvector/4096hash_tree_root/bitfields/bitvector/512hash_tree_root/bitfields/bitvector/64hash_tree_root/consensus_containers/attestation_datahash_tree_root/consensus_containers/checkpointhash_tree_root/consensus_containers/eth1_datahash_tree_root/consensus_containers/forkhash_tree_root/consensus_containers/pending_attestationhash_tree_root/containers/beacon_block_headerhash_tree_root/containers/validatorhash_tree_root/list_u64/100hash_tree_root/list_u64/1000hash_tree_root/list_u64/100000hash_tree_root/list_validator/100hash_tree_root/list_validator/1000hash_tree_root/list_validator/10000hash_tree_root/nested_container/10hash_tree_root/nested_container/100hash_tree_root/nested_container/1000hash_tree_root/primitives/boolhash_tree_root/primitives/bytes32hash_tree_root/primitives/u128hash_tree_root/primitives/u16hash_tree_root/primitives/u32hash_tree_root/primitives/u64hash_tree_root/primitives/u8hash_tree_root/unions/0hash_tree_root/unions/1hash_tree_root/unions/2hash_tree_root/unions/3hash_tree_root/variable_container/100hash_tree_root/variable_container/1000hash_tree_root/variable_container/10000hash_tree_root/vec_u64/100hash_tree_root/vec_u64/1000hash_tree_root/vec_u64/100000hash_tree_root/vector_bytes32/100hash_tree_root/vector_bytes32/1000merkle/hash_nodes/single†merkle/merkleize/1merkle/merkleize/1024merkle/merkleize/16merkle/merkleize/256merkle/merkleize/4merkle/merkleize/4096merkle/merkleize/64merkle/merkleize_with_limit/chunks/1024_of_1048576merkle/merkleize_with_limit/chunks/256_of_4096merkle/merkleize_with_limit/chunks/64_of_1024merkle/merkleize_with_limit/chunks/65536_of_262144merkle/merkleize_with_limit/chunks/65537_of_262144merkle/mix_in_length/singlemerkle/pack/1024†merkle/pack/256†merkle/pack/32†merkle/pack/32768†merkle/pack_bits/2048†merkle/pack_bits/512†merkle/pack_bits/64†merkle/pack_bits/8192†† Re-run alternating with the other side (see "Noise" above).
Apple M3 Pro, hardware acceleration: SHA-256 wins by ~3x
On aarch64,
sha2'sasmfeature turns on the ARMv8 SHA-256 instructions (x86 already gets SHA-NI by default), andsha3'sasmfeature turns on the ARMv8 SHA3 instructions (the M3 has both). Neither is enabled in this repo. I built each side with itsasmfeature in throwaway worktrees (not committed) and ran all four variants back to back, twice in alternation.Round 2 was clean: load average under 5, and the no-hash control
merkleize/1is flat across all four variants. Round 1 hit another load spike that doubled the control for soft SHA-256, so its soft rows are discarded; its twoasmcolumns reproduced round 2 within ~4%.asmasmmerkle/hash_nodes/singlemerkle/merkleize/1024merkle/merkleize/4096hash_tree_root/list_validator/1000hash_tree_root/beacon_state/16384merkle/merkleize/1Ratios are relative to soft SHA-256.
asm.sha3'sasmgives Keccak only ~5–7% (159 → 148 ns perhash_nodes).hash_tree_root~3x slower, besides breaking consensus compatibility.Side finding, worth its own PR: using the ARM SHA-256 instructions makes libssz's
hash_tree_root~5x faster on Apple Silicon / ARM. Onbeacon_state/16384that's 12.86 ms, against 21.41 ms for Lighthouse'stree_hashin the first run above (a cross-run comparison). The way to get this is probably bumpingsha2to 0.11 rather than enablingasmon 0.10: 0.11.1 detects the ARMv8 SHA-256 instructions (and x86 SHA-NI) at runtime in its default build, with noasmfeature or C toolchain. It moves todigest0.11, so it's an API bump, not a drop-in. I haven't benchmarked 0.11 itself.x86_64 (Ryzen 9 9950X3D): SHA-256 wins by ~3.5x
Setup: AMD Ryzen 9 9950X3D (Zen 5, SHA-NI, AVX-512), Linux 6.12, rustc 1.95.0, same
benchprofile and benches as above.taskset -c 4.sha20.10 picks SHA-NI at runtime by default, somainis hardware SHA-256 here with no feature changes.sha3'sasmfeature only affects aarch64, so this branch is software Keccak either way.mainwithsha2'sforce-softfeature (not committed).Noise: results are very stable. The full run and two alternating rounds agree within ~1–2%, and the controls (Lighthouse, ssz_rs,
pack, primitives) are flat.Summary
merkle/hash_nodes/singlemerkle/mix_in_length/singlemerkle/merkleize/1024merkle/merkleize_with_limit/chunks/65536_of_262144hash_tree_root/containers/validatorhash_tree_root/list_validator/10000hash_tree_root/vec_u64/100000hash_tree_root/beacon_state/300000diff/htr/beacon_state/libssz/1000000diff/htr/beacon_state/lighthouse/1000000diff/htr/beacon_state/ssz_rs/1000000merkle/pack/32768hash_tree_root/primitives/u64Software SHA-256 reference
force-softhash_tree_root/beacon_state/16384hash_tree_root/list_validator/1000merkle/hash_nodes/singlemerkle/merkleize/1024merkle/merkleize/4096merkle/merkleize/1Round 1 matched round 2 within 3.5%.
hash_tree_root2.6–3.9x slower across all 57 benches that hash. The low end is small objects that only do a few hashes (bitfields,Checkpoint, unions); large lists andBeaconStateare 3.3–3.6x; a single 64-bytehash_nodesis 3.9x (53 ns vs 207 ns). The 20 benches that don't hash are within ±5%.mainalready gets it by default. The gap is aarch64 only (see the side finding above).All 91 benchmarks (x86_64)
diff/htr/beacon_state/libssz/100000diff/htr/beacon_state/libssz/1000000diff/htr/beacon_state/libssz/16384diff/htr/beacon_state/libssz/300000diff/htr/beacon_state/lighthouse/100000diff/htr/beacon_state/lighthouse/1000000diff/htr/beacon_state/lighthouse/16384diff/htr/beacon_state/lighthouse/300000diff/htr/beacon_state/ssz_rs/100000diff/htr/beacon_state/ssz_rs/1000000diff/htr/beacon_state/ssz_rs/16384diff/htr/beacon_state/ssz_rs/300000diff/htr/consensus_containers/libssz/attestation_datadiff/htr/consensus_containers/libssz/checkpointdiff/htr/consensus_containers/libssz/eth1_datadiff/htr/consensus_containers/libssz/forkdiff/htr/libssz/booldiff/htr/libssz/bytes32diff/htr/libssz/u64diff/htr/lighthouse/booldiff/htr/lighthouse/bytes32diff/htr/lighthouse/u64diff/htr/ssz_rs/booldiff/htr/ssz_rs/bytes32diff/htr/ssz_rs/u64hash_tree_root/beacon_state/100000hash_tree_root/beacon_state/16384hash_tree_root/beacon_state/300000hash_tree_root/bitfields/bitlist/2048hash_tree_root/bitfields/bitlist/4096hash_tree_root/bitfields/bitlist/64hash_tree_root/bitfields/bitvector/4096hash_tree_root/bitfields/bitvector/512hash_tree_root/bitfields/bitvector/64hash_tree_root/consensus_containers/attestation_datahash_tree_root/consensus_containers/checkpointhash_tree_root/consensus_containers/eth1_datahash_tree_root/consensus_containers/forkhash_tree_root/consensus_containers/pending_attestationhash_tree_root/containers/beacon_block_headerhash_tree_root/containers/validatorhash_tree_root/list_u64/100hash_tree_root/list_u64/1000hash_tree_root/list_u64/100000hash_tree_root/list_validator/100hash_tree_root/list_validator/1000hash_tree_root/list_validator/10000hash_tree_root/nested_container/10hash_tree_root/nested_container/100hash_tree_root/nested_container/1000hash_tree_root/primitives/boolhash_tree_root/primitives/bytes32hash_tree_root/primitives/u128hash_tree_root/primitives/u16hash_tree_root/primitives/u32hash_tree_root/primitives/u64hash_tree_root/primitives/u8hash_tree_root/unions/0hash_tree_root/unions/1hash_tree_root/unions/2hash_tree_root/unions/3hash_tree_root/variable_container/100hash_tree_root/variable_container/1000hash_tree_root/variable_container/10000hash_tree_root/vec_u64/100hash_tree_root/vec_u64/1000hash_tree_root/vec_u64/100000hash_tree_root/vector_bytes32/100hash_tree_root/vector_bytes32/1000merkle/hash_nodes/singlemerkle/merkleize/1merkle/merkleize/1024merkle/merkleize/16merkle/merkleize/256merkle/merkleize/4merkle/merkleize/4096merkle/merkleize/64merkle/merkleize_with_limit/chunks/1024_of_1048576merkle/merkleize_with_limit/chunks/256_of_4096merkle/merkleize_with_limit/chunks/64_of_1024merkle/merkleize_with_limit/chunks/65536_of_262144merkle/merkleize_with_limit/chunks/65537_of_262144merkle/mix_in_length/singlemerkle/pack/1024merkle/pack/256merkle/pack/32merkle/pack/32768merkle/pack_bits/2048merkle/pack_bits/512merkle/pack_bits/64merkle/pack_bits/8192