From 5c775ab68a23593de1d6d00ebf89cd8479c7de56 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Wed, 15 Jul 2026 20:36:40 -0300 Subject: [PATCH] perf(crypto): stream field-element bytes into hashers and transcript AsBytes::as_bytes() allocates a Vec per field element, hit on every Merkle leaf hash and every Fiat-Shamir transcript append. Adds a stream_bytes(sink) method (default falls back to as_bytes) overridden zero-alloc for Goldilocks and its degree-3 extension, and wires DefaultTranscript::append_field_element plus the two Merkle backends (field_element.rs, field_element_vector.rs) to use it instead of as_bytes(). Multi-query recursion profile: 1,325,439,243 -> 856,519,112 cycles (-35.4%); step 4 (openings) 635,327,899 -> 185,739,531 cycles (-70.8%). --- crypto/crypto/src/fiat_shamir/default_transcript.rs | 12 ++++++------ .../crypto/src/merkle_tree/backends/field_element.rs | 2 +- .../src/merkle_tree/backends/field_element_vector.rs | 6 +++--- crypto/math/src/field/extensions_goldilocks.rs | 11 +++++++++++ crypto/math/src/field/goldilocks.rs | 5 +++++ crypto/math/src/traits.rs | 6 ++++++ 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crypto/crypto/src/fiat_shamir/default_transcript.rs b/crypto/crypto/src/fiat_shamir/default_transcript.rs index 8ab3eafc3..819b0f761 100644 --- a/crypto/crypto/src/fiat_shamir/default_transcript.rs +++ b/crypto/crypto/src/fiat_shamir/default_transcript.rs @@ -8,7 +8,7 @@ use math::{ element::FieldElement, traits::{HasDefaultTranscript, IsField, IsSubFieldOf}, }, - traits::ByteConversion, + traits::AsBytes, }; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; @@ -29,7 +29,7 @@ impl Clone for DefaultTranscript { impl DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, { pub fn new(data: &[u8]) -> Self { let mut res = Self { @@ -51,7 +51,7 @@ where impl Default for DefaultTranscript where F: HasDefaultTranscript, - FieldElement: ByteConversion, + FieldElement: AsBytes, { fn default() -> Self { Self::new(&[]) @@ -61,14 +61,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] { @@ -95,7 +95,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 e6daace0d..d60419cf4 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 @@ -102,7 +102,7 @@ where pub fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> [u8; NUM_BYTES] { let mut hasher = D::new(); for element in a.iter().chain(b.iter()) { - hasher.update(element.as_bytes()); + element.stream_bytes(&mut |bytes| hasher.update(bytes)); } let mut result_hash = [0_u8; NUM_BYTES]; result_hash.copy_from_slice(&hasher.finalize()); diff --git a/crypto/math/src/field/extensions_goldilocks.rs b/crypto/math/src/field/extensions_goldilocks.rs index d6bac98df..246a3cb87 100644 --- a/crypto/math/src/field/extensions_goldilocks.rs +++ b/crypto/math/src/field/extensions_goldilocks.rs @@ -554,6 +554,17 @@ impl AsBytes for FieldElement { fn as_bytes(&self) -> alloc::vec::Vec { self.to_bytes_be() } + + // One sink call over a stack buffer instead of three (one per limb): each + // sink call lands as its own `Digest::update` on the guest, and dyn dispatch + // here is fully devirtualized by the #[inline(always)] chain, so call count + // — not indirection — is the cost being cut. + #[inline(always)] + fn stream_bytes(&self, sink: &mut dyn FnMut(&[u8])) { + let mut buf = [0u8; 24]; + ByteConversion::write_bytes_be(self, &mut buf); + sink(&buf); + } } 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")]