Skip to content

perf: return static Poseidon instances instead of cloning per hash call - #58

Open
wstran wants to merge 1 commit into
leanEthereum:mainfrom
wstran:static-poseidon-instances
Open

perf: return static Poseidon instances instead of cloning per hash call#58
wstran wants to merge 1 commit into
leanEthereum:mainfrom
wstran:static-poseidon-instances

Conversation

@wstran

@wstran wstran commented Jul 18, 2026

Copy link
Copy Markdown

What

Every scalar tweakable-hash call fetches its Poseidon permutation through poseidon1_16() / poseidon1_24(), and those getters do get_or_init(...).clone(): each hash pays a heap-allocating clone of the round-constant tables. That cost lands on every chain step in sign and verify, every Merkle path node, and every message hash. The SIMD key-generation path is unaffected because it hoists the instance out of its loops, which is why only the scalar paths are slowed.

Root cause: the OnceLock getters in lib.rs return owned instances; the Vec-backed layer tables make that clone ~450ns, on par with the ~900ns width-16 permutation itself.

Fix: return &'static references from both getters and pass them through at the call sites. No arithmetic changes; net diff is -4 lines.

This also answers part of #27 (hotspot profiling): the per-step anomaly was found by regressing verify time against the deterministic chain-step count (v * (w-1) - target_sum), which showed each step costing ~1.3us against a ~0.9us raw permutation.

Numbers

Apple M3 Pro (aarch64 NEON). One chain step (tweak encode + instance fetch + width-16 permutation):

before: 1318 ns   (clone alone: 452 ns)
after:   908 ns   (1.45x)

Criterion, lifetime 2^18, change in mean (all p < 0.05; 10% offset variants within 2 points):

w verify sign
1 -13.4% -7.5%
2 -24.0% -12.8%
4 -29.9% -19.0%
8 -31.0% -18.4%

repro:

cargo bench --bench benchmark

Correctness

  • Signatures are byte-identical to main: a seeded 32-signature serialization digest matches across both builds, and cross-verification passes.
  • All 111 tests pass; fmt and clippy clean.
  • The shared instance is immutable and Sync; the SIMD paths already share one instance per call today.

Every scalar tweakable-hash call (chain steps in sign and verify, Merkle
path nodes, message hashing) fetches its Poseidon permutation through
poseidon1_16()/poseidon1_24(), which do get_or_init(...).clone() and so
pay a heap-allocating clone of the round-constant tables on each hash.

Root cause: the OnceLock getters in lib.rs return owned instances; the
Vec-backed layer tables make that clone ~450ns, on par with the ~900ns
width-16 permutation itself. The SIMD key-generation path is unaffected
(it hoists the instance out of the loop), which is why only sign, verify
and path verification are slowed.

Fix: return &'static references from both getters and pass them through
at the call sites. No arithmetic changes.

Measured (M3 Pro, one chain step = tweak encode + instance fetch +
width-16 permutation):

  chain step: 1318 ns -> 908 ns  (clone alone: 452 ns)

Criterion, lifetime 2^18 (change in mean, all p < 0.05):

  verify  w1: -13.4%   w2: -24.0%   w4: -29.9%   w8: -31.0%
  sign    w1:  -7.5%   w2: -12.8%   w4: -19.0%   w8: -18.4%
  (10% offset variants within 2 points of the above)

Signatures are byte-identical to main: a seeded 32-signature digest
matches across both builds, and all 111 tests pass.
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.

1 participant