-
Notifications
You must be signed in to change notification settings - Fork 1
tooling(recursion): count keccak hashes to verify a proof (excl. grin… #987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| //! Host-only keccak-hash counters — a metric for the cost of VERIFYING a proof | ||
| //! (a proxy for the recursion guest's dominant work: keccak hashing). | ||
| //! | ||
| //! [`count_total`] fires on EVERY keccak-256 finalize (the host | ||
| //! [`crate::hash::platform_keccak::PlatformKeccak256`] wrapper) — Merkle trees, | ||
| //! the Fiat-Shamir transcript, the program-id/ELF fold, everything. The Merkle | ||
| //! backend additionally splits its share via [`count_merkle`] (every Merkle | ||
| //! finalize) and [`count_merkle_node`] (auth-path parent compressions), so a | ||
| //! caller can report `nodes`, `leaves = merkle − nodes`, and | ||
| //! `transcript+other = total − merkle`. | ||
| //! | ||
| //! GRINDING IS EXCLUDED at its call site: the verifier wraps `is_valid_nonce` | ||
| //! with [`disable`]/re-[`enable`] (guarded by [`is_enabled`]), so the proof-of- | ||
| //! work check's finalizes are not counted. | ||
| //! | ||
| //! Compiled OUT on the riscv64 guest (`#[cfg]`) so the guest — whose cycles we | ||
| //! actually care about — pays nothing. On the host each counted site is one | ||
| //! relaxed atomic load (negligible vs prove time; disabled by default). | ||
|
|
||
| #[cfg(not(target_arch = "riscv64"))] | ||
| mod host { | ||
| use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; | ||
|
|
||
| static ENABLED: AtomicBool = AtomicBool::new(false); | ||
| static TOTAL: AtomicU64 = AtomicU64::new(0); | ||
| static MERKLE: AtomicU64 = AtomicU64::new(0); | ||
| static MERKLE_NODES: AtomicU64 = AtomicU64::new(0); | ||
|
|
||
| /// Every keccak-256 finalize, from any site (called by the host | ||
| /// `PlatformKeccak256` wrapper). The headline "all hashes" number. | ||
| #[inline(always)] | ||
| pub fn count_total() { | ||
| if ENABLED.load(Ordering::Relaxed) { | ||
| TOTAL.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| } | ||
|
|
||
| /// A Merkle finalize (leaf or node). Subset of [`count_total`]. | ||
| #[inline(always)] | ||
| pub fn count_merkle() { | ||
| if ENABLED.load(Ordering::Relaxed) { | ||
| MERKLE.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| } | ||
|
|
||
| /// A Merkle parent (auth-path) compression. Subset of [`count_merkle`]. | ||
| #[inline(always)] | ||
| pub fn count_merkle_node() { | ||
| if ENABLED.load(Ordering::Relaxed) { | ||
| MERKLE_NODES.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| } | ||
|
|
||
| /// Whether counting is currently on — so the grinding exclusion can restore | ||
| /// the prior state instead of blindly re-enabling. | ||
| #[inline(always)] | ||
| pub fn is_enabled() -> bool { | ||
| ENABLED.load(Ordering::Relaxed) | ||
| } | ||
|
|
||
| pub fn enable() { | ||
| ENABLED.store(true, Ordering::Relaxed); | ||
| } | ||
|
|
||
| pub fn disable() { | ||
| ENABLED.store(false, Ordering::Relaxed); | ||
| } | ||
|
|
||
| /// Zero the counters (does not change the enabled state). | ||
| pub fn reset() { | ||
| TOTAL.store(0, Ordering::Relaxed); | ||
| MERKLE.store(0, Ordering::Relaxed); | ||
| MERKLE_NODES.store(0, Ordering::Relaxed); | ||
| } | ||
|
|
||
| /// `(total, merkle, merkle_nodes)`. leaves = merkle − nodes; | ||
| /// transcript+other = total − merkle. | ||
| pub fn snapshot() -> (u64, u64, u64) { | ||
| ( | ||
| TOTAL.load(Ordering::Relaxed), | ||
| MERKLE.load(Ordering::Relaxed), | ||
| MERKLE_NODES.load(Ordering::Relaxed), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(not(target_arch = "riscv64"))] | ||
| pub use host::{ | ||
| count_merkle, count_merkle_node, count_total, disable, enable, is_enabled, reset, snapshot, | ||
| }; | ||
|
|
||
| // Guest stubs — compiled to nothing; the guest must not pay for measurement. | ||
| // `enable`/`disable`/`is_enabled` exist too so the shared verifier code (which | ||
| // wraps the grinding check) compiles for the guest without `#[cfg]` noise. | ||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn count_total() {} | ||
|
|
||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn count_merkle() {} | ||
|
|
||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn count_merkle_node() {} | ||
|
|
||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn enable() {} | ||
|
|
||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn disable() {} | ||
|
|
||
| #[cfg(target_arch = "riscv64")] | ||
| #[inline(always)] | ||
| pub fn is_enabled() -> bool { | ||
| false | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,9 @@ use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; | |
| fn hash_streamed<D: Digest + 'static, const NUM_BYTES: usize>( | ||
| feed: impl Fn(&mut dyn FnMut(&[u8])), | ||
| ) -> [u8; NUM_BYTES] { | ||
| // Metric: a Merkle finalize (leaf or node) — the total keccak count is taken | ||
| // at the primitive; this is the Merkle sub-count. No-op on guest / disabled. | ||
| crate::hash_metrics::count_merkle(); | ||
|
Comment on lines
+46
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low/Medium (metric accuracy): the Merkle sub-counters only cover this file. Also, Cheapest fix: count in |
||
| #[cfg(target_arch = "riscv64")] | ||
| if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() { | ||
| let mut hasher = SyscallKeccak256::new(); | ||
|
|
@@ -75,6 +78,10 @@ fn hash_new_parent_bytes<D: Digest + 'static, const NUM_BYTES: usize>( | |
| left: &[u8; NUM_BYTES], | ||
| right: &[u8; NUM_BYTES], | ||
| ) -> [u8; NUM_BYTES] { | ||
| // Metric: a Merkle parent (auth-path) compression. On the host this also | ||
| // flows through `hash_streamed` (one `count_merkle`), so merkle − nodes = | ||
| // leaves. No-op on the guest / when disabled. | ||
| crate::hash_metrics::count_merkle_node(); | ||
| #[cfg(target_arch = "riscv64")] | ||
| if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() { | ||
| let l: &[u8; 32] = left[..].try_into().unwrap(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1664,9 +1664,17 @@ pub trait IsStarkVerifier< | |
| // verify grinding | ||
| let grinding_factor = air.context().proof_options.grinding_factor; | ||
| if grinding_factor > 0 { | ||
| // Exclude the proof-of-work check from the host hash metric | ||
| // (`crypto::hash_metrics`): the metric asks for "all hashes except | ||
| // grinding". No-op on the guest and whenever counting is off. | ||
| let hm_was = crypto::hash_metrics::is_enabled(); | ||
| crypto::hash_metrics::disable(); | ||
| let nonce_is_valid = proof.nonce().is_some_and(|nonce_value| { | ||
| grinding::is_valid_nonce(&challenges.grinding_seed, nonce_value, grinding_factor) | ||
| }); | ||
| if hm_was { | ||
| crypto::hash_metrics::enable(); | ||
| } | ||
|
|
||
|
Comment on lines
+1667
to
1678
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low (simplicity): this whole exclusion — 12 lines of global save/restore in shared verifier code, plus the Suggest dropping the exclusion entirely (and Secondary, if it stays: the toggle is a process-global, so a rayon-parallel verify would drop any hashes other threads finalize inside this window, and a concurrent second verify would race the restore. |
||
| if !nonce_is_valid { | ||
| #[cfg(not(feature = "test_fiat_shamir"))] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1052,6 +1052,61 @@ fn test_dump_recursion_input() { | |
| } | ||
| } | ||
|
|
||
| /// Count the keccak hashes done to VERIFY the dumped recursion blob — a | ||
| /// prover-change metric: fewer hashes ⇒ a cheaper recursion guest. Runs the exact | ||
| /// guest verify (`verify_continuation_and_attest`) on `/tmp/recursion_input.bin` | ||
| /// (override with `RECURSION_INPUT_PATH`) with `crypto::hash_metrics` counting | ||
| /// EVERY keccak-256 finalize EXCEPT the grinding proof-of-work check. The preset | ||
| /// MUST match the dump's `RECURSION_DUMP_PRESET`, or the verify fails. | ||
| /// | ||
| /// Loop: change the prover → re-run `test_dump_recursion_input` (re-proves + | ||
| /// dumps the new blob) → run this (fast, verify-only) → compare the counts. | ||
| /// | ||
| /// RECURSION_DUMP_PRESET=blowup4 cargo test --release \ | ||
| /// -p lambda-vm-prover --lib test_count_recursion_hashes -- --ignored --nocapture | ||
| #[test] | ||
| #[ignore = "diagnostic: counts keccak hashes verifying the dumped recursion blob"] | ||
| fn test_count_recursion_hashes() { | ||
| let preset_name = | ||
| std::env::var("RECURSION_DUMP_PRESET").unwrap_or_else(|_| "blowup4".to_string()); | ||
|
Comment on lines
+1070
to
+1071
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low (usability): two footguns for the documented loop.
Also: |
||
| let preset = Preset::ALL | ||
| .into_iter() | ||
| .find(|p| p.name() == preset_name) | ||
| .unwrap_or_else(|| panic!("unknown RECURSION_DUMP_PRESET '{preset_name}'")); | ||
| let path = std::env::var("RECURSION_INPUT_PATH") | ||
| .unwrap_or_else(|_| "/tmp/recursion_input.bin".to_string()); | ||
| let blob = std::fs::read(&path) | ||
| .unwrap_or_else(|e| panic!("read {path} (run test_dump_recursion_input first): {e}")); | ||
|
|
||
| crypto::hash_metrics::reset(); | ||
| crypto::hash_metrics::enable(); | ||
| let attestation = recursion::verify_continuation_and_attest(&blob, &preset.options()) | ||
| .expect("verify_continuation_and_attest errored"); | ||
| crypto::hash_metrics::disable(); | ||
| let (total, merkle, nodes) = crypto::hash_metrics::snapshot(); | ||
|
|
||
| assert!( | ||
| attestation.is_some(), | ||
| "the blob must verify under preset '{}' — does it match the dump's RECURSION_DUMP_PRESET?", | ||
| preset.name() | ||
| ); | ||
| // `total` = every keccak-256 finalize EXCEPT the grinding PoW check (excluded | ||
| // at its call site). `merkle` is its Merkle subset; `total - merkle` is the | ||
| // transcript + program-id/ELF fold + anything else. | ||
| println!( | ||
| "[hash-count] preset={} blob={}B fri_queries={} | total(excl. grinding)={} | \ | ||
| merkle={} (nodes={} leaves={}) | transcript+other={}", | ||
| preset.name(), | ||
| blob.len(), | ||
| preset.options().fri_number_of_queries, | ||
| total, | ||
| merkle, | ||
| nodes, | ||
| merkle.saturating_sub(nodes), | ||
| total.saturating_sub(merkle), | ||
| ); | ||
| } | ||
|
|
||
| /// Cycle count only of the recursion guest verifying a 1-query inner proof. | ||
| #[test] | ||
| #[ignore = "diagnostic: fast; recursion guest cycle count (1 query)"] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Medium (performance): none of the forwarding methods are
#[inline]. They are non-generic inherent trait impls defined incrypto, so downstream crates (stark,prover) call them as real cross-crate calls unless LTO kicks in — whereas todayPlatformKeccak256 = sha3::Keccak256resolves to digest'sCoreWrappermethods, which are#[inline].That matters most for
update: the Merkle leaf path streams field elements 8 bytes at a time (element.stream_bytes(sink)), so this adds one call per chunk, not per hash, in the prover's hottest loop. Andfinalize_intonow moves the ~200-byte sponge by value through an extra newtype layer — the exact shape the DO-NOT-REFACTOR note infield_element_vector.rs:32-41says was measured slower.At minimum add
#[inline(always)]to all six forwarding methods. Better, since this is a host-only diagnostic: keeppub type PlatformKeccak256 = sha3::Keccak256;as the default and put the counting wrapper behind a cargo feature, so a normal prover build is provably unchanged.