Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion crypto/crypto/src/hash/platform_keccak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,49 @@ mod imp {

#[cfg(not(target_arch = "riscv64"))]
mod imp {
pub type PlatformKeccak256 = sha3::Keccak256;
use digest::{
FixedOutput, FixedOutputReset, HashMarker, Output, OutputSizeUser, Reset, Update,
};

/// Host keccak-256: `sha3::Keccak256` plus a finalize counter for
/// [`crate::hash_metrics`] (the total "all hashes" verify metric). The
/// counter is a PURE SIDE EFFECT — every method forwards to the inner
/// `sha3::Keccak256`, so the digest is byte-identical to the bare hasher.
/// Only compiled on the host; the guest keeps the syscall passthrough above.
#[derive(Clone, Default)]
pub struct PlatformKeccak256(sha3::Keccak256);

impl HashMarker for PlatformKeccak256 {}

impl OutputSizeUser for PlatformKeccak256 {
type OutputSize = digest::typenum::U32;
}

impl Update for PlatformKeccak256 {
fn update(&mut self, data: &[u8]) {
Update::update(&mut self.0, data);
}
}

impl FixedOutput for PlatformKeccak256 {
fn finalize_into(self, out: &mut Output<Self>) {
crate::hash_metrics::count_total();
FixedOutput::finalize_into(self.0, out);
}
}

impl Reset for PlatformKeccak256 {
fn reset(&mut self) {
Reset::reset(&mut self.0);
}
}

Comment on lines +81 to +99

Copy link
Copy Markdown
Contributor

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 in crypto, so downstream crates (stark, prover) call them as real cross-crate calls unless LTO kicks in — whereas today PlatformKeccak256 = sha3::Keccak256 resolves to digest's CoreWrapper methods, 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. And finalize_into now moves the ~200-byte sponge by value through an extra newtype layer — the exact shape the DO-NOT-REFACTOR note in field_element_vector.rs:32-41 says was measured slower.

At minimum add #[inline(always)] to all six forwarding methods. Better, since this is a host-only diagnostic: keep pub type PlatformKeccak256 = sha3::Keccak256; as the default and put the counting wrapper behind a cargo feature, so a normal prover build is provably unchanged.

impl FixedOutputReset for PlatformKeccak256 {
fn finalize_into_reset(&mut self, out: &mut Output<Self>) {
crate::hash_metrics::count_total();
FixedOutputReset::finalize_into_reset(&mut self.0, out);
}
}
}

pub use imp::PlatformKeccak256;
119 changes: 119 additions & 0 deletions crypto/crypto/src/hash_metrics.rs
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
}
1 change: 1 addition & 0 deletions crypto/crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ extern crate alloc;

pub mod fiat_shamir;
pub mod hash;
pub mod hash_metrics;
pub mod merkle_tree;
#[cfg(feature = "disk-spill")]
pub mod mmap_util;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low/Medium (metric accuracy): the Merkle sub-counters only cover this file. FieldElementBackend (backends/field_element.rs, aliased as Keccak256Backend / FriMerkleTreeBackend in stark/src/config.rs:10) hashes via its own hasher.finalize() and is never counted — those hashes land in total and therefore get reported as transcript+other. It looks unused in the current verify path, but the split will silently misreport the day a tree switches backends.

Also, count_merkle here fires for any D, while count_total only fires for keccak. With a non-keccak backend (Poseidon, Sha3_256, Keccak512 in the crypto tests) merkle can exceed total, and the test's total.saturating_sub(merkle) prints a plausible-looking 0 instead of flagging the inconsistency.

Cheapest fix: count in field_element.rs too, and have the test assert merkle <= total / nodes <= merkle rather than saturating.

#[cfg(target_arch = "riscv64")]
if NUM_BYTES == 32 && TypeId::of::<D>() == TypeId::of::<PlatformKeccak256>() {
let mut hasher = SyscallKeccak256::new();
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions crypto/stark/src/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 is_enabled API and its guest stub — removes exactly 2 hashes per proof. is_valid_nonce is get_inner_hash (1 finalize) + is_valid_nonce_for_inner_hash (1 finalize); against the ~900k in the PR description that is noise, and grinding verification is work the recursion guest actually does, so excluding it makes the proxy slightly less faithful, not more.

Suggest dropping the exclusion entirely (and is_enabled with it): it keeps measurement state out of the verifier, and the headline number stays "every keccak the verifier does".

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"))]
Expand Down
55 changes: 55 additions & 0 deletions prover/src/tests/recursion_smoke_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (usability): two footguns for the documented loop.

  1. This defaults to blowup4, but test_dump_recursion_input defaults to min — running the pair with no env at all produces a verify failure rather than a count. Default to "min" to match the producer.
  2. It always calls verify_continuation_and_attest, which only accepts a continuation blob. A dump made without RECURSION_DUMP_EPOCH_LOG2 (the dump test's default branch) fails inside rkyv::access and surfaces as .expect("verify_continuation_and_attest errored") with an opaque validation message. Worth saying "requires a dump made with RECURSION_DUMP_EPOCH_LOG2 set" in the doc comment, and pointing the panic message at that.

Also: enable() is not restored if the verify below panics, leaving counting on for the rest of the test process. Minor, but a disable() before the assert!/expect (or just disabling first thing after snapshot) avoids it.

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)"]
Expand Down
Loading