feat(crypto_primitives_bench): migrate to criterion harness

This commit is contained in:
Moudy 2026-05-21 16:44:40 +02:00
parent 694e484228
commit b608d10ca1
7 changed files with 294 additions and 209 deletions

168
Cargo.lock generated
View File

@ -91,6 +91,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@ -126,6 +135,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.21"
@ -1405,6 +1420,12 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cbindgen"
version = "0.29.2"
@ -1489,6 +1510,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -1860,6 +1908,41 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools 0.13.0",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools 0.13.0",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@ -1942,12 +2025,10 @@ dependencies = [
name = "crypto_primitives_bench"
version = "0.1.0"
dependencies = [
"anyhow",
"criterion",
"key_protocol",
"nssa_core",
"rand 0.8.5",
"serde",
"serde_json",
]
[[package]]
@ -2098,7 +2179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@ -2582,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3145,6 +3226,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hash32"
version = "0.2.1"
@ -3561,7 +3653,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@ -6583,6 +6675,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@ -6737,6 +6835,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -6893,6 +7001,34 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.11.0"
@ -7235,7 +7371,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -7272,7 +7408,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
@ -8167,7 +8303,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -9138,7 +9274,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -9373,6 +9509,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
@ -10469,7 +10615,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@ -131,6 +131,7 @@ url = { version = "2.5.4", features = ["serde"] }
tokio-retry = "0.3.0"
schemars = "1.2"
async-stream = "0.3.6"
criterion = "0.8"
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }
logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" }

View File

@ -14,15 +14,17 @@ Cryptographic primitives used by client/wallet code. Measures the per-call cost
## Results
100 timed iterations per operation, 2 warmup discarded.
Criterion sample_size = 50, warm_up_time = 2 s, measurement_time = 10 s. Slope-regression point estimate in the middle column; 95% confidence interval bounds in the outer columns.
| Operation | best (µs) | mean (µs) | stdev (µs) |
|---|---:|---:|---:|
| KeyChain::new_os_random | 2,979.62 (2.98 ms) | 3,138.18 (3.14 ms) | 258.59 (0.26 ms) |
| KeyChain::new_mnemonic | 2,979.12 (2.98 ms) | 3,012.76 (3.01 ms) | 46.09 (0.05 ms) |
| SharedSecretKey::new (sender DH) | 74.17 (0.07 ms) | 74.48 (0.07 ms) | 0.22 (<0.01 ms) |
| EncryptionScheme::encrypt | 0.88 (<0.01 ms) | 0.92 (<0.01 ms) | 0.03 (<0.01 ms) |
| EncryptionScheme::decrypt | 0.75 (<0.01 ms) | 0.78 (<0.01 ms) | 0.04 (<0.01 ms) |
| Operation | low | point | high | outliers (mild + severe) |
|---|---:|---:|---:|---:|
| keychain/new_os_random | 3.11 ms | 3.21 ms | 3.34 ms | 3 + 5 |
| keychain/new_mnemonic | 3.05 ms | 3.11 ms | 3.23 ms | 0 + 2 |
| shared_secret_key/sender_dh | 76.7 µs | 78.4 µs | 80.6 µs | 3 + 4 |
| encryption/encrypt | 1.11 µs | 1.17 µs | 1.25 µs | 1 + 5 |
| encryption/decrypt | 907 ns | 928 ns | 954 ns | 0 + 3 |
Numbers from a single M2 Pro dev box. For full estimates (slope, mean, median, MAD, std-dev) and the noise model, see `target/criterion/<group>/<bench>/estimates.json` after running locally.
## Findings
@ -33,10 +35,21 @@ Cryptographic primitives used by client/wallet code. Measures the per-call cost
## Reproduce
```sh
cargo run --release -p crypto_primitives_bench
cargo bench -p crypto_primitives_bench --bench primitives
```
JSON output: `target/crypto_primitives_bench.json`.
JSON estimates: `target/criterion/<group>/<bench>/estimates.json`. HTML report: `target/criterion/report/index.html`.
## Baseline comparison
```sh
# On main:
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
# On your branch:
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
```
Criterion reports per-bench change as a percentage with a 95% confidence interval; deltas within the CI are reported as "no significant change" rather than red.
## Caveats

View File

