optimization: batch XOR operations 12% faster IBD
Depends on https://github.com/bitcoin/bitcoin/pull/31551.
The descriptions and stats will be updated after the dependent PRs are already merged, but so far it seems that the 3 PRs combined can achieve a 12% IBD speedup (with GCC at least).
Anyone can reproduce the results by following this guide: https://gist.github.com/l0rinc/83d2bdfce378ad7396610095ceb7bed5
Block XOR obfuscations introduced a measurable serialization cost (seen during IBD benchmarking). This PR is meant to alleviate that.
Summary
The obfuscation's effect on IBD seems' to be about ~4% when it still has to be applied, and ~9% when it's turned off, see:
- https://github.com/bitcoin/bitcoin/pull/31144#issuecomment-2519764142
- https://github.com/bitcoin/bitcoin/pull/31144#issuecomment-2523295114
Changes in testing, benchmarking and implementation
- Added new tests comparing randomized inputs against a trivial implementation and performing roundtrip checks with random chunks.
- Updated the benchmark to reflect realistic scenarios by capturing every call of
util::Xor
for the first 860k blocks, creating a model with the same data distribution. An additional benchmark checks the effect of short-circuiting XOR when the key is zero, ensuring no speed regression occurs when the obfuscation feature is disabled. - Optimized the Xor function to process in batches (64/32/16/8 bits instead of per-byte).
- Migrated remaining
std::vector<std::byte>(8)
values touint64_t
.
Reproducer and assembly
Memory alignment is handled via std::memcpy
, optimized out on tested platforms (see https://godbolt.org/z/GGYcedjzY):
- Clang (x86-64) - 32 bytes/iter using SSE vector operations
- GCC (x86-64) - 16 bytes/iter using unrolled 64-bit XORs
- RISC-V (32-bit) - 8 bytes/iter using load/XOR/store sequence
- s390x (big-endian) - 64 bytes/iter with unrolled 8-byte XORs
Endianness
The only endianness issue was with bit rotation, intended to realign the key if obfuscation halted before full key consumption. Elsewhere, memory is read, processed, and written back in the same endianness, preserving byte order. Since CI lacks a big-endian machine, testing was done locally via Docker.
Details
brew install podman pigz
softwareupdate --install-rosetta
podman machine init
podman machine start
docker run --platform linux/s390x -it ubuntu:latest /bin/bash
apt update && apt install -y git build-essential cmake ccache pkg-config libevent-dev libboost-dev libssl-dev libsqlite3-dev && \
cd /mnt && git clone https://github.com/bitcoin/bitcoin.git && cd bitcoin && git remote add l0rinc https://github.com/l0rinc/bitcoin.git && git fetch --all && git checkout l0rinc/optimize-xor && \
cmake -B build && cmake --build build --target test_bitcoin -j$(nproc) && \
./build/src/test/test_bitcoin --run_test=streams_tests
Performance
cmake -B build -DBUILD_BENCH=ON -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j$(nproc) \
&& build/src/bench/bench_bitcoin -filter='XorHistogram|AutoFileXor' -min-time=10000
The 860k block profile contains a lot of very big arrays (96'233 separate sizes, biggest was 3'992'470 bytes long) - a big departure from the previous 400k and 700k blocks (having 1500 sizes, biggest was 9319 bytes long).
The performance characteristics are also quite different, now that we have more and bigger byte arrays:
C++ compiler .......................... AppleClang 16.0.0.16000026
Before:
ns/byte | byte/s | err% | total | benchmark |
---|---|---|---|---|
1.00 | 1,000,913,427.27 | 0.7% | 10.20 | AutoFileXor |
0.85 | 1,173,442,964.60 | 0.2% | 11.16 | XorHistogram |
After:
ns/byte | byte/s | err% | total | benchmark |
---|---|---|---|---|
0.09 | 11,204,183,007.86 | 0.6% | 11.08 | AutoFileXor |
0.15 | 6,459,482,269.06 | 0.3% | 10.97 | XorHistogram |
i.e. ~11/5.5x (disabled/enabled) faster with Clang at processing the data with representative histograms.
C++ compiler .......................... GNU 13.2.0
Before:
ns/byte | byte/s | err% | ins/byte | cyc/byte | IPC | bra/byte | miss% | total | benchmark |
---|---|---|---|---|---|---|---|---|---|
1.87 | 535,253,389.72 | 0.0% | 9.20 | 3.45 | 2.669 | 1.03 | 0.1% | 11.02 | AutoFileXor |
1.70 | 587,844,715.57 | 0.0% | 9.35 | 5.41 | 1.729 | 1.05 | 1.7% | 10.95 | XorHistogram |
After:
ns/byte | byte/s | err% | ins/byte | cyc/byte | IPC | bra/byte | miss% | total | benchmark |
---|---|---|---|---|---|---|---|---|---|
0.59 | 1,706,433,032.76 | 0.1% | 0.00 | 0.00 | 0.620 | 0.00 | 1.8% | 11.01 | AutoFileXor |
0.47 | 2,145,375,849.71 | 0.0% | 0.95 | 1.48 | 0.642 | 0.20 | 9.6% | 10.93 | XorHistogram |
i.e. ~3.2/3.5x faster (disabled/enabled) with GCC at processing the data with representative histograms.
A few other benchmarks that seem to have improved as well (tested with Clang only)
Before:
ns/op | op/s | err% | total | benchmark |
---|---|---|---|---|
2,237,168.64 | 446.99 | 0.3% | 10.91 | ReadBlockFromDiskTest |
748,837.59 | 1,335.40 | 0.2% | 10.68 | ReadRawBlockFromDiskTest |
After:
ns/op | op/s | err% | total | benchmark |
---|---|---|---|---|
1,827,436.12 | 547.21 | 0.7% | 10.95 | ReadBlockFromDiskTest |
49,276.48 | 20,293.66 | 0.2% | 10.99 | ReadRawBlockFromDiskTest |
Also visible on https://corecheck.dev/bitcoin/bitcoin/pulls/31144