From fa38ca94b145a8f1c24f402c104c3655efc870cf Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 3 Jul 2026 15:31:32 -0300 Subject: [PATCH 1/2] perf(crypto): stream field-element bytes into hashers and transcript Eliminates the per-element heap allocation in Merkle leaf hashing and Fiat-Shamir transcript appends. AsBytes gains a stream_bytes sink method, overridden zero-alloc for Goldilocks base and degree-3 extension; the Merkle backends and DefaultTranscript use it instead of as_bytes()/ to_bytes_be(). Adds hash_data_parts + verify_merkle_path_from_hash so row-pair openings hash two slices directly instead of concatenating them into a throwaway Vec first; verify_composition_poly_opening and FieldElementVectorBackend::hash_data now delegate to their row-pair/parts siblings instead of duplicating the same hashing loop. Recursion guest: single-query 89.7M -> 73.7M cycles, multi-query 2.21B -> 1.82B cycles. --- .../src/fiat_shamir/default_transcript.rs | 12 +++---- .../src/merkle_tree/backends/field_element.rs | 2 +- .../backends/field_element_vector.rs | 31 +++++++++++----- crypto/crypto/src/merkle_tree/proof.rs | 18 ++++++++-- .../math/src/field/extensions_goldilocks.rs | 7 ++++ crypto/math/src/field/goldilocks.rs | 5 +++ crypto/math/src/traits.rs | 6 ++++ crypto/stark/src/verifier.rs | 36 ++++++++++--------- 8 files changed, 83 insertions(+), 34 deletions(-) diff --git a/crypto/crypto/src/fiat_shamir/default_transcript.rs b/crypto/crypto/src/fiat_shamir/default_transcript.rs index 7c3c0bf99..60922aec2 100644 --- a/crypto/crypto/src/fiat_shamir/default_transcript.rs +++ b/crypto/crypto/src/fiat_shamir/default_transcript.rs @@ -6,7 +6,7 @@ use math::{ element::FieldElement, traits::{HasDefaultTranscript, IsField, IsSubFieldOf}, }, - traits::ByteConversion, + traits::AsBytes, }; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; use sha3::{Digest, Keccak256}; @@ -28,7 +28,7 @@ impl Clone for DefaultTranscript { impl DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, { pub fn new(data: &[u8]) -> Self { let mut res = Self { @@ -50,7 +50,7 @@ where impl Default for DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, { fn default() -> Self { Self::new(&[]) @@ -60,14 +60,14 @@ where impl IsTranscript for DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, { fn append_bytes(&mut self, new_bytes: &[u8]) { self.hasher.update(new_bytes); } fn append_field_element(&mut self, element: &FieldElement) { - self.append_bytes(&element.to_bytes_be()); + element.stream_bytes(&mut |b| self.hasher.update(b)); } fn state(&self) -> [u8; 32] { @@ -94,7 +94,7 @@ where impl IsStarkTranscript for DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, S: IsField + IsSubFieldOf, { // nothing to implement: sample_z_ood uses the default body diff --git a/crypto/crypto/src/merkle_tree/backends/field_element.rs b/crypto/crypto/src/merkle_tree/backends/field_element.rs index d5d5c32d7..e8f106f5a 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element.rs @@ -34,7 +34,7 @@ where fn hash_data(input: &FieldElement) -> [u8; NUM_BYTES] { let mut hasher = D::new(); - hasher.update(input.as_bytes()); + input.stream_bytes(&mut |b| hasher.update(b)); hasher.finalize().into() } diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index 25ba807c6..870eb5c17 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -39,8 +39,8 @@ where fn hash_data(input: &[FieldElement; 2]) -> [u8; NUM_BYTES] { let mut hasher = D::new(); - hasher.update(input[0].as_bytes()); - hasher.update(input[1].as_bytes()); + input[0].stream_bytes(&mut |b| hasher.update(b)); + input[1].stream_bytes(&mut |b| hasher.update(b)); let mut result_hash = [0_u8; NUM_BYTES]; result_hash.copy_from_slice(&hasher.finalize()); result_hash @@ -86,6 +86,25 @@ where result.copy_from_slice(&hasher.finalize()); result } + + /// Hashes the concatenation of `parts` (each a slice of field elements) as a + /// single leaf, without allocating a buffer to concatenate them first. Byte- + /// identical to hashing `parts.concat()` via [`Self::hash_data`]. + pub fn hash_data_parts(parts: &[&[FieldElement]]) -> [u8; NUM_BYTES] + where + F: IsField, + FieldElement: AsBytes, + { + let mut hasher = D::new(); + for part in parts { + for element in part.iter() { + element.stream_bytes(&mut |b| hasher.update(b)); + } + } + let mut result = [0u8; NUM_BYTES]; + result.copy_from_slice(&hasher.finalize()); + result + } } impl IsMerkleTreeBackend @@ -100,13 +119,7 @@ where type Data = Vec>; fn hash_data(input: &Vec>) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - for element in input.iter() { - hasher.update(element.as_bytes()); - } - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + Self::hash_data_parts(&[input.as_slice()]) } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { diff --git a/crypto/crypto/src/merkle_tree/proof.rs b/crypto/crypto/src/merkle_tree/proof.rs index 2bbcfb3c5..518412ec8 100644 --- a/crypto/crypto/src/merkle_tree/proof.rs +++ b/crypto/crypto/src/merkle_tree/proof.rs @@ -30,14 +30,28 @@ pub struct Proof { pub fn verify_merkle_path( merkle_path: &[B::Node], root_hash: &B::Node, - mut index: usize, + index: usize, value: &B::Data, ) -> bool where B: IsMerkleTreeBackend, { - let mut hashed_value = B::hash_data(value); + verify_merkle_path_from_hash::(merkle_path, root_hash, index, B::hash_data(value)) +} +/// Same check as [`verify_merkle_path`], starting from an already-computed leaf +/// hash. Lets callers that can hash their leaf data more efficiently than the +/// backend's `hash_data` (e.g. hashing several slices without concatenating +/// them first) still walk the shared path-verification logic. +pub fn verify_merkle_path_from_hash( + merkle_path: &[B::Node], + root_hash: &B::Node, + mut index: usize, + mut hashed_value: B::Node, +) -> bool +where + B: IsMerkleTreeBackend, +{ for sibling_node in merkle_path.iter() { if index.is_multiple_of(2) { hashed_value = B::hash_new_parent(&hashed_value, sibling_node); diff --git a/crypto/math/src/field/extensions_goldilocks.rs b/crypto/math/src/field/extensions_goldilocks.rs index 45fd7274b..47af76d76 100644 --- a/crypto/math/src/field/extensions_goldilocks.rs +++ b/crypto/math/src/field/extensions_goldilocks.rs @@ -537,6 +537,13 @@ impl AsBytes for FieldElement { fn as_bytes(&self) -> alloc::vec::Vec { self.to_bytes_be() } + + #[inline(always)] + fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) { + self.value()[0].stream_bytes(sink); + self.value()[1].stream_bytes(sink); + self.value()[2].stream_bytes(sink); + } } impl HasDefaultTranscript for Degree3GoldilocksExtensionField { diff --git a/crypto/math/src/field/goldilocks.rs b/crypto/math/src/field/goldilocks.rs index 082d57325..1d60ee5b2 100644 --- a/crypto/math/src/field/goldilocks.rs +++ b/crypto/math/src/field/goldilocks.rs @@ -488,6 +488,11 @@ impl AsBytes for FieldElement { fn as_bytes(&self) -> alloc::vec::Vec { ByteConversion::to_bytes_be(self) } + + #[inline(always)] + fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) { + sink(&self.canonical_u64().to_be_bytes()); + } } // Implement IsPrimeField for the native Goldilocks diff --git a/crypto/math/src/traits.rs b/crypto/math/src/traits.rs index 0e902c6ff..e16b5bfb1 100644 --- a/crypto/math/src/traits.rs +++ b/crypto/math/src/traits.rs @@ -39,6 +39,12 @@ pub trait ByteConversion { pub trait AsBytes { /// Default serialize without args fn as_bytes(&self) -> alloc::vec::Vec; + + /// Streams the byte representation to `sink` without heap-allocating a `Vec`. + /// Default falls back to `as_bytes`; override for zero-allocation hashing/transcript hot paths. + fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) { + sink(&self.as_bytes()); + } } #[cfg(feature = "alloc")] diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 7ab1843a2..78d5017f0 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -16,7 +16,7 @@ use crate::{ }, }; use crypto::fiat_shamir::is_transcript::IsStarkTranscript; -use crypto::merkle_tree::proof::verify_merkle_path; +use crypto::merkle_tree::proof::verify_merkle_path_from_hash; use math::field::element::ArchivedFieldElement; #[cfg(not(feature = "test_fiat_shamir"))] use log::error; @@ -369,13 +369,15 @@ where E::BaseType: rkyv::Archive, Field: IsSubFieldOf, { - let mut value = evals(&opening.evaluations).to_vec(); - value.extend_from_slice(evals(&opening.evaluations_sym)); - verify_merkle_path::>( + let leaf_hash = BatchedMerkleTreeBackend::::hash_data_parts(&[ + evals(&opening.evaluations), + evals(&opening.evaluations_sym), + ]); + verify_merkle_path_from_hash::>( opening.proof.merkle_path.as_slice(), root, iota, - &value, + leaf_hash, ) } @@ -426,6 +428,8 @@ where /// Verify opening Open(Hįµ¢(D_LDE), šœ) and Open(Hįµ¢(D_LDE), -šœ) for all parts Hįµ¢of the composition /// polynomial, where šœ and -šœ are the elements corresponding to the index challenge `iota`. + /// The composition-poly opening has the identical row-pair leaf layout as + /// `verify_opening_pair`, so it's the same check with `E = FieldExtension`. fn verify_composition_poly_opening( deep_poly_openings: &ArchivedDeepPolynomialOpening, composition_poly_merkle_root: &Commitment, @@ -435,14 +439,10 @@ where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let mut value = evals(&deep_poly_openings.composition_poly.evaluations).to_vec(); - value.extend_from_slice(evals(&deep_poly_openings.composition_poly.evaluations_sym)); - - verify_merkle_path::>( - deep_poly_openings.composition_poly.proof.merkle_path.as_slice(), + Self::verify_opening_pair::( + &deep_poly_openings.composition_poly, composition_poly_merkle_root, *iota, - &value, ) } @@ -485,17 +485,21 @@ where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let evaluations = if iota % 2 == 1 { - vec![evaluation_sym.clone(), evaluation.clone()] + let (first, second) = if iota % 2 == 1 { + (evaluation_sym, evaluation) } else { - vec![evaluation.clone(), evaluation_sym.clone()] + (evaluation, evaluation_sym) }; + let leaf_hash = BatchedMerkleTreeBackend::::hash_data_parts(&[ + core::slice::from_ref(first), + core::slice::from_ref(second), + ]); - verify_merkle_path::>( + verify_merkle_path_from_hash::>( auth_path_sym, merkle_root, iota >> 1, - &evaluations, + leaf_hash, ) } From aa8d48ca51170050202664bd3d85b94d058bd504 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 3 Jul 2026 16:52:35 -0300 Subject: [PATCH 2/2] perf(math): batch degree-3 extension bytes into one hasher update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream_bytes was calling the base-field override three times, one per limb, each landing as its own Digest::update on the guest — three BlockBuffer::digest_blocks + memcpy calls instead of one. Disassembly of the guest ELF showed dyn dispatch itself was fully devirtualized by the #[inline(always)] chain, so the actual cost was the call count, not indirection. Reuse the existing write_bytes_be override (already zero-alloc, byte-identical layout) into a stack buffer and sink once. Recursion guest: single-query 73.9M -> 71.1M cycles, multi-query 1.82B -> 1.78B cycles. --- crypto/math/src/field/extensions_goldilocks.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crypto/math/src/field/extensions_goldilocks.rs b/crypto/math/src/field/extensions_goldilocks.rs index 47af76d76..8eaf5a427 100644 --- a/crypto/math/src/field/extensions_goldilocks.rs +++ b/crypto/math/src/field/extensions_goldilocks.rs @@ -540,9 +540,9 @@ impl AsBytes for FieldElement { #[inline(always)] fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) { - self.value()[0].stream_bytes(sink); - self.value()[1].stream_bytes(sink); - self.value()[2].stream_bytes(sink); + let mut buf = [0u8; 24]; + crate::traits::ByteConversion::write_bytes_be(self, &mut buf); + sink(&buf); } }