Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,13 @@ jobs:
env:
STAGE_FEATURE: ${{ matrix.features }}
run: |
# `orbits` (a halo2_proofs default) is re-listed here so this
# job's runtime coverage includes the prepared zero-check path
# `orbits` (off by default) is enabled here so this job's
# runtime coverage includes the prepared zero-check path
# (the msm unit tests and plonk_api's K = 7 block route through
# it; plonk_api's pinned K = 5 verifications exceed the extras
# guard and cover the armed-but-guarded fallback); the 32-bit
# and platform-smoke jobs run without it and cover the
# orbits-off fallback.
# default orbits-off fallback.
features=batch,dev-graph,gadget-traces,test-dependencies,orbits
if [[ -n "$STAGE_FEATURE" ]]; then
features="$features,$STAGE_FEATURE"
Expand Down
31 changes: 23 additions & 8 deletions halo2_proofs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to Rust's notion of
## [Unreleased]

- Advice-witness denominators now use the prover's two-lane batch inversion.
- Added `Params::prepare_commitments`: builds prepared fixed-base multiexp
tables over `[g..., w, u]` (shared with `prepare_zero_checks`) and
`[g_lagrange..., w, u]`; when armed, `Params::commit` and
`Params::commit_lagrange` evaluate through them on pools of at most eight
effective threads (measured 1.2-1.8x per commitment at 1-8 threads on
x86-64 and Apple silicon, across full-width and witness-like coefficient
distributions) and keep the planned multiexp on wider pools, so arming is
never a pessimization.
- Proof witness collection now stores advice numerators directly and retains
only rational denominators for batched inversion.
- IPA opening proofs now keep late generator-fold rounds parallel instead of
Expand All @@ -28,7 +36,7 @@ and this project adheres to Rust's notion of
- Public-instance commitments and polynomial transforms for independent proof
circuits are now prepared in parallel when multicore support is enabled,
while retaining transcript order.
- Added a default-enabled `orbits` feature (forwarding `pasta_curves/orbits`)
- Added an opt-in (default-off) `orbits` feature (forwarding `pasta_curves/orbits`)
gating the prepared zero-check integration below. Built without it,
`Params::prepare_zero_checks` is a no-op returning `false` and
`MSM::eval` always evaluates the plain multiexp, so the machinery can
Expand All @@ -44,13 +52,20 @@ and this project adheres to Rust's notion of
plain multiexp. Preparation is explicit (hundreds of milliseconds and
tens of MiB at typical `k`, amortized across every verification with
those params; batch verification pays one prepared check per batch), is
never serialized, and is shared with all clones of the params. On the
Pasta curves the prepared final check measured ~1.6x faster serially
and up to ~2.4x on 8–16 workers at `k = 11` scale. Since the prepared
check now runs the accumulated commitment terms as their own planned
MSM, `MSM::eval` uses it while those terms do not outnumber the fixed
bases (the measured crossover on Ironwood batch validation), instead
of the earlier half-the-fixed-bases cutoff.
never serialized, and is shared with all clones of the params. The
routing is thread-aware: `MSM::eval` uses the prepared check on pools
of at most eight effective threads — where the prepared final check
measured ~1.5-2.4x faster on the Pasta curves at `k = 11` scale, and
armed verification won by 8-22% end to end at 4-8 threads — and falls
back to the plain planned multiexp on wider pools, where the prepared
evaluation stops scaling while the unprepared planner keeps scaling
(before the gate, armed verification measured 14-16% slower end to end
on a 16-thread pool and 22-27% slower on a 32-thread pool), so arming
is never a pessimization. Since the prepared check runs the
accumulated commitment terms as their own planned MSM, `MSM::eval`
also uses it only while those terms do not outnumber the fixed bases
(the measured crossover on Ironwood batch validation), instead of the
earlier half-the-fixed-bases cutoff.
- Proving keys now retain reusable floor-planning data produced during key
generation. V1 proof creation consumes the cached layout instead of
measuring and positioning the circuit again.
Expand Down
7 changes: 3 additions & 4 deletions halo2_proofs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ beta = []
default = [
"batch",
"multicore",
"orbits",
]
dev-graph = [
"plotters",
Expand All @@ -72,9 +71,9 @@ nightly = [
"unstable-verifier-fingerprint",
]
# The Eisenstein-orbit MSM backend and the prepared fixed-base zero-check
# (`Params::prepare_zero_checks`, routed through `MSM::eval`). Default-on;
# disabling it reverts to the Signed-Booth-only planner and the plain final
# identity test.
# (`Params::prepare_zero_checks`, routed through `MSM::eval`). Opt-in
# (default-off); without it halo2 keeps the Signed-Booth-only planner and
# the plain final identity test.
orbits = ["pasta_curves/orbits"]
sanity-checks = []
test-dev-graph = [
Expand Down
197 changes: 193 additions & 4 deletions halo2_proofs/src/poly/commitment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@ pub struct Params<C: CurveAffine> {
instance_window_cache: InstanceWindowCache<C>,
#[cfg(feature = "orbits")]
zero_check_cache: ZeroCheckCache<C>,
#[cfg(feature = "orbits")]
lagrange_table_cache: ZeroCheckCache<C>,
}

/// A lazily built prepared fixed-base zero-check over `[g..., w, u]` (see
/// [`Params::prepare_zero_checks`]), shared across clones of the params
/// that hold it. Never serialized; rebuilt on demand after `read`.
/// A lazily built prepared fixed-base multiexp table — over `[g..., w, u]`
/// for [`Params::prepare_zero_checks`], or `[g_lagrange..., w, u]` for the
/// Lagrange half of [`Params::prepare_commitments`] — shared across clones
/// of the params that hold it. Never serialized; rebuilt on demand after
/// `read`.
#[cfg(feature = "orbits")]
#[derive(Clone)]
struct ZeroCheckCache<C: CurveAffine>(
Expand Down Expand Up @@ -235,13 +239,36 @@ impl<C: CurveAffine> Params<C> {
instance_window_cache: InstanceWindowCache::default(),
#[cfg(feature = "orbits")]
zero_check_cache: ZeroCheckCache::default(),
#[cfg(feature = "orbits")]
lagrange_table_cache: ZeroCheckCache::default(),
}
}

/// This computes a commitment to a polynomial described by the provided
/// slice of coefficients. The commitment will be blinded by the blinding
/// factor `r`.
pub fn commit(&self, poly: &Polynomial<C::Scalar, Coeff>, r: Blind<C::Scalar>) -> C::Curve {
// A prepared table over [g..., w, u] (built by
// `Params::prepare_commitments`, or shared from
// `Params::prepare_zero_checks`) evaluates this commitment as a
// fixed-base multiexp with the blind riding `w` and `u` unused.
// Like `MSM::eval`, the routing is thread-gated: past
// `PREPARED_MSM_MAX_THREADS` effective threads the planned
// multiexp out-scales the prepared evaluation.
#[cfg(feature = "orbits")]
if crate::multicore::current_num_threads() <= msm::PREPARED_MSM_MAX_THREADS {
if let Some(prepared) = self.zero_check() {
let n = self.n as usize;
if prepared.terms() == n + 2 && poly.len() == n {
let mut fixed = Vec::with_capacity(n + 2);
fixed.extend(poly.iter());
fixed.push(r.0);
fixed.push(C::Scalar::ZERO);
return prepared.multiexp_with_terms_vartime(&fixed, &[]);
}
}
}

let mut tmp_scalars = Vec::with_capacity(poly.len() + 1);
let mut tmp_bases = Vec::with_capacity(poly.len() + 1);

Expand All @@ -262,6 +289,23 @@ impl<C: CurveAffine> Params<C> {
poly: &Polynomial<C::Scalar, LagrangeCoeff>,
r: Blind<C::Scalar>,
) -> C::Curve {
// The Lagrange-basis counterpart of `commit`'s prepared route,
// over the [g_lagrange..., w, u] table built by
// `Params::prepare_commitments`; same thread gate.
#[cfg(feature = "orbits")]
if crate::multicore::current_num_threads() <= msm::PREPARED_MSM_MAX_THREADS {
if let Some(prepared) = self.lagrange_table() {
let n = self.n as usize;
if prepared.terms() == n + 2 && poly.len() == n {
let mut fixed = Vec::with_capacity(n + 2);
fixed.extend(poly.iter());
fixed.push(r.0);
fixed.push(C::Scalar::ZERO);
return prepared.multiexp_with_terms_vartime(&fixed, &[]);
}
}
}

let mut tmp_scalars = Vec::with_capacity(poly.len() + 1);
let mut tmp_bases = Vec::with_capacity(poly.len() + 1);

Expand Down Expand Up @@ -330,6 +374,8 @@ impl<C: CurveAffine> Params<C> {
instance_window_cache: InstanceWindowCache::default(),
#[cfg(feature = "orbits")]
zero_check_cache: ZeroCheckCache::default(),
#[cfg(feature = "orbits")]
lagrange_table_cache: ZeroCheckCache::default(),
})
}

Expand All @@ -340,6 +386,14 @@ impl<C: CurveAffine> Params<C> {
/// Pasta curves this measured the verifier's final check ~1.5–2.4x
/// faster; other curves without a prepared backend make this a no-op.
///
/// The routing is thread-aware: on pools wider than
/// `msm::PREPARED_MSM_MAX_THREADS` (currently eight) effective threads
/// the unprepared planner out-scales the prepared evaluation (measured
/// end-to-end on 16- and 32-thread pools), so `eval` falls back to the
/// plain multiexp there and arming is never a pessimization — a
/// wide-pool validator simply amortizes the preparation over the
/// verifications that run on narrower pools.
///
/// Preparation costs hundreds of milliseconds and tens of mebibytes
/// at typical `k`, amortized across every subsequent verification
/// with these params (batch verification folds many proofs into a
Expand All @@ -351,7 +405,7 @@ impl<C: CurveAffine> Params<C> {
/// `false` means arming was a no-op — the curve has no prepared
/// backend, the backend declined (its prepared table for this SRS
/// would exceed its internal memory budget; very large `k`), or the
/// `orbits` feature (enabled by default) is off — and verification
/// `orbits` feature (disabled by default) is off — and verification
/// simply keeps evaluating the plain multiexp. Callers may ignore the
/// result; long-lived validators that expect the speedup can assert
/// or log it.
Expand Down Expand Up @@ -384,6 +438,68 @@ impl<C: CurveAffine> Params<C> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}

/// 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 `msm::PREPARED_MSM_MAX_THREADS` (currently eight)
/// effective threads — measured 1.2–1.8x per commitment at 1–8 threads
/// on x86-64 and Apple silicon alike, across full-width and
/// witness-like (boolean, byte, zero-padded) coefficient
/// distributions — and keep the planned multiexp on wider pools, where
/// the prepared evaluation stops scaling, so arming is never a
/// pessimization.
///
/// 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; a table
/// that is already armed is kept, so repeat calls are free. 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).
pub fn prepare_commitments(&self) -> bool {
#[cfg(feature = "orbits")]
{
// The tables depend only on the immutable bases, so a table
// that is already armed is kept rather than rebuilt.
if !(self.zero_check().is_some() || self.prepare_zero_checks()) {
// Both tables have the same term count, so a coefficient
// decline means the Lagrange build would decline too.
return false;
}
if self.lagrange_table().is_some() {
return true;
}
let mut bases = Vec::with_capacity(self.g_lagrange.len() + 2);
bases.extend_from_slice(&self.g_lagrange);
bases.push(self.w);
bases.push(self.u);
if let Some(prepared) = C::CurveExt::try_prepare_zero_check(&bases) {
*self
.lagrange_table_cache
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::from(prepared));
return true;
}
}
false
}

/// The cached Lagrange-basis prepared table, if
/// [`Self::prepare_commitments`] built one.
#[cfg(feature = "orbits")]
pub(crate) fn lagrange_table(&self) -> Option<Arc<dyn PreparedZeroCheck<C::CurveExt>>> {
self.lagrange_table_cache
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}

#[cfg(test)]
Expand Down Expand Up @@ -633,6 +749,74 @@ fn test_commit_lagrange_eqaffine() {
assert_eq!(params.commit(&b, alpha), params.commit_lagrange(&a, alpha));
}

/// Prepared commitment tables must not change a single commitment: armed
/// params (routed through the prepared tables inside a pool within the
/// thread gate, and through the planned multiexp on the ambient pool) agree
/// with independent unarmed params on random polynomials in both bases,
/// including sparse witness-like coefficient patterns.
#[test]
fn prepared_commitments_match_unprepared() {
const K: u32 = 6;

use rand::rng;

use crate::pasta::{EqAffine, Fp};
// Clones share the preparation caches, so an unarmed control needs its
// own independently constructed (deterministic) params.
let armed = Params::<EqAffine>::new(K);
let unarmed = Params::<EqAffine>::new(K);
let domain = super::EvaluationDomain::new(1, K);
let armed_ok = armed.prepare_commitments();
#[cfg(feature = "orbits")]
{
assert!(armed_ok, "Pasta params must arm under the orbits feature");
assert!(armed.zero_check().is_some());
assert!(armed.lagrange_table().is_some());
}
#[cfg(not(feature = "orbits"))]
assert!(!armed_ok);

let mut rng = rng();
let exercise = |armed: &Params<EqAffine>, unarmed: &Params<EqAffine>, seed: u64| {
let mut a = domain.empty_lagrange();
for (i, a) in a.iter_mut().enumerate() {
// A witness-like mix: zero padding, booleans, small values, and
// full-width entries.
*a = match i % 4 {
0 => Fp::zero(),
1 => Fp::from((i as u64) & 1),
2 => Fp::from(seed + i as u64),
_ => Fp::random(&mut rand::rng()),
};
}
let b = domain.lagrange_to_coeff(a.clone());
let alpha = Blind(Fp::random(&mut rand::rng()));
assert_eq!(armed.commit(&b, alpha), unarmed.commit(&b, alpha));
assert_eq!(
armed.commit_lagrange(&a, alpha),
unarmed.commit_lagrange(&a, alpha)
);
assert_eq!(armed.commit(&b, alpha), armed.commit_lagrange(&a, alpha));
};

// Ambient pool: whatever width the host provides.
exercise(&armed, &unarmed, Fp::random(&mut rng).to_repr()[0] as u64);
// 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"))]
for num_threads in [
msm::PREPARED_MSM_MAX_THREADS,
msm::PREPARED_MSM_MAX_THREADS + 1,
] {
maybe_rayon::ThreadPoolBuilder::new()
.num_threads(num_threads)
.build()
.expect("test pool must build")
.install(|| exercise(&armed, &unarmed, 41 + num_threads as u64));
}
}

#[test]
fn test_opening_proof() {
const K: u32 = 6;
Expand All @@ -656,6 +840,11 @@ fn test_opening_proof() {
let mut params_buffer = vec![];
params.write(&mut params_buffer).unwrap();
let params: Params<EpAffine> = Params::read::<_>(&mut &params_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.
params.prepare_commitments();

let domain = EvaluationDomain::new(1, K);

Expand Down
Loading
Loading