Skip to content

Speed up non-circuit Pedersen hash ~3x via fused precomputation (returns ExtendedPoint)#1

Open
p0mvn wants to merge 5 commits into
mainfrom
pedersen-hash-fused-precompute
Open

Speed up non-circuit Pedersen hash ~3x via fused precomputation (returns ExtendedPoint)#1
p0mvn wants to merge 5 commits into
mainfrom
pedersen-hash-fused-precompute

Conversation

@p0mvn

@p0mvn p0mvn commented Jun 20, 2026

Copy link
Copy Markdown

Summary

Optimizes the non-circuit Sapling Pedersen hash (pedersen_hash), which backs Node::combine / merkle_hash and the note-commitment hash. ~3× faster on a raw 510-bit hash (20.8 µs → 6.9 µs), bit-for-bit identical output.

Two ideas:

  1. Fused precomputation. "Unfurl" each segment into its linear combination Σ enc(chunk_j)·2^{4j}·G, precompute the scaled points, and fold C = PEDERSEN_HASH_CHUNKS_PER_BLOCK (default 3) consecutive chunks into one table lookup. Eliminates the per-chunk Fr scalar accumulation and cuts point additions.
  2. AffineNiels tables + ExtendedPoint accumulator. Store table entries in jubjub's precomputed-addition form (AffineNielsPoint, 96 B vs 160 B) and accumulate via fast, low-latency mixed additions.

⚠️ Breaking changes (public API)

  • pedersen_hash::pedersen_hash now returns jubjub::ExtendedPoint (was SubgroupPoint). Mixed addition needs an ExtendedPoint accumulator, and there is no cheap ExtendedPoint → SubgroupPoint conversion (only a field inversion), so returning ExtendedPoint avoids an inversion on every hash. The result is still in the prime-order subgroup.
  • Removed constants::PEDERSEN_HASH_EXP_TABLE and PEDERSEN_HASH_EXP_WINDOW_SIZE (now unused).
  • Added constants::PEDERSEN_HASH_CHUNKS_PER_BLOCK, PEDERSEN_HASH_SINGLE_TABLE, PEDERSEN_HASH_BLOCK_TABLE.

Requires a minor version bump (0.8.0); documented under CHANGELOG.md [Unreleased].

Migration

  • Internal callers handled in this PR: tree.rs and the circuit witness/test sites already wrapped in ExtendedPoint::from(...) (now identity, removed); spec.rs::windowed_pedersen_commit re-wraps to SubgroupPoint with one affine conversion (cold path), so its signature and everything downstream are unchanged.
  • External callers needing a SubgroupPoint can re-derive via to_bytes/from_bytes, or work with the ExtendedPoint directly (most call sites already convert to affine to extract a coordinate).

Tradeoff (one-line tunable constant)

C speedup table size
3 (default) ~3.0× ~7 MB
4 ~3.5× ~36 MB
5 ~4.3× ~227 MB

C=3 gives ~3× at roughly the original exp-table's footprint. Tables built lazily with a batched inversion (jubjub::batch_normalize), so init stays cheap at the default.

Correctness

Output is bit-for-bit identical — consensus-critical; generators unchanged.

  • Existing Zcash consensus test vectors (test_pedersen_hash_points) pass.
  • New matches_reference_across_boundaries compares against a reference impl across input lengths straddling chunk / block / generator boundaries (incl. the 6-bit personalization shift) up to capacity.
  • Padding invariant preserved: exactly ⌈len/3⌉ chunks processed; the zero-padded final chunk is folded by zero-filling, chunks beyond the message are never added.

Production readiness review

  • ✅ Bit-identical output (consensus vectors + boundary equivalence test); full suite (70 tests + doc-test) green.
  • no_std verified (cargo build --no-default-features compiles; only core/alloc/jubjub used).
  • clippy --all-targets, cargo fmt --check, and cargo doc (intra-doc links) all clean.
  • ✅ Bounds/panics: capacity panic (>6 generators / 1128 input bits) matches prior behavior; all table indexing proven in-range; bit reads are bounds-checked with zero-padding.
  • ✅ Lazy static tables are thread-safe; build cost negligible at C=3 (heavier at C=5, documented).
  • ℹ️ The two new tables are exported pub to match the module's existing convention (PEDERSEN_HASH_GENERATORS, etc.); they're implementation details and could be tightened to pub(crate) if preferred.

Considered and rejected

  • Sign-symmetry half-table (negation trick): halves memory but measured −34% speed (the conditional negate sits on the accumulator dependency chain). Kept full tables; noted as a memory-only lever.
  • GLV: not applicable — jubjub has no efficient endomorphism beyond [−1], and the hash is now a sum of precomputed points, not a scalar mult.

