experiments/signature_benchmarks — Research Axis B (ECDSA / BIP340 Schnorr)
Benchmarks of legitimate secp256k1 signature implementations on synthetic keys (mandate §5, §9;
research questions B2 and B5). Nothing here touches real keys: every secret key, message, aux-rand
value and batch weight is drawn from a ChaCha20 RNG seeded with --seed (default 34).
Layout
| Path | Role |
|---|---|
sigbench/ |
Rust crate (binary). Dependencies: secp256k1 0.33 (rust-secp256k1 → vendored libsecp256k1 C, features rand, global-context, std), k256 0.14 (RustCrypto, features ecdsa, schnorr, arithmetic, precomputed-tables, std, alloc), sha2, rand/rand_chacha 0.9, serde(_json), clap. Release profile: opt-level 3, fat LTO, 1 codegen unit. |
sigbench/src/msm.rs |
Own multi-scalar multiplication on k256::{AffinePoint, ProjectivePoint, Scalar}: Straus (interleaved width-5 wNAF, per-term odd-multiple tables) and Pippenger (bucket method, c-bit windows, mixed additions, running-sum trick), both instrumented with counters (projective adds, mixed adds, doublings, identity adds). Also lift_x, the BIP340 challenge hash and batch_verify. |
sigbench/src/vectors.rs |
Parser + checker for data/vectors/bip340_test_vectors.csv against both libraries (sign where a secret key is given, verify everywhere, compare with the expected column). |
sigbench/src/stats.rs |
Median / quartiles / IQR / mean / sd and a bootstrap (percentile) 95 % CI for a ratio of medians. |
run_bench.py |
Thin wrapper: cargo build --release, run the binary, run python_crosscheck.py on the dumped triples, run python_baselines.py, add the wp34.provenance.record() block plus rustc/cargo/crate versions (cargo tree), write results/signature_benchmarks/bench_<hostname>.json. Standard library only (runs with a bare python3 on cluster nodes). |
python_crosscheck.py |
Verifies Rust-produced (seckey, msg, sig) triples with the exact-arithmetic references src/wp34/ecdsa.py and src/wp34/schnorr.py, re-signs in Python and compares bytes, and rejects a tampered signature. Used by tests/test_sig_crosscheck.py. |
python_baselines.py |
Pure-Python src/wp34, pure-Python ecdsa package, and coincurve (CFFI → libsecp256k1) timings, 20 ops × 7 reps. |
summarize.py |
Collects every bench_*.json into results/signature_benchmarks/summary.json and prints a Markdown digest. |
../../figures/make_sig_figures.py |
Regenerates figures/sig_batch_verification.pdf and figures/sig_implementations.pdf from the JSON files. |
Running
# laptop (repository root)
PYTHONPATH=src .venv/bin/python experiments/signature_benchmarks/run_bench.py --seed 34 --label "Laptop (Apple M5 Max)"
# cluster node (crate + src/wp34 + data/vectors rsync'ed to ~/wp34-sigbench, cargo in PATH)
ssh M3U96a 'cd ~/wp34-sigbench && PYTHONPATH=src python3 experiments/signature_benchmarks/run_bench.py --seed 34 --label "M3U96a (Apple M3 Ultra)"'
rsync -a M3U96a:~/wp34-sigbench/results/signature_benchmarks/ results/signature_benchmarks/
.venv/bin/python experiments/signature_benchmarks/summarize.py
.venv/bin/python figures/make_sig_figures.py
.venv/bin/python -m pytest tests/test_sig_crosscheck.py -qBinary options (sigbench --help): --seed, --reps (7), --batch-ops (2000 operations per repetition),
--batch-min-sigs (≥ 2000 signatures per repetition in the batch benchmark), --batch-sizes,
--crosscheck-n (1000), --python-crosscheck-n (200), --bootstrap (2000 resamples), --pippenger-c
(0 = size heuristic), --ct-lincomb-max-batch (256). Everything runs on the main thread; macOS has no
hard CPU pinning from user space, so "pinned single thread" means single-threaded with no parallelism.
What is measured
- Correctness gates (all recorded in the JSON under
measurements.correctness_gates): (a) 19/19 BIP340 vectors with both libraries (verification column + byte-identical signing for the 8 vectors with a secret key); (b) 1000 cross-library checks in each direction for ECDSA (RFC 6979, SHA-256 prehash, low-S) and Schnorr (with aux_rand) — signatures are also byte-identical across libraries because both nonce derivations are deterministic; (c) 200 triples verified and re-signed by the pure-Python references (python_reference_crosscheck); (d) own wNAF scalar multiplication agrees with k256 on 64 cases; (e) batch negative controls (one signature with s+1, a flipped message bit, or r+1 inside a batch of 64) rejected by all four MSM back-ends. - Sign / verify ns per op (median, IQR over 7 reps × 2000 ops): ECDSA and Schnorr, libsecp256k1 and k256.
- Batch verification Σ a_i s_i G = Σ a_i R_i + Σ a_i e_i P_i (a_1 = 1, a_i random 128-bit), merged
into one MSM with 2b + 1 terms (points negated instead of scalars so the weights stay 128-bit),
b ∈ {1, …, 2048}, with four back-ends: own Straus, own Pippenger, k256
lincomb_vartime(GLV + wNAF), k256lincomb(constant time, up to b = 256). Reported per signature: wall-clock, MSM-only time, operation counts, and speed-up S(b) relative to single verification (k256verify_rawand libsecp256k1) with bootstrap 95 % CI. - Scalar multiplication: k256 constant-time (GLV, generic point and generator), k256
mul_by_generator(precomputed table), k256 vartime (GLV + wNAF), own wNAF-5 double-and-add without GLV (with op counts), libsecp256k1ecmult_gen(PublicKey::from_secret_key) andecmult_const(PublicKey::mul_tweak). GLV cannot be toggled from either library's public API (seescalar_mult.glv_notein the JSON); the only non-GLV data point is our own implementation. - Python baselines for the implementation spread (pure Python ≈ milliseconds, CFFI ≈ C).
Headline numbers (seed 34, 2026-11-01)
| Laptop, Apple M5 Max | M3U96a, Apple M3 Ultra | |
|---|---|---|
| ECDSA sign / verify, libsecp256k1 | 23.6 / 12.6 µs | 29.2 / 16.7 µs |
| ECDSA sign / verify, k256 | 15.3 / 25.0 µs | 18.8 / 33.5 µs |
| Schnorr sign / verify, libsecp256k1 | 20.5 / 13.2 µs | 24.7 / 16.9 µs |
| Schnorr sign / verify, k256 | 11.6 / 24.5 µs | 14.8 / 32.5 µs |
| Batch verify, own Pippenger, b = 2048 | 7.9 µs/sig, S = 3.10 [3.05, 3.16] vs k256, 1.67 vs libsecp256k1 | 10.7 µs/sig, S = 3.03 [3.00, 3.04] vs k256, 1.58 vs libsecp256k1 |
| Batch verify, own Straus (plateau b ≥ 32) | ≈ 12.1–12.7 µs/sig, S ≈ 2.0 | ≈ 16.1 µs/sig, S ≈ 2.0 |
Pure Python src/wp34 Schnorr verify |
7.4 ms | 9.2 ms |
Full digest: results/signature_benchmarks/summary.json; interpretation and classification:
research_log/2026-11-01-signatures.md.