Skip to content

Commit 1570bf3

Browse files
committed
tooling(recursion): count keccak hashes to verify a proof (excl. grinding)
A host-only diagnostic, behind the `hash-metrics` cargo feature, that counts the keccak-256 hashes done to VERIFY a proof — a fast, deterministic proxy for the recursion guest's dominant cost (Merkle path hashing + Fiat-Shamir). A default build is provably unchanged: PlatformKeccak256 stays = sha3::Keccak256 and every counter compiles to nothing. With the feature on, the host keccak wrapper bumps a global counter on every finalize (inlined, byte-identical digest); the Merkle backends and the grinding PoW check tag their own sub-counters. So test_count_recursion_hashes reports total(excl. grinding), merkle (nodes/leaves), transcript+other, and grinding. Nothing is added to the verifier and there is no enable/disable toggle — no cross-thread race under a parallel verify. Compiled out on the riscv64 guest. RECURSION_DUMP_PRESET=<preset> cargo test --release --features hash-metrics \ -p lambda-vm-prover --lib test_count_recursion_hashes -- --ignored --nocapture
1 parent 99d7567 commit 1570bf3

9 files changed

Lines changed: 253 additions & 3 deletions

File tree

‎crypto/crypto/Cargo.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,7 @@ serde = ["dep:serde"]
4444
parallel = ["dep:rayon"]
4545
disk-spill = ["std", "dep:memmap2", "dep:tempfile", "dep:libc"]
4646
alloc = []
47-
rkyv = ["dep:rkyv", "math/rkyv"]
47+
rkyv = ["dep:rkyv", "math/rkyv"]
48+
# Host-only diagnostic: count keccak finalizes during verify (see `hash_metrics`).
49+
# Off by default → `PlatformKeccak256 = sha3::Keccak256`, provably unchanged.
50+
hash-metrics = []

‎crypto/crypto/src/hash/platform_keccak.rs‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,59 @@ mod imp {
5858
}
5959
}
6060

61-
#[cfg(not(target_arch = "riscv64"))]
61+
// Host, `hash-metrics` feature ON: `sha3::Keccak256` plus a finalize counter for
62+
// [`crate::hash_metrics`]. The counter is a PURE SIDE EFFECT — every method
63+
// forwards to the inner hasher (byte-identical digest) and is `#[inline(always)]`,
64+
// so no cross-crate call is added over the bare alias.
65+
#[cfg(all(not(target_arch = "riscv64"), feature = "hash-metrics"))]
66+
mod imp {
67+
use digest::{
68+
FixedOutput, FixedOutputReset, HashMarker, Output, OutputSizeUser, Reset, Update,
69+
};
70+
71+
#[derive(Clone, Default)]
72+
pub struct PlatformKeccak256(sha3::Keccak256);
73+
74+
impl HashMarker for PlatformKeccak256 {}
75+
76+
impl OutputSizeUser for PlatformKeccak256 {
77+
type OutputSize = digest::typenum::U32;
78+
}
79+
80+
impl Update for PlatformKeccak256 {
81+
#[inline(always)]
82+
fn update(&mut self, data: &[u8]) {
83+
Update::update(&mut self.0, data);
84+
}
85+
}
86+
87+
impl FixedOutput for PlatformKeccak256 {
88+
#[inline(always)]
89+
fn finalize_into(self, out: &mut Output<Self>) {
90+
crate::hash_metrics::count_total();
91+
FixedOutput::finalize_into(self.0, out);
92+
}
93+
}
94+
95+
impl Reset for PlatformKeccak256 {
96+
#[inline(always)]
97+
fn reset(&mut self) {
98+
Reset::reset(&mut self.0);
99+
}
100+
}
101+
102+
impl FixedOutputReset for PlatformKeccak256 {
103+
#[inline(always)]
104+
fn finalize_into_reset(&mut self, out: &mut Output<Self>) {
105+
crate::hash_metrics::count_total();
106+
FixedOutputReset::finalize_into_reset(&mut self.0, out);
107+
}
108+
}
109+
}
110+
111+
// Default host build (no `hash-metrics` feature): the plain alias, provably
112+
// unchanged from upstream.
113+
#[cfg(all(not(target_arch = "riscv64"), not(feature = "hash-metrics")))]
62114
mod imp {
63115
pub type PlatformKeccak256 = sha3::Keccak256;
64116
}