Notes

  • The in-circuit Pedersen hasher is not touched.
  • Adds a merkle-hash criterion bench (full Node::combine path). Design write-up in docs/pedersen-hash-fused-precompute.md.

🤖 Generated with Claude Code

p0mvn and others added 2 commits June 20, 2026 01:07
Replace the per-segment "accumulate into an Fr scalar, then 8-bit-window
fixed-base multiply" approach with precomputed tables indexed directly by
the raw input bits. Each segment hash is the linear combination
`sum_j enc(chunk_j) * 2^{4j} * G`; precomputing the scaled points and
folding PEDERSEN_HASH_CHUNKS_PER_BLOCK (C=4) consecutive chunks into one
lookup eliminates the Fr accumulation entirely and roughly halves the
point additions.

Measured ~2.4x faster on a 510-bit Merkle hash (20.8us -> 8.8us) at ~60 MB
of lazily-built tables. C is a one-line tunable constant (C=3 ~1.7x/~11 MB,
C=5 ~2.9x/~380 MB).

Output is bit-for-bit identical (consensus-critical): generators are
unchanged, the existing Zcash test vectors pass, and a new equivalence test
checks against a reference implementation across chunk/block/generator
boundaries. The circuit hasher is untouched; the old exp-table constants
remain exported to avoid a breaking API change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Store the precomputed Pedersen hash tables in jubjub's precomputed-addition
form (AffineNielsPoint, 96 bytes vs 160) and accumulate into an ExtendedPoint
via mixed additions. This is both smaller and lower-latency on the
accumulator dependency chain.

Using mixed addition requires an ExtendedPoint accumulator, and there is no
cheap ExtendedPoint -> SubgroupPoint conversion (only via a field inversion),
so `pedersen_hash` now returns `jubjub::ExtendedPoint` (breaking). Caller
impact is small: tree.rs and the circuit witness/test sites already wrapped
the result in `ExtendedPoint::from(...)` (now identity, removed), and
`windowed_pedersen_commit` re-wraps to SubgroupPoint with one affine
conversion off the hot path.

Tables built with a batched inversion (jubjub::batch_normalize) to keep lazy
init cheap. Output remains bit-for-bit identical (consensus vectors + the
boundary equivalence test pass).

With this, the default drops to C=3: ~3.0x faster than the original 8-bit
exp-window (raw 510-bit hash, 20.8us -> 6.9us) at ~7 MB, roughly the original
table footprint. C=4 gives 3.5x/~36 MB, C=5 gives 4.3x/~227 MB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@p0mvn p0mvn changed the title Speed up non-circuit Pedersen hash via fused chunk-block precomputation Speed up non-circuit Pedersen hash ~3x via fused precomputation (returns ExtendedPoint) Jun 20, 2026
p0mvn and others added 3 commits June 20, 2026 13:19
PEDERSEN_HASH_EXP_TABLE, PEDERSEN_HASH_EXP_WINDOW_SIZE, and
generate_pedersen_hash_exp_table are no longer used now that pedersen_hash
uses the fused AffineNiels tables. The PR is already a breaking API change,
so drop them rather than leave dead public items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the pedersen_hash return-type change, the new tunable constant and
tables, and the removed exp-window constants under [Unreleased].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use C=2 as the default operating point: ~2x faster than the previous
exp-window implementation at the smallest memory footprint (~1.4 MB, below
the original exp table). Higher C remains a one-line tradeoff for more speed.

Output is unchanged (consensus vectors + boundary equivalence test pass).
Docs and CHANGELOG updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@p0mvn

p0mvn commented Jun 20, 2026

Copy link
Copy Markdown
Author

@v12-auditor review

@v12-auditor

v12-auditor Bot commented Jun 20, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found one issue worth reviewing.

Open the full results here.

FindingSeverityDetails
F-89492 🟡 Medium
Unbounded Pedersen Input Allocation

The public pedersen_hash() API now materializes the entire caller-supplied bit iterator into a Vec<bool> before it computes the chunk count or reaches the fixed generator-capacity check. Because src/lib.rs exports pub mod pedersen_hash, downstream callers can invoke this function with arbitrary finite or infinite IntoIterator<Item = bool> values. Oversized inputs are ultimately invalid because the implementation has a fixed generator set and later panics when block_tables.get(generator) fails, but that bound is checked only after the untrusted iterator has already been fully consumed. An infinite iterator such as core::iter::repeat(true) therefore never reaches the generator check, while a very large finite iterator forces proportional heap growth before the function panics. In-crate callers use fixed-size note-commitment and Merkle inputs, so the exploitable boundary is the public library API used by downstream services or tools.

Analyzed seven files, diff c5e596c...74544ce.

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