diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4e29b..0dddd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Optimized the stock arkworks `DoryRoutines`: `G1Routines::msm`/`G2Routines::msm` + now convert bases to affine with one shared Montgomery batch inversion + (`normalize_batch`) instead of one field inversion per point, and the vector + ops (`fixed_base_vector_scalar_mul`, `fixed_scalar_mul_bases_then_add`, + `fixed_scalar_mul_vs_then_add`, `fold_field_vectors`) parallelize behind the + existing `parallel` feature. Group results are unchanged, so proofs are + byte-identical; without `parallel` the sequential behavior is exactly as + before. + ## [0.4.0] - 2026-07-03 ### Security diff --git a/src/backends/arkworks/ark_group.rs b/src/backends/arkworks/ark_group.rs index 55c5c31..3005163 100644 --- a/src/backends/arkworks/ark_group.rs +++ b/src/backends/arkworks/ark_group.rs @@ -255,6 +255,28 @@ impl<'a> Mul<&'a ArkGT> for ArkFr { } } +/// left\[i\] = left\[i\] * scalar + right\[i\], parallelized when the `parallel` +/// feature is enabled. +fn fold_field_vectors_impl(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + assert_eq!(left.len(), right.len(), "Lengths must match"); + + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + // Per-element work is a single field mul-add (~tens of ns); without a + // minimum split length, rayon overhead dominates the short vectors of + // the late reduce-fold rounds. + left.par_iter_mut() + .zip(right.par_iter()) + .with_min_len(1 << 10) + .for_each(|(l, r)| *l = *l * *scalar + *r); + } + #[cfg(not(feature = "parallel"))] + for (l, r) in left.iter_mut().zip(right.iter()) { + *l = *l * *scalar + *r; + } +} + pub struct G1Routines; impl DoryRoutines for G1Routines { @@ -270,19 +292,43 @@ impl DoryRoutines for G1Routines { return ArkG1::identity(); } - let bases_affine: Vec = bases.iter().map(|b| b.0.into_affine()).collect(); + // Already-normalized bases (z = 1, e.g. setup generators) convert for + // free via `into_affine`'s short-circuit; otherwise one shared + // Montgomery batch inversion replaces one field inversion per point. + let bases_affine: Vec = if bases.iter().all(|b| b.0.z.is_one()) { + bases.iter().map(|b| b.0.into_affine()).collect() + } else { + let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); + G1Projective::normalize_batch(&bases_proj) + }; let scalars_fr: Vec = scalars.iter().map(|s| s.0).collect(); ArkG1(G1Projective::msm(&bases_affine, &scalars_fr).expect("MSM failed")) } fn fixed_base_vector_scalar_mul(base: &ArkG1, scalars: &[ArkFr]) -> Vec { - scalars.iter().map(|s| base.scale(s)).collect() + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + scalars.par_iter().map(|s| base.scale(s)).collect() + } + #[cfg(not(feature = "parallel"))] + { + scalars.iter().map(|s| base.scale(s)).collect() + } } fn fixed_scalar_mul_bases_then_add(bases: &[ArkG1], vs: &mut [ArkG1], scalar: &ArkFr) { assert_eq!(bases.len(), vs.len(), "Lengths must match"); + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + vs.par_iter_mut() + .zip(bases.par_iter()) + .for_each(|(v, base)| *v = v.add(&base.scale(scalar))); + } + #[cfg(not(feature = "parallel"))] for (v, base) in vs.iter_mut().zip(bases.iter()) { *v = v.add(&base.scale(scalar)); } @@ -291,10 +337,22 @@ impl DoryRoutines for G1Routines { fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG1], addends: &[ArkG1], scalar: &ArkFr) { assert_eq!(vs.len(), addends.len(), "Lengths must match"); + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + vs.par_iter_mut() + .zip(addends.par_iter()) + .for_each(|(v, addend)| *v = v.scale(scalar).add(addend)); + } + #[cfg(not(feature = "parallel"))] for (v, addend) in vs.iter_mut().zip(addends.iter()) { *v = v.scale(scalar).add(addend); } } + + fn fold_field_vectors(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + fold_field_vectors_impl(left, right, scalar); + } } pub struct G2Routines; @@ -312,19 +370,43 @@ impl DoryRoutines for G2Routines { return ArkG2::identity(); } - let bases_affine: Vec = bases.iter().map(|b| b.0.into_affine()).collect(); + // Already-normalized bases (z = 1, e.g. setup generators) convert for + // free via `into_affine`'s short-circuit; otherwise one shared + // Montgomery batch inversion replaces one field inversion per point. + let bases_affine: Vec = if bases.iter().all(|b| b.0.z.is_one()) { + bases.iter().map(|b| b.0.into_affine()).collect() + } else { + let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); + G2Projective::normalize_batch(&bases_proj) + }; let scalars_fr: Vec = scalars.iter().map(|s| s.0).collect(); ArkG2(G2Projective::msm(&bases_affine, &scalars_fr).expect("MSM failed")) } fn fixed_base_vector_scalar_mul(base: &ArkG2, scalars: &[ArkFr]) -> Vec { - scalars.iter().map(|s| base.scale(s)).collect() + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + scalars.par_iter().map(|s| base.scale(s)).collect() + } + #[cfg(not(feature = "parallel"))] + { + scalars.iter().map(|s| base.scale(s)).collect() + } } fn fixed_scalar_mul_bases_then_add(bases: &[ArkG2], vs: &mut [ArkG2], scalar: &ArkFr) { assert_eq!(bases.len(), vs.len(), "Lengths must match"); + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + vs.par_iter_mut() + .zip(bases.par_iter()) + .for_each(|(v, base)| *v = v.add(&base.scale(scalar))); + } + #[cfg(not(feature = "parallel"))] for (v, base) in vs.iter_mut().zip(bases.iter()) { *v = v.add(&base.scale(scalar)); } @@ -333,8 +415,20 @@ impl DoryRoutines for G2Routines { fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG2], addends: &[ArkG2], scalar: &ArkFr) { assert_eq!(vs.len(), addends.len(), "Lengths must match"); + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + vs.par_iter_mut() + .zip(addends.par_iter()) + .for_each(|(v, addend)| *v = v.scale(scalar).add(addend)); + } + #[cfg(not(feature = "parallel"))] for (v, addend) in vs.iter_mut().zip(addends.iter()) { *v = v.scale(scalar).add(addend); } } + + fn fold_field_vectors(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + fold_field_vectors_impl(left, right, scalar); + } } diff --git a/tests/arkworks/mod.rs b/tests/arkworks/mod.rs index bb7042c..0d57d1a 100644 --- a/tests/arkworks/mod.rs +++ b/tests/arkworks/mod.rs @@ -15,6 +15,7 @@ pub mod evaluation; pub mod homomorphic; pub mod integration; pub mod non_square; +pub mod routines; pub mod serialization; pub mod setup; pub mod soundness; diff --git a/tests/arkworks/routines.rs b/tests/arkworks/routines.rs new file mode 100644 index 0000000..9bf9d3d --- /dev/null +++ b/tests/arkworks/routines.rs @@ -0,0 +1,249 @@ +//! Equivalence tests for the optimized `DoryRoutines` implementations. +//! +//! The optimized routines (batch-normalized MSM, `parallel`-gated vector ops) +//! must return exactly the results of the naive per-element reference, +//! including the edge cases the optimizations could plausibly mishandle: +//! identity points (batch normalization skips their zero z-coordinates), +//! zero scalars, and empty inputs. + +use dory_pcs::backends::arkworks::{ArkFr, ArkG1, ArkG2, G1Routines, G2Routines}; +use dory_pcs::primitives::arithmetic::{DoryRoutines, Field, Group}; + +fn naive_msm(bases: &[G], scalars: &[G::Scalar]) -> G { + bases + .iter() + .zip(scalars.iter()) + .fold(G::identity(), |acc, (base, scalar)| { + acc.add(&base.scale(scalar)) + }) +} + +fn random_g1_fixture(len: usize) -> (Vec, Vec) { + // Odd indices are scalar-mul outputs (z ≠ 1, the reduce-fold shape). On + // G1 the even `random()` points come out normalized (z = 1, cofactor 1), + // so the fixture mixes both coordinate shapes; on G2 `random()` is + // already z ≠ 1 and the identity injected below covers z = 0 either way. + let mut bases: Vec = (0..len) + .map(|i| { + let p = ArkG1::random(); + if i % 2 == 1 { + p.scale(&ArkFr::random()) + } else { + p + } + }) + .collect(); + let mut scalars: Vec = (0..len).map(|_| ArkFr::random()).collect(); + // Identity points and zero scalars exercise the batch-normalization and + // parallel-iteration edge cases the random fixtures miss. + bases[len / 2] = ArkG1::identity(); + scalars[len / 3] = ArkFr::zero(); + (bases, scalars) +} + +fn random_g2_fixture(len: usize) -> (Vec, Vec) { + let mut bases: Vec = (0..len) + .map(|i| { + let p = ArkG2::random(); + if i % 2 == 1 { + p.scale(&ArkFr::random()) + } else { + p + } + }) + .collect(); + let mut scalars: Vec = (0..len).map(|_| ArkFr::random()).collect(); + bases[len / 2] = ArkG2::identity(); + scalars[len / 3] = ArkFr::zero(); + (bases, scalars) +} + +#[test] +fn g1_msm_matches_naive() { + let (bases, scalars) = random_g1_fixture(33); + assert_eq!( + G1Routines::msm(&bases, &scalars), + naive_msm(&bases, &scalars) + ); +} + +#[test] +fn g2_msm_matches_naive() { + let (bases, scalars) = random_g2_fixture(17); + assert_eq!( + G2Routines::msm(&bases, &scalars), + naive_msm(&bases, &scalars) + ); +} + +#[test] +fn msm_empty_is_identity() { + assert_eq!(G1Routines::msm(&[], &[]), ArkG1::identity()); + assert_eq!(G2Routines::msm(&[], &[]), ArkG2::identity()); +} + +#[test] +fn msm_all_identity_bases() { + let bases = vec![ArkG1::identity(); 7]; + let scalars: Vec = (0..7).map(|_| ArkFr::random()).collect(); + assert_eq!(G1Routines::msm(&bases, &scalars), ArkG1::identity()); +} + +#[test] +fn msm_normalized_bases_fast_path() { + // All bases with z = 1 (the setup-generator shape) take the free + // `into_affine` conversion instead of batch normalization; the result + // must be identical either way. Non-identity points only — an identity + // (z = 0) would deliberately route to the batch branch. + use ark_ec::CurveGroup; + use ark_ff::One; + let bases: Vec = (0..9) + .map(|_| ArkG1::random().scale(&ArkFr::random())) + .collect(); + let normalized: Vec = bases + .iter() + .map(|b| ArkG1(b.0.into_affine().into())) + .collect(); + assert!( + normalized.iter().all(|b| b.0.z.is_one()), + "fixture must be normalized" + ); + let scalars: Vec = (0..9).map(|_| ArkFr::random()).collect(); + assert_eq!( + G1Routines::msm(&normalized, &scalars), + naive_msm(&normalized, &scalars) + ); + assert_eq!( + G1Routines::msm(&normalized, &scalars), + G1Routines::msm(&bases, &scalars) + ); + + let bases_g2: Vec = (0..9) + .map(|_| ArkG2::random().scale(&ArkFr::random())) + .collect(); + let normalized_g2: Vec = bases_g2 + .iter() + .map(|b| ArkG2(b.0.into_affine().into())) + .collect(); + assert!( + normalized_g2.iter().all(|b| b.0.z.is_one()), + "fixture must be normalized" + ); + assert_eq!( + G2Routines::msm(&normalized_g2, &scalars), + naive_msm(&normalized_g2, &scalars) + ); + assert_eq!( + G2Routines::msm(&normalized_g2, &scalars), + G2Routines::msm(&bases_g2, &scalars) + ); +} + +#[test] +fn g1_fixed_base_vector_scalar_mul_matches_naive() { + let (bases, scalars) = random_g1_fixture(33); + let expected: Vec = scalars.iter().map(|s| bases[0].scale(s)).collect(); + assert_eq!( + G1Routines::fixed_base_vector_scalar_mul(&bases[0], &scalars), + expected + ); + // Identity base: every product is the identity. + assert_eq!( + G1Routines::fixed_base_vector_scalar_mul(&ArkG1::identity(), &scalars), + vec![ArkG1::identity(); scalars.len()] + ); +} + +#[test] +fn g2_fixed_base_vector_scalar_mul_matches_naive() { + let (bases, scalars) = random_g2_fixture(17); + let expected: Vec = scalars.iter().map(|s| bases[0].scale(s)).collect(); + assert_eq!( + G2Routines::fixed_base_vector_scalar_mul(&bases[0], &scalars), + expected + ); + assert_eq!( + G2Routines::fixed_base_vector_scalar_mul(&ArkG2::identity(), &scalars), + vec![ArkG2::identity(); scalars.len()] + ); +} + +#[test] +fn g1_fixed_scalar_mul_bases_then_add_matches_naive() { + let (bases, _) = random_g1_fixture(33); + let scalar = ArkFr::random(); + let (mut vs, _) = random_g1_fixture(33); + let expected: Vec = vs + .iter() + .zip(bases.iter()) + .map(|(v, base)| v.add(&base.scale(&scalar))) + .collect(); + G1Routines::fixed_scalar_mul_bases_then_add(&bases, &mut vs, &scalar); + assert_eq!(vs, expected); +} + +#[test] +fn g2_fixed_scalar_mul_bases_then_add_matches_naive() { + let (bases, _) = random_g2_fixture(17); + let scalar = ArkFr::random(); + let (mut vs, _) = random_g2_fixture(17); + let expected: Vec = vs + .iter() + .zip(bases.iter()) + .map(|(v, base)| v.add(&base.scale(&scalar))) + .collect(); + G2Routines::fixed_scalar_mul_bases_then_add(&bases, &mut vs, &scalar); + assert_eq!(vs, expected); +} + +#[test] +fn g1_fixed_scalar_mul_vs_then_add_matches_naive() { + let (addends, _) = random_g1_fixture(33); + let scalar = ArkFr::random(); + let (mut vs, _) = random_g1_fixture(33); + let expected: Vec = vs + .iter() + .zip(addends.iter()) + .map(|(v, addend)| v.scale(&scalar).add(addend)) + .collect(); + G1Routines::fixed_scalar_mul_vs_then_add(&mut vs, &addends, &scalar); + assert_eq!(vs, expected); +} + +#[test] +fn g2_fixed_scalar_mul_vs_then_add_matches_naive() { + let (addends, _) = random_g2_fixture(17); + let scalar = ArkFr::random(); + let (mut vs, _) = random_g2_fixture(17); + let expected: Vec = vs + .iter() + .zip(addends.iter()) + .map(|(v, addend)| v.scale(&scalar).add(addend)) + .collect(); + G2Routines::fixed_scalar_mul_vs_then_add(&mut vs, &addends, &scalar); + assert_eq!(vs, expected); +} + +#[test] +fn fold_field_vectors_matches_naive() { + let scalar = ArkFr::random(); + let right: Vec = (0..33).map(|_| ArkFr::random()).collect(); + let mut left: Vec = (0..33).map(|_| ArkFr::random()).collect(); + left[4] = ArkFr::zero(); + let expected: Vec = left + .iter() + .zip(right.iter()) + .map(|(l, r)| *l * scalar + *r) + .collect(); + >::fold_field_vectors(&mut left, &right, &scalar); + assert_eq!(left, expected); + + let mut left_g2: Vec = expected.clone(); + let expected_g2: Vec = left_g2 + .iter() + .zip(right.iter()) + .map(|(l, r)| *l * scalar + *r) + .collect(); + >::fold_field_vectors(&mut left_g2, &right, &scalar); + assert_eq!(left_g2, expected_g2); +}