‎crypto/crypto/src/hash_metrics.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//! Host-only keccak-hash counters for measuring the cost of VERIFYING a proof
2+
//! (a proxy for the recursion guest's dominant work: keccak hashing).
3+
//!
4+
//! Behind the `hash-metrics` cargo feature: a normal build keeps
5+
//! `PlatformKeccak256 = sha3::Keccak256` and every counter call compiles to
6+
//! nothing, so the prover is provably unchanged. With the feature on (host only),
7+
//! the host `PlatformKeccak256` wrapper bumps [`count_total`] on EVERY keccak-256
8+
//! finalize — Merkle trees, the Fiat-Shamir transcript, the program-id/ELF fold.
9+
//! The Merkle backends split their share ([`count_merkle`] / [`count_merkle_node`])
10+
//! and grinding tags its own ([`count_grinding`]), so a caller can report
11+
//! `total`, `merkle` (nodes/leaves), `grinding`, and everything else.
12+
//!
13+
//! No enable/disable toggle and nothing in the verifier: counting is always on
14+
//! under the feature, and a measuring caller just [`reset`]s before the verify
15+
//! and reads [`snapshot`] after. Grinding is separated by counter, not excluded
16+
//! at a call site, so there is no cross-thread race under a parallel verify.
17+
18+
#[cfg(all(not(target_arch = "riscv64"), feature = "hash-metrics"))]
19+
mod imp {
20+
use core::sync::atomic::{AtomicU64, Ordering};
21+
22+
static TOTAL: AtomicU64 = AtomicU64::new(0);
23+
static MERKLE: AtomicU64 = AtomicU64::new(0);
24+
static MERKLE_NODES: AtomicU64 = AtomicU64::new(0);
25+
static GRINDING: AtomicU64 = AtomicU64::new(0);
26+
27+
/// Every keccak-256 finalize, from any site (host `PlatformKeccak256`).
28+
#[inline(always)]
29+
pub fn count_total() {
30+
TOTAL.fetch_add(1, Ordering::Relaxed);
31+
}
32+
33+
/// A Merkle finalize (leaf or node), counted ONLY when the backend digest is
34+
/// the platform keccak wrapper — the one whose `finalize` also bumps
35+
/// [`count_total`]. This keeps `merkle` a strict subset of `total` for ANY
36+
/// `D` (a non-keccak backend, as in the crypto tests, does not go through the
37+
/// counted wrapper, so counting it here would let `merkle` exceed `total`).
38+
#[inline(always)]
39+
pub fn count_merkle<D: 'static>() {
40+
if core::any::TypeId::of::<D>()
41+
== core::any::TypeId::of::<crate::hash::platform_keccak::PlatformKeccak256>()
42+
{
43+
MERKLE.fetch_add(1, Ordering::Relaxed);
44+
}
45+
}
46+
47+
/// A Merkle parent (auth-path) compression. Subset of [`count_merkle`];
48+
/// same keccak-only guard.
49+
#[inline(always)]
50+
pub fn count_merkle_node<D: 'static>() {
51+
if core::any::TypeId::of::<D>()
52+
== core::any::TypeId::of::<crate::hash::platform_keccak::PlatformKeccak256>()
53+
{
54+
MERKLE_NODES.fetch_add(1, Ordering::Relaxed);
55+
}
56+
}
57+
58+
/// A grinding (proof-of-work) finalize. Subset of [`count_total`]; a caller
59+
/// reports `total - grinding` to exclude the PoW check.
60+
#[inline(always)]
61+
pub fn count_grinding() {
62+
GRINDING.fetch_add(1, Ordering::Relaxed);
63+
}
64+
65+
/// Zero all counters.
66+
pub fn reset() {
67+
TOTAL.store(0, Ordering::Relaxed);
68+
MERKLE.store(0, Ordering::Relaxed);
69+
MERKLE_NODES.store(0, Ordering::Relaxed);
70+
GRINDING.store(0, Ordering::Relaxed);
71+
}
72+
73+
/// `(total, merkle, merkle_nodes, grinding)`. leaves = merkle − nodes;
74+
/// transcript+other = total − merkle − grinding; excl. grinding = total − grinding.
75+
pub fn snapshot() -> (u64, u64, u64, u64) {
76+
(
77+
TOTAL.load(Ordering::Relaxed),
78+
MERKLE.load(Ordering::Relaxed),
79+
MERKLE_NODES.load(Ordering::Relaxed),
80+
GRINDING.load(Ordering::Relaxed),
81+
)
82+
}
83+
}
84+
85+
// Feature off, or the riscv64 guest: every entry compiles to nothing.
86+
#[cfg(any(target_arch = "riscv64", not(feature = "hash-metrics")))]
87+
mod imp {
88+
#[inline(always)]
89+
pub fn count_total() {}
90+
#[inline(always)]
91+
pub fn count_merkle<D: 'static>() {}
92+
#[inline(always)]
93+
pub fn count_merkle_node<D: 'static>() {}
94+
#[inline(always)]
95+
pub fn count_grinding() {}
96+
pub fn reset() {}
97+
pub fn snapshot() -> (u64, u64, u64, u64) {
98+
(0, 0, 0, 0)
99+
}
100+
}
101+
102+
pub use imp::{count_grinding, count_merkle, count_merkle_node, count_total, reset, snapshot};

