Skip to content

perf: Avoid heap allocations in state, input buffers and byte hash path - #61

Open
vadorovsky wants to merge 2 commits into
Lightprotocol:mainfrom
vadorovsky:vad/arrayvec
Open

vadorovsky wants to merge 2 commits into
Lightprotocol:mainfrom
vadorovsky:vad/arrayvec

Conversation

@vadorovsky

@vadorovsky vadorovsky commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

See individual commits for details.

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

@vadorovsky
vadorovsky marked this pull request as ready for review September 21, 2026 07:28
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<F, MAX_X5_LEN>`. 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<F, MAX_INPUTS>` 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.
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<u32>`) 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<u8>`. 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
```
@ananas-block

Copy link
Copy Markdown
Contributor

Both of these come from the same root cause: the Circom-specific constants became universal capacities.

1. hash_bytes_* panics on too many inputs. The macro fills ArrayVec<F, MAX_INPUTS> (12) before hash validates the count, so 13 valid 32-byte inputs hit CapacityError instead of InvalidNumberOfInputs. The existing test loops 1..13, one short of the first overflow. Validate the count before filling the buffer.

2. Poseidon::new loses widths above 13. state: ArrayVec<F, MAX_X5_LEN> makes the Circom x5 bound the capacity for every hasher; on main it only gated new_circom, and state capacity came from params.width. Custom parameters with width = 14 pass the input check and then panic on the last push. Either keep generic widths, or make the capacity explicit and the constructor fallible.

Generated with Claude Code

@vadorovsky

Copy link
Copy Markdown
Contributor Author

Good catch, quite embarrassing from my side. We can focus on landing your PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants