From 7205dc4b16201ef1b2ff8bf0fd53301fa10114cb Mon Sep 17 00:00:00 2001 From: vad Date: Mon, 14 Sep 2026 14:42:18 +0200 Subject: [PATCH 1/2] perf: Move state and input buffers from the heap to the stack The Poseidon state holds at most `MAX_X5_LEN` (13) field elements and the number of inputs is at most 12, so neither buffer needs dynamic allocation. `Poseidon.state` is now an `ArrayVec`. The per-round `collect()` in `apply_mds` no longer mallocs and frees a fresh `Vec` (one allocation pair per round, 64-74 per hash) - the replacement state is built on the stack and moved into place. `hash_bytes_be`/`hash_bytes_le` deserialize inputs into a single stack-resident `ArrayVec` instead of building two intermediate `Vec`s (validation results and converted elements). This adds the `arrayvec` dependency and a public `MAX_INPUTS` constant. Two incidental refactors: the input-length check is extracted into `validate_inputs_length`, and `validate_bytes_length` now returns `Result<(), PoseidonError>` instead of `Result<&[u8], PoseidonError>` - the identity return was only used by the old map/collect pattern. Note that this last part is a public API change. --- light-poseidon/Cargo.toml | 1 + light-poseidon/src/lib.rs | 49 +++++++++++++++-------------- light-poseidon/tests/bn254_fq_x5.rs | 3 +- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/light-poseidon/Cargo.toml b/light-poseidon/Cargo.toml index 6b86875..ea16311 100644 --- a/light-poseidon/Cargo.toml +++ b/light-poseidon/Cargo.toml @@ -12,6 +12,7 @@ edition = "2021" [dependencies] ark-bn254 = "0.5.0" ark-ff = "0.5.0" +arrayvec = "0.7.8" num-bigint = "0.4.4" thiserror = "1.0" diff --git a/light-poseidon/src/lib.rs b/light-poseidon/src/lib.rs index d3a9d02..c619d92 100644 --- a/light-poseidon/src/lib.rs +++ b/light-poseidon/src/lib.rs @@ -130,12 +130,14 @@ //! read the audit report [here](https://github.com/Lightprotocol/light-poseidon/blob/main/assets/audit.pdf). use ark_bn254::Fr; use ark_ff::{BigInteger, PrimeField, Zero}; +use arrayvec::ArrayVec; use thiserror::Error; pub mod parameters; pub const HASH_LEN: usize = 32; pub const MAX_X5_LEN: usize = 13; +pub const MAX_INPUTS: usize = 12; #[derive(Error, Debug, PartialEq)] pub enum PoseidonError { @@ -312,7 +314,7 @@ pub trait PoseidonBytesHasher { pub struct Poseidon { params: PoseidonParameters, domain_tag: F, - state: Vec, + state: ArrayVec, } impl Poseidon { @@ -325,14 +327,24 @@ impl Poseidon { } fn with_domain_tag(params: PoseidonParameters, domain_tag: F) -> Self { - let width = params.width; Self { domain_tag, params, - state: Vec::with_capacity(width), + state: ArrayVec::new(), } } + fn validate_inputs_length(&self, inputs: &[T]) -> Result<(), PoseidonError> { + if inputs.len() != self.params.width - 1 { + return Err(PoseidonError::InvalidNumberOfInputs { + inputs: inputs.len(), + max_limit: self.params.width - 1, + width: self.params.width, + }); + } + Ok(()) + } + #[inline(always)] fn apply_ark(&mut self, round: usize) { self.state.iter_mut().enumerate().for_each(|(i, a)| { @@ -388,14 +400,7 @@ impl Poseidon { impl PoseidonHasher for Poseidon { fn hash(&mut self, inputs: &[F]) -> Result { - if inputs.len() != self.params.width - 1 { - return Err(PoseidonError::InvalidNumberOfInputs { - inputs: inputs.len(), - max_limit: self.params.width - 1, - width: self.params.width, - }); - } - + self.validate_inputs_length(&inputs)?; self.state.push(self.domain_tag); for input in inputs { @@ -433,17 +438,13 @@ impl PoseidonHasher for Poseidon { macro_rules! impl_hash_bytes { ($fn_name:ident, $bytes_to_prime_field_element_fn:ident, $to_bytes_fn:ident) => { fn $fn_name(&mut self, inputs: &[&[u8]]) -> Result<[u8; HASH_LEN], PoseidonError> { - let inputs: Result, _> = inputs - .iter() - .map(|input| validate_bytes_length::(input)) - .collect(); - let inputs = inputs?; - let inputs: Result, _> = inputs - .iter() - .map(|input| $bytes_to_prime_field_element_fn(input)) - .collect(); - let inputs = inputs?; - let hash = self.hash(&inputs)?; + let mut deserialized_inputs: ArrayVec = ArrayVec::new(); + for input in inputs { + validate_bytes_length::(input)?; + let deserialized_input = $bytes_to_prime_field_element_fn(input)?; + deserialized_inputs.push(deserialized_input); + } + let hash = self.hash(deserialized_inputs.as_slice())?; hash.into_bigint() .$to_bytes_fn() @@ -468,7 +469,7 @@ impl PoseidonBytesHasher for Poseidon { /// to collisions. The purpose of this function is to prevent them by returning /// and error. It should be always used before converting byte slices to /// prime field elements. -pub fn validate_bytes_length(input: &[u8]) -> Result<&[u8], PoseidonError> +pub fn validate_bytes_length(input: &[u8]) -> Result<(), PoseidonError> where F: PrimeField, { @@ -482,7 +483,7 @@ where modulus_bytes_len, }); } - Ok(input) + Ok(()) } macro_rules! impl_bytes_to_prime_field_element { diff --git a/light-poseidon/tests/bn254_fq_x5.rs b/light-poseidon/tests/bn254_fq_x5.rs index b7e764c..adac60a 100644 --- a/light-poseidon/tests/bn254_fq_x5.rs +++ b/light-poseidon/tests/bn254_fq_x5.rs @@ -177,8 +177,7 @@ fn test_poseidon_bn254_x5_fq_validate_bytes_length() { } let input = vec![1u8; 32]; - let res = validate_bytes_length::(&input).unwrap(); - assert_eq!(res, &input); + validate_bytes_length::(&input).unwrap(); for i in 33..64 { let input = vec![1u8; i]; From fb624a60b08cacdbbf7246e06349097c3f6031a1 Mon Sep 17 00:00:00 2001 From: vad Date: Fri, 18 Sep 2026 16:54:37 +0200 Subject: [PATCH 2/2] perf: Eliminate remaining heap allocations from the byte hash path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The byte-input path still performed small heap allocations per call. `bytes_to_prime_field_element_be/le` built a `num_bigint::BigUint` (a heap `Vec`) for every input. The value is now assembled directly in `F::BigInt` (a fixed-size stack array of limbs) from the bytes, most significant byte first, and checked with `F::from_bigint`, which performs the modulus check in ark-ff 0.5. `hash_bytes_be/le` converted the result via `to_bytes_be/le`, which return a `Vec`. A new `IntoHashBytes` extension trait for `PrimeField` serializes the result into the fixed-size output array directly from its limbs instead. This removes the `num-bigint` dependency. Edge-case behavior is preserved: short inputs and oversized inputs with leading zero bytes are still accepted, `BytesToBigInt` is still returned for values that do not fit in `F::BigInt`, and `InputLargerThanModulus` for values >= the modulus. Also drops a needless borrow in `hash` flagged by clippy. Benchmarked on AMD EPYC 7H12 64-core with the following results. Current `main`: ``` poseidon_bn254_x5_1 time: [24.091 µs 24.130 µs 24.180 µs] poseidon_bn254_x5_2 time: [34.334 µs 34.372 µs 34.420 µs] poseidon_bn254_x5_3 time: [46.463 µs 46.499 µs 46.544 µs] poseidon_bn254_x5_4 time: [65.584 µs 65.697 µs 65.855 µs] poseidon_bn254_x5_5 time: [84.879 µs 85.005 µs 85.172 µs] poseidon_bn254_x5_6 time: [113.84 µs 114.37 µs 114.97 µs] poseidon_bn254_x5_7 time: [144.51 µs 144.71 µs 144.95 µs] poseidon_bn254_x5_8 time: [176.00 µs 176.13 µs 176.28 µs] poseidon_bn254_x5_9 time: [205.66 µs 205.80 µs 205.95 µs] poseidon_bn254_x5_10 time: [272.40 µs 273.08 µs 273.98 µs] poseidon_bn254_x5_11 time: [298.14 µs 298.41 µs 298.69 µs] poseidon_bn254_x5_12 time: [378.33 µs 378.96 µs 379.70 µs] ``` This change + arrayvec migration: ``` poseidon_bn254_x5_1 time: [22.893 µs 22.909 µs 22.925 µs] change: [-5.0821% -4.9214% -4.7493%] (p = 0.00 < 0.05) Performance has improved. Found 7 outliers among 100 measurements (7.00%) 4 (4.00%) high mild 3 (3.00%) high severe poseidon_bn254_x5_2 time: [33.052 µs 33.081 µs 33.113 µs] change: [-3.9722% -3.8519% -3.7366%] (p = 0.00 < 0.05) Performance has improved. Found 5 outliers among 100 measurements (5.00%) 4 (4.00%) high mild 1 (1.00%) high severe poseidon_bn254_x5_3 time: [45.248 µs 45.295 µs 45.355 µs] change: [-2.6216% -2.5166% -2.4136%] (p = 0.00 < 0.05) Performance has improved. Found 10 outliers among 100 measurements (10.00%) 4 (4.00%) high mild 6 (6.00%) high severe poseidon_bn254_x5_4 time: [64.118 µs 64.156 µs 64.196 µs] change: [-2.4788% -2.2897% -2.1257%] (p = 0.00 < 0.05) Performance has improved. Found 4 outliers among 100 measurements (4.00%) 3 (3.00%) high mild 1 (1.00%) high severe poseidon_bn254_x5_5 time: [83.835 µs 83.908 µs 83.999 µs] change: [-1.3413% -1.1845% -1.0296%] (p = 0.00 < 0.05) Performance has improved. Found 7 outliers among 100 measurements (7.00%) 5 (5.00%) high mild 2 (2.00%) high severe poseidon_bn254_x5_6 time: [111.90 µs 111.99 µs 112.10 µs] change: [-2.1066% -1.7171% -1.3534%] (p = 0.00 < 0.05) Performance has improved. Found 7 outliers among 100 measurements (7.00%) 6 (6.00%) high mild 1 (1.00%) high severe poseidon_bn254_x5_7 time: [143.53 µs 143.65 µs 143.79 µs] change: [-0.9447% -0.7207% -0.5351%] (p = 0.00 < 0.05) Change within noise threshold. Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) high mild 1 (1.00%) high severe poseidon_bn254_x5_8 time: [175.51 µs 175.66 µs 175.83 µs] change: [-0.3432% -0.1513% +0.1045%] (p = 0.26 > 0.05) No change in performance detected. Found 6 outliers among 100 measurements (6.00%) 3 (3.00%) high mild 3 (3.00%) high severe poseidon_bn254_x5_9 time: [207.51 µs 207.86 µs 208.27 µs] change: [+0.9627% +1.1469% +1.3456%] (p = 0.00 < 0.05) Change within noise threshold. Found 4 outliers among 100 measurements (4.00%) 2 (2.00%) high mild 2 (2.00%) high severe poseidon_bn254_x5_10 time: [272.48 µs 272.89 µs 273.35 µs] change: [-1.3056% -0.5531% +0.0423%] (p = 0.12 > 0.05) No change in performance detected. Found 7 outliers among 100 measurements (7.00%) 5 (5.00%) high mild 2 (2.00%) high severe poseidon_bn254_x5_11 time: [297.25 µs 297.69 µs 298.21 µs] change: [-0.3990% -0.2486% -0.0819%] (p = 0.00 < 0.05) Change within noise threshold. Found 6 outliers among 100 measurements (6.00%) 1 (1.00%) high mild 5 (5.00%) high severe poseidon_bn254_x5_12 time: [376.93 µs 377.70 µs 378.61 µs] change: [-0.6526% -0.3721% -0.0959%] (p = 0.01 < 0.05) Change within noise threshold. Found 9 outliers among 100 measurements (9.00%) 1 (1.00%) high mild 8 (8.00%) high severe ``` --- light-poseidon/Cargo.toml | 1 - light-poseidon/src/lib.rs | 118 +++++++++++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 23 deletions(-) diff --git a/light-poseidon/Cargo.toml b/light-poseidon/Cargo.toml index ea16311..56105a1 100644 --- a/light-poseidon/Cargo.toml +++ b/light-poseidon/Cargo.toml @@ -13,7 +13,6 @@ edition = "2021" ark-bn254 = "0.5.0" ark-ff = "0.5.0" arrayvec = "0.7.8" -num-bigint = "0.4.4" thiserror = "1.0" [dev-dependencies] diff --git a/light-poseidon/src/lib.rs b/light-poseidon/src/lib.rs index c619d92..e2dbd1f 100644 --- a/light-poseidon/src/lib.rs +++ b/light-poseidon/src/lib.rs @@ -400,7 +400,7 @@ impl Poseidon { impl PoseidonHasher for Poseidon { fn hash(&mut self, inputs: &[F]) -> Result { - self.validate_inputs_length(&inputs)?; + self.validate_inputs_length(inputs)?; self.state.push(self.domain_tag); for input in inputs { @@ -435,8 +435,56 @@ impl PoseidonHasher for Poseidon { } } +/// Serializes prime field elements into the fixed-size hash output byte +/// array ([`HASH_LEN`]) directly from their limbs, without the intermediate +/// `Vec` produced by `to_bytes_be`/`to_bytes_le`. +trait IntoHashBytes { + /// Serializes the element in big-endian byte order, returning `None` + /// when the element's byte length does not match [`HASH_LEN`]. + fn into_hash_bytes_be(self) -> Option<[u8; HASH_LEN]>; + /// Serializes the element in little-endian byte order, returning `None` + /// when the element's byte length does not match [`HASH_LEN`]. + fn into_hash_bytes_le(self) -> Option<[u8; HASH_LEN]>; +} + +fn fill_hash_bytes_be(element: F) -> Option<[u8; HASH_LEN]> { + let bigint = element.into_bigint(); + let limbs = bigint.as_ref(); + if limbs.len() * 8 != HASH_LEN { + return None; + } + let mut hash_bytes = [0u8; HASH_LEN]; + for (i, limb) in limbs.iter().rev().enumerate() { + hash_bytes[i * 8..i * 8 + 8].copy_from_slice(&limb.to_be_bytes()); + } + Some(hash_bytes) +} + +fn fill_hash_bytes_le(element: F) -> Option<[u8; HASH_LEN]> { + let bigint = element.into_bigint(); + let limbs = bigint.as_ref(); + if limbs.len() * 8 != HASH_LEN { + return None; + } + let mut hash_bytes = [0u8; HASH_LEN]; + for (i, limb) in limbs.iter().enumerate() { + hash_bytes[i * 8..i * 8 + 8].copy_from_slice(&limb.to_le_bytes()); + } + Some(hash_bytes) +} + +impl IntoHashBytes for F { + fn into_hash_bytes_be(self) -> Option<[u8; HASH_LEN]> { + fill_hash_bytes_be(self) + } + + fn into_hash_bytes_le(self) -> Option<[u8; HASH_LEN]> { + fill_hash_bytes_le(self) + } +} + macro_rules! impl_hash_bytes { - ($fn_name:ident, $bytes_to_prime_field_element_fn:ident, $to_bytes_fn:ident) => { + ($fn_name:ident, $bytes_to_prime_field_element_fn:ident, $into_hash_bytes_fn:ident) => { fn $fn_name(&mut self, inputs: &[&[u8]]) -> Result<[u8; HASH_LEN], PoseidonError> { let mut deserialized_inputs: ArrayVec = ArrayVec::new(); for input in inputs { @@ -446,17 +494,22 @@ macro_rules! impl_hash_bytes { } let hash = self.hash(deserialized_inputs.as_slice())?; - hash.into_bigint() - .$to_bytes_fn() - .try_into() - .map_err(|_| PoseidonError::VecToArray) + hash.$into_hash_bytes_fn().ok_or(PoseidonError::VecToArray) } }; } impl PoseidonBytesHasher for Poseidon { - impl_hash_bytes!(hash_bytes_le, bytes_to_prime_field_element_le, to_bytes_le); - impl_hash_bytes!(hash_bytes_be, bytes_to_prime_field_element_be, to_bytes_be); + impl_hash_bytes!( + hash_bytes_le, + bytes_to_prime_field_element_le, + into_hash_bytes_le + ); + impl_hash_bytes!( + hash_bytes_be, + bytes_to_prime_field_element_be, + into_hash_bytes_be + ); } /// Checks whether a slice of bytes is not empty or its length does not exceed @@ -487,33 +540,54 @@ where } macro_rules! impl_bytes_to_prime_field_element { - ($name:ident, $from_bytes_method:ident, $endianess:expr) => { + ($name:ident, $is_be:literal, $endian:expr) => { #[doc = "Converts a slice of "] - #[doc = $endianess] + #[doc = $endian] #[doc = "-endian bytes into a prime field element, \ represented by the [`ark_ff::PrimeField`](ark_ff::PrimeField) trait."] pub fn $name(input: &[u8]) -> Result where F: PrimeField, { - let element = num_bigint::BigUint::$from_bytes_method(input); - let element = F::BigInt::try_from(element).map_err(|_| PoseidonError::BytesToBigInt)?; - - // In theory, `F::from_bigint` should also perform a check whether input is - // larger than modulus (and return `None` if it is), but it's not reliable... - // To be sure, we check it ourselves. - if element >= F::MODULUS { - return Err(PoseidonError::InputLargerThanModulus); + let max_len = F::BigInt::NUM_LIMBS * 8; + // Trim the leading (most-significant-end) zero bytes so that inputs + // longer than the modulus size but representing a smaller value are + // still accepted. + let trimmed = if $is_be { + let start = input.iter().position(|b| *b != 0).unwrap_or(input.len()); + &input[start..] + } else { + let end = input + .iter() + .rposition(|b| *b != 0) + .map(|i| i + 1) + .unwrap_or(0); + &input[..end] + }; + if trimmed.len() > max_len { + return Err(PoseidonError::BytesToBigInt); + } + + // Build the value in `F::BigInt` (a fixed-size stack array of limbs) + // directly from the bytes, most significant byte first. + let mut value = F::BigInt::default(); + if $is_be { + for byte in trimmed.iter() { + value = (value << 8) | F::BigInt::from(*byte); + } + } else { + for byte in trimmed.iter().rev() { + value = (value << 8) | F::BigInt::from(*byte); + } } - let element = F::from_bigint(element).ok_or(PoseidonError::InputLargerThanModulus)?; - Ok(element) + F::from_bigint(value).ok_or(PoseidonError::InputLargerThanModulus) } }; } -impl_bytes_to_prime_field_element!(bytes_to_prime_field_element_le, from_bytes_le, "little"); -impl_bytes_to_prime_field_element!(bytes_to_prime_field_element_be, from_bytes_be, "big"); +impl_bytes_to_prime_field_element!(bytes_to_prime_field_element_le, false, "little"); +impl_bytes_to_prime_field_element!(bytes_to_prime_field_element_be, true, "big"); impl Poseidon { pub fn new_circom(nr_inputs: usize) -> Result, PoseidonError> {