‎crypto/crypto/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ extern crate alloc;
99

1010
pub mod fiat_shamir;
1111
pub mod hash;
12+
pub mod hash_metrics;
1213
pub mod merkle_tree;
1314
#[cfg(feature = "disk-spill")]
1415
pub mod mmap_util;

‎crypto/crypto/src/merkle_tree/backends/field_element.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl<F, D: Digest, const NUM_BYTES: usize> Default for FieldElementBackend<F, D,
2222
}
2323
}
2424

25-
impl<F, D: Digest, const NUM_BYTES: usize> IsMerkleTreeBackend
25+
impl<F, D: Digest + 'static, const NUM_BYTES: usize> IsMerkleTreeBackend
2626
for FieldElementBackend<F, D, NUM_BYTES>
2727
where
2828
F: IsField,
@@ -33,12 +33,18 @@ where
3333
type Data = FieldElement<F>;
3434

3535
fn hash_data(input: &FieldElement<F>) -> [u8; NUM_BYTES] {
36+
// Merkle leaf finalize (see `crate::hash_metrics`); counts only when `D`
37+
// is the platform keccak (so `merkle ⊆ total`), no-op without the feature.
38+
crate::hash_metrics::count_merkle::<D>();
3639
let mut hasher = D::new();
3740
input.stream_bytes(&mut |b| hasher.update(b));
3841
hasher.finalize().into()
3942
}
4043

