From d0fb893fd53d7fbb7d73274999149c27ad1c1bef Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:21:21 -0400 Subject: [PATCH 1/3] perf(backends/arkworks): batch normalization + rayon-parallel DoryRoutines msm() converts bases to affine with a single batched inversion (normalize_batch) instead of one field inversion per point, for both G1 and G2. fixed_base_vector_scalar_mul, fixed_scalar_mul_bases_then_add, fixed_scalar_mul_vs_then_add, and fold_field_vectors run their per-element loops in parallel under the existing `parallel` feature, falling back to the previous serial loops otherwise. Group results are exact, so commitments, proofs, and transcripts are byte-identical to the previous routines. Unit tests pin every routine to a naive reference, including identity-point / zero-scalar edge cases. Ported from a16z/jolt#1714 (JoltG1Routines/JoltG2Routines). --- CHANGELOG.md | 11 ++ src/backends/arkworks/ark_group.rs | 201 +++++++++++++++++++++++++---- 2 files changed, 188 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4e29b..995dc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 + +- Faster arkworks `DoryRoutines`: `msm` converts bases to affine with a single + batched inversion (`normalize_batch`) instead of one inversion per point, and + `fixed_base_vector_scalar_mul`, `fixed_scalar_mul_bases_then_add`, + `fixed_scalar_mul_vs_then_add`, and `fold_field_vectors` are parallelized + under the `parallel` feature. Group results are unchanged, so commitments and + proofs are byte-identical. + ## [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..d7a4fe9 100644 --- a/src/backends/arkworks/ark_group.rs +++ b/src/backends/arkworks/ark_group.rs @@ -5,7 +5,7 @@ #![allow(clippy::missing_panics_doc)] use super::ark_field::ArkFr; -use crate::primitives::arithmetic::{DoryRoutines, Group}; +use crate::primitives::arithmetic::{DoryRoutines, Field, Group}; use ark_bn254::{Bn254, Fq12, G1Affine, G1Projective, G2Affine, G2Projective}; use ark_ec::pairing::{Pairing, PairingOutput}; use ark_ec::{CurveGroup, VariableBaseMSM}; @@ -13,6 +13,9 @@ use ark_ff::{Field as ArkField, One, PrimeField, UniformRand, Zero as ArkZero}; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use ark_std::ops::{Add, Mul, Neg, Sub}; +#[cfg(feature = "parallel")] +use rayon::prelude::*; + #[derive(Default, Clone, Copy, PartialEq, Eq, Debug, CanonicalSerialize, CanonicalDeserialize)] #[repr(transparent)] pub struct ArkG1(pub G1Projective); @@ -255,6 +258,76 @@ impl<'a> Mul<&'a ArkGT> for ArkFr { } } +/// `[base * scalars[0], base * scalars[1], ...]`, parallelized when the +/// `parallel` feature is enabled. +fn fixed_base_scalar_muls(base: &G, scalars: &[G::Scalar]) -> Vec { + #[cfg(feature = "parallel")] + { + scalars.par_iter().map(|s| base.scale(s)).collect() + } + #[cfg(not(feature = "parallel"))] + { + scalars.iter().map(|s| base.scale(s)).collect() + } +} + +/// `vs[i] = vs[i] + scalar * bases[i]`, parallelized when the `parallel` +/// feature is enabled. +fn add_scaled_bases(bases: &[G], vs: &mut [G], scalar: &G::Scalar) { + assert_eq!(bases.len(), vs.len(), "Lengths must match"); + + #[cfg(feature = "parallel")] + { + 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)); + } + } +} + +/// `vs[i] = scalar * vs[i] + addends[i]`, parallelized when the `parallel` +/// feature is enabled. +fn scale_vs_then_add(vs: &mut [G], addends: &[G], scalar: &G::Scalar) { + assert_eq!(vs.len(), addends.len(), "Lengths must match"); + + #[cfg(feature = "parallel")] + { + 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); + } + } +} + +/// `left[i] = left[i] * scalar + right[i]`, parallelized when the `parallel` +/// feature is enabled. +fn fold_scalars(left: &mut [F], right: &[F], scalar: &F) { + assert_eq!(left.len(), right.len(), "Lengths must match"); + + #[cfg(feature = "parallel")] + { + left.par_iter_mut() + .zip(right.par_iter()) + .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,30 +343,29 @@ impl DoryRoutines for G1Routines { return ArkG1::identity(); } - let bases_affine: Vec = bases.iter().map(|b| b.0.into_affine()).collect(); + // One batched inversion for the whole slice instead of one field + // inversion per point (`into_affine`). + let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); + let bases_affine = 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")) + ArkG1(G1Projective::msm_unchecked(&bases_affine, &scalars_fr)) } fn fixed_base_vector_scalar_mul(base: &ArkG1, scalars: &[ArkFr]) -> Vec { - scalars.iter().map(|s| base.scale(s)).collect() + fixed_base_scalar_muls(base, scalars) } fn fixed_scalar_mul_bases_then_add(bases: &[ArkG1], vs: &mut [ArkG1], scalar: &ArkFr) { - assert_eq!(bases.len(), vs.len(), "Lengths must match"); - - for (v, base) in vs.iter_mut().zip(bases.iter()) { - *v = v.add(&base.scale(scalar)); - } + add_scaled_bases(bases, vs, scalar); } fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG1], addends: &[ArkG1], scalar: &ArkFr) { - assert_eq!(vs.len(), addends.len(), "Lengths must match"); + scale_vs_then_add(vs, addends, scalar); + } - 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_scalars(left, right, scalar); } } @@ -312,29 +384,110 @@ impl DoryRoutines for G2Routines { return ArkG2::identity(); } - let bases_affine: Vec = bases.iter().map(|b| b.0.into_affine()).collect(); + // One batched inversion for the whole slice instead of one field + // inversion per point (`into_affine`). + let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); + let bases_affine = 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")) + ArkG2(G2Projective::msm_unchecked(&bases_affine, &scalars_fr)) } fn fixed_base_vector_scalar_mul(base: &ArkG2, scalars: &[ArkFr]) -> Vec { - scalars.iter().map(|s| base.scale(s)).collect() + fixed_base_scalar_muls(base, scalars) } fn fixed_scalar_mul_bases_then_add(bases: &[ArkG2], vs: &mut [ArkG2], scalar: &ArkFr) { - assert_eq!(bases.len(), vs.len(), "Lengths must match"); - - for (v, base) in vs.iter_mut().zip(bases.iter()) { - *v = v.add(&base.scale(scalar)); - } + add_scaled_bases(bases, vs, scalar); } fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG2], addends: &[ArkG2], scalar: &ArkFr) { - assert_eq!(vs.len(), addends.len(), "Lengths must match"); + scale_vs_then_add(vs, addends, scalar); + } - 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_scalars(left, right, scalar); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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)) + }) + } + + /// Random fixtures with an identity point and a zero scalar mixed in: + /// those exercise the batch-normalization (point at infinity) and MSM + /// bucket edge cases that purely random inputs miss. + fn fixtures>(len: usize) -> (Vec, Vec) { + let mut bases: Vec = (0..len).map(|_| G::random()).collect(); + let mut scalars: Vec = (0..len).map(|_| ArkFr::random()).collect(); + bases[len / 2] = G::identity(); + scalars[len / 3] = ArkFr::zero(); + (bases, scalars) + } + + fn routines_match_naive(len: usize) + where + G: Group + std::fmt::Debug, + R: DoryRoutines, + { + let (bases, scalars) = fixtures::(len); + let scalar = ArkFr::random(); + + assert_eq!(R::msm(&bases, &scalars), naive_msm(&bases, &scalars)); + assert_eq!(R::msm(&[], &[]), G::identity()); + + let expected: Vec = scalars.iter().map(|s| bases[0].scale(s)).collect(); + assert_eq!( + R::fixed_base_vector_scalar_mul(&bases[0], &scalars), + expected + ); + let expected: Vec = scalars.iter().map(|s| G::identity().scale(s)).collect(); + assert_eq!( + R::fixed_base_vector_scalar_mul(&G::identity(), &scalars), + expected + ); + assert_eq!(R::fixed_base_vector_scalar_mul(&bases[0], &[]), vec![]); + + let (mut vs, _) = fixtures::(len); + let mut expected = vs.clone(); + R::fixed_scalar_mul_bases_then_add(&bases, &mut vs, &scalar); + for (v, base) in expected.iter_mut().zip(bases.iter()) { + *v = v.add(&base.scale(&scalar)); } + assert_eq!(vs, expected); + + R::fixed_scalar_mul_vs_then_add(&mut vs, &bases, &scalar); + for (v, addend) in expected.iter_mut().zip(bases.iter()) { + *v = v.scale(&scalar).add(addend); + } + assert_eq!(vs, expected); + + let right: Vec = (0..len).map(|_| ArkFr::random()).collect(); + let mut left: Vec = (0..len).map(|_| ArkFr::random()).collect(); + let mut expected = left.clone(); + R::fold_field_vectors(&mut left, &right, &scalar); + for (l, r) in expected.iter_mut().zip(right.iter()) { + *l = *l * scalar + *r; + } + assert_eq!(left, expected); + } + + #[test] + fn g1_routines_match_naive() { + routines_match_naive::(33); + } + + #[test] + fn g2_routines_match_naive() { + routines_match_naive::(17); } } From e0b85c44e6a3c0041e183cee72623cbcc5b74980 Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:22:05 -0400 Subject: [PATCH 2/3] feat(backends/arkworks): safe conversions for ArkFr/ArkG1/ArkG2 wrappers into_inner() plus From impls in both directions between the wrappers and their inner arkworks types, so downstream consumers no longer need transmutes (the wrappers are repr(transparent), but per-element conversions cover the common cases safely). --- CHANGELOG.md | 6 +++ src/backends/arkworks/ark_field.rs | 22 +++++++++++ src/backends/arkworks/ark_group.rs | 59 ++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995dc62..e385650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Safe conversions between the arkworks wrappers and their inner types: + `into_inner()` plus `From` impls in both directions for + `ArkFr`/`ark_bn254::Fr`, `ArkG1`/`G1Projective`, and `ArkG2`/`G2Projective`. + ### Changed - Faster arkworks `DoryRoutines`: `msm` converts bases to affine with a single diff --git a/src/backends/arkworks/ark_field.rs b/src/backends/arkworks/ark_field.rs index dbfb94b..7e0bc28 100644 --- a/src/backends/arkworks/ark_field.rs +++ b/src/backends/arkworks/ark_field.rs @@ -12,6 +12,28 @@ use ark_std::ops::{Add, Mul, Neg, Sub}; #[repr(transparent)] pub struct ArkFr(pub Fr); +impl ArkFr { + /// Returns the wrapped `ark_bn254::Fr`. + #[inline] + pub fn into_inner(self) -> Fr { + self.0 + } +} + +impl From for ArkFr { + #[inline] + fn from(value: Fr) -> Self { + ArkFr(value) + } +} + +impl From for Fr { + #[inline] + fn from(value: ArkFr) -> Self { + value.0 + } +} + impl Field for ArkFr { fn zero() -> Self { ArkFr(Fr::from(0u64)) diff --git a/src/backends/arkworks/ark_group.rs b/src/backends/arkworks/ark_group.rs index d7a4fe9..5b652fb 100644 --- a/src/backends/arkworks/ark_group.rs +++ b/src/backends/arkworks/ark_group.rs @@ -20,10 +20,54 @@ use rayon::prelude::*; #[repr(transparent)] pub struct ArkG1(pub G1Projective); +impl ArkG1 { + /// Returns the wrapped `ark_bn254::G1Projective`. + #[inline] + pub fn into_inner(self) -> G1Projective { + self.0 + } +} + +impl From for ArkG1 { + #[inline] + fn from(value: G1Projective) -> Self { + ArkG1(value) + } +} + +impl From for G1Projective { + #[inline] + fn from(value: ArkG1) -> Self { + value.0 + } +} + #[derive(Default, Clone, Copy, PartialEq, Eq, Debug, CanonicalSerialize, CanonicalDeserialize)] #[repr(transparent)] pub struct ArkG2(pub G2Projective); +impl ArkG2 { + /// Returns the wrapped `ark_bn254::G2Projective`. + #[inline] + pub fn into_inner(self) -> G2Projective { + self.0 + } +} + +impl From for ArkG2 { + #[inline] + fn from(value: G2Projective) -> Self { + ArkG2(value) + } +} + +impl From for G2Projective { + #[inline] + fn from(value: ArkG2) -> Self { + value.0 + } +} + #[derive(Clone, Copy, PartialEq, Eq, Debug, CanonicalSerialize, CanonicalDeserialize)] #[repr(transparent)] pub struct ArkGT(pub PairingOutput); @@ -490,4 +534,19 @@ mod tests { fn g2_routines_match_naive() { routines_match_naive::(17); } + + #[test] + fn wrapper_conversions_round_trip() { + let fr = ArkFr::random(); + assert_eq!(ArkFr::from(fr.into_inner()), fr); + assert_eq!(ArkFr::from(ark_bn254::Fr::from(fr)), fr); + + let g1 = ArkG1::random(); + assert_eq!(ArkG1::from(g1.into_inner()), g1); + assert_eq!(ArkG1::from(G1Projective::from(g1)), g1); + + let g2 = ArkG2::random(); + assert_eq!(ArkG2::from(g2.into_inner()), g2); + assert_eq!(ArkG2::from(G2Projective::from(g2)), g2); + } } From f210eaac12aa2e2120737e28ddff33fe4a0d0da2 Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:56:36 -0400 Subject: [PATCH 3/3] perf(backends/arkworks): keep inversion-free msm path for normalized bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch normalization is only a win when bases actually need an inversion. Dory's setup generators (and hence the commit-path row MSMs and the first-round e_beta MSMs, whose bases are setup slices) are already affine-normalized (z = one), where into_affine short-circuits without any inversion and normalize_batch pays ~6 field muls per point plus a slice copy for nothing — a measurable commit regression at 2^26. msm now scans z values (two field comparisons per point) and dispatches: all z in {0, 1} -> per-element into_affine (no inversions at all); otherwise -> one batched inversion for the whole slice. Folded vectors mid reduce-round have arbitrary z and keep the batch path. --- CHANGELOG.md | 14 ++++++---- src/backends/arkworks/ark_group.rs | 44 ++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e385650..a93c8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Faster arkworks `DoryRoutines`: `msm` converts bases to affine with a single - batched inversion (`normalize_batch`) instead of one inversion per point, and - `fixed_base_vector_scalar_mul`, `fixed_scalar_mul_bases_then_add`, - `fixed_scalar_mul_vs_then_add`, and `fold_field_vectors` are parallelized - under the `parallel` feature. Group results are unchanged, so commitments and - proofs are byte-identical. +- Faster arkworks `DoryRoutines`: when MSM bases are not already normalized + (e.g. folded vectors mid reduce-round), `msm` converts them to affine with a + single batched inversion (`normalize_batch`) instead of one field inversion + per point; already-normalized bases (setup generators) keep the + inversion-free per-element path. `fixed_base_vector_scalar_mul`, + `fixed_scalar_mul_bases_then_add`, `fixed_scalar_mul_vs_then_add`, and + `fold_field_vectors` are parallelized under the `parallel` feature. Group + results are unchanged, so commitments and proofs are byte-identical. ## [0.4.0] - 2026-07-03 diff --git a/src/backends/arkworks/ark_group.rs b/src/backends/arkworks/ark_group.rs index 5b652fb..d18d594 100644 --- a/src/backends/arkworks/ark_group.rs +++ b/src/backends/arkworks/ark_group.rs @@ -302,6 +302,26 @@ impl<'a> Mul<&'a ArkGT> for ArkFr { } } +/// Converts projective bases to affine for an MSM. +/// +/// Already-normalized bases (`z` is one, e.g. fresh setup generators) and +/// identities convert with no field inversions, so they take the per-element +/// path. Anything else (e.g. folded vectors mid reduce-round) pays one batched +/// inversion for the whole slice instead of one inversion per point. +macro_rules! msm_bases_to_affine { + ($proj:ty, $bases:expr) => {{ + if $bases + .iter() + .all(|b| b.0.z.is_one() || ArkZero::is_zero(&b.0)) + { + $bases.iter().map(|b| b.0.into_affine()).collect() + } else { + let proj: Vec<$proj> = $bases.iter().map(|b| b.0).collect(); + <$proj>::normalize_batch(&proj) + } + }}; +} + /// `[base * scalars[0], base * scalars[1], ...]`, parallelized when the /// `parallel` feature is enabled. fn fixed_base_scalar_muls(base: &G, scalars: &[G::Scalar]) -> Vec { @@ -387,10 +407,7 @@ impl DoryRoutines for G1Routines { return ArkG1::identity(); } - // One batched inversion for the whole slice instead of one field - // inversion per point (`into_affine`). - let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); - let bases_affine = G1Projective::normalize_batch(&bases_proj); + let bases_affine: Vec = msm_bases_to_affine!(G1Projective, bases); let scalars_fr: Vec = scalars.iter().map(|s| s.0).collect(); ArkG1(G1Projective::msm_unchecked(&bases_affine, &scalars_fr)) @@ -428,10 +445,7 @@ impl DoryRoutines for G2Routines { return ArkG2::identity(); } - // One batched inversion for the whole slice instead of one field - // inversion per point (`into_affine`). - let bases_proj: Vec = bases.iter().map(|b| b.0).collect(); - let bases_affine = G2Projective::normalize_batch(&bases_proj); + let bases_affine: Vec = msm_bases_to_affine!(G2Projective, bases); let scalars_fr: Vec = scalars.iter().map(|s| s.0).collect(); ArkG2(G2Projective::msm_unchecked(&bases_affine, &scalars_fr)) @@ -469,10 +483,15 @@ mod tests { /// Random fixtures with an identity point and a zero scalar mixed in: /// those exercise the batch-normalization (point at infinity) and MSM - /// bucket edge cases that purely random inputs miss. + /// bucket edge cases that purely random inputs miss. Odd-index bases are + /// re-scaled so they are not affine-normalized (`z` is not one), forcing + /// `msm` onto the batch-inversion path (`G::random()` yields `z` = one). fn fixtures>(len: usize) -> (Vec, Vec) { let mut bases: Vec = (0..len).map(|_| G::random()).collect(); let mut scalars: Vec = (0..len).map(|_| ArkFr::random()).collect(); + for base in bases.iter_mut().skip(1).step_by(2) { + *base = base.scale(&ArkFr::random()); + } bases[len / 2] = G::identity(); scalars[len / 3] = ArkFr::zero(); (bases, scalars) @@ -486,7 +505,14 @@ mod tests { let (bases, scalars) = fixtures::(len); let scalar = ArkFr::random(); + // Mixed-z bases take the batch-normalization path. assert_eq!(R::msm(&bases, &scalars), naive_msm(&bases, &scalars)); + // Already-normalized bases take the per-element path. + let normalized: Vec = (0..len).map(|_| G::random()).collect(); + assert_eq!( + R::msm(&normalized, &scalars), + naive_msm(&normalized, &scalars) + ); assert_eq!(R::msm(&[], &[]), G::identity()); let expected: Vec = scalars.iter().map(|s| bases[0].scale(s)).collect();