@ -8,11 +8,12 @@ publish = false
[lints]
workspace = true
[dependencies]
[dev-dependencies]
key_protocol.workspace = true
nssa_core = { workspace = true, features = ["host"] }
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
rand = { workspace = true }
criterion = { workspace = true, features = ["html_reports"] }
[[bench]]
name = "primitives"
harness = false

View File

@ -1,20 +1,29 @@
# crypto_primitives_bench
Cryptographic primitive microbenchmarks used by client/wallet code. Single host binary, no live sequencer or Bedrock needed.
Criterion-driven microbenchmarks for the cryptographic primitives client/wallet code uses on every transaction. No live sequencer or Bedrock needed.
## Run
```sh
cargo run --release -p crypto_primitives_bench
cargo bench -p crypto_primitives_bench --bench primitives
```
## What you'll see
Per-operation `best_us`, `mean_us`, and `stdev_us` over 100 iterations (plus 2 warmup):
Criterion's per-operation report (point estimate, 95% CI, outlier counts) for:
- `KeyChain::new_os_random` — full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
- `KeyChain::new_mnemonic` — same pipeline, mnemonic exposed.
- `SharedSecretKey::new (sender DH)` — secp256k1 ECDH per recipient.
- `EncryptionScheme::encrypt` / `decrypt` ChaCha20 over an Account note.
- `keychain/new_os_random`: full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
- `keychain/new_mnemonic`: same pipeline, mnemonic exposed.
- `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen).
- `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note.
JSON output is written to `target/crypto_primitives_bench.json`.
Per-bench JSON estimates are written under `target/criterion/<group>/<bench>/`. HTML reports at `target/criterion/report/index.html`.
## Baseline comparison
```sh
# On main:
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
# On your branch:
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
```

View File

@ -0,0 +1,90 @@
//! Criterion microbenchmarks for client/wallet cryptographic primitives.
//!
//! Measures:
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
use std::time::Duration;
use criterion::{Criterion, criterion_group, criterion_main};
use key_protocol::key_management::KeyChain;
use nssa_core::{
Commitment, EncryptionScheme, SharedSecretKey,
account::{Account, AccountId},
encryption::{EphemeralPublicKey, EphemeralSecretKey},
program::PrivateAccountKind,
};
use rand::{RngCore as _, rngs::OsRng};
fn bench_keychain(c: &mut Criterion) {
let mut g = c.benchmark_group("keychain");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("new_os_random", |b| b.iter(KeyChain::new_os_random));
g.bench_function("new_mnemonic", |b| {
b.iter(|| {
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
});
});
g.finish();
}
fn bench_shared_secret_key(c: &mut Criterion) {
// One-time setup: recipient's viewing public key (sender side bench).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
let mut g = c.benchmark_group("shared_secret_key");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("sender_dh", |b| {
b.iter(|| {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
let _epk = EphemeralPublicKey::from(&esk);
SharedSecretKey::new(esk, &vpk)
});
});
g.finish();
}
fn bench_encryption(c: &mut Criterion) {
// One-time setup: a fixed Account/Commitment and a SharedSecretKey to bench
// encrypt/decrypt over a representative note. ESK gen is excluded from the
// measured loop (covered by the SharedSecretKey bench above).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
let account = Account::default();
let account_id = AccountId::new([7; 32]);
let commitment = Commitment::new(&account_id, &account);
let shared = {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
SharedSecretKey::new(esk, &vpk)
};
let kind = PrivateAccountKind::Regular(0_u128);
let output_index: u32 = 0;
let mut g = c.benchmark_group("encryption");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("encrypt", |b| {
b.iter(|| EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index));
});
// One ciphertext for the decrypt bench (encrypt is deterministic given inputs).
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
g.bench_function("decrypt", |b| {
b.iter(|| EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index));
});
g.finish();
}
criterion_group! {
name = benches;
config = Criterion::default()
.warm_up_time(Duration::from_secs(2))
.measurement_time(Duration::from_secs(10));
targets = bench_keychain, bench_shared_secret_key, bench_encryption
}
criterion_main!(benches);

View File