4144
fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] {
45+
// Merkle auth-path (node) compression; keccak-only guard, no-op without
46+
// the feature.
47+
crate::hash_metrics::count_merkle_node::<D>();
4248
let mut hasher = D::new();
4349
hasher.update(left);
4450
hasher.update(right);

‎crypto/crypto/src/merkle_tree/backends/field_element_vector.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256;
4343
fn hash_streamed<D: Digest + 'static, const NUM_BYTES: usize>(
4444
feed: impl Fn(&mut dyn FnMut(&[u8])),
4545
) -> [u8; NUM_BYTES] {
46+
// Metric: a Merkle finalize (leaf or node). Counts only when `D` is the
47+
// platform keccak (so `merkle ⊆ total`); no-op on guest / without the feature.
48+
crate::hash_metrics::count_merkle::<D>();
4649
#[cfg(target_arch = "riscv64")]
4750
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
4851
let mut hasher = SyscallKeccak256::new();
@@ -75,6 +78,10 @@ fn hash_new_parent_bytes<D: Digest + 'static, const NUM_BYTES: usize>(
7578
left: &[u8; NUM_BYTES],
7679
right: &[u8; NUM_BYTES],
7780
) -> [u8; NUM_BYTES] {
81+
// Metric: a Merkle parent (auth-path) compression. On the host this also
82+
// flows through `hash_streamed` (one `count_merkle`), so merkle − nodes =
83+
// leaves. Keccak-only guard; no-op on guest / without the feature.
84+
crate::hash_metrics::count_merkle_node::<D>();
7885
#[cfg(target_arch = "riscv64")]
7986
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
8087
let l: &[u8; 32] = left[..].try_into().unwrap();

‎crypto/stark/src/grinding.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ pub fn generate_nonce(seed: &[u8; 32], grinding_factor: u8) -> Option<u64> {
6565
/// when interpreted as `u64`.
6666
#[inline(always)]
6767
fn is_valid_nonce_for_inner_hash(inner_hash: &[u8; 32], candidate_nonce: u64, limit: u64) -> bool {
68+
// Tag this finalize as grinding so a verify-hash metric can report it apart
69+
// (see `crypto::hash_metrics`); no-op unless the `hash-metrics` feature is on.
70+
crypto::hash_metrics::count_grinding();
6871
let mut data = [0; 40];
6972
data[..32].copy_from_slice(inner_hash);
7073
data[32..].copy_from_slice(&candidate_nonce.to_be_bytes());
@@ -79,6 +82,8 @@ fn is_valid_nonce_for_inner_hash(inner_hash: &[u8; 32], candidate_nonce: u64, li
7982
/// Hash(prefix || seed || grinding_factor)
8083
/// `prefix` is the bit-string `0x123456789abcded`
8184
fn get_inner_hash(seed: &[u8; 32], grinding_factor: u8) -> [u8; 32] {
85+
// Grinding finalize (see `crypto::hash_metrics`); no-op unless enabled.
86+
crypto::hash_metrics::count_grinding();
8287
let mut inner_data = [0u8; 41];
8388
inner_data[0..8].copy_from_slice(&PREFIX);
8489
inner_data[8..40].copy_from_slice(seed);

‎prover/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ instruments = ["stark/instruments"]
1515
nvtx = ["cuda", "instruments", "stark/nvtx"]
1616
profile-markers = ["stark/profile-markers"]
1717
disk-spill = ["stark/disk-spill"]
18+
# Host-only verify-hash counter for `test_count_recursion_hashes` (see `hash_metrics`).
19+
hash-metrics = ["crypto/hash-metrics"]
1820

1921
[dependencies]
2022
stark = { path = "../crypto/stark" }

‎prover/src/tests/recursion_smoke_test.rs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,78 @@ fn test_dump_recursion_input() {
10521052
}
10531053
}
10541054

1055+
/// Count the keccak hashes done to VERIFY the dumped recursion blob — a
1056+
/// prover-change metric: fewer hashes ⇒ a cheaper recursion guest. Runs the exact
1057+
/// guest verify (`verify_continuation_and_attest`) on `/tmp/recursion_input.bin`
1058+
/// (override with `RECURSION_INPUT_PATH`) with `crypto::hash_metrics` counting
1059+
/// every keccak-256 finalize, and reports the grinding proof-of-work hashes apart
1060+
/// so the headline `total` excludes them.
1061+
///
1062+
/// Requires:
1063+
/// * the `hash-metrics` cargo feature — without it counting is a no-op (all zeros
1064+
/// → this test asserts and tells you to add the feature);
1065+
/// * a CONTINUATION dump: `test_dump_recursion_input` with `RECURSION_DUMP_EPOCH_LOG2`
1066+
/// set (this path verifies via `verify_continuation_and_attest`);
1067+
/// * `RECURSION_DUMP_PRESET` matching the dump (default `min`), else the verify fails.
1068+
///
1069+
/// Loop: change the prover → re-run `test_dump_recursion_input` (re-proves + dumps
1070+
/// the new blob) → run this (fast, verify-only) → compare `total`.
1071+
///
1072+
/// RECURSION_DUMP_PRESET=blowup4 cargo test --release --features hash-metrics \
1073+
/// -p lambda-vm-prover --lib test_count_recursion_hashes -- --ignored --nocapture
1074+
#[test]
1075+
#[ignore = "diagnostic: counts keccak hashes verifying the dumped recursion blob"]
1076+
fn test_count_recursion_hashes() {
1077+
let preset_name = std::env::var("RECURSION_DUMP_PRESET").unwrap_or_else(|_| "min".to_string());
1078+
let preset = Preset::ALL
1079+
.into_iter()
1080+
.find(|p| p.name() == preset_name)
1081+
.unwrap_or_else(|| panic!("unknown RECURSION_DUMP_PRESET '{preset_name}'"));
1082+
let path = std::env::var("RECURSION_INPUT_PATH")
1083+
.unwrap_or_else(|_| "/tmp/recursion_input.bin".to_string());
1084+
let blob = std::fs::read(&path)
1085+
.unwrap_or_else(|e| panic!("read {path} (run test_dump_recursion_input first): {e}"));
1086+
1087+
// Counting is always-on under the `hash-metrics` feature, so just zero the
1088+
// counters, verify, and read — no enable/disable, nothing in the verifier.
1089+
crypto::hash_metrics::reset();
1090+
let attestation = recursion::verify_continuation_and_attest(&blob, &preset.options()).expect(
1091+
"verify_continuation_and_attest errored — needs a CONTINUATION dump \
1092+
(RECURSION_DUMP_EPOCH_LOG2 set) under a matching RECURSION_DUMP_PRESET",
1093+
);
1094+
let (total, merkle, nodes, grinding) = crypto::hash_metrics::snapshot();
1095+
1096+
assert!(
1097+
attestation.is_some(),
1098+
"the blob must verify under preset '{}' — does it match the dump's RECURSION_DUMP_PRESET?",
1099+
preset.name()
1100+
);
1101+
assert!(
1102+
total > 0,
1103+
"hash counters are zero — build with `--features hash-metrics`"
1104+
);
1105+
// `merkle` and `grinding` are disjoint subsets of `total`; `nodes` ⊆ `merkle`.
1106+
// (Holds for the keccak recursion verify; flags a backend/counter mismatch.)
1107+
assert!(
1108+
nodes <= merkle && merkle + grinding <= total,
1109+
"inconsistent counters: total={total} merkle={merkle} nodes={nodes} grinding={grinding}"
1110+
);
1111+
1112+
println!(
1113+
"[hash-count] preset={} blob={}B fri_queries={} | total(excl. grinding)={} | \
1114+
merkle={} (nodes={} leaves={}) | transcript+other={} | grinding={}",
1115+
preset.name(),
1116+
blob.len(),
1117+
preset.options().fri_number_of_queries,
1118+
total - grinding,
1119+
merkle,
1120+
nodes,
1121+
merkle - nodes,
1122+
total - merkle - grinding,
1123+
grinding,
1124+
);
1125+
}
1126+
10551127
/// Cycle count only of the recursion guest verifying a 1-query inner proof.
10561128
#[test]
10571129
#[ignore = "diagnostic: fast; recursion guest cycle count (1 query)"]

0 commit comments

Comments
 (0)