Expand comparative benchmarks to three-way hpke-ng vs hpke-rs vs rust-hpke suite - #7
Conversation
Expand tests and module docs to detail coverage and Criterion settings. Increase the hpke-rs PRNG seed buffer to 64 MiB to prevent InsufficientRandomness panics, and hoist all seed() calls out of the timed b.iter() closures to ensure accurate performance measurements.
Use a seeded ChaCha20Rng per benchmark function instead of constructing OsRng on every iteration. Fix context_open comments that wrongly claimed the receiver context is rebuilt each iteration. Minor comment wording tweaks.
|
That's a big one. Some notes: The 64 MiB
|
Replace const SEED with static SEED, fix imports, fix PskBundle struct literal, add rust-hpke to x25519_chacha20/setup_sender_psk, add detailed omission comments to all rust-hpke-absent groups, fix export exclusion comment, and fix doc header.
|
@nadimkobeissi Thanks for the detailed review! All four points are addressed: |
This PR substantially rewrites
benches/comparative.rsto addrust-hpkeas a third comparison point, fixes systematic PRNG overhead that was biasing every timed iteration, and fills in missing benchmark categories. It also adds aCONTRIBUTORS.mdfile to the repository.See full benchmark suite output for numerical details: benchmark_results.txt (updated after review)
Changes
rust-hpke added as a third library. Where it lacks a feature (no K256,
Vecallocation in the timed path biases the comparison, etc.) this is explicitly noted inline. Rows where it has no bareencap/decapequivalent carry a_via_setup_*suffix and a comparability warning (those include the full key schedule on top of the KEM).PRNG unified to ChaCha20Rng. The previous code constructed a new
OsRngon almost everyb.iter()call, charging syscall overhead to every hpke-ng and rust-hpke measurement. hpke-rs was also callingseed()insideb.iter()(~58 ns/call). All three now use a singleChaCha20Rng::from_seed([0x42; 32])per benchmark function, withseed()hoisted into setup code. TheSEEDbuffer is also changed from aconst &[u8](which baked 64 MiB into the binary's rodata segment, inflating it to ~72 MB) to astatic LazyLock<Vec<u8>>heap-allocated at first use, keeping the binary ~5 MB while retaining the full 64 MiB at runtime to preventInsufficientRandomnesspanics on fast ops that Criterion runs 10^5-10^6 times per window.New benchmark functions:
bench_setup_x25519_aes256,bench_setup_p256_aes256,bench_open_x25519_aes128_payload_sweep,bench_context_open_x25519_chacha.Criterion budgets justified per group. KEM/setup groups use the 60-sample/3s default. Seal/open payload sweeps use 40/2s (24 benchmarks per group; trimming keeps total wall-clock under ~2 min). Context seal/open uses 50/2s (cheaper per-iteration, fewer benchmarks). Round-trip uses 50/3s (most expensive per-iteration; two full KEM+key-schedule cycles).
Context open fix via a "lockstep" design
The naive approach to benchmarking context open fails (silently): a receiver context can only open message N when its sequence counter is at N, so pre-sealing one ciphertext and re-opening it breaks after the first iteration. The fix is "lockstep": establish both sender and receiver contexts once outside
b.iter(), then seal-then-open inside each iteration. Both counters advance together, no KEM cost is included, and the reported time isseal + opencombined. To get pure open latency, subtract thecontext_sealmeasurement. This design is applied to all three libraries.Interesting benchmark observations
hpke-ng wins the headline round-trip at 205.35 µs vs hpke-rs 229.85 µs vs rust-hpke 246.03 µs (X25519/ChaCha20, 1 KiB). All three now have tight CIs ([203.14-208.26 µs], [229.50-230.21 µs], [245.60-246.47 µs]); the wide rust-hpke CI seen in earlier runs was a benchmark artifact from
setup_receiverbeing called insideb.iter(), resolved by the lockstep fix.P-256 rust-hpke
encapis ~50% slower than hpke-rs (565.10 µs vs 377.46 µs, both measured viasetup_sender); despite all three sharing the same RustCryptop256crate. Since key generation is identical across all three (~185 µs), the overhead is in rust-hpke's wrapper, not the field arithmetic. Worth investigating upstream.ML-KEM
derive_key_pair: the previously reported 8–12% gap has essentially closed. hpke-ng now measures 51.185 µs vs hpke-rs 51.894 µs for ML-KEM-768 (hpke-ng ~1.4% faster), and 82.678 µs vs 81.287 µs for ML-KEM-1024 (hpke-ng ~1.7% slower, within noise). The gap visible in the prior run was almost certainly a benchmark artifact from PRNG overhead and unhoistedseed()calls contaminating the timed path; both are now fixed.generateis similarly within noise across all sizes.Context seal: three-way tie at medium-to-large payloads, with a notable exception at 1 KiB. At 16 KiB all three libraries are essentially identical (hpke-ng 21.461 µs, hpke-rs 21.315 µs, rust-hpke 21.341 µs). At 64 KiB, rust-hpke trails by ~3% (87.646 µs vs 85.138/85.551 µs). However, at 1 KiB, hpke-ng's context_seal (2.1414 µs) is ~17% slower than hpke-rs (1.8238 µs) and rust-hpke (1.8272 µs). This is an isolated mid-size regression worth investigating in hpke-ng's AEAD framing path.
hpke-ng context open at 16 KiB: prior anomaly was a benchmark artifact, now resolved. The previous run showed hpke-ng CI [47.201-56.225 µs] vs [42.5-43.0 µs] for the other two — that variance arose because the old benchmark called
setup_receiver(including a full decap) insideb.iter(), making timing highly sensitive to KEM latency fluctuations. With the lockstep fix, all three now show tight CIs ([42.582-42.754 µs], [42.645-42.816 µs], [42.332-42.550 µs]) and essentially identical derived open latencies: hpke-ng 42.676 - 21.461 = 21.215 µs, hpke-rs 42.722 - 21.315 = 21.407 µs, rust-hpke 42.435 - 21.341 = 21.094 µs.X-Wing
generate/derive_key_pairand ML-KEMgenerateare within noise between hpke-ng and hpke-rs. This is absolutely not surprising, since the KEM implementations are effectively equivalent on those paths.