@ -1,175 +0,0 @@
//! Cryptographic primitive microbenchmarks used by client/wallet code.
//!
//! Measures:
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
//!
//! Reports best-of-N wall time per operation. No live stack required.
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_precision_loss,
clippy::float_arithmetic,
clippy::print_stdout,
reason = "Bench tool"
)]
use std::{path::PathBuf, time::Instant};
use anyhow::Result;
use key_protocol::key_management::KeyChain;
use nssa_core::{
Commitment, EncryptionScheme, SharedSecretKey,
account::{Account, AccountId},
encryption::{EphemeralPublicKey, EphemeralSecretKey},
program::PrivateAccountKind,
};
use rand::{RngCore as _, rngs::OsRng};
use serde::Serialize;
const ITERS: usize = 100;
#[derive(Debug, Serialize)]
struct OpResult {
op: &'static str,
iters: usize,
best_us: f64,
mean_us: f64,
stdev_us: f64,
}
fn time<F: FnMut()>(op: &'static str, iters: usize, mut f: F) -> OpResult {
// Warmup
for _ in 0..2 {
f();
}
let mut samples_ns: Vec<f64> = Vec::with_capacity(iters);
for _ in 0..iters {
let t = Instant::now();
f();
samples_ns.push(t.elapsed().as_nanos() as f64);
}
let best_ns = samples_ns.iter().copied().fold(f64::INFINITY, f64::min);
let mean_ns: f64 = samples_ns.iter().sum::<f64>() / iters as f64;
let stdev_ns = if iters > 1 {
let var: f64 = samples_ns
.iter()
.map(|s| (s - mean_ns).powi(2))
.sum::<f64>()
/ (iters - 1) as f64;
var.sqrt()
} else {
0.0
};
OpResult {
op,
iters,
best_us: best_ns / 1_000.0,
mean_us: mean_ns / 1_000.0,
stdev_us: stdev_ns / 1_000.0,
}
}
fn main() -> Result<()> {
let mut results: Vec<OpResult> = Vec::new();
results.push(time("KeyChain::new_os_random", ITERS, || {
let _kc = KeyChain::new_os_random();
}));
results.push(time("KeyChain::new_mnemonic", ITERS, || {
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
}));
// SharedSecretKey: caller has ephemeral secret, recipient has VSK→VPK.
// We bench the SENDER side: derive ephemeral pubkey, then SharedSecretKey::new(scalar, point).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
results.push(time("SharedSecretKey::new (sender DH)", ITERS, || {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
let _epk = EphemeralPublicKey::from(&esk);
let _ssk = SharedSecretKey::new(esk, &vpk);
}));
// EncryptionScheme::encrypt / decrypt over a small Account note.
let account = Account::default();
let account_id = AccountId::new([7; 32]);
let commitment = Commitment::new(&account_id, &account);
let shared = {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
SharedSecretKey::new(esk, &vpk)
};
let kind = PrivateAccountKind::Regular(0_u128);
let output_index: u32 = 0;
let mut produced_ct = None;
results.push(time("EncryptionScheme::encrypt", ITERS, || {
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
produced_ct = Some(ct);
}));
let ct = produced_ct.expect("encrypt produced ciphertext");
results.push(time("EncryptionScheme::decrypt", ITERS, || {
let _decoded = EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index);
}));
print_table(&results);
write_json(&results)?;
Ok(())
}
fn print_table(results: &[OpResult]) {
let ow = results
.iter()
.map(|r| r.op.len())
.max()
.unwrap_or(0)
.max("op".len());
let cw = 22_usize;
println!(
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
"op", "iters", "best_us (ms)", "mean_us (ms)", "stdev_us (ms)",
);
println!("{}", "-".repeat(ow + 6 + cw * 3 + 8));
for r in results {
println!(
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
r.op,
r.iters,
fmt_us_ms(r.best_us),
fmt_us_ms(r.mean_us),
fmt_us_ms(r.stdev_us),
);
}
}
fn fmt_us_ms(us: f64) -> String {
let ms = us / 1_000.0;
if ms < 0.01 {
format!("{us:.2} (<0.01 ms)")
} else {
format!("{us:.2} ({ms:.2} ms)")
}
}
fn write_json(results: &[OpResult]) -> Result<()> {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()?;
let out_path = workspace_root
.join("target")
.join("crypto_primitives_bench.json");
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, serde_json::to_string_pretty(&results)?)?;
println!("\nJSON written to {}", out_path.display());
Ok(())
}