diff --git a/crates/halo2_proofs/src/plonk/keygen.rs b/crates/halo2_proofs/src/plonk/keygen.rs index 794707d6..5f4957b2 100644 --- a/crates/halo2_proofs/src/plonk/keygen.rs +++ b/crates/halo2_proofs/src/plonk/keygen.rs @@ -31,7 +31,7 @@ fn commit_fixed_lagrange( params: &Params, polynomial: &Polynomial, ) -> C::Curve { - #[cfg(feature = "orbits")] + #[cfg(any(feature = "multicore", feature = "orbits"))] if params.lagrange_table().is_some() { return params.commit_lagrange(polynomial, Blind::default()); } diff --git a/crates/halo2_proofs/src/poly/commitment.rs b/crates/halo2_proofs/src/poly/commitment.rs index 1cd8f379..f8ac950e 100644 --- a/crates/halo2_proofs/src/poly/commitment.rs +++ b/crates/halo2_proofs/src/poly/commitment.rs @@ -89,7 +89,7 @@ //! [BCMS20]: https://eprint.iacr.org/2020/499 use super::{Coeff, LagrangeCoeff, Polynomial}; -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] use crate::arithmetic::PreparedZeroCheck; use crate::arithmetic::{CurveAffine, CurveExt, best_fft, best_multiexp, parallelize}; use crate::helpers::CurveRead; @@ -103,9 +103,9 @@ use group::{Curve, Group}; use std::ops::{Add, AddAssign, Mul, MulAssign}; #[cfg(feature = "batch")] use std::sync::Mutex; -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] use std::sync::OnceLock; -#[cfg(any(feature = "batch", feature = "orbits"))] +#[cfg(any(feature = "batch", feature = "multicore", feature = "orbits"))] use std::{fmt, sync::Arc}; mod msm; @@ -113,16 +113,24 @@ mod prover; /// The `k = 11` SRS shape, whose current Pasta α7 tables remain ahead /// through all ten cores on the benchmarked Apple M4 systems. -#[cfg(all(feature = "orbits", target_arch = "aarch64", target_os = "macos"))] +#[cfg(all( + any(feature = "multicore", feature = "orbits"), + target_arch = "aarch64", + target_os = "macos" +))] const APPLE_TEN_WORKER_PREPARED_COMMITMENT_K: u32 = 11; -#[cfg(all(feature = "orbits", target_arch = "aarch64", target_os = "macos"))] +#[cfg(all( + any(feature = "multicore", feature = "orbits"), + target_arch = "aarch64", + target_os = "macos" +))] const APPLE_PREPARED_COMMITMENT_MAX_THREADS: usize = 10; /// The widest measured pool for prepared prover commitments at `k`. /// Unmeasured SRS shapes keep the verifier's conservative eight-worker /// bound. This applies to every prepared backend at `k = 11`; Pasta is /// currently the only such backend. -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] fn prepared_commitment_max_threads(k: u32) -> usize { #[cfg(all(target_arch = "aarch64", target_os = "macos"))] if k == APPLE_TEN_WORKER_PREPARED_COMMITMENT_K { @@ -153,6 +161,8 @@ pub struct Params { instance_window_cache: InstanceWindowCache, #[cfg(feature = "orbits")] zero_check_cache: ZeroCheckCache, + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + commitment_tables_cache: CommitmentTablesCache, #[cfg(feature = "orbits")] lagrange_table_cache: ZeroCheckCache, } @@ -209,6 +219,80 @@ impl fmt::Debug for ZeroCheckCache { } } +/// The no-orbits prover's exact-`n` coefficient and Lagrange preparations. +/// One lock makes their initialization atomic and prevents concurrent calls +/// from duplicating the two large table builds. The cached handles are marked +/// unwind-safe because [`OnceLock`] does not publish a panicking initializer and +/// the cache never mutates or replaces published handles. +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[derive(Clone)] +struct CommitmentTablesCache( + #[allow(clippy::type_complexity)] + Arc< + OnceLock< + Option<( + AssertUnwindSafe>>, + AssertUnwindSafe>>, + )>, + >, + >, +); + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +impl Default for CommitmentTablesCache { + fn default() -> Self { + Self(Arc::new(OnceLock::new())) + } +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +impl CommitmentTablesCache { + #[allow(clippy::type_complexity)] + fn initialize( + &self, + initialize: impl FnOnce() -> Option<( + Box>, + Box>, + )>, + ) -> bool { + self.0 + .get_or_init(|| { + initialize().map(|(coefficient, lagrange)| { + ( + AssertUnwindSafe(Arc::from(coefficient)), + AssertUnwindSafe(Arc::from(lagrange)), + ) + }) + }) + .is_some() + } + + fn coefficient(&self) -> Option>> { + self.0 + .get() + .and_then(Option::as_ref) + .map(|(coefficient, _)| Arc::clone(&coefficient.0)) + } + + fn lagrange(&self) -> Option>> { + self.0 + .get() + .and_then(Option::as_ref) + .map(|(_, lagrange)| Arc::clone(&lagrange.0)) + } +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +impl fmt::Debug for CommitmentTablesCache { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let armed = matches!(self.0.get(), Some(Some(_))); + formatter + .debug_tuple("CommitmentTablesCache") + .field(&armed) + .finish() + } +} + #[cfg(feature = "batch")] #[derive(Clone)] struct InstanceWindowCache(Arc>>>>); @@ -367,6 +451,8 @@ impl Params { instance_window_cache: InstanceWindowCache::default(), #[cfg(feature = "orbits")] zero_check_cache: ZeroCheckCache::default(), + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + commitment_tables_cache: CommitmentTablesCache::default(), #[cfg(feature = "orbits")] lagrange_table_cache: ZeroCheckCache::default(), } @@ -396,6 +482,18 @@ impl Params { } } + // Without `orbits`, the prepared table covers exactly `g`; the blind + // remains one extra term, so the polynomial is borrowed directly. + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + if crate::multicore::current_num_threads() <= prepared_commitment_max_threads(self.k) + && let Some(prepared) = self.commitment_table() + { + let n = self.n as usize; + if prepared.terms() == n && poly.len() == n { + return prepared.multiexp_with_terms_vartime(poly, &[(r.0, self.w)]); + } + } + let mut tmp_scalars = Vec::with_capacity(poly.len() + 1); let mut tmp_bases = Vec::with_capacity(poly.len() + 1); @@ -433,6 +531,17 @@ impl Params { } } + // The exact-`n` Lagrange table mirrors the coefficient route above. + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + if crate::multicore::current_num_threads() <= prepared_commitment_max_threads(self.k) + && let Some(prepared) = self.lagrange_table() + { + let n = self.n as usize; + if prepared.terms() == n && poly.len() == n { + return prepared.multiexp_with_terms_vartime(poly, &[(r.0, self.w)]); + } + } + let mut tmp_scalars = Vec::with_capacity(poly.len() + 1); let mut tmp_bases = Vec::with_capacity(poly.len() + 1); @@ -501,6 +610,8 @@ impl Params { instance_window_cache: InstanceWindowCache::default(), #[cfg(feature = "orbits")] zero_check_cache: ZeroCheckCache::default(), + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + commitment_tables_cache: CommitmentTablesCache::default(), #[cfg(feature = "orbits")] lagrange_table_cache: ZeroCheckCache::default(), }) @@ -557,6 +668,11 @@ impl Params { } } + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + fn commitment_table(&self) -> Option>> { + self.commitment_tables_cache.coefficient() + } + /// The cached prepared zero-check, if [`Self::prepare_zero_checks`] /// built one. #[cfg(feature = "orbits")] @@ -564,27 +680,28 @@ impl Params { self.zero_check_cache.get() } - /// Builds and caches prepared fixed-base multiexp tables for the - /// prover's commitments: the coefficient-basis table over `[g..., w, u]` - /// (shared with [`Self::prepare_zero_checks`] — [`Self::commit`] and the - /// verifier's final check use the same bases) and a second table over - /// `[g_lagrange..., w, u]` for [`Self::commit_lagrange`]. When armed, - /// both commit methods evaluate through the prepared tables on pools of - /// at most eight effective threads. Orchard-sized (`k = 11`) tables on - /// AArch64 macOS extend that bound to ten, where end-to-end proving stays - /// ahead on the benchmarked M4 system. Wider pools and unmeasured SRS - /// shapes keep the planned multiexp. Measurements covered full-width and - /// witness-like (boolean, byte, zero-padded) coefficient distributions. + /// Builds and caches prepared fixed-base multiexp tables for the prover's + /// commitments. With `orbits`, the coefficient table over `[g..., w, u]` + /// is shared with [`Self::prepare_zero_checks`], and the Lagrange table + /// covers `[g_lagrange..., w, u]`. Without `orbits`, the two tables cover + /// exactly `g` and `g_lagrange`; each blind remains a one-term addition, + /// so the polynomial slice is borrowed without an `n + 2` copy. /// - /// Costs roughly twice [`Self::prepare_zero_checks`] (two tables, each - /// hundreds of milliseconds and tens of mebibytes at typical `k`), - /// amortized across every subsequent proof with these params. Concurrent - /// and repeat calls share each table's same initialization attempt, - /// including a backend decline. The caches are shared with all clones - /// and never serialized; call again after [`Params::read`]. Returns - /// whether both tables are armed (`false` when the `orbits` feature is - /// off or the backend declined — a commit route without its table simply - /// keeps the planned multiexp). + /// Both commit methods use the tables on pools of at most eight effective + /// threads. Orchard-sized (`k = 11`) tables on AArch64 macOS extend that + /// bound to ten, where end-to-end proving stays ahead on the benchmarked + /// M4 system. Wider pools and unmeasured SRS shapes keep the planned + /// multiexp. Measurements covered full-width and witness-like (boolean, + /// byte, zero-padded) coefficient distributions. + /// + /// The two α7 tables account for about 24.8 MiB at `k = 11`, amortized + /// across every subsequent proof with these params. Concurrent and repeat + /// calls share their initialization attempts, including a backend decline. + /// Without `orbits`, one paired initialization prevents either table from + /// being exposed until both have built. The caches are shared with all + /// clones and never serialized, so call again after [`Params::read`]. + /// Returns whether both tables are armed. Without `orbits`, preparation + /// also requires the default `multicore` feature. /// /// Call this once before entering concurrent Rayon work that uses these /// params. Concurrent callers outside that pool safely wait for and share @@ -606,7 +723,15 @@ impl Params { C::CurveExt::try_prepare_zero_check(&bases) }) } - #[cfg(not(feature = "orbits"))] + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + { + self.commitment_tables_cache.initialize(|| { + let coefficient = C::CurveExt::try_prepare_zero_check(&self.g)?; + let lagrange = C::CurveExt::try_prepare_zero_check(&self.g_lagrange)?; + Some((coefficient, lagrange)) + }) + } + #[cfg(all(not(feature = "multicore"), not(feature = "orbits")))] { false } @@ -614,9 +739,16 @@ impl Params { /// The cached Lagrange-basis prepared table, if /// [`Self::prepare_commitments`] built one. - #[cfg(feature = "orbits")] + #[cfg(any(feature = "multicore", feature = "orbits"))] pub(crate) fn lagrange_table(&self) -> Option>> { - self.lagrange_table_cache.get() + #[cfg(feature = "orbits")] + { + self.lagrange_table_cache.get() + } + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + { + self.commitment_tables_cache.lagrange() + } } } @@ -889,6 +1021,99 @@ fn prepared_cache_memoizes_decline_and_retries_panic() { assert!(!panicked.initialize(|| None)); } +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn commitment_tables_cache_initializes_once_across_clones() { + const K: u32 = 4; + const CALLERS: usize = 4; + + use std::sync::{ + Arc, Barrier, + atomic::{AtomicUsize, Ordering}, + }; + + use crate::pasta::{Eq, EqAffine}; + + let params = Params::::new(K); + let coefficient_bases = Arc::new(params.g.clone()); + let lagrange_bases = Arc::new(params.g_lagrange.clone()); + let cache = params.commitment_tables_cache.clone(); + let attempts = AtomicUsize::new(0); + let start = Barrier::new(CALLERS); + + let tables = std::thread::scope(|scope| { + (0..CALLERS) + .map(|_| { + let coefficient_bases = Arc::clone(&coefficient_bases); + let lagrange_bases = Arc::clone(&lagrange_bases); + let cache = cache.clone(); + let attempts = &attempts; + let start = &start; + scope.spawn(move || { + start.wait(); + assert!(cache.initialize(|| { + attempts.fetch_add(1, Ordering::Relaxed); + let coefficient = Eq::try_prepare_zero_check(coefficient_bases.as_slice())?; + let lagrange = Eq::try_prepare_zero_check(lagrange_bases.as_slice())?; + Some((coefficient, lagrange)) + })); + ( + cache.coefficient().expect("coefficient table is armed"), + cache.lagrange().expect("Lagrange table is armed"), + ) + }) + }) + .collect::>() + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>() + }); + + assert_eq!(attempts.load(Ordering::Relaxed), 1); + assert!(tables.iter().skip(1).all(|(coefficient, lagrange)| { + Arc::ptr_eq(&tables[0].0, coefficient) && Arc::ptr_eq(&tables[0].1, lagrange) + })); +} + +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +#[test] +fn commitment_tables_cache_memoizes_decline_and_retries_panic() { + use std::{ + panic::catch_unwind, + sync::atomic::{AtomicUsize, Ordering}, + }; + + use crate::pasta::{Eq, EqAffine}; + + fn assert_unwind_safe() {} + + assert_unwind_safe::>(); + + let declined = CommitmentTablesCache::::default(); + let attempts = AtomicUsize::new(0); + for _ in 0..2 { + assert!(!declined.initialize(|| { + attempts.fetch_add(1, Ordering::Relaxed); + None + })); + } + assert_eq!(attempts.load(Ordering::Relaxed), 1); + assert!(declined.coefficient().is_none()); + assert!(declined.lagrange().is_none()); + + let params = Params::::new(4); + let panicked = CommitmentTablesCache::::default(); + let result = catch_unwind(|| panicked.initialize(|| panic!("test initialization panic"))); + assert!(result.is_err()); + assert!(panicked.initialize(|| { + let coefficient = Eq::try_prepare_zero_check(¶ms.g)?; + let lagrange = Eq::try_prepare_zero_check(¶ms.g_lagrange)?; + Some((coefficient, lagrange)) + })); + assert!(panicked.coefficient().is_some()); + assert!(panicked.lagrange().is_some()); +} + /// Wrapper type around a blinding factor. #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub struct Blind(pub F); @@ -1009,8 +1234,22 @@ fn prepared_commitments_match_unprepared() { assert!(armed.zero_check().is_some()); assert!(armed.lagrange_table().is_some()); } - #[cfg(not(feature = "orbits"))] - assert!(!armed_ok); + #[cfg(all(feature = "multicore", not(feature = "orbits")))] + { + assert!(armed_ok, "Pasta commitment tables must prepare"); + assert!(!armed.prepare_zero_checks()); + let coefficient = armed.commitment_table().unwrap(); + let lagrange = armed.lagrange_table().unwrap(); + let cloned = armed.clone(); + assert!(cloned.prepare_commitments()); + assert!(Arc::ptr_eq( + &coefficient, + &cloned.commitment_table().unwrap() + )); + assert!(Arc::ptr_eq(&lagrange, &cloned.lagrange_table().unwrap())); + } + #[cfg(all(not(feature = "multicore"), not(feature = "orbits")))] + assert!(!armed_ok, "preparation stays disabled without multicore"); let mut rng = rng(); let exercise = |armed: &Params, unarmed: &Params, seed: u64| { @@ -1040,7 +1279,7 @@ fn prepared_commitments_match_unprepared() { // Two capped pools: one within the thread gate pins the prepared route // itself, and one just past it pins the armed fall-through to the // planned multiexp — both regardless of the host's width. - #[cfg(all(feature = "orbits", feature = "multicore"))] + #[cfg(feature = "multicore")] for num_threads in [ prepared_commitment_max_threads(armed.k), prepared_commitment_max_threads(armed.k) + 1, @@ -1053,7 +1292,7 @@ fn prepared_commitments_match_unprepared() { } } -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] #[test] fn prepared_commitment_thread_policy_is_scoped() { assert_eq!( @@ -1096,10 +1335,10 @@ fn test_opening_proof() { let mut params_buffer = vec![]; params.write(&mut params_buffer).unwrap(); let params: Params = Params::read::<_>(&mut ¶ms_buffer[..]).unwrap(); - // Arm the prepared commitment tables (a no-op without `orbits`): on - // hosts within the thread gate this routes the commitment below and the - // verifier's final check through the preparations, so the round trip - // covers the prepared prover and verifier paths against each other. + // Arm the prepared commitment tables. Within the thread gate, multicore + // builds route the commitment below through them; with `orbits`, the + // verifier's final check is prepared too, while without it the verifier + // keeps its plain path. Either way the round trip covers the armed prover. params.prepare_commitments(); let domain = EvaluationDomain::new(1, K); diff --git a/crates/halo2_proofs/src/poly/commitment/msm.rs b/crates/halo2_proofs/src/poly/commitment/msm.rs index a4907e84..05c69e1b 100644 --- a/crates/halo2_proofs/src/poly/commitment/msm.rs +++ b/crates/halo2_proofs/src/poly/commitment/msm.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; /// 16 threads (Apple M4 Max) and +22–27% at a 32-thread pool /// (32-hw-thread Skylake-X) — the crossover sits between 8 and 16 on both /// architectures, on the assembly and portable field backends alike. -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] pub(crate) const PREPARED_MSM_MAX_THREADS: usize = 8; type ArbitraryTerm = ( diff --git a/crates/orchard/benches/orchard_k11_prover.rs b/crates/orchard/benches/orchard_k11_prover.rs index f9f03ad4..98d6ffbb 100644 --- a/crates/orchard/benches/orchard_k11_prover.rs +++ b/crates/orchard/benches/orchard_k11_prover.rs @@ -45,11 +45,8 @@ fn orchard_k11_prover(c: &mut Criterion) { let vk = VerifyingKey::build(version); let pk = ProvingKey::build(version); // Keep the one-time table build outside the timed proving routine. - #[cfg(feature = "orbits")] - assert!( - pk.prepare_proving(), - "Pasta commitment tables must prepare with the orbits feature", - ); + #[cfg(any(feature = "multicore", feature = "orbits"))] + assert!(pk.prepare_proving(), "Pasta commitment tables must prepare",); let mut group = c.benchmark_group("orchard-k11"); group.sample_size(BENCHMARK_SAMPLES); diff --git a/crates/orchard/src/circuit.rs b/crates/orchard/src/circuit.rs index 4eb35dc4..10a0cb2a 100644 --- a/crates/orchard/src/circuit.rs +++ b/crates/orchard/src/circuit.rs @@ -1172,15 +1172,15 @@ pub struct ProvingKey { impl ProvingKey { /// Builds and caches prepared fixed-base commitment tables over this /// key's SRS (see - /// `halo2_proofs::poly::commitment::Params::prepare_commitments`). + /// [`halo2_proofs::poly::commitment::Params::prepare_commitments`]). /// Long-lived provers (wallet backends, proving services) should call /// this once after constructing the key: the prover's polynomial /// commitments then evaluate through the preparations on pools of at /// most eight effective threads, extended to ten on AArch64 macOS for /// Orchard's `k = 11` SRS (measured end to end on Apple M4). Wider pools - /// retain their usual multiexp. One-shot provers need not prepare: - /// setup costs hundreds of milliseconds and tens of mebibytes, - /// amortized across proofs. + /// retain their usual multiexp. One-shot provers need not prepare: at + /// `k = 11`, the two tables account for about 24.8 MiB and took about + /// 34 ms to build on the benchmarked M4, amortized across proofs. /// /// Call this once before entering concurrent Rayon proving work. /// Concurrent callers outside that pool safely wait for and share the same @@ -1188,9 +1188,9 @@ impl ProvingKey { /// other workers and serialize the initializer's parallel work. /// /// Returns whether the tables were actually built and cached; `false` - /// means arming was a no-op (Orchard was built without its opt-in - /// `orbits` feature, or its backend declined) and proving simply keeps - /// its unprepared path. Callers may ignore the result. + /// means arming was a no-op (Orchard was built with neither `multicore` + /// nor `orbits`, or its backend declined) and proving simply keeps its + /// unprepared path. Callers may ignore the result. pub fn prepare_proving(&self) -> bool { self.params.prepare_commitments() } diff --git a/crates/pasta_curves/Cargo.toml b/crates/pasta_curves/Cargo.toml index 0e61a005..30618bac 100644 --- a/crates/pasta_curves/Cargo.toml +++ b/crates/pasta_curves/Cargo.toml @@ -55,9 +55,10 @@ gpu = ["alloc", "ec-gpu"] # `glv.rs` through the Rayon pool; implies `glv` (and therefore `alloc`). multicore = ["glv", "dep:maybe-rayon", "maybe-rayon/threads"] # The Eisenstein-orbit MSM backend (`glv::orbit`, planned against the -# Signed-Booth backend per input) and the prepared fixed-base zero-checks -# (`glv::zero`, exposed through `CurveExt::try_prepare_zero_check`). -# Without it the arbitrary-scalar MSM plans the Signed-Booth backend alone. +# Signed-Booth backend per input) and public prepared zero-check module +# (`glv::zero`). Without it arbitrary-scalar MSMs plan only the Signed-Booth +# backend; multicore callers can still use the private prepared evaluator +# through `CurveExt::try_prepare_zero_check`. orbits = ["glv"] repr-c = [] serde = ["hex", "serde_crate"] diff --git a/crates/pasta_curves/src/arithmetic/curves.rs b/crates/pasta_curves/src/arithmetic/curves.rs index 6aee9c19..63fa2f9b 100644 --- a/crates/pasta_curves/src/arithmetic/curves.rs +++ b/crates/pasta_curves/src/arithmetic/curves.rs @@ -157,17 +157,18 @@ pub trait CurveExt: /// backend return `None`, and implementations may also decline — /// the Pasta backend returns `None` when its prepared table for this /// many bases would exceed its internal table-footprint budget. - /// Preparation - /// can cost hundreds of milliseconds and tens of mebibytes for a few - /// thousand bases, so callers should invoke this once and reuse the - /// handle across checks. + /// Preparation can cost hundreds of milliseconds and tens of mebibytes + /// for a few thousand bases, so callers should invoke this once and + /// reuse the handle across checks. /// /// # Security /// - /// The returned check runs in variable time with respect to scalars - /// and points. **All inputs must be public.** - #[cfg(feature = "orbits")] - #[cfg_attr(docsrs, doc(cfg(feature = "orbits")))] + /// The returned handle runs in variable time with respect to scalars and + /// points. Inputs to its zero-check methods must be public. Callers using + /// [`PreparedZeroCheck::multiexp_with_terms_vartime`] with secret scalars + /// must explicitly accept the timing side channel of a variable-time MSM. + #[cfg(any(feature = "multicore", feature = "orbits"))] + #[cfg_attr(docsrs, doc(cfg(any(feature = "multicore", feature = "orbits"))))] fn try_prepare_zero_check( bases: &[Self::AffineExt], ) -> Option>> { @@ -181,10 +182,10 @@ pub trait CurveExt: /// for the fixed bases $P_i$ captured at preparation plus per-check /// `extra` terms $(s_j, Q_j)$. Obtained from /// [`CurveExt::try_prepare_zero_check`]; the Pasta curves implement it -/// with the prepared codebook backend (the `glv::zero` module), and the -/// check is exact — it accepts iff the sum is the identity. -#[cfg(feature = "orbits")] -#[cfg_attr(docsrs, doc(cfg(feature = "orbits")))] +/// with an internal prepared codebook backend, and the check is exact — it +/// accepts iff the sum is the identity. +#[cfg(any(feature = "multicore", feature = "orbits"))] +#[cfg_attr(docsrs, doc(cfg(any(feature = "multicore", feature = "orbits"))))] pub trait PreparedZeroCheck: core::fmt::Debug + Send + Sync { /// The number of fixed bases this preparation covers; `scalars` below /// must have exactly this length. diff --git a/crates/pasta_curves/src/curves.rs b/crates/pasta_curves/src/curves.rs index d1515631..43664c85 100644 --- a/crates/pasta_curves/src/curves.rs +++ b/crates/pasta_curves/src/curves.rs @@ -93,17 +93,11 @@ macro_rules! impl_multiexp_vartime { #[cfg(feature = "alloc")] macro_rules! impl_prepare_zero_check { (glv, $name:ident) => { - #[cfg(feature = "orbits")] + #[cfg(any(feature = "multicore", feature = "orbits"))] fn try_prepare_zero_check( bases: &[Self::AffineExt], ) -> Option>> { - // `prepare` declines (None) when no codebook mode fits its 13 MiB - // accounted-footprint budget — from roughly 2^13 Pasta bases — so - // callers fall back instead of allocating past it. - crate::glv::zero::PreparedZeroMsm::<$name>::prepare(bases).map(|prepared| { - alloc::boxed::Box::new(prepared) - as alloc::boxed::Box> - }) + crate::glv::prepare_zero_check::<$name>(bases) } }; (native, $name:ident) => {}; diff --git a/crates/pasta_curves/src/glv.rs b/crates/pasta_curves/src/glv.rs index 46ce21ef..7c2f801c 100644 --- a/crates/pasta_curves/src/glv.rs +++ b/crates/pasta_curves/src/glv.rs @@ -42,10 +42,11 @@ //! integrates buckets with a hexagonal spanning-tree reducer. Both fill their //! buckets through the shared batched-affine tree reduction below //! (`reduce_affine_buckets`, one fused inversion-and-completion pass per tree -//! level). The orbit backend, the planning step, and the prepared zero-checks -//! built on the same machinery (the `zero` submodule) are gated behind the -//! `orbits` feature; without it large MSMs window through the Signed-Booth -//! backend alone. +//! level). The orbit backend, its planning step, and the public `zero` +//! submodule are gated behind the `orbits` feature. Without it, +//! large MSMs use the Signed-Booth backend alone. Multicore builds still +//! compile the prepared evaluator privately for fixed-base callers through +//! [`CurveExt::try_prepare_zero_check`]. //! //! This path is variable-time in the scalar (GLV decomposition plus digit //! recoding); the `_glv` naming distinguishes it from the native `Mul` @@ -89,11 +90,13 @@ use maybe_rayon::prelude::*; use crate::arithmetic::{CurveExt, mac, sbb}; use crate::{pallas, vesta}; -#[cfg(feature = "orbits")] +#[cfg(any(feature = "multicore", feature = "orbits"))] mod orbit; #[cfg(feature = "orbits")] #[cfg_attr(docsrs, doc(cfg(feature = "orbits")))] pub mod zero; +#[cfg(all(feature = "multicore", not(feature = "orbits")))] +mod zero; mod private { use crate::arithmetic::CurveExt; @@ -178,6 +181,18 @@ mod private { } } +#[cfg(any(feature = "multicore", feature = "orbits"))] +pub(super) fn prepare_zero_check( + bases: &[C::AffineExt], +) -> Option>> { + // `prepare` declines when no codebook mode fits its 13 MiB + // accounted-footprint budget. + zero::PreparedZeroMsm::::prepare(bases).map(|prepared| { + alloc::boxed::Box::new(prepared) + as alloc::boxed::Box> + }) +} + /// Per-curve GLV constants: a short basis for the lattice /// $\{(a, b) : a + b\lambda \equiv 0 \pmod n\}$ — where $n$ is the order of the /// group (equivalently the scalar field modulus) and $\lambda$ = `Scalar::ZETA` @@ -543,7 +558,9 @@ fn joint_digits(mut a: i128, mut b: i128) -> ([u8; MAX_JOINT_DIGITS], usize) { const BATCH_AFFINE_MIN_POINTS: usize = 32; // This range is tuned for the k = 11 parameter generation used by Orchard. // Larger domains retain the point-major schedule above the measured range. +#[cfg(feature = "multicore")] const TWIDDLE_MAJOR_MIN_CHUNK: usize = 16; +#[cfg(feature = "multicore")] const TWIDDLE_MAJOR_MAX_CHUNK: usize = 2048; /// Montgomery-batched inversion for a nonempty slice of provably nonzero @@ -1765,7 +1782,7 @@ fn multiexp_serial( /// `window_sum` (an arithmetic guard) propagates out. Shared by the /// Eisenstein-orbit backend and the prepared zero-check's main-window and /// tail drivers. -#[cfg(all(feature = "multicore", feature = "orbits"))] +#[cfg(feature = "multicore")] fn paired_windows_sum( windows: usize, window_bits: usize, diff --git a/crates/pasta_curves/src/glv/orbit.rs b/crates/pasta_curves/src/glv/orbit.rs index 26232a5a..872a46a0 100644 --- a/crates/pasta_curves/src/glv/orbit.rs +++ b/crates/pasta_curves/src/glv/orbit.rs @@ -89,13 +89,15 @@ use alloc::vec::Vec; use ff::Field; +#[cfg(feature = "orbits")] use group::CurveAffine as _; -#[cfg(feature = "multicore")] +#[cfg(all(feature = "multicore", feature = "orbits"))] use maybe_rayon::prelude::*; +#[cfg(feature = "orbits")] +use super::MagnitudeProfile; use super::{ - AffinePoint, GLV_COMPONENT_BITS, GlvParams, MagnitudeProfile, SignedMagnitude, private, - reduce_affine_buckets, + AffinePoint, GLV_COMPONENT_BITS, GlvParams, SignedMagnitude, private, reduce_affine_buckets, }; /// The window widths [`multiexp`] supports. Wider than 6 needs @@ -109,6 +111,7 @@ pub(super) const MAX_WINDOW_BITS: usize = 6; /// $(4^3 + 2)/6 = 11$ buckets only ever modeled ahead on MSMs of a few /// hundred terms, where they measured 4–17% *behind* the Booth backend /// (per-window overhead dominates 200-odd visits); planning starts at 4. +#[cfg(feature = "orbits")] pub(super) const PLAN_MIN_WINDOW_BITS: usize = 4; /// The smallest MSM [`estimated_costs`] will price for the planner. At 256 /// terms the backend's fixed per-window costs measured it 2–7% behind @@ -116,6 +119,7 @@ pub(super) const PLAN_MIN_WINDOW_BITS: usize = 4; /// 512 terms up it wins. This gates on the *term count*, not liveness — /// sparse-but-large MSMs stay profitable (witness-shaped 2048-term inputs /// measured +16..20% over Booth). +#[cfg(feature = "orbits")] pub(super) const PLAN_MIN_TERMS: usize = 512; /// Marks a wedge node whose reducer-tree parent is the origin. @@ -311,6 +315,7 @@ impl OrbitParams { } /// The window width $c$ these parameters were built for. + #[cfg(feature = "multicore")] pub(super) fn width(&self) -> usize { self.window_bits } @@ -327,6 +332,7 @@ pub(super) struct RotatedBase { pub(super) y: F, } +#[cfg(feature = "orbits")] pub(super) fn rotate_base(base: &C::AffineExt) -> RotatedBase { let (x, y) = C::affine_xy(base); let xz = x * >::ZETA; @@ -336,6 +342,7 @@ pub(super) fn rotate_base(base: &C::AffineExt) -> RotatedBase( bases: &[C::AffineExt], num_threads: usize, @@ -392,6 +399,7 @@ pub(super) fn recode_row( /// zero so the window fills never test bases again. Small-magnitude /// workloads recode to far fewer active windows than the bound, and the /// drivers walk only those. +#[cfg(feature = "orbits")] fn digit_matrix( params: &OrbitParams, components: &[(SignedMagnitude, SignedMagnitude)], @@ -545,6 +553,7 @@ pub(super) fn windows_sum( /// [`super::checked_signed_magnitudes`] enforces). Parallel runs schedule /// the windows through the shared paired-window driver /// ([`super::paired_windows_sum`]). +#[cfg(feature = "orbits")] pub(super) fn multiexp( components: &[(SignedMagnitude, SignedMagnitude)], bases: &[C::AffineExt], @@ -570,7 +579,7 @@ pub(super) fn multiexp( } /// Test convenience: the work component of [`estimated_costs`]. -#[cfg(test)] +#[cfg(all(test, feature = "orbits"))] pub(super) fn estimated_work( profile: &MagnitudeProfile, window_bits: usize, @@ -616,6 +625,7 @@ pub(super) fn estimated_work( /// (its $\lceil W/\text{workers}\rceil$ windows plus the top window's shift /// doublings) — work stealing achieves the former at low worker counts; /// the latter binds when workers exceed half the window count. +#[cfg(feature = "orbits")] pub(super) fn estimated_costs( profile: &MagnitudeProfile, window_bits: usize, @@ -676,7 +686,7 @@ pub(super) fn estimated_costs( Some((balanced.max(quantized), traffic)) } -#[cfg(test)] +#[cfg(all(test, feature = "orbits"))] mod tests { use super::super::{GlvParams, decompose, digit_scalar, testutil}; use super::*; diff --git a/crates/pasta_curves/src/glv/zero.rs b/crates/pasta_curves/src/glv/zero.rs index e9f2a088..4d1a3aec 100644 --- a/crates/pasta_curves/src/glv/zero.rs +++ b/crates/pasta_curves/src/glv/zero.rs @@ -51,8 +51,11 @@ //! rebuilding from the bases is the only trust story that needs no //! separate soundness argument. //! -//! Everything here is variable-time in scalars and points; all inputs must -//! be public. +//! Everything here is variable-time in scalars and points. Inputs to the +//! zero-check APIs must be public. The point-returning +//! [`PreparedZeroMsm::multiexp_with_terms_vartime`] may process secret scalars +//! only when the caller explicitly accepts a variable-time MSM's timing side +//! channel. //! //! # Deferred by measurement //! @@ -93,7 +96,9 @@ use alloc::vec::Vec; -use ff::{Field, FromUniformBytes, PrimeField, WithSmallOrderMulGroup}; +#[cfg(any(test, feature = "orbits"))] +use ff::FromUniformBytes; +use ff::{Field, PrimeField, WithSmallOrderMulGroup}; use group::CurveAffine as _; #[cfg(feature = "multicore")] use maybe_rayon::prelude::*; @@ -141,8 +146,10 @@ const DEFAULT_TABLE_FOOTPRINT_BUDGET: usize = 13 << 20; /// /// Preparation cost and memory scale with the mode's variant count (see /// [`CodebookMode`]); [`Self::prepared_bytes`] reports the footprint. The -/// check itself is exact — see the module docs for the soundness story — -/// and variable-time in everything, so all inputs must be public. +/// check itself is exact — see the module docs for the soundness story — and +/// variable-time in everything. Inputs to zero-check methods must be public; +/// the point-returning MSM follows its separately documented side-channel +/// contract. pub struct PreparedZeroMsm { codebook: Codebook, table: VariantTable, @@ -165,6 +172,7 @@ pub struct PreparedZeroMsm { /// BLAKE2b-256 over the prepared bases, mixed into /// [`Self::is_zero_batch_vartime`]'s derived challenge (and available /// to any future table-trust story). + #[cfg(any(test, feature = "orbits"))] bases_digest: [u8; 32], } @@ -229,18 +237,22 @@ impl PreparedZeroMsm { let live_count = live.iter().filter(|&&l| l).count(); let tail_width = tail_width_index(live_count, codebook.tail_bound()); - let mut digest = blake2b_simd::Params::new() - .hash_length(32) - .personal(b"zakura-zero-base") - .to_state(); - digest.update(&(bases.len() as u64).to_le_bytes()); - for base in bases { - let (x, y) = C::affine_xy(base); - digest.update(x.to_repr().as_ref()); - digest.update(y.to_repr().as_ref()); - } - let mut bases_digest = [0u8; 32]; - bases_digest.copy_from_slice(digest.finalize().as_bytes()); + #[cfg(any(test, feature = "orbits"))] + let bases_digest = { + let mut digest = blake2b_simd::Params::new() + .hash_length(32) + .personal(b"zakura-zero-base") + .to_state(); + digest.update(&(bases.len() as u64).to_le_bytes()); + for base in bases { + let (x, y) = C::affine_xy(base); + digest.update(x.to_repr().as_ref()); + digest.update(y.to_repr().as_ref()); + } + let mut bases_digest = [0u8; 32]; + bases_digest.copy_from_slice(digest.finalize().as_bytes()); + bases_digest + }; PreparedZeroMsm { codebook, @@ -250,11 +262,13 @@ impl PreparedZeroMsm { tail_bases, tail_params, tail_width, + #[cfg(any(test, feature = "orbits"))] bases_digest, } } /// The codebook mode this preparation uses. + #[cfg(any(test, feature = "orbits"))] pub fn mode(&self) -> CodebookMode { self.codebook.mode() } @@ -280,6 +294,7 @@ impl PreparedZeroMsm { /// # Panics /// /// Panics if `scalars.len()` differs from the prepared base count. + #[cfg(any(test, feature = "orbits"))] pub fn is_zero_vartime(&self, scalars: &[C::ScalarExt]) -> bool { self.is_zero_with_terms_vartime(scalars, &[]) } @@ -415,6 +430,7 @@ impl PreparedZeroMsm { /// Protocols that already own a transcript should instead squeeze /// their own challenge — *after absorbing every equation* — and call /// [`Self::is_zero_batch_with_challenge_vartime`]. + #[cfg(any(test, feature = "orbits"))] pub fn is_zero_batch_vartime(&self, equations: &[&[C::ScalarExt]]) -> bool where C::ScalarExt: ff::FromUniformBytes<64>, @@ -449,6 +465,7 @@ impl PreparedZeroMsm { /// challenge known in advance lets a prover construct false equations /// that cancel in the combination. Prefer the self-deriving variant /// unless a protocol transcript owns the challenge. + #[cfg(any(test, feature = "orbits"))] pub fn is_zero_batch_with_challenge_vartime( &self, equations: &[&[C::ScalarExt]], @@ -833,6 +850,7 @@ fn scan_relations( /// # Panics /// /// Panics if the equations have differing lengths. +#[cfg(any(test, feature = "orbits"))] pub fn combine_equations( equations: &[&[C::ScalarExt]], challenge: C::ScalarExt, diff --git a/crates/pasta_curves/src/glv/zero/codebook.rs b/crates/pasta_curves/src/glv/zero/codebook.rs index 49831537..29de0972 100644 --- a/crates/pasta_curves/src/glv/zero/codebook.rs +++ b/crates/pasta_curves/src/glv/zero/codebook.rs @@ -122,6 +122,7 @@ pub enum CodebookMode { /// searchable point of the design space (and would be the shape to /// revisit with a terminal-window recoding program that cancels /// residuals), not as a planner default. + #[cfg(any(test, feature = "orbits"))] ExponentBox { /// The window width $c$; supported range 5..=9. window_bits: usize, @@ -146,8 +147,9 @@ impl CodebookMode { /// The radix width $c$ of this mode. pub const fn window_bits(&self) -> usize { match self { - CodebookMode::Subgroup { window_bits, .. } - | CodebookMode::ExponentBox { window_bits, .. } => *window_bits, + CodebookMode::Subgroup { window_bits, .. } => *window_bits, + #[cfg(any(test, feature = "orbits"))] + CodebookMode::ExponentBox { window_bits, .. } => *window_bits, } } } @@ -416,6 +418,7 @@ fn derive_subgroup_classes( /// established by enumeration here, not assumed from a formula. /// Returns `(coset_of, coset_count, coeff_class_of, coeff_count)`, with /// `coeff_class_of` marking exactly the |D| tile residues. +#[cfg(any(test, feature = "orbits"))] fn derive_box_classes( c: usize, alpha_extent: usize, @@ -612,6 +615,7 @@ impl Codebook { CodebookMode::Subgroup { beta_power, .. } => { derive_subgroup_classes(c, beta_power, table_len) } + #[cfg(any(test, feature = "orbits"))] CodebookMode::ExponentBox { alpha_extent, beta_extent, diff --git a/docs/changelog/unreleased/270.md b/docs/changelog/unreleased/270.md new file mode 100644 index 00000000..65910b13 --- /dev/null +++ b/docs/changelog/unreleased/270.md @@ -0,0 +1,29 @@ +## zakura-halo2-proofs + +### Changed + +- `Params::prepare_commitments` now builds and uses coefficient- and + Lagrange-basis prepared tables under the default `multicore` feature, + without requiring `orbits` + ([#270](https://github.com/zakura-core/common/pull/270)). +- The `arithmetic` re-export now exposes `PreparedZeroCheck` and + `CurveExt::try_prepare_zero_check` whenever either `multicore` or `orbits` + is enabled + ([#270](https://github.com/zakura-core/common/pull/270)). + +## zakura-orchard + +### Changed + +- `ProvingKey::prepare_proving` now prepares prover commitments under the + default multicore feature set, without requiring orbit MSMs + ([#270](https://github.com/zakura-core/common/pull/270)). + +## zakura-pasta-curves + +### Changed + +- `PreparedZeroCheck` and `CurveExt::try_prepare_zero_check` are now + available when either `multicore` or `orbits` is enabled; their signatures + are unchanged, and single-core `glv` builds remain unaffected + ([#270](https://github.com/zakura-core/common/pull/270)).