From e9c0db5c206bcedb9838cdef9fa739b23d13b4c8 Mon Sep 17 00:00:00 2001 From: Alexander Abdugafarov Date: Wed, 16 Sep 2026 12:34:02 +0100 Subject: [PATCH 1/4] Post-GKR sumcheck, minimal surface --- Cargo.lock | 23 ++ Cargo.toml | 1 + README.md | 20 ++ crates/common/src/virtual_map.rs | 42 ++- crates/pcs/Cargo.toml | 3 +- crates/pcs/src/lib.rs | 9 +- crates/pcs/src/opening.rs | 55 +++- crates/pcs/src/opening/tests.rs | 2 +- crates/pcs/src/sumcheck.rs | 155 --------- crates/pcs/src/sumcheck/tests.rs | 213 ------------ crates/post_gkr/Cargo.toml | 27 ++ crates/post_gkr/benches/reduce.rs | 106 ++++++ crates/post_gkr/src/lib.rs | 472 +++++++++++++++++++++++++++ crates/post_gkr/src/sumcheck.rs | 348 ++++++++++++++++++++ crates/post_gkr/src/test_util.rs | 134 ++++++++ crates/prover/src/lib.rs | 2 +- crates/prover/src/prove.rs | 9 +- crates/prover/src/reduce.rs | 21 +- crates/tests/Cargo.toml | 18 +- crates/tests/benches/sha256.rs | 217 ++++++++++++ crates/tests/benches/sha256_steps.rs | 370 +++++++++++++++++++++ crates/tests/src/lib.rs | 368 ++++++++++++++++++++- crates/tests/tests/sha256.rs | 26 ++ crates/verifier/src/lib.rs | 2 +- crates/verifier/src/reduce.rs | 10 +- 25 files changed, 2247 insertions(+), 406 deletions(-) delete mode 100644 crates/pcs/src/sumcheck.rs delete mode 100644 crates/pcs/src/sumcheck/tests.rs create mode 100644 crates/post_gkr/Cargo.toml create mode 100644 crates/post_gkr/benches/reduce.rs create mode 100644 crates/post_gkr/src/lib.rs create mode 100644 crates/post_gkr/src/sumcheck.rs create mode 100644 crates/post_gkr/src/test_util.rs create mode 100644 crates/tests/benches/sha256.rs create mode 100644 crates/tests/benches/sha256_steps.rs create mode 100644 crates/tests/tests/sha256.rs diff --git a/Cargo.lock b/Cargo.lock index fa7857b8..871bee6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,7 @@ dependencies = [ "cfg-if", "constant_time_eq", "cpufeatures 0.3.0", + "rayon-core", ] [[package]] @@ -1017,6 +1018,7 @@ dependencies = [ "field", "flock-core", "num-traits", + "post_gkr", "proptest", "transcript", ] @@ -1039,6 +1041,21 @@ dependencies = [ "rayon", ] +[[package]] +name = "post_gkr" +version = "0.1.0" +dependencies = [ + "common", + "divan", + "field", + "num-traits", + "poly", + "rand_core 0.10.1", + "rand_pcg 0.10.2", + "rayon", + "transcript", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1513,16 +1530,22 @@ dependencies = [ name = "tests" version = "0.1.0" dependencies = [ + "blake3", "circuit", "common", "crypto-primitives", + "divan", "field", + "flock-core", "host", "num-traits", "pcs", + "poly", + "post_gkr", "prover", "rand_chacha 0.10.0", "rand_core 0.10.1", + "rayon", "transcript", "verifier", ] diff --git a/Cargo.toml b/Cargo.toml index 8087ace1..30b462f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ prover = { path = "crates/prover" } transcript = { path = "crates/transcript" } verifier = { path = "crates/verifier" } gkr = { path = "crates/gkr"} +post_gkr = { path = "crates/post_gkr" } aes = "0.9.2" binius-field = { git = "https://github.com/IrreducibleOSS/binius64.git", rev = "49deecec1bf691c57aeadcda2499316d8094fcd8" } diff --git a/README.md b/README.md index f2d0ccee..1386869f 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,23 @@ opened through a ring-switch + recursive Ligerito pipeline. cargo test --workspace cargo clippy --workspace --all-targets ``` + +## Benchmarking + +Independent SHA-256 compressions end to end, per batch size, with the PIOP +mocked and the grand product real: + +```sh +RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256 +``` + +The same pipeline one step at a time, prover and verifier, with medians per +step: + +```sh +RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256_steps +``` + +Each bench's header documents its knobs. Micro-benchmarks live next to their +crates: `cargo bench -p field`, `-p poly`, `-p circuit`, and `-p post_gkr` +for the post-GKR sumcheck on its own. diff --git a/crates/common/src/virtual_map.rs b/crates/common/src/virtual_map.rs index 0f1dc9fd..baa89939 100644 --- a/crates/common/src/virtual_map.rs +++ b/crates/common/src/virtual_map.rs @@ -13,6 +13,8 @@ use field::{F128, Fq}; use num_traits::ConstZero; +#[cfg(feature = "parallel")] +use rayon::prelude::*; use crate::{ BitZParams, ClaimError, LinearClaim, OpeningQuery, Shape, VirtualParams, VirtualParamsError, @@ -47,6 +49,8 @@ pub enum VirtualMapError { ClaimWeightCountMismatch, /// The input omits coordinates of `h`, or the output does not have one weight per bit of `f`. WeightCountMismatch, + /// The map's weights on `f` outnumber the committed bits. + CommittedShapeTooSmall, } /// What the protocol asks of `M`. @@ -170,12 +174,10 @@ impl<'a, const Q: u128, M: VirtualMap> VirtualStatement<'a, Q, M> { { return Err(VirtualMapError::ClaimWeightCountMismatch); } - let weights = claim - .column_weights() - .iter() - .flat_map(|column| claim.row_weights().iter().map(move |row| *row * *column)) - .collect(); - (weights, claim.target()) + ( + flatten(claim.row_weights(), claim.column_weights()), + claim.target(), + ) } }; let transposed = self.map.transpose(&weights)?; @@ -185,7 +187,11 @@ impl<'a, const Q: u128, M: VirtualMap> VirtualStatement<'a, Q, M> { } let target = transposed.adjusted_target(target); let mut weights = transposed.into_weights(); - weights.resize(1 << self.params.committed_shape().log_bits(), F128::ZERO); + let committed_bits = 1 << self.params.committed_shape().log_bits(); + if weights.len() > committed_bits { + return Err(VirtualMapError::CommittedShapeTooSmall); + } + weights.resize(committed_bits, F128::ZERO); let shape = Shape::new(self.params.committed_shape().log_bits(), 0) .expect("a valid committed bit count permits a single-column shape"); let claim = LinearClaim::from_shape(&shape, weights, vec![F128::from(1u64)], target) @@ -194,6 +200,28 @@ impl<'a, const Q: u128, M: VirtualMap> VirtualStatement<'a, Q, M> { } } +/// `column_weights (x) row_weights` written out per bit of `h`, one column +/// after another. +fn flatten(row_weights: &[F128], column_weights: &[F128]) -> Vec { + let mut weights = vec![F128::ZERO; row_weights.len() * column_weights.len()]; + let column = |(slot, &scale): (&mut [F128], &F128)| { + for (weight, &row) in slot.iter_mut().zip(row_weights) { + *weight = scale * row; + } + }; + #[cfg(feature = "parallel")] + weights + .par_chunks_mut(row_weights.len()) + .zip(column_weights) + .for_each(column); + #[cfg(not(feature = "parallel"))] + weights + .chunks_mut(row_weights.len()) + .zip(column_weights) + .for_each(column); + weights +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pcs/Cargo.toml b/crates/pcs/Cargo.toml index 79839972..873517ba 100644 --- a/crates/pcs/Cargo.toml +++ b/crates/pcs/Cargo.toml @@ -7,10 +7,11 @@ license.workspace = true [dependencies] bincode = { workspace = true } -blake3 = { workspace = true } +blake3 = { workspace = true, features = ["rayon"] } common = { workspace = true } field = { workspace = true, features = ["spongefish"] } flock-core = { workspace = true } +post_gkr = { workspace = true } transcript = { workspace = true } num-traits = { workspace = true } diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 2c636d7c..68ab2c40 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -22,7 +22,7 @@ //! Ring-switching transposes `(s_v)` into `(s_u)` and samples `batching_point`. //! It sets `packed_target = Σ_u eq(batching_point, u) · s_u`. //! Recursive Ligerito proves `Σ_y B(y) · q_pkd(y) = packed_target` against the committed root. -//! Quadratic sumcheck reduces factored inner-product claims to MLE claims before this opening protocol. +//! The post-GKR sumcheck (`post_gkr`) reduces factored inner-product claims to MLE claims before this opening protocol. //! //! # Interface //! @@ -95,7 +95,6 @@ mod ligerito; mod mle; mod opening; mod profiles; -mod sumcheck; mod transpose; #[cfg(test)] @@ -130,7 +129,7 @@ pub enum StatementBinding { /// `q̂(r) = Σ_{b ∈ {0,1}^m} q(b) · eq(b, r) = target`, where /// `eq(b, r) = ∏_i (b_i · r_i + (1 - b_i) · (1 - r_i))`. /// [`OpeningQuery::InnerProduct`] accepts row weights, column weights, and a target over `F128`. -/// Quadratic sumcheck reduces this claim to an MLE claim before the opening protocol. +/// The post-GKR sumcheck reduces this claim to an MLE claim before the opening protocol. pub trait CommitScheme { /// The public commitment. type Commitment; @@ -147,7 +146,7 @@ pub trait CommitScheme { /// Consumes the exact packed witness and proves either opening query. /// - /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. + /// Inner-product claims first pass through the post-GKR sumcheck and then the MLE opening protocol. fn prove_lin( &self, data: &Self::ProverData, @@ -159,7 +158,7 @@ pub trait CommitScheme { /// Verifies either opening query against `commitment`. /// - /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. + /// Inner-product claims first pass through the post-GKR sumcheck and then the MLE opening protocol. fn verify_lin( &self, commitment: &Self::Commitment, diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index 904dc68d..fdd7c850 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -8,11 +8,14 @@ use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; use crate::ligerito::{self, ReducedProver}; -use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle, sumcheck}; +use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle}; const MLE_STATEMENT_LABEL: &[u8] = b"bitz/pcs/mle-opening/v1"; -const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v2"; -const SUMCHECK_LABEL: &[u8] = b"bitz/pcs/inner-product-sumcheck/v1"; +const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v3"; +const INNER_PRODUCT_DIGEST_CONTEXT: &str = "bitz/pcs/bit-inner-product-weights/v1"; +/// Weights per digest update: `2^16` elements, one megabyte. +const INNER_PRODUCT_DIGEST_CHUNK: usize = 1 << 16; +const SUMCHECK_LABEL: &[u8] = b"bitz/pcs/inner-product-sumcheck/v2"; const MLE_CLAIMS_LABEL: &[u8] = b"bitz/pcs/mle-claims/v1"; const CHALLENGES_LABEL: &[u8] = b"bitz/pcs/ring-switch-challenges/v1"; @@ -82,6 +85,24 @@ impl From for VerifyError { } } +impl From for ProveError { + fn from(error: post_gkr::ProveError) -> Self { + match error { + post_gkr::ProveError::WitnessLengthMismatch => Self::PackedWitnessLengthMismatch, + post_gkr::ProveError::ClaimDoesNotHold => Self::InvalidClaim, + } + } +} + +impl From for VerifyError { + fn from(error: post_gkr::VerifyError) -> Self { + match error { + post_gkr::VerifyError::MalformedProof => Self::MalformedProof, + post_gkr::VerifyError::EvaluationMismatch => Self::VerificationFailed, + } + } +} + pub(crate) fn prove( pcs: &Pcs, data: &ProverData, @@ -106,7 +127,7 @@ pub(crate) fn prove( bind_inner_product_statement(pcs, &data.commitment().root, claim, transcript); } transcript.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::prove(claim, prover.witness(), transcript)?; + let reduced = post_gkr::prove(claim, prover.witness(), transcript)?; let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; // AlreadyBound covers the original claim, before the reduction produces this MLE claim. bind_mle_statement( @@ -142,7 +163,7 @@ pub(crate) fn verify( bind_inner_product_statement(pcs, &commitment.0, claim, transcript); } transcript.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::verify(claim, transcript)?; + let reduced = post_gkr::verify(claim, transcript)?; let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; bind_mle_statement( pcs, @@ -251,17 +272,37 @@ fn bind_mle_statement( transcript.public_message(&target); } -/// Binds both tensor factors before the first sumcheck challenge. +/// Binds both tensor factors before the first sumcheck challenge: their +/// lengths, a digest of their weights, and the target. A factor can be one +/// weight per committed bit, and absorbing it whole would cost the sponge +/// as much as the sumcheck costs the prover. fn bind_inner_product_statement( pcs: &Pcs, root: &[u8; 32], claim: &LinearClaim, transcript: &mut impl PublicTranscript, ) { + // Little-endian words, a megabyte at a time, hashed on the pool; the + // digest does not depend on the chunking. + let mut hasher = blake3::Hasher::new_derive_key(INNER_PRODUCT_DIGEST_CONTEXT); + let mut buffer = Vec::with_capacity(INNER_PRODUCT_DIGEST_CHUNK * 16); + for factor in [claim.row_weights(), claim.column_weights()] { + for chunk in factor.chunks(INNER_PRODUCT_DIGEST_CHUNK) { + buffer.clear(); + for weight in chunk { + buffer.extend_from_slice(&weight.lo.to_le_bytes()); + buffer.extend_from_slice(&weight.hi.to_le_bytes()); + } + hasher.update_rayon(&buffer); + } + } transcript.public_message(INNER_PRODUCT_STATEMENT_LABEL); transcript.public_message(root); transcript.public_message(pcs); - transcript.public_message(claim); + transcript.public_message(&(claim.row_weights().len() as u64)); + transcript.public_message(&(claim.column_weights().len() as u64)); + transcript.public_message(hasher.finalize().as_bytes()); + transcript.public_message(&claim.target()); } #[cfg(test)] diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index cb7bf44c..286b94a4 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -69,7 +69,7 @@ fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { let mut verifier = build_verifier(SESSION, INSTANCE, &proof); bind_inner_product_statement(&fixture.pcs, &fixture.root.0, &fixture.claim, &mut verifier); verifier.public_message(SUMCHECK_LABEL); - let reduced = sumcheck::verify(&fixture.claim, &mut verifier).unwrap(); + let reduced = post_gkr::verify(&fixture.claim, &mut verifier).unwrap(); verify( &fixture.pcs, &fixture.root, diff --git a/crates/pcs/src/sumcheck.rs b/crates/pcs/src/sumcheck.rs deleted file mode 100644 index 63d6c28f..00000000 --- a/crates/pcs/src/sumcheck.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! The input contains arbitrary row and column weights over `F128`. -//! Bit `column * row_weights.len() + row` has weight `row_weights[row] * column_weights[column]`. -//! Each packed witness element stores bits 0 through 63 in `lo`, then bits 64 through 127 in `hi`. -//! Packed element `i`, local bit `v`, supplies logical bit `128 * i + v`. -//! The target is the claimed weighted sum of the original committed bits. -//! -//! `LinearClaim` ensures nonempty factors whose lengths are powers of two. -//! Opening code checks the total weight count, packed witness length, and retained prover parameters. -//! Opening code or the outer caller binds the original statement before this stage. -//! Opening code always adds the sumcheck domain before calling `prove` or `verify`. -//! -//! This module owns the round messages, challenges, and terminal product check in the supplied transcript. -//! It returns the witness MLE claim and leaves the remaining transcript for the opening protocol. -//! Opening code binds that claim and performs the final PCS opening, which authenticates the witness evaluation. - -use common::LinearClaim; -use field::F128; -use num_traits::ConstZero; -use transcript::{ProverState, VerifierState}; - -use crate::{ProveError, VerifyError}; - -/// A pending evaluation claim over the original committed bit polynomial. -pub(super) struct MleClaim { - /// The evaluation point, with row coordinates before column coordinates, in low-bit-first order. - /// Its length is `log2(row_weights.len()) + log2(column_weights.len())`. - pub(super) point: Vec, - /// The witness MLE evaluation, rather than the terminal product evaluation. - pub(super) target: F128, -} - -/// Proves the reduction using a temporary dense table of witness evaluations. -pub(super) fn prove( - claim: &LinearClaim, - packed_witness: &[F128], - transcript: &mut ProverState, -) -> Result { - let mut rows = claim.row_weights().to_vec(); - let mut columns = claim.column_weights().to_vec(); - let bit_len = rows.len() * columns.len(); - if packed_witness.len().checked_mul(128) != Some(bit_len) { - return Err(ProveError::PackedWitnessLengthMismatch); - } - let mut witness = Vec::with_capacity(bit_len); - for packed in packed_witness { - let bits = u128::from(packed.lo) | (u128::from(packed.hi) << 64); - for bit in 0..128 { - witness.push(F128::from(((bits >> bit) & 1) as u64)); - } - } - - let mut target = claim.target(); - let mut point = Vec::with_capacity(bit_len.ilog2() as usize); - while witness.len() > 1 { - let coefficients = round_polynomial(&witness, &rows, &columns); - // In characteristic two, g(0) + g(1) = a1 + a2. - if coefficients[1] + coefficients[2] != target { - return Err(ProveError::InvalidClaim); - } - transcript.prover_message(&coefficients); - let challenge = transcript.verifier_message::(); - target = evaluate_round(coefficients, challenge); - point.push(challenge); - fold(&mut witness, challenge); - if rows.len() > 1 { - fold(&mut rows, challenge); - } else { - fold(&mut columns, challenge); - } - } - - let evaluation = witness[0]; - if target != evaluation * rows[0] * columns[0] { - return Err(ProveError::InvalidClaim); - } - transcript.prover_message(&evaluation); - Ok(MleClaim { - point, - target: evaluation, - }) -} - -/// Verifies sumcheck and returns a witness claim that still requires the final PCS opening. -pub(super) fn verify( - claim: &LinearClaim, - transcript: &mut VerifierState<'_>, -) -> Result { - let mut rows = claim.row_weights().to_vec(); - let mut columns = claim.column_weights().to_vec(); - let rounds = rows.len().ilog2() as usize + columns.len().ilog2() as usize; - let mut point = Vec::with_capacity(rounds); - let mut target = claim.target(); - for _ in 0..rounds { - let coefficients = transcript - .prover_message::<[F128; 3]>() - .map_err(|_| VerifyError::MalformedProof)?; - if coefficients[1] + coefficients[2] != target { - return Err(VerifyError::VerificationFailed); - } - let challenge = transcript.verifier_message::(); - target = evaluate_round(coefficients, challenge); - point.push(challenge); - if rows.len() > 1 { - fold(&mut rows, challenge); - } else { - fold(&mut columns, challenge); - } - } - - let evaluation = transcript - .prover_message::() - .map_err(|_| VerifyError::MalformedProof)?; - // Multiplication also handles zero weights; the MLE opening authenticates the witness value. - if target != evaluation * rows[0] * columns[0] { - return Err(VerifyError::VerificationFailed); - } - Ok(MleClaim { - point, - target: evaluation, - }) -} - -/// Returns the coefficients of the next degree-two round polynomial. -fn round_polynomial(witness: &[F128], rows: &[F128], columns: &[F128]) -> [F128; 3] { - let mut at_zero = F128::ZERO; - let mut at_one = F128::ZERO; - let mut quadratic = F128::ZERO; - for (pair_index, pair) in witness.chunks_exact(2).enumerate() { - let index = 2 * pair_index; - let weight_zero = rows[index % rows.len()] * columns[index / rows.len()]; - let weight_one = rows[(index + 1) % rows.len()] * columns[(index + 1) / rows.len()]; - at_zero += pair[0] * weight_zero; - at_one += pair[1] * weight_one; - quadratic += (pair[0] + pair[1]) * (weight_zero + weight_one); - } - [at_zero, at_zero + at_one + quadratic, quadratic] -} - -fn evaluate_round([constant, linear, quadratic]: [F128; 3], challenge: F128) -> F128 { - constant + challenge * (linear + challenge * quadratic) -} - -/// Fixes the lowest remaining coordinate without changing the order of the remaining coordinates. -fn fold(values: &mut Vec, challenge: F128) { - let len = values.len() / 2; - for index in 0..len { - let zero = values[2 * index]; - let one = values[2 * index + 1]; - values[index] = zero + challenge * (zero + one); - } - values.truncate(len); -} - -#[cfg(test)] -mod tests; diff --git a/crates/pcs/src/sumcheck/tests.rs b/crates/pcs/src/sumcheck/tests.rs deleted file mode 100644 index 198888a1..00000000 --- a/crates/pcs/src/sumcheck/tests.rs +++ /dev/null @@ -1,213 +0,0 @@ -use std::sync::OnceLock; - -use common::{LinearClaim, Shape}; -use field::F128; -use num_traits::ConstZero; -use transcript::{Proof, PublicTranscript, VerifierState, build_prover, build_verifier}; - -use super::{prove, verify}; -use crate::{ - CommitScheme, HashKind, LigeritoProfile, OpeningQuery, Pcs, StatementBinding, VerifyError, -}; - -const M: usize = 22; -const SESSION: &[u8] = b"pcs-sumcheck-format-test"; -const INSTANCE: &[u8] = b"factored-inner-product"; -const SET_BITS: [usize; 8] = [0, 63, 64, 127, 128, 255, 256, (1 << M) - 1]; -const ROUND_BYTES: usize = 3 * 16; -const EVALUATION_OFFSET: usize = M * ROUND_BYTES; - -fn shape() -> Shape { - Shape::new(8, M - 8).unwrap() -} - -fn factor_weight(index: usize) -> F128 { - let index = index as u64; - F128::new( - index.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0x0123_4567_89ab_cdef, - index.rotate_left(29) ^ 0xa5a5_5a5a_f0f0_0f0f, - ) -} - -fn sparse_witness(packed_len: usize) -> Vec { - let mut witness = vec![F128::ZERO; packed_len]; - for index in SET_BITS { - if index % 128 < 64 { - witness[index / 128].lo |= 1 << (index % 128); - } else { - witness[index / 128].hi |= 1 << (index % 128 - 64); - } - } - witness -} - -fn bind_claim(transcript: &mut impl PublicTranscript, claim: &LinearClaim) { - transcript.public_message(b"sumcheck-test/claim/v1" as &[u8]); - transcript.public_message(claim); -} - -struct Fixture { - claim: LinearClaim, - proof: Proof, - point: Vec, - evaluation: F128, -} - -impl Fixture { - fn build() -> Self { - // Shape requires at least 2^22 bits, so this is the smallest valid commitment size. - let shape = shape(); - let target = SET_BITS - .into_iter() - .map(|index| { - factor_weight(index / shape.rows() + shape.rows()) - * factor_weight(index % shape.rows()) - }) - .sum(); - let claim = LinearClaim::from_shape( - &shape, - (0..shape.rows()).map(factor_weight).collect(), - (0..shape.columns()) - .map(|column| factor_weight(column + shape.rows())) - .collect(), - target, - ) - .unwrap(); - let witness = sparse_witness(1 << shape.log_packed_len()); - let mut prover = build_prover(SESSION, INSTANCE); - bind_claim(&mut prover, &claim); - let reduced = prove(&claim, &witness, &mut prover).unwrap(); - Self { - claim, - proof: prover.finish(), - point: reduced.point, - evaluation: reduced.target, - } - } - - fn verifier<'proof>(&self, proof: &'proof Proof) -> VerifierState<'proof> { - let mut verifier = build_verifier(SESSION, INSTANCE, proof); - bind_claim(&mut verifier, &self.claim); - verifier - } -} - -fn fixture() -> &'static Fixture { - static FIXTURE: OnceLock = OnceLock::new(); - FIXTURE.get_or_init(Fixture::build) -} - -#[test] -fn standalone_proof_returns_the_witness_mle_evaluation() { - let fixture = fixture(); - let mut verifier = fixture.verifier(&fixture.proof); - let reduced = verify(&fixture.claim, &mut verifier).unwrap(); - assert_eq!(reduced.point, fixture.point); - assert_eq!(reduced.target, fixture.evaluation); - let expected = SET_BITS - .into_iter() - .map(|index| { - reduced.point.iter().copied().enumerate().fold( - F128::from(1u64), - |product, (coordinate, value)| { - product - * if (index >> coordinate) & 1 == 1 { - value - } else { - F128::from(1u64) + value - } - }, - ) - }) - .sum::(); - assert_eq!(reduced.target, expected); - verifier.check_eof().unwrap(); -} - -#[test] -fn rejects_truncated_rounds_and_witness_evaluation() { - let fixture = fixture(); - for length in [ - 0, - 16, - ROUND_BYTES - 1, - EVALUATION_OFFSET - 1, - EVALUATION_OFFSET + 15, - ] { - let mut proof = fixture.proof.clone(); - proof.narg_string.truncate(length); - let mut verifier = fixture.verifier(&proof); - assert_eq!( - verify(&fixture.claim, &mut verifier).err(), - Some(VerifyError::MalformedProof), - ); - } -} - -#[test] -fn rejects_changed_round_coefficients_and_witness_evaluation() { - let fixture = fixture(); - for offset in [ - 0, - 16, - 32, - ROUND_BYTES * (M / 2) + 16, - ROUND_BYTES * (M - 1) + 32, - EVALUATION_OFFSET, - ] { - let mut proof = fixture.proof.clone(); - proof.narg_string[offset] ^= 1; - let mut verifier = fixture.verifier(&proof); - assert_eq!( - verify(&fixture.claim, &mut verifier).err(), - Some(VerifyError::VerificationFailed), - ); - } -} - -#[test] -fn zero_weight_factor_still_requires_the_correct_pcs_witness_evaluation() { - let shape = shape(); - let pcs = Pcs::new(&shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let witness = sparse_witness(pcs.packed_len()); - let (commitment, data) = pcs.commit(&witness).unwrap(); - - for zero_rows in [true, false] { - let mut row_weights = (0..shape.rows()).map(factor_weight).collect::>(); - let mut column_weights = (0..shape.columns()) - .map(|column| factor_weight(column + shape.rows())) - .collect::>(); - if zero_rows { - row_weights.fill(F128::ZERO); - } else { - column_weights.fill(F128::ZERO); - } - let query = OpeningQuery::InnerProduct { - claim: LinearClaim::from_shape(&shape, row_weights, column_weights, F128::ZERO) - .unwrap(), - }; - let mut prover = build_prover(SESSION, b"zero-inner-product-factor"); - pcs.prove_lin( - &data, - witness.clone(), - &query, - StatementBinding::Bind, - &mut prover, - ) - .unwrap(); - let proof = prover.finish(); - let mut verifier = build_verifier(SESSION, b"zero-inner-product-factor", &proof); - pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier) - .unwrap(); - verifier.check_eof().unwrap(); - - // A zero factor leaves this value unconstrained until the full PCS checks the MLE opening. - let mut changed_proof = proof; - changed_proof.narg_string[EVALUATION_OFFSET] ^= 1; - let mut verifier = build_verifier(SESSION, b"zero-inner-product-factor", &changed_proof); - assert_eq!( - pcs.verify_lin(&commitment, &query, StatementBinding::Bind, &mut verifier), - Err(VerifyError::VerificationFailed), - ); - } -} diff --git a/crates/post_gkr/Cargo.toml b/crates/post_gkr/Cargo.toml new file mode 100644 index 00000000..01b2c2d3 --- /dev/null +++ b/crates/post_gkr/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "post_gkr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[features] +default = ["parallel"] +parallel = ["dep:rayon"] + +[dependencies] +common = { workspace = true } +field = { workspace = true, features = ["spongefish"] } +num-traits = { workspace = true } +poly = { workspace = true } +rayon = { workspace = true, optional = true } +transcript = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +rand_core = { workspace = true } +rand_pcg = { workspace = true } + +[[bench]] +name = "reduce" +harness = false diff --git a/crates/post_gkr/benches/reduce.rs b/crates/post_gkr/benches/reduce.rs new file mode 100644 index 00000000..99af31d1 --- /dev/null +++ b/crates/post_gkr/benches/reduce.rs @@ -0,0 +1,106 @@ +//! The post-GKR sumcheck on its own, prover and verifier. +//! +//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p post_gkr --bench reduce`. +//! Without the flag the field falls back to its portable kernel, which is +//! the whole difference on every multiplication. +//! +//! The claim holds by construction over a pseudo-random witness, so the +//! prover's own check passes and the rounds run. One weight per bit, the +//! shape a virtual map leaves: `m = 22`, the commitment floor, and +//! `m = 25`, the SHA-256 batch as the step bench commits it. + +use std::sync::OnceLock; + +use common::{LinearClaim, Shape}; +use divan::Bencher; +use divan::counter::ItemsCount; +use field::F128; +use num_traits::ConstZero; +use rand_core::{Rng, SeedableRng}; +use rand_pcg::Pcg64; +use transcript::{Proof, build_prover, build_verifier}; + +const LOG_BITS: &[usize] = &[22, 25]; + +const SEED: u64 = 0x_5245_4455_4345_0000; + +fn main() { + divan::main(); +} + +/// One witness and claim per size, built on first use. +struct Fixture { + packed: Vec, + claim: LinearClaim, +} + +static FIXTURES: OnceLock> = OnceLock::new(); + +fn fixture(log_bits: usize) -> &'static Fixture { + let fixtures = FIXTURES.get_or_init(|| { + LOG_BITS + .iter() + .map(|&log_bits| (log_bits, Fixture::new(log_bits))) + .collect() + }); + &fixtures + .iter() + .find(|(candidate, _)| *candidate == log_bits) + .expect("every size is built up front") + .1 +} + +fn random(rng: &mut Pcg64, count: usize) -> Vec { + (0..count) + .map(|_| F128::new(rng.next_u64(), rng.next_u64())) + .collect() +} + +impl Fixture { + fn new(log_bits: usize) -> Self { + let mut rng = Pcg64::seed_from_u64(SEED ^ log_bits as u64); + let packed = random(&mut rng, 1 << (log_bits - 7)); + let weights = random(&mut rng, 1 << log_bits); + let mut target = F128::ZERO; + for (index, element) in packed.iter().enumerate() { + let mut bits = u128::from(element.lo) | (u128::from(element.hi) << 64); + while bits != 0 { + target += weights[(index << 7) | bits.trailing_zeros() as usize]; + bits &= bits - 1; + } + } + let shape = Shape::new(log_bits, 0).unwrap(); + let claim = + LinearClaim::from_shape(&shape, weights, vec![F128::from(1u64)], target).unwrap(); + Self { packed, claim } + } + + fn proof(&self) -> Proof { + let mut transcript = build_prover("post_gkr-bench", "reduce"); + post_gkr::prove(&self.claim, &self.packed, &mut transcript).unwrap(); + transcript.finish() + } +} + +fn bits(log_bits: usize) -> ItemsCount { + ItemsCount::new(1usize << log_bits) +} + +#[divan::bench(args = LOG_BITS, sample_count = 5, sample_size = 1)] +fn prove(bencher: Bencher, log_bits: usize) { + let fixture = fixture(log_bits); + bencher.counter(bits(log_bits)).bench_local(|| { + let mut transcript = build_prover("post_gkr-bench", "reduce"); + post_gkr::prove(&fixture.claim, &fixture.packed, &mut transcript).unwrap() + }); +} + +#[divan::bench(args = LOG_BITS, sample_count = 5, sample_size = 1)] +fn verify(bencher: Bencher, log_bits: usize) { + let fixture = fixture(log_bits); + let proof = fixture.proof(); + bencher.counter(bits(log_bits)).bench_local(|| { + let mut transcript = build_verifier("post_gkr-bench", "reduce", &proof); + post_gkr::verify(&fixture.claim, &mut transcript).unwrap() + }); +} diff --git a/crates/post_gkr/src/lib.rs b/crates/post_gkr/src/lib.rs new file mode 100644 index 00000000..8c93cf26 --- /dev/null +++ b/crates/post_gkr/src/lib.rs @@ -0,0 +1,472 @@ +//! Step 5.3 of 2.1. "A simple version of BitZ": from the grand product's linear +//! claim on the committed bits to the evaluation claim the opening takes. +//! +//! The grand product (6. "A GKR protocol for low entropy batched grand +//! products via lookup tables") ends on ` = C - 1` +//! over the `2^t x 2^s` committed bits `f`, a `LinearClaim` with row +//! factor `omega` and column factor `eq(., r_c)`. A virtualization `h = M f` +//! (2.2. "Virtual F_2-linear transforms in F2Z and NP-complete dually linear +//! relations") moves it onto `f` as ``, one weight per bit: the +//! same type with a single column of weight one. +//! +//! The opening scheme (`pcs`) proves evaluation claims `MLE[f](r) = v` and +//! does the ring switch from the bits to the packed vector itself (Appendix +//! B. "Ring switching via Galois orbits"). This crate's sumcheck turns the +//! linear claim into such an evaluation claim: all `m = t + s` variables +//! are bound against the bits, the row ones first as the bits are indexed. +//! The verifier's closing weight `MLE[omega](rho_b) MLE[eq](rho_c)` costs +//! `2^t + 2^s` multiplications, so it is linear in the bits only when a +//! factor is. +//! +//! The first round is taken off the packed bits: a bit is zero or one, so +//! the round's coefficients are sums of weights with no multiplication, and +//! the table the prover folds to has one entry per two bits. The weights +//! are never written out in full: the folded table is the folded row factor +//! tensored with the column factor. Proof: `2m + 1` elements, `rho` low +//! coordinate first ([`MleClaim`]). +//! +//! # Transcript +//! +//! Each round's coefficients are written and absorbed before its challenge +//! is squeezed; the closing evaluation follows the last round. The claim is +//! not re-absorbed here: the opening scheme binds its statement before it +//! calls the sumcheck. The round count derives from the claim and is not +//! absorbed either. + +mod sumcheck; +#[cfg(test)] +mod test_util; + +use common::LinearClaim; +use common::shape::PACK_BITS; +use field::F128; +use num_traits::{ConstOne, ConstZero}; +#[cfg(feature = "parallel")] +use poly::parallel::workload_size; +#[cfg(feature = "parallel")] +use rayon::prelude::*; +use transcript::{ProverState, VerifierState}; + +use crate::sumcheck::{ + Pair, RoundMessage, advance, evaluate, folded, prove_evaluation, prove_rounds, + verify_evaluation, verify_rounds, +}; + +/// The evaluation claim the sumcheck leaves: `MLE[f](point) = target` over +/// the committed bits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MleClaim { + /// The challenges, low coordinate first: the `t` row coordinates then + /// the `s` column ones. + pub point: Vec, + /// `v`, the prover's closing evaluation. + pub target: F128, +} + +/// A reduction the prover cannot run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProveError { + /// The packed witness is not one element per 128 bits of the claim. + WitnessLengthMismatch, + /// The claim's weights against the witness do not give the target. + ClaimDoesNotHold, +} + +/// A reduction the verifier rejects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifyError { + /// A record is missing or does not decode. + MalformedProof, + /// The weights at the challenges times `v` is not the running claim. + EvaluationMismatch, +} + +/// The bits one packed element carries. +const ELEMENT_BITS: usize = 1 << PACK_BITS; + +/// The even bit positions of a packed element. +const EVEN: u128 = 0x5555_5555_5555_5555_5555_5555_5555_5555; + +/// Packed elements per parallel task: the weights they carry fill the cache +/// budget. +#[cfg(feature = "parallel")] +const ELEMENTS_PER_TASK: usize = workload_size::() / ELEMENT_BITS; + +/// Runs the sumcheck for `claim` on `packed`, the committed `f` column +/// major, and returns the evaluation claim it leaves. +pub fn prove( + claim: &LinearClaim, + packed: &[F128], + transcript: &mut ProverState, +) -> Result { + prove_factors( + claim.row_weights(), + claim.column_weights(), + claim.target(), + packed, + transcript, + ) +} + +/// Replays the sumcheck for `claim`, checks that the closing evaluation +/// fits, and returns the evaluation claim for the opening scheme to verify +/// against the commitment. +pub fn verify( + claim: &LinearClaim, + transcript: &mut VerifierState<'_>, +) -> Result { + verify_factors( + claim.row_weights(), + claim.column_weights(), + claim.target(), + transcript, + ) +} + +/// [`prove`] on the claim's parts. `rows` has `2^t` weights with `t >= 7` +/// and `columns` `2^s`, as `LinearClaim` guarantees. +fn prove_factors( + rows: &[F128], + columns: &[F128], + target: F128, + packed: &[F128], + transcript: &mut ProverState, +) -> Result { + let factors = Factors { rows, columns }; + if packed.len() != factors.packed_len() { + return Err(ProveError::WitnessLengthMismatch); + } + if factors.weighted_sum(packed) != target { + return Err(ProveError::ClaimDoesNotHold); + } + + let message = factors.first_message(packed); + transcript.prover_message(&message); + let challenge: F128 = transcript.verifier_message(); + let running = advance(target, message, challenge); + let mut pair = Pair::new(factors.folded(challenge), bind_first(packed, challenge)); + + let rounds = factors.log_bits() - 1; + let (rest, running) = prove_rounds(&mut pair, rounds, running, transcript); + let target = prove_evaluation(&pair, running, transcript); + let mut point = vec![challenge]; + point.extend(rest); + Ok(MleClaim { point, target }) +} + +/// [`verify`] on the claim's parts. +fn verify_factors( + rows: &[F128], + columns: &[F128], + target: F128, + transcript: &mut VerifierState<'_>, +) -> Result { + let log_rows = rows.len().trailing_zeros() as usize; + let rounds = log_rows + columns.len().trailing_zeros() as usize; + let (point, running) = verify_rounds(rounds, target, transcript)?; + // `MLE[rows (x) columns](rho) = MLE[rows](rho_b) MLE[columns](rho_c)`. + let weight = evaluate(rows, &point[..log_rows]) * evaluate(columns, &point[log_rows..]); + let target = verify_evaluation(weight, running, transcript)?; + Ok(MleClaim { point, target }) +} + +/// The weights `rows (x) columns` over the bits, read without being written +/// out: bit `(c << t) | b` weighs `rows[b] columns[c]`, so packed element +/// `e` covers rows `128 (e mod 2^(t-7)) ..` of column `e / 2^(t-7)`. +struct Factors<'a> { + rows: &'a [F128], + columns: &'a [F128], +} + +impl Factors<'_> { + fn log_bits(&self) -> usize { + (self.rows.len() * self.columns.len()).trailing_zeros() as usize + } + + fn packed_len(&self) -> usize { + (self.rows.len() >> PACK_BITS) * self.columns.len() + } + + /// The row weights element `index` covers, and its column's weight. + fn element(&self, index: usize) -> (&[F128], F128) { + let per_column = self.rows.len() >> PACK_BITS; + let rows = &self.rows[(index % per_column) << PACK_BITS..][..ELEMENT_BITS]; + (rows, self.columns[index / per_column]) + } + + /// ``: the weights at the set bits. + fn weighted_sum(&self, packed: &[F128]) -> F128 { + let element = |(index, &element): (usize, &F128)| -> F128 { + let (rows, column) = self.element(index); + column * set_bits(bits(element)).map(|v| rows[v]).sum::() + }; + #[cfg(feature = "parallel")] + return packed + .par_iter() + .enumerate() + .with_min_len(ELEMENTS_PER_TASK) + .map(element) + .sum(); + #[cfg(not(feature = "parallel"))] + packed.iter().enumerate().map(element).sum() + } + + /// `(a_0, a_2)` of the first round off the bits: `a_0` sums the weights + /// at even positions whose bit is set, `a_2` the pair sums `w_0 + w_1` + /// where the two bits differ, since `(w_0 + w_1)(f_0 + f_1)` is that + /// exactly then. + fn first_message(&self, packed: &[F128]) -> RoundMessage { + let element = |(index, &element): (usize, &F128)| -> (F128, F128) { + let (rows, column) = self.element(index); + let bits = bits(element); + let a0: F128 = set_bits(bits & EVEN).map(|v| rows[v]).sum(); + let a2: F128 = set_bits((bits ^ (bits >> 1)) & EVEN) + .map(|v| rows[v] + rows[v + 1]) + .sum(); + (column * a0, column * a2) + }; + let add = |(a0, a2): (F128, F128), (b0, b2): (F128, F128)| (a0 + b0, a2 + b2); + #[cfg(feature = "parallel")] + let (a0, a2) = packed + .par_iter() + .enumerate() + .with_min_len(ELEMENTS_PER_TASK) + .map(element) + .reduce(|| (F128::ZERO, F128::ZERO), add); + #[cfg(not(feature = "parallel"))] + let (a0, a2) = packed + .iter() + .enumerate() + .map(element) + .fold((F128::ZERO, F128::ZERO), add); + [a0, a2] + } + + /// The weights with their first variable bound: the folded row factor + /// tensored with the column factor, one entry per two bits. + fn folded(&self, challenge: F128) -> Vec { + let rows = folded(self.rows, challenge); + let mut table = Vec::with_capacity(rows.len() * self.columns.len()); + for &column in self.columns { + table.extend(rows.iter().map(|&row| column * row)); + } + table + } +} + +/// `lo || hi` as one word, bit `v` the row at offset `v` of the 128 the +/// element covers (`common::BitTable`). +fn bits(element: F128) -> u128 { + u128::from(element.lo) | (u128::from(element.hi) << 64) +} + +/// The positions of the set bits of `word`, ascending. +fn set_bits(mut word: u128) -> impl Iterator { + std::iter::from_fn(move || { + (word != 0).then(|| { + let position = word.trailing_zeros() as usize; + word &= word - 1; + position + }) + }) +} + +/// `MLE[f]` with its first variable bound: over each pair of bits +/// `(f_0, f_1)`, `f_0 + rho (f_0 + f_1)`, one of `0`, `1`, `rho` and +/// `1 + rho`. +fn bind_first(packed: &[F128], challenge: F128) -> Vec { + let one_plus = F128::ONE + challenge; + let values = [F128::ZERO, one_plus, challenge, F128::ONE]; + let element = |(slot, &element): (&mut [F128], &F128)| { + let bits = bits(element); + for (pair, value) in slot.iter_mut().enumerate() { + *value = values[((bits >> (2 * pair)) & 3) as usize]; + } + }; + let mut table = vec![F128::ZERO; packed.len() * ELEMENT_BITS / 2]; + #[cfg(feature = "parallel")] + table + .par_chunks_mut(ELEMENT_BITS / 2) + .zip(packed) + .with_min_len(ELEMENTS_PER_TASK) + .for_each(element); + #[cfg(not(feature = "parallel"))] + table + .chunks_mut(ELEMENT_BITS / 2) + .zip(packed) + .for_each(element); + table +} + +#[cfg(test)] +mod tests { + use common::Shape; + use poly::DenseMultilinearExtension; + use transcript::{Proof, build_prover, build_verifier}; + + use super::*; + use crate::sumcheck::inner_product; + use crate::test_util::{Leaf, random, rng}; + + fn reduced(leaf: &Leaf) -> (MleClaim, Proof) { + let mut prover = build_prover("post_gkr-tests", "reduce"); + let sent = prove_factors( + &leaf.rows, + &leaf.columns, + leaf.target, + &leaf.packed, + &mut prover, + ) + .unwrap(); + (sent, prover.finish()) + } + + fn verified(leaf: &Leaf, proof: &Proof) -> Result { + let mut verifier = build_verifier("post_gkr-tests", "reduce", proof); + let received = verify_factors(&leaf.rows, &leaf.columns, leaf.target, &mut verifier)?; + assert!(verifier.check_eof().is_ok()); + Ok(received) + } + + #[test] + fn set_bits_are_read_in_ascending_order() { + let element = F128::new(0b1011, 1 << 63); + assert_eq!( + set_bits(bits(element)).collect::>(), + vec![0, 1, 3, 127] + ); + assert_eq!(set_bits(0).count(), 0); + assert_eq!(set_bits(u128::MAX).count(), 128); + } + + /// Factored over several columns, and a single column of weight one: + /// the shape the transposition leaves. + #[test] + fn the_two_sides_agree_on_a_true_claim() { + for (log_rows, log_columns, seed) in [(8, 2, 63), (10, 0, 64)] { + let leaf = Leaf::random(log_rows, log_columns, seed); + let (sent, proof) = reduced(&leaf); + assert_eq!(proof.narg_string.len(), (2 * 10 + 1) * 16); + assert!(proof.hints.is_empty()); + + let received = verified(&leaf, &proof).unwrap(); + assert_eq!(sent, received); + assert_eq!(received.point.len(), 10); + assert_eq!( + leaf.bits_extension().evaluate(&received.point).unwrap(), + received.target, + "{log_rows} x {log_columns}" + ); + } + } + + /// Through `LinearClaim`, at the smallest committed shape. + #[test] + fn a_linear_claim_reduces_to_an_evaluation_the_witness_satisfies() { + let leaf = Leaf::sparse(7, 15, 65); + let claim = LinearClaim::from_shape( + &Shape::new(7, 15).unwrap(), + leaf.rows.clone(), + leaf.columns.clone(), + leaf.target, + ) + .unwrap(); + let mut prover = build_prover("post_gkr-tests", "reduce"); + let sent = prove(&claim, &leaf.packed, &mut prover).unwrap(); + let proof = prover.finish(); + let mut verifier = build_verifier("post_gkr-tests", "reduce", &proof); + assert_eq!(verify(&claim, &mut verifier), Ok(sent.clone())); + assert_eq!(leaf.evaluate(&sent.point), sent.target); + } + + /// The first round off the bits sends what the generic round over the + /// weights and bits written out would, and folds to the same tables. + #[test] + fn the_first_round_off_the_bits_is_the_generic_round() { + let leaf = Leaf::random(7, 2, 67); + let factors = Factors { + rows: &leaf.rows, + columns: &leaf.columns, + }; + let weights = leaf.weights(); + let written_out: Vec = (0..1 << 9) + .map(|index| F128::from(leaf.bit(index))) + .collect(); + assert_eq!(factors.weighted_sum(&leaf.packed), leaf.target); + assert_eq!(inner_product(&weights, &written_out), leaf.target); + + let mut generic = Pair::new(weights.clone(), written_out.clone()); + let mut prover = build_prover("post_gkr-tests", "reduce"); + let (point, _) = prove_rounds(&mut generic, 1, leaf.target, &mut prover); + let proof = prover.finish(); + let message = factors.first_message(&leaf.packed); + assert_eq!( + proof.narg_string[..32], + [message[0].to_bytes(), message[1].to_bytes()].concat() + ); + + let fold = |table: Vec| { + let mut folded = DenseMultilinearExtension { evaluations: table }; + folded.fold(&point).unwrap(); + folded.evaluations + }; + assert_eq!(bind_first(&leaf.packed, point[0]), fold(written_out)); + assert_eq!(factors.folded(point[0]), fold(weights)); + } + + /// Every record off by one bit fails the closing check; a different + /// target does too; a proof cut short does not decode. + #[test] + fn a_reduction_altered_in_transit_is_caught() { + let mut leaf = Leaf::random(7, 1, 66); + let (_, proof) = reduced(&leaf); + let records = 2 * 8 + 1; + assert_eq!(proof.narg_string.len(), records * 16); + for record in 0..records { + let mut altered = proof.clone(); + altered.narg_string[record * 16] ^= 1; + assert_eq!( + verified(&leaf, &altered), + Err(VerifyError::EvaluationMismatch), + "record {record}" + ); + } + let mut short = proof.clone(); + short.narg_string.truncate(proof.narg_string.len() - 16); + assert_eq!(verified(&leaf, &short), Err(VerifyError::MalformedProof)); + + leaf.target += F128::ONE; + assert_eq!( + verified(&leaf, &proof), + Err(VerifyError::EvaluationMismatch) + ); + } + + #[test] + fn the_reduction_refuses_what_it_cannot_prove() { + let leaf = Leaf::random(7, 1, 56); + let mut transcript = build_prover("post_gkr-tests", "reduce"); + assert_eq!( + prove_factors( + &leaf.rows, + &leaf.columns, + leaf.target + random(&mut rng(57)), + &leaf.packed, + &mut transcript + ), + Err(ProveError::ClaimDoesNotHold) + ); + assert_eq!( + prove_factors( + &leaf.rows, + &leaf.columns, + leaf.target, + &leaf.packed[1..], + &mut transcript + ), + Err(ProveError::WitnessLengthMismatch) + ); + // Nothing was written before the refusals. + assert!(transcript.finish().narg_string.is_empty()); + } +} diff --git a/crates/post_gkr/src/sumcheck.rs b/crates/post_gkr/src/sumcheck.rs new file mode 100644 index 00000000..5a9e3d16 --- /dev/null +++ b/crates/post_gkr/src/sumcheck.rs @@ -0,0 +1,348 @@ +//! The degree-two sumcheck: from `sum_x W(x) V(x) = h_0` over two tables to +//! `MLE[V](rho) = v`, with `MLE[W](rho) * v = h_n` left for the caller to +//! check. +//! +//! Each round splits off the lowest remaining variable of both tables and +//! sends the round polynomial +//! +//! ```text +//! p(X) = sum_{x'} MLE[W](X, x') MLE[V](X, x') = a_0 + a_1 X + a_2 X^2. +//! ``` +//! +//! In characteristic two `p(0) + p(1) = a_1 + a_2`, so the running claim +//! `h = p(0) + p(1)` fixes `a_1 = h + a_2` and only `(a_0, a_2)` is sent +//! ([`RoundMessage`]). Both sides continue with `h' = p(rho)` and the prover +//! folds its tables at `rho`. There is no per-round check: a wrong message +//! in any round surfaces in the closing check. Soundness error at most +//! `2n / |E|` for `n` rounds, plus the probability that `MLE[W](rho) = 0`. + +use common::shape::PACK_BITS; +use field::{F128, Wide256}; +#[cfg(feature = "parallel")] +use poly::parallel::workload_size; +use poly::{DenseMultilinearExtension, eq_table}; +#[cfg(feature = "parallel")] +use rayon::prelude::*; +use transcript::{ProverState, VerifierState}; + +use crate::VerifyError; + +/// `(a_0, a_2)` of `p(X) = a_0 + a_1 X + a_2 X^2`; `a_1` the running claim +/// implies. +pub(crate) type RoundMessage = [F128; 2]; + +/// Entries below which [`evaluate`] and [`folded`] run on one thread: the +/// pool's overhead is that of a few hundred thousand multiplications. +#[cfg(feature = "parallel")] +const PARALLEL_EVALUATE_MIN: usize = 1 << 18; + +/// The public weights `W` and the prover's values `V` over the same +/// variables, folded together as the rounds bind them. +#[derive(Debug, Clone)] +pub(crate) struct Pair { + weights: Vec, + values: Vec, +} + +impl Pair { + /// Two tables of the same power-of-two length. + pub(crate) fn new(weights: Vec, values: Vec) -> Self { + debug_assert_eq!(weights.len(), values.len()); + debug_assert!(weights.len().is_power_of_two()); + Self { weights, values } + } + + /// `(MLE[W](rho), MLE[V](rho))` once every variable is bound. + pub(crate) fn bound(&self) -> (F128, F128) { + debug_assert_eq!(self.weights.len(), 1); + (self.weights[0], self.values[0]) + } + + /// `(a_0, a_2)`: `a_0 = sum w_0 v_0` and `a_2 = sum (w_0 + w_1)(v_0 + v_1)` + /// over adjacent entries, since `MLE[W](X, x') = w_0 + X (w_0 + w_1)` + /// and likewise for `MLE[V]`. + fn message(&self) -> RoundMessage { + let (a0, a2) = coefficients(&self.weights, &self.values); + [a0.reduce(), a2.reduce()] + } + + fn fold(&mut self, challenge: F128) { + fold(&mut self.weights, challenge); + fold(&mut self.values, challenge); + } +} + +/// Runs `rounds` rounds over `pair`, folding it in place. Returns the +/// challenges in the order they were drawn and the running claim. +pub(crate) fn prove_rounds( + pair: &mut Pair, + rounds: usize, + mut claim: F128, + transcript: &mut ProverState, +) -> (Vec, F128) { + let mut point = Vec::with_capacity(rounds); + for _ in 0..rounds { + let message = pair.message(); + transcript.prover_message(&message); + let challenge: F128 = transcript.verifier_message(); + claim = advance(claim, message, challenge); + point.push(challenge); + pair.fold(challenge); + } + (point, claim) +} + +/// Replays `rounds` rounds from the records alone. Returns the challenges +/// and the running claim. +pub(crate) fn verify_rounds( + rounds: usize, + mut claim: F128, + transcript: &mut VerifierState<'_>, +) -> Result<(Vec, F128), VerifyError> { + let mut point = Vec::with_capacity(rounds); + for _ in 0..rounds { + let message: RoundMessage = transcript + .prover_message() + .map_err(|_| VerifyError::MalformedProof)?; + let challenge: F128 = transcript.verifier_message(); + claim = advance(claim, message, challenge); + point.push(challenge); + } + Ok((point, claim)) +} + +/// `h' = p(rho)` with `a_1 = h + a_2`. +pub(crate) fn advance(claim: F128, [a0, a2]: RoundMessage, challenge: F128) -> F128 { + a0 + challenge * (claim + a2 + challenge * a2) +} + +/// Writes `v = MLE[V](rho)`, the one entry left in the folded pair. +pub(crate) fn prove_evaluation(pair: &Pair, claim: F128, transcript: &mut ProverState) -> F128 { + let (weight, evaluation) = pair.bound(); + debug_assert_eq!(weight * evaluation, claim); + transcript.prover_message(&evaluation); + evaluation +} + +/// Reads `v` and checks `MLE[W](rho) * v = h` for the caller's `MLE[W](rho)`. +pub(crate) fn verify_evaluation( + bound_weight: F128, + claim: F128, + transcript: &mut VerifierState<'_>, +) -> Result { + let evaluation: F128 = transcript + .prover_message() + .map_err(|_| VerifyError::MalformedProof)?; + if bound_weight * evaluation != claim { + return Err(VerifyError::EvaluationMismatch); + } + Ok(evaluation) +} + +/// [`fold`] into a fresh table, for a table that is only borrowed. +pub(crate) fn folded(table: &[F128], challenge: F128) -> Vec { + let entry = |pair: &[F128]| pair[0] + challenge * (pair[0] + pair[1]); + #[cfg(feature = "parallel")] + if table.len() >= PARALLEL_EVALUATE_MIN { + return table.par_chunks_exact(2).map(entry).collect(); + } + table.chunks_exact(2).map(entry).collect() +} + +/// Fixes the lowest remaining variable of `table` at `challenge`. +pub(crate) fn fold(table: &mut Vec, challenge: F128) { + let mut extension = DenseMultilinearExtension { + evaluations: std::mem::take(table), + }; + extension.fold(&[challenge]).expect("a variable remains"); + *table = extension.evaluations; +} + +/// `MLE[weights](point)`, one multiplication per weight: the weights have +/// no succinct form. The equality table is factored at the pack width, so +/// the larger factor is one element per 128 weights. +pub(crate) fn evaluate(weights: &[F128], point: &[F128]) -> F128 { + debug_assert_eq!(weights.len(), 1 << point.len()); + let (low, high) = point.split_at(point.len().min(PACK_BITS as usize)); + let eq_low = eq_table(low); + let eq_high = eq_table(high); + let term = |(chunk, weight): (&[F128], &F128)| *weight * inner_product(chunk, &eq_low); + #[cfg(feature = "parallel")] + if weights.len() >= PARALLEL_EVALUATE_MIN { + return weights + .par_chunks_exact(eq_low.len()) + .zip(&eq_high) + .map(term) + .sum(); + } + weights + .chunks_exact(eq_low.len()) + .zip(&eq_high) + .map(term) + .sum() +} + +pub(crate) fn inner_product(a: &[F128], b: &[F128]) -> F128 { + debug_assert_eq!(a.len(), b.len()); + a.iter() + .zip(b) + .fold(Wide256::zero(), |sum, (x, y)| sum + Wide256::mul(*x, *y)) + .reduce() +} + +/// `(a_0, a_2)` over the adjacent pairs of `weights` and `values`, left +/// unreduced. Large tables are split into cache-sized chunks summed on the +/// Rayon pool. +fn coefficients(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) { + #[cfg(feature = "parallel")] + { + // An even chunk length keeps every pair inside one chunk. + let chunk = workload_size::() & !1; + if weights.len() > chunk { + return weights + .par_chunks(chunk) + .zip(values.par_chunks(chunk)) + .map(|(w, v)| coefficients_serial(w, v)) + .reduce( + || (Wide256::zero(), Wide256::zero()), + |(a0, a2), (b0, b2)| (a0 + b0, a2 + b2), + ); + } + } + coefficients_serial(weights, values) +} + +fn coefficients_serial(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) { + let mut a0 = Wide256::zero(); + let mut a2 = Wide256::zero(); + for (w, v) in weights.chunks_exact(2).zip(values.chunks_exact(2)) { + a0 += Wide256::mul(w[0], v[0]); + a2 += Wide256::mul(w[0] + w[1], v[0] + v[1]); + } + (a0, a2) +} + +#[cfg(test)] +mod tests { + use num_traits::{ConstOne, ConstZero}; + use transcript::{build_prover, build_verifier}; + + use super::*; + use crate::test_util::{random, random_elements, rng}; + + fn pair(n: usize, rng: &mut rand_pcg::Pcg64) -> Pair { + Pair::new(random_elements(rng, 1 << n), random_elements(rng, 1 << n)) + } + + /// `sum_x MLE[W](x, x') MLE[V](x, x')` at `x`, by interpolating each pair. + fn round_polynomial(pair: &Pair, x: F128) -> F128 { + let interpolate = |entries: &[F128]| entries[0] + x * (entries[0] + entries[1]); + pair.weights + .chunks_exact(2) + .zip(pair.values.chunks_exact(2)) + .map(|(w, v)| interpolate(w) * interpolate(v)) + .sum() + } + + #[test] + fn the_coefficients_agree_between_the_chunked_and_the_serial_sums() { + // Larger than one cache-sized chunk, and not a multiple of it. + let pair = pair(15, &mut rng(0)); + let (a0, a2) = coefficients(&pair.weights, &pair.values); + let (b0, b2) = coefficients_serial(&pair.weights, &pair.values); + assert_eq!((a0.reduce(), a2.reduce()), (b0.reduce(), b2.reduce())); + let (a0, a2) = coefficients(&pair.weights[..6000], &pair.values[..6000]); + let (b0, b2) = coefficients_serial(&pair.weights[..6000], &pair.values[..6000]); + assert_eq!((a0.reduce(), a2.reduce()), (b0.reduce(), b2.reduce())); + } + + #[test] + fn the_message_is_the_round_polynomial() { + let mut rng = rng(1); + let pair = pair(5, &mut rng); + let claim = inner_product(&pair.weights, &pair.values); + let message = pair.message(); + assert_eq!( + round_polynomial(&pair, F128::ZERO) + round_polynomial(&pair, F128::ONE), + claim + ); + assert_eq!(message[0], round_polynomial(&pair, F128::ZERO)); + let rho = random(&mut rng); + assert_eq!(advance(claim, message, rho), round_polynomial(&pair, rho)); + } + + #[test] + fn an_honest_run_closes_and_a_false_claim_does_not() { + let mut rng = rng(3); + let mut pair = pair(6, &mut rng); + let (weights, values) = (pair.weights.clone(), pair.values.clone()); + let claim = inner_product(&weights, &values); + + let mut prover = build_prover("post_gkr-tests", "sumcheck"); + let (point, running) = prove_rounds(&mut pair, 6, claim, &mut prover); + let evaluation = prove_evaluation(&pair, running, &mut prover); + let proof = prover.finish(); + assert_eq!(proof.narg_string.len(), (2 * 6 + 1) * 16); + + let extension = |table: &[F128]| { + DenseMultilinearExtension { + evaluations: table.to_vec(), + } + .evaluate(&point) + .unwrap() + }; + assert_eq!(evaluation, extension(&values)); + assert_eq!(evaluate(&weights, &point), extension(&weights)); + + let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); + let (same_point, same_running) = verify_rounds(6, claim, &mut verifier).unwrap(); + assert_eq!(same_point, point); + assert_eq!(same_running, running); + assert_eq!( + verify_evaluation(evaluate(&weights, &point), running, &mut verifier), + Ok(evaluation) + ); + assert!(verifier.check_eof().is_ok()); + + // The same records against a claim one off: the gap survives every + // round and the closing check catches it. + let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); + let (_, running) = verify_rounds(6, claim + F128::ONE, &mut verifier).unwrap(); + assert_eq!( + verify_evaluation(evaluate(&weights, &point), running, &mut verifier), + Err(VerifyError::EvaluationMismatch) + ); + } + + #[test] + fn the_evaluation_and_the_fold_agree_with_the_extension() { + let mut rng = rng(9); + for n in [3, 7, 9, 13] { + let weights = random_elements(&mut rng, 1 << n); + let point = random_elements(&mut rng, n); + assert_eq!( + evaluate(&weights, &point), + inner_product(&weights, &eq_table(&point)), + "{n} variables" + ); + let mut table = weights.clone(); + fold(&mut table, point[0]); + assert_eq!(folded(&weights, point[0]), table); + } + } + + #[test] + fn a_truncated_reduction_is_malformed() { + let mut pair = pair(3, &mut rng(10)); + let claim = inner_product(&pair.weights, &pair.values); + let mut prover = build_prover("post_gkr-tests", "sumcheck"); + prove_rounds(&mut pair, 3, claim, &mut prover); + let mut proof = prover.finish(); + proof.narg_string.truncate(proof.narg_string.len() - 16); + let mut verifier = build_verifier("post_gkr-tests", "sumcheck", &proof); + assert_eq!( + verify_rounds(3, claim, &mut verifier).err(), + Some(VerifyError::MalformedProof) + ); + } +} diff --git a/crates/post_gkr/src/test_util.rs b/crates/post_gkr/src/test_util.rs new file mode 100644 index 00000000..8dfa3eaa --- /dev/null +++ b/crates/post_gkr/src/test_util.rs @@ -0,0 +1,134 @@ +//! Random instances whose claims hold by construction. + +use field::F128; +use num_traits::ConstZero; +use poly::{DenseMultilinearExtension, eq_table}; +use rand_core::{Rng, SeedableRng}; +use rand_pcg::Pcg64; + +pub fn rng(seed: u64) -> Pcg64 { + Pcg64::seed_from_u64(seed) +} + +pub fn random(rng: &mut Pcg64) -> F128 { + F128::new(rng.next_u64(), rng.next_u64()) +} + +pub fn random_elements(rng: &mut Pcg64, count: usize) -> Vec { + (0..count).map(|_| random(rng)).collect() +} + +/// A claim ` = target` with the witness it is about, +/// `f` packed column major with `2^log_rows` rows. +pub struct Leaf { + pub log_rows: usize, + pub log_columns: usize, + pub rows: Vec, + pub columns: Vec, + /// From the definition. + pub target: F128, + pub packed: Vec, +} + +impl Leaf { + /// Random weights over a random witness. + pub fn random(log_rows: usize, log_columns: usize, seed: u64) -> Self { + let mut rng = rng(seed); + let packed = random_elements(&mut rng, 1 << (log_rows + log_columns - 7)); + Self::with_witness(log_rows, log_columns, packed, &mut rng) + } + + /// Random weights over a witness of a few set bits, so that the + /// extension stays cheap to evaluate at large shapes. + pub fn sparse(log_rows: usize, log_columns: usize, seed: u64) -> Self { + let mut rng = rng(seed); + let mut packed = vec![F128::ZERO; 1 << (log_rows + log_columns - 7)]; + for _ in 0..64 { + let index = rng.next_u64() as usize % packed.len(); + packed[index] = random(&mut rng); + } + Self::with_witness(log_rows, log_columns, packed, &mut rng) + } + + fn with_witness( + log_rows: usize, + log_columns: usize, + packed: Vec, + rng: &mut Pcg64, + ) -> Self { + let rows = random_elements(rng, 1 << log_rows); + let columns = random_elements(rng, 1 << log_columns); + let mut leaf = Self { + log_rows, + log_columns, + rows, + columns, + target: F128::ZERO, + packed, + }; + leaf.target = leaf + .set_bits() + .map(|index| leaf.rows[index % (1 << log_rows)] * leaf.columns[index >> log_rows]) + .sum(); + leaf + } + + /// Bit `index` of the packed witness. + pub fn bit(&self, index: usize) -> bool { + let element = self.packed[index >> 7]; + let half = if index % 128 < 64 { + element.lo + } else { + element.hi + }; + (half >> (index % 64)) & 1 == 1 + } + + fn set_bits(&self) -> impl Iterator + '_ { + (0..self.packed.len() << 7).filter(|&index| self.bit(index)) + } + + /// `rows (x) columns` written out per bit. + pub fn weights(&self) -> Vec { + (0..self.packed.len() << 7) + .map(|index| { + self.rows[index % (1 << self.log_rows)] * self.columns[index >> self.log_rows] + }) + .collect() + } + + /// `MLE[f]` over all `t + s` variables. Dense, so small instances only. + pub fn bits_extension(&self) -> DenseMultilinearExtension { + let log_bits = self.log_rows + self.log_columns; + let table = (0..1usize << log_bits) + .map(|index| F128::from(self.bit(index))) + .collect(); + DenseMultilinearExtension::from_evaluations(log_bits, table).unwrap() + } + + /// `MLE[f](point)` from the set bits alone. + pub fn evaluate(&self, point: &[F128]) -> F128 { + let (low, high) = point.split_at(7); + let eq_low = eq_table(low); + let eq_high = eq_table(high); + self.set_bits() + .map(|index| eq_high[index >> 7] * eq_low[index % 128]) + .sum() + } +} + +#[test] +fn the_leaf_is_its_own_extension_on_the_cube() { + let leaf = Leaf::random(7, 2, 98); + let extension = leaf.bits_extension(); + assert_eq!(extension.num_vars(), 9); + assert_eq!(extension[(3 << 7) | 5], F128::from(leaf.bit((3 << 7) | 5))); + let written_out: F128 = extension + .iter() + .zip(leaf.weights()) + .map(|(f, weight)| weight * *f) + .sum(); + assert_eq!(written_out, leaf.target); + let point: Vec = (0..9).map(|i| F128::new(i + 3, 7)).collect(); + assert_eq!(leaf.evaluate(&point), extension.evaluate(&point).unwrap()); +} diff --git a/crates/prover/src/lib.rs b/crates/prover/src/lib.rs index a341e91a..dec471bf 100644 --- a/crates/prover/src/lib.rs +++ b/crates/prover/src/lib.rs @@ -7,5 +7,5 @@ pub mod setup; pub use fold::SendError; pub use prove::{ProveError, VirtualWitness}; -pub use reduce::gkr_reduce; +pub use reduce::{ReduceError, gkr_reduce}; pub use setup::BitZProver; diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index 57e71ac9..174bbe37 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -1,14 +1,13 @@ //! `ProveBitZ`. use common::{ - BitTable, ClaimError, LinearClaim, OpeningQuery, TableError, VirtualMap, VirtualMapError, - VirtualStatement, + BitTable, LinearClaim, OpeningQuery, TableError, VirtualMap, VirtualMapError, VirtualStatement, }; use field::{F128, Fq}; use pcs::{CommitScheme, Pcs, ProveError as OpeningProveError, ProverData, StatementBinding}; use transcript::ProverState; -use crate::{BitZProver, SendError, reduce::gkr_reduce}; +use crate::{BitZProver, SendError, reduce::ReduceError, reduce::gkr_reduce}; /// A proof the prover cannot produce. #[derive(Debug, Clone, PartialEq, Eq)] @@ -21,8 +20,8 @@ pub enum ProveError { Witness(TableError), /// The fold round failed. Fold(SendError), - /// The derived GKR weight counts do not match the table shape. - Reduction(ClaimError), + /// The grand product left no claim. + Reduction(ReduceError), /// The opening failed, so the reduction's claim was never discharged. Opening(OpeningProveError), } diff --git a/crates/prover/src/reduce.rs b/crates/prover/src/reduce.rs index 8bf1ef56..7a926899 100644 --- a/crates/prover/src/reduce.rs +++ b/crates/prover/src/reduce.rs @@ -8,10 +8,21 @@ use common::{BitTable, ClaimError, Fold, LinearClaim, OpeningQuery, TransposeError}; use field::F128; use gkr::{GrandProductCircuit, gpgkr_prove}; -use num_traits::ConstOne; +use num_traits::{ConstOne, ConstZero}; use poly::eq_table; use transcript::ProverState; +/// A reduction that produced no claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReduceError { + /// The fold's batch point has a zero coordinate, which the verifier's + /// GKR rounds divide by. A `2^-128` accident of the transcript, not + /// something a prover can arrange. + DegenerateChallenge, + /// The derived weight counts do not match the table shape. + Claim(ClaimError), +} + #[inline(never)] fn init_circuit(table: &BitTable, fold: &Fold) -> GrandProductCircuit { let columns = table.shape().columns(); @@ -54,7 +65,10 @@ pub fn gkr_reduce( transcript: &mut ProverState, fold: &Fold, table: &BitTable, -) -> Result { +) -> Result { + if fold.zeta.contains(&F128::ZERO) { + return Err(ReduceError::DegenerateChallenge); + } let circuit = init_circuit(table, fold); let (_last_value, witnesses) = circuit.batched_eval(table.shape().columns()); @@ -77,7 +91,8 @@ pub fn gkr_reduce( .collect(); let u2 = eq_table(&alfa_c); - let claim = LinearClaim::from_shape(table.shape(), u1, u2, inner_product_claim)?; + let claim = LinearClaim::from_shape(table.shape(), u1, u2, inner_product_claim) + .map_err(ReduceError::Claim)?; Ok(OpeningQuery::InnerProduct { claim }) } diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 7c06ffbb..4cce85cf 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -10,17 +10,31 @@ publish = false doctest = false [dependencies] +blake3 = { workspace = true } +circuit = { workspace = true } common = { workspace = true } crypto-primitives = { workspace = true } field = { workspace = true, features = ["spongefish"] } host = { workspace = true } +num-traits = { workspace = true } pcs = { workspace = true } +poly = { workspace = true } prover = { workspace = true } rand_chacha = { workspace = true } rand_core = { workspace = true } +rayon = { workspace = true } transcript = { workspace = true } verifier = { workspace = true } [dev-dependencies] -circuit = { workspace = true } -num-traits = { workspace = true } +divan = { workspace = true } +flock-core = { workspace = true } +post_gkr = { workspace = true } + +[[bench]] +name = "sha256" +harness = false + +[[bench]] +name = "sha256_steps" +harness = false diff --git a/crates/tests/benches/sha256.rs b/crates/tests/benches/sha256.rs new file mode 100644 index 00000000..af3994a1 --- /dev/null +++ b/crates/tests/benches/sha256.rs @@ -0,0 +1,217 @@ +//! Independent SHA-256 compressions end to end, per batch size. +//! +//! Ported from f2z-pcs's `benches/sha256_compressions.rs`. The witness is the +//! circuit crate's; the committed bits `f` and the assignment `h = M f` are +//! laid out one compression per column (2.2. "Virtual F_2-linear transforms +//! in F2Z and NP-complete dually linear relations"). The PIOP +//! ([`MockSpartan`]) is mocked; the commitment, the fold, the grand product +//! (the `gkr` crate), the transposition onto `f`, the +//! post-GKR sumcheck and the opening are real. `sha256_steps` times the same +//! pipeline one step at a time. +//! +//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256`. +//! Knobs: +//! - `BITZ_BENCH_SHAPES`: log2 of the compression counts, space or comma +//! separated; default `9 10 11 12`. The commitment floor puts the minimum +//! at 9; larger batches only cost time and memory. +//! - `BITZ_LIG_PROFILE`: `fast` (default), `slim` or `secure`. +//! - Divan's `DIVAN_SAMPLE_COUNT`, `DIVAN_SAMPLE_SIZE`, ...; the rows default +//! to three samples of one run each, the source's repetition count. +//! +//! As in the source, `witness` is outside `prove`; unlike it, `commit` is its +//! own row rather than part of `prove`. Any other `BITZ_*` variable aborts the +//! run. The header lists each batch's table sizes and its proof size after +//! verifying that proof; the prover is deterministic, so the timed proofs are +//! the same bytes. The allocation columns count the benchmarking thread only. +//! +//! [`MockSpartan`]: tests::MockSpartan + +use std::fmt::{self, Display}; +use std::sync::OnceLock; + +use divan::counter::ItemsCount; +use divan::{AllocProfiler, Bencher}; +use field::Q100; +use host::wire_proof; +use pcs::LigeritoProfile; +use tests::{Sha256Batch, Sha256Instance}; +use transcript::Proof; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +/// `q = 2^100 - 15`; the assignment table's `2^15` rows are well within what +/// it admits. +const Q: u128 = Q100; + +const KNOWN_ENV: &[&str] = &["BITZ_BENCH_SHAPES", "BITZ_LIG_PROFILE"]; + +/// log2 of the compression counts: source tables of `2^22` to `2^25` bits. +const DEFAULT_SHAPES: &[usize] = &[9, 10, 11, 12]; + +const SEED: u64 = 0x_5348_4132_5600_0000; + +fn main() { + enforce_known_env(); + let _ = flock_core::init_perf_thread_pool(); + + println!( + "profile {:?}, {} threads", + profile(), + rayon::current_num_threads() + ); + for shape in shapes() { + let fixture = fixture(shape); + let batch = &fixture.instance.batch; + println!( + "{shape}: {} compressions, f 2^{} bits, h 2^{} bits, proof {} B (narg {} B + hints {} B), verified", + 1usize << shape.log_compressions, + batch.source_shape().log_bits(), + batch.assignment_shape().log_bits(), + fixture.bytes.len(), + fixture.proof.narg_string.len(), + fixture.proof.hints.len(), + ); + } + + divan::main(); +} + +/// Aborts on any exported `BITZ_*` variable this bench does not know. +fn enforce_known_env() { + let mut unknown: Vec = std::env::vars_os() + .filter_map(|(key, _)| key.into_string().ok()) + .filter(|key| key.starts_with("BITZ_") && !KNOWN_ENV.contains(&key.as_str())) + .collect(); + if unknown.is_empty() { + return; + } + unknown.sort(); + eprintln!( + "error: unknown BITZ_* variable(s): {}; known: {}", + unknown.join(", "), + KNOWN_ENV.join(", ") + ); + std::process::exit(2); +} + +fn profile() -> LigeritoProfile { + match std::env::var("BITZ_LIG_PROFILE").as_deref() { + Err(_) | Ok("fast") => LigeritoProfile::Fast, + Ok("slim") => LigeritoProfile::Slim, + Ok("secure") => LigeritoProfile::Secure, + Ok(other) => panic!("BITZ_LIG_PROFILE: unknown profile `{other}` (fast | slim | secure)"), + } +} + +/// `2^log_compressions` compressions; the row of the divan table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BenchShape { + log_compressions: usize, +} + +impl Display for BenchShape { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "2^{}", self.log_compressions) + } +} + +fn shapes() -> Vec { + let exponents: Vec = match std::env::var("BITZ_BENCH_SHAPES") { + Ok(list) => list + .split([',', ' ']) + .filter(|token| !token.is_empty()) + .map(|token| { + token.parse().unwrap_or_else(|_| { + panic!("BITZ_BENCH_SHAPES: `{token}` is not a log2 compression count") + }) + }) + .collect(), + Err(_) => DEFAULT_SHAPES.to_vec(), + }; + exponents + .into_iter() + .map(|log_compressions| BenchShape { log_compressions }) + .collect() +} + +/// One committed batch with the proof the timed rows reproduce. +struct Fixture { + instance: Sha256Instance, + proof: Proof, + bytes: Vec, +} + +impl Fixture { + fn new(shape: BenchShape, profile: LigeritoProfile, seed: u64) -> Self { + let instance = Sha256Instance::::new(shape.log_compressions, profile, seed); + let proof = instance.prove(); + let bytes = wire_proof::encode(&proof); + instance + .verify(&proof) + .expect("the fixture's own proof verifies"); + Self { + instance, + proof, + bytes, + } + } +} + +static FIXTURES: OnceLock> = OnceLock::new(); + +/// Every shape's fixture is built on the first call, so the rows share one +/// batch per shape. +fn fixture(shape: BenchShape) -> &'static Fixture { + let fixtures = FIXTURES.get_or_init(|| { + let profile = profile(); + shapes() + .into_iter() + .map(|shape| { + let seed = SEED ^ shape.log_compressions as u64; + (shape, Fixture::new(shape, profile, seed)) + }) + .collect() + }); + &fixtures + .iter() + .find(|(candidate, _)| *candidate == shape) + .expect("every shape is built up front") + .1 +} + +/// The circuit crate's witness generation, into the two tables. Outside +/// `prove`, as in the source. +#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] +fn witness(bencher: Bencher, shape: BenchShape) { + bencher + .counter(ItemsCount::new(1usize << shape.log_compressions)) + .bench_local(|| Sha256Batch::generate(shape.log_compressions, SEED)); +} + +/// Step 1: the commitment to `f`. +#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] +fn commit(bencher: Bencher, shape: BenchShape) { + let instance = &fixture(shape).instance; + bencher + .counter(ItemsCount::new(1usize << shape.log_compressions)) + .bench_local(|| instance.pcs.commit(&instance.batch.source).unwrap()); +} + +/// Steps 3 to 6 on the committed batch: the mocked PIOP's claim, the fold, +/// the grand product, the transposition, the sumcheck, the opening. +#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] +fn prove(bencher: Bencher, shape: BenchShape) { + let instance = &fixture(shape).instance; + bencher + .counter(ItemsCount::new(1usize << shape.log_compressions)) + .bench_local(|| instance.prove()); +} + +#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] +fn verify(bencher: Bencher, shape: BenchShape) { + let fixture = fixture(shape); + bencher + .counter(ItemsCount::new(1usize << shape.log_compressions)) + .bench_local(|| fixture.instance.verify(&fixture.proof).unwrap()); +} diff --git a/crates/tests/benches/sha256_steps.rs b/crates/tests/benches/sha256_steps.rs new file mode 100644 index 00000000..02eb9c5d --- /dev/null +++ b/crates/tests/benches/sha256_steps.rs @@ -0,0 +1,370 @@ +//! The SHA-256 pipeline one step at a time, prover and verifier. +//! +//! The same pipeline as `sha256`, driven through the crates' public steps +//! with a clock around each: the commitment (`pcs`), the mocked PIOP's claim, +//! the binding and the fold (`prover`/`verifier`), the grand product's leaf +//! claim (the `gkr` crate), its transposition onto the committed bits, and +//! the opening (`pcs`), which runs the post-GKR sumcheck (`post_gkr`) before +//! the ring switch. The sumcheck is also clocked alone, on a scratch +//! transcript, since the opening does not expose it as a step. Medians over +//! the repetitions are printed per step. As in the source, `prove` includes +//! the commitment and excludes witness generation. +//! +//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256_steps`. +//! Knobs: +//! - `BITZ_BENCH_SHAPES`: log2 of the compression counts, space or comma +//! separated; default `9 10 11 12`. +//! - `BITZ_BENCH_REPS`: measured repetitions after one warm-up; default 3. +//! - `BITZ_LIG_PROFILE`: `fast` (default), `slim` or `secure`. +//! +//! Each repetition generates a fresh batch and verifies its proof. The +//! warm-up also checks that the step-driven proof is byte for byte the one +//! `prove` produces, so the split cannot drift from the protocol. + +use std::time::{Duration, Instant}; + +use common::{OpeningQuery, VirtualMap}; +use field::{F128, Q100}; +use pcs::{CommitScheme, LigeritoProfile, StatementBinding}; +use tests::{MockSpartan, Sha256Batch, Sha256Instance, prover_transcript, verifier_transcript}; +use transcript::{Proof, ProverState, VerifierState}; + +const Q: u128 = Q100; + +const KNOWN_ENV: &[&str] = &["BITZ_BENCH_REPS", "BITZ_BENCH_SHAPES", "BITZ_LIG_PROFILE"]; +const DEFAULT_SHAPES: &[usize] = &[9, 10, 11, 12]; +const SEED: u64 = 0x_5348_4132_5654_4550; + +fn main() { + enforce_known_env(); + let _ = flock_core::init_perf_thread_pool(); + let profile = profile(); + let reps = reps(); + + println!( + "SHA-256 compressions, step by step: profile {profile:?}, {} threads, {reps} reps after 1 warm-up", + rayon::current_num_threads() + ); + for log_compressions in shapes() { + flock_core::scratch::clear(); + // Setup is excluded: the map, the parameters and the scheme are + // public and shape-only. One committed batch stands for them across + // the reps. + let setup = Instant::now(); + bench_instance( + Sha256Instance::::new(log_compressions, profile, SEED), + setup, + reps, + ); + } +} + +fn bench_instance(instance: Sha256Instance, setup: Instant, reps: usize) { + let setup = setup.elapsed(); + let batch = &instance.batch; + let log_compressions = batch.log_compressions; + println!( + "\n=== 2^{log_compressions} = {} compressions: f 2^{} bits, h 2^{} bits ===", + 1usize << log_compressions, + batch.source_shape().log_bits(), + batch.assignment_shape().log_bits(), + ); + println!( + " setup (excluded): {} witness + commit + map", + fmt(setup) + ); + + // Warm-up on the setup's own batch, and the check that the steps are the + // protocol: the one-call proof of the same batch must be the same bytes. + let (warm, proof) = run(&instance, &instance.batch); + assert_eq!(proof, instance.prove(), "the steps must reproduce prove"); + instance.verify(&proof).expect("the warm-up proof verifies"); + drop(warm); + + let mut witness = Vec::with_capacity(reps); + let mut runs = Vec::with_capacity(reps); + let mut last = None; + for rep in 0..reps { + let started = Instant::now(); + let batch = Sha256Batch::generate(log_compressions, SEED ^ (rep as u64 + 1)); + witness.push(started.elapsed()); + let (timings, proof) = run(&instance, &batch); + runs.push(timings); + last = Some(proof); + } + let proof = last.expect("at least one rep"); + + print_side("prove", &runs, |run| &run.prove, |run| run.prove_total()); + print_side("verify", &runs, |run| &run.verify, |run| run.verify_total()); + println!(" witness (excluded): {}", fmt(median(witness.into_iter()))); + println!( + " proof: {} B = narg {} B + hints {} B", + proof.narg_string.len() + proof.hints.len(), + proof.narg_string.len(), + proof.hints.len() + ); +} + +/// One repetition's step timings. +struct Run { + prove: Vec<(&'static str, Duration)>, + verify: Vec<(&'static str, Duration)>, +} + +impl Run { + fn prove_total(&self) -> Duration { + self.prove.iter().map(|(_, time)| *time).sum() + } + + fn verify_total(&self) -> Duration { + self.verify.iter().map(|(_, time)| *time).sum() + } +} + +/// Proves and verifies `batch` step by step on `instance`'s public setup. +/// The batch is committed inside the prover's clock. +fn run(instance: &Sha256Instance, batch: &Sha256Batch) -> (Run, Proof) { + let mut prove = Vec::new(); + let mut time = |label, work: &mut dyn FnMut()| { + let started = Instant::now(); + work(); + prove.push((label, started.elapsed())); + }; + + let committed = batch.source.clone(); + let mut data = None; + time("commit", &mut || { + data = Some(instance.pcs.commit(&batch.source).unwrap()); + }); + let (com, data) = data.unwrap(); + + let mut transcript = prover_transcript(); + let table = instance.params.table(&batch.assignment).unwrap(); + let mut claim = None; + time("PIOP claim (mock)", &mut || { + claim = Some(MockSpartan::claim_prover( + &instance.params, + &table, + &mut transcript, + )); + }); + let claim = claim.unwrap(); + + let statement = instance.statement(&claim); + time("bind", &mut || { + transcript.public_message(b"bitz/virtual-statement/v1"); + transcript.public_message(&com.0); + transcript.public_message(statement.params()); + transcript.public_message(&instance.map.digest()); + transcript.public_message(&claim); + }); + + let mut fold = None; + time("fold", &mut || { + fold = Some( + instance + .prover + .send_fold(&claim, &table, &mut transcript) + .unwrap(), + ); + }); + let fold = fold.unwrap(); + + let mut leaf = None; + time("GKR leaf", &mut || { + leaf = Some(prover::gkr_reduce(&mut transcript, &fold, &table).unwrap()); + }); + + let mut query = None; + time("transposition", &mut || { + query = Some(statement.transpose_query(leaf.take().unwrap()).unwrap()); + }); + let query = query.unwrap(); + + // Off the protocol's transcript: the opening runs this inside itself. + let mut scratch = None; + time("sumcheck (alone)", &mut || { + let mut transcript = prover_transcript(); + sumcheck_prover(&query, &batch.source, &mut transcript); + scratch = Some(transcript.finish()); + }); + let scratch = scratch.unwrap(); + + let mut committed = Some(committed); + time("opening", &mut || { + instance + .pcs + .prove_lin( + &data, + committed.take().unwrap(), + &query, + StatementBinding::Bind, + &mut transcript, + ) + .unwrap(); + }); + let proof = transcript.finish(); + + let mut verify = Vec::new(); + let mut time = |label, work: &mut dyn FnMut()| { + let started = Instant::now(); + work(); + verify.push((label, started.elapsed())); + }; + + let mut transcript = verifier_transcript(&proof); + let mut claim = None; + time("PIOP claim (mock)", &mut || { + claim = Some(MockSpartan::claim_verifier(&instance.params, &mut transcript).unwrap()); + }); + let claim = claim.unwrap(); + + let statement = instance.statement(&claim); + time("bind", &mut || { + transcript.public_message(b"bitz/virtual-statement/v1"); + transcript.public_message(&com.0); + transcript.public_message(statement.params()); + transcript.public_message(&instance.map.digest()); + transcript.public_message(&claim); + }); + + let mut fold = None; + time("fold", &mut || { + fold = Some( + instance + .verifier + .receive_fold(&claim, &mut transcript) + .unwrap(), + ); + }); + let fold = fold.unwrap(); + + let mut leaf = None; + time("GKR leaf", &mut || { + leaf = Some(verifier::gkr_reduce(&mut transcript, &fold, instance.params.shape()).unwrap()); + }); + + let mut query = None; + time("transposition", &mut || { + query = Some(statement.transpose_query(leaf.take().unwrap()).unwrap()); + }); + let query = query.unwrap(); + + time("sumcheck (alone)", &mut || { + let mut transcript = verifier_transcript(&scratch); + sumcheck_verifier(&query, &mut transcript); + }); + + time("opening", &mut || { + instance + .pcs + .verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) + .unwrap(); + }); + + let mut transcript = Some(transcript); + time("exhaustion", &mut || { + transcript.take().unwrap().check_eof().unwrap(); + }); + + (Run { prove, verify }, proof) +} + +/// The post-GKR sumcheck for whichever form the transposition left. +fn sumcheck_prover(query: &OpeningQuery, packed: &[F128], transcript: &mut ProverState) { + let OpeningQuery::InnerProduct { claim } = query else { + unreachable!("the transposition leaves an inner-product claim"); + }; + post_gkr::prove(claim, packed, transcript).unwrap(); +} + +fn sumcheck_verifier(query: &OpeningQuery, transcript: &mut VerifierState<'_>) { + let OpeningQuery::InnerProduct { claim } = query else { + unreachable!("the transposition leaves an inner-product claim"); + }; + post_gkr::verify(claim, transcript).unwrap(); +} + +fn print_side( + side: &str, + runs: &[Run], + steps: impl Fn(&Run) -> &Vec<(&'static str, Duration)>, + total: impl Fn(&Run) -> Duration, +) { + println!(" {side}: {}", fmt(median(runs.iter().map(&total)))); + for (index, (label, _)) in steps(&runs[0]).iter().enumerate() { + let step = median(runs.iter().map(|run| steps(run)[index].1)); + println!(" {label:<18} {}", fmt(step)); + } +} + +fn median(samples: impl Iterator) -> Duration { + let mut samples: Vec = samples.collect(); + samples.sort(); + samples[samples.len() / 2] +} + +fn fmt(time: Duration) -> String { + let micros = time.as_secs_f64() * 1e6; + if micros < 1_000.0 { + format!("{micros:8.1} us") + } else if micros < 1_000_000.0 { + format!("{:8.2} ms", micros / 1e3) + } else { + format!("{:8.3} s ", micros / 1e6) + } +} + +fn reps() -> usize { + let reps = std::env::var("BITZ_BENCH_REPS") + .map(|value| { + value + .parse() + .unwrap_or_else(|_| panic!("BITZ_BENCH_REPS: `{value}` is not a count")) + }) + .unwrap_or(3); + assert!(reps > 0, "BITZ_BENCH_REPS must be positive"); + reps +} + +fn shapes() -> Vec { + match std::env::var("BITZ_BENCH_SHAPES") { + Ok(list) => list + .split([',', ' ']) + .filter(|token| !token.is_empty()) + .map(|token| { + token.parse().unwrap_or_else(|_| { + panic!("BITZ_BENCH_SHAPES: `{token}` is not a log2 compression count") + }) + }) + .collect(), + Err(_) => DEFAULT_SHAPES.to_vec(), + } +} + +fn profile() -> LigeritoProfile { + match std::env::var("BITZ_LIG_PROFILE").as_deref() { + Err(_) | Ok("fast") => LigeritoProfile::Fast, + Ok("slim") => LigeritoProfile::Slim, + Ok("secure") => LigeritoProfile::Secure, + Ok(other) => panic!("BITZ_LIG_PROFILE: unknown profile `{other}` (fast | slim | secure)"), + } +} + +/// Aborts on any exported `BITZ_*` variable this bench does not know. +fn enforce_known_env() { + let mut unknown: Vec = std::env::vars_os() + .filter_map(|(key, _)| key.into_string().ok()) + .filter(|key| key.starts_with("BITZ_") && !KNOWN_ENV.contains(&key.as_str())) + .collect(); + if unknown.is_empty() { + return; + } + unknown.sort(); + eprintln!( + "error: unknown BITZ_* variable(s): {}; known: {}", + unknown.join(", "), + KNOWN_ENV.join(", ") + ); + std::process::exit(2); +} diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 6223457b..bfc7e70d 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -9,13 +9,26 @@ //! The fixtures live here rather than under `tests/` so they compile once //! rather than once per test binary. -use common::{BitTable, BitZParams, LinearClaim, Root, Shape}; +use circuit::matrix_transpose::{MTransposeGenerator, MaterializedMTranspose}; +use circuit::sha256::{COMPRESSION_HINT_BITS, COMPRESSION_INPUT_BITS, compression_circuit}; +use circuit::witgen::Witgen; +use common::{ + BitTable, BitZParams, LinearClaim, Root, Shape, TransposedWeights, VirtualMap, VirtualMapError, + VirtualStatement, shape::PACK_BITS, +}; use crypto_primitives::LiftElement; use field::{F128, Fq, gf128::smallest_generator}; +use num_traits::{ConstOne, ConstZero}; use pcs::{HashKind, LigeritoProfile, Pcs, ProverData}; +use poly::eq_table; +use prover::VirtualWitness; use rand_chacha::ChaCha8Rng; use rand_core::{Rng, SeedableRng}; -use transcript::{Proof, ProverState, VerifierState, build_prover, build_verifier}; +use rayon::prelude::*; +use transcript::{ + Proof, ProverState, VerificationError, VerificationResult, VerifierState, build_prover, + build_verifier, +}; /// The specification's fixed modulus, `2^100 − 15`. Under it the fold bound /// admits every row width up to `t = 27`, so the reference split @@ -27,7 +40,7 @@ pub const Q: u128 = field::Q100; pub const WINDOW: u32 = 8; /// Builds a packed witness and advances the RNG past its words. -pub fn packed_witness(shape: Shape, rng: &mut impl RngCore) -> Vec { +pub fn packed_witness(shape: Shape, rng: &mut impl Rng) -> Vec { (0..1usize << shape.log_packed_len()) .map(|_| F128::new(rng.next_u64(), rng.next_u64())) .collect() @@ -154,3 +167,352 @@ pub fn prover_transcript() -> ProverState { pub fn verifier_transcript(proof: &Proof) -> VerifierState<'_> { build_verifier(SESSION, INSTANCE, proof) } + +/// A batch of independent SHA-256 compressions as the two tables the virtual +/// pipeline works on (2.2. "Virtual F_2-linear transforms in F2Z and +/// NP-complete dually linear relations"). +/// +/// Compression `j` is column `j` of both tables. Its 20456 assignment cells +/// `h_j = M_0 (1, f_j)` fill the assignment column's first rows, with the +/// circuit's constant cell left out: it is public, and the transposition +/// accounts for it. The source column holds the compression's 7144 +/// committed bits, block and state both inputs. Only power-of-two batches, +/// so every column is live. +#[derive(Debug, Clone)] +pub struct Sha256Batch { + pub log_compressions: usize, + /// `f`, column major, `2^SOURCE_LOG_ROWS` rows. + pub source: Vec, + /// `h`, column major, `2^ASSIGNMENT_LOG_ROWS` rows. + pub assignment: Vec, +} + +/// `7144 <= 2^13` committed bits per compression. +pub const SOURCE_LOG_ROWS: usize = 13; +/// `20456 <= 2^15` assignment cells per compression. +pub const ASSIGNMENT_LOG_ROWS: usize = 15; + +/// Committed bits per independent compression: the 768 input bits and the +/// hint bits. +pub const SOURCE_BITS: usize = COMPRESSION_INPUT_BITS + COMPRESSION_HINT_BITS; + +impl Sha256Batch { + /// Runs `2^log_compressions` compressions on random blocks and states. + pub fn generate(log_compressions: usize, seed: u64) -> Self { + let columns: Vec<(Vec, Vec)> = (0..1u64 << log_compressions) + .into_par_iter() + .map(|compression| { + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ compression.rotate_left(32)); + let inputs: [bool; COMPRESSION_INPUT_BITS] = + std::array::from_fn(|_| rng.next_u32() & 1 == 1); + let mut witgen = Witgen::with_inputs_and_capacity(&inputs, SOURCE_BITS); + let _ = compression_circuit(&mut witgen, &inputs); + let (source, assignment) = witgen.into_witnesses(); + debug_assert_eq!(source.bit_len(), SOURCE_BITS); + debug_assert_eq!(assignment.bit_len(), ASSIGNMENT_CELLS + 1); + ( + pack_column(source.words(), SOURCE_LOG_ROWS), + pack_column(&drop_constant_cell(assignment.words()), ASSIGNMENT_LOG_ROWS), + ) + }) + .collect(); + + let mut source = Vec::with_capacity(columns.len() << (SOURCE_LOG_ROWS - 7)); + let mut assignment = Vec::with_capacity(columns.len() << (ASSIGNMENT_LOG_ROWS - 7)); + for (f, h) in columns { + source.extend(f); + assignment.extend(h); + } + Self { + log_compressions, + source, + assignment, + } + } + + pub fn source_shape(&self) -> Shape { + Shape::new(SOURCE_LOG_ROWS, self.log_compressions).unwrap() + } + + pub fn assignment_shape(&self) -> Shape { + Shape::new(ASSIGNMENT_LOG_ROWS, self.log_compressions).unwrap() + } +} + +/// Assignment cells per compression, the constant cell excluded. +const ASSIGNMENT_CELLS: usize = 20_456; + +/// One column of `2^log_rows` bits from little-endian words, zero padded. +fn pack_column(words: &[u64], log_rows: usize) -> Vec { + (0..1usize << (log_rows - 7)) + .map(|element| { + let word = |index: usize| words.get(index).copied().unwrap_or(0); + F128::new(word(2 * element), word(2 * element + 1)) + }) + .collect() +} + +/// The integer witness without its leading constant cell: every bit moved +/// down one place. +fn drop_constant_cell(words: &[u64]) -> Vec { + (0..words.len()) + .map(|index| (words[index] >> 1) | words.get(index + 1).map_or(0, |next| next << 63)) + .collect() +} + +/// `M_0^T` for one compression: `M_0` has a row per assignment cell (the +/// constant cell first) and a column per committed bit (the constant first). +fn compression_transpose() -> MaterializedMTranspose { + let mut generator = MTransposeGenerator::new(COMPRESSION_INPUT_BITS); + let inputs = generator.take_boxed_inputs::(); + let _ = compression_circuit(&mut generator, &inputs); + generator.finish() +} + +/// The batch's map over `2^log_compressions` independent compressions. +pub fn compression_map(log_compressions: usize) -> CompressionMap { + CompressionMap { + compression: compression_transpose(), + log_compressions, + } +} + +/// The batch's map: `Id (x) M_0`, one compression per column. +/// +/// The transposition goes column by column: compression `c`'s cells are +/// rows `c 2^15 ..` of `h` and its bits rows `c 2^13 ..` of `f`, so `M_0^T` +/// moves the weights on the one onto the other. The constant cell is not in +/// the table, so its row weighs nothing; the constant column's weights add +/// up across the compressions and leave the target. +#[derive(Debug)] +pub struct CompressionMap { + pub compression: MaterializedMTranspose, + pub log_compressions: usize, +} + +impl VirtualMap for CompressionMap { + fn transpose(&self, weights: &[F128]) -> Result { + if weights.len() < self.h_len() { + return Err(VirtualMapError::WeightCountMismatch); + } + let cells = self.compression.row_count() - 1; + let bits = self.compression.column_count() - 1; + let columns: Vec> = (0..1usize << self.log_compressions) + .into_par_iter() + .map(|compression| { + let mut challenges = Vec::with_capacity(cells + 1); + challenges.push(F128::ZERO); + challenges + .extend_from_slice(&weights[compression << ASSIGNMENT_LOG_ROWS..][..cells]); + self.compression.apply(&challenges).unwrap() + }) + .collect(); + let constant_weight = columns.iter().map(|column| column[0]).sum(); + let mut on_bits = vec![F128::ZERO; self.f_len() - 1]; + for (compression, column) in columns.iter().enumerate() { + on_bits[compression << SOURCE_LOG_ROWS..][..bits].copy_from_slice(&column[1..]); + } + Ok(TransposedWeights::new(on_bits, constant_weight)) + } + + fn digest(&self) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"bitz/tests/compression-map/v1"); + hasher.update(&(self.log_compressions as u64).to_le_bytes()); + hasher.update(&self.compression.digest()); + *hasher.finalize().as_bytes() + } + + /// Through the last compression's cells, in `h`'s layout. + fn h_len(&self) -> usize { + let last = (1usize << self.log_compressions) - 1; + (last << ASSIGNMENT_LOG_ROWS) + self.compression.row_count() - 1 + } + + /// Through the last compression's bits, in `f`'s layout, plus the + /// constant. + fn f_len(&self) -> usize { + let last = (1usize << self.log_compressions) - 1; + 1 + (last << SOURCE_LOG_ROWS) + self.compression.column_count() - 1 + } +} + +/// Stands in for the PIOP (step 3 of 2.1. "A simple version of BitZ"). +/// +/// A PIOP ends on an evaluation claim `MLE[h](r) = y` on the assignment. The +/// mock squeezes `r` and has the prover compute `y` from `h` and send it, +/// where the PIOP would leave the verifier holding it. +#[derive(Debug, Clone, Copy)] +pub struct MockSpartan; + +impl MockSpartan { + /// Squeezes `r`, evaluates `MLE[h](r)` over `table`, and sends it. + pub fn claim_prover( + params: &BitZParams, + table: &BitTable<'_>, + transcript: &mut ProverState, + ) -> LinearClaim> { + let shape = params.shape(); + let row_point: Vec> = (0..shape.log_rows()) + .map(|_| sample_fq(transcript.verifier_message())) + .collect(); + let column_point: Vec> = (0..shape.log_columns()) + .map(|_| sample_fq(transcript.verifier_message())) + .collect(); + let row_weights = eq_table(&row_point); + let column_weights = eq_table(&column_point); + let target = evaluate_fq(table, &row_weights, &column_weights); + transcript.prover_message(&target); + + LinearClaim::new(params, row_weights, column_weights, target).unwrap() + } + + /// Squeezes the same `r` and reads `y`. + pub fn claim_verifier( + params: &BitZParams, + transcript: &mut VerifierState<'_>, + ) -> VerificationResult>> { + let shape = params.shape(); + let row_point: Vec> = (0..shape.log_rows()) + .map(|_| sample_fq(transcript.verifier_message())) + .collect(); + let column_point: Vec> = (0..shape.log_columns()) + .map(|_| sample_fq(transcript.verifier_message())) + .collect(); + let target = transcript.prover_message::>()?; + + LinearClaim::new( + params, + eq_table(&row_point), + eq_table(&column_point), + target, + ) + .map_err(|_| VerificationError) + } +} + +/// A field element from 256 squeezed bits: the bias is below `2^-150`. +fn sample_fq(bytes: [u8; 32]) -> Fq { + let (low, high) = bytes.split_at(16); + let low = u128::from_le_bytes(low.try_into().unwrap()); + let high = u128::from_le_bytes(high.try_into().unwrap()); + let shift = Fq::from(u128::MAX) + Fq::ONE; + Fq::from(high) * shift + Fq::from(low) +} + +/// `` over `F_q`: each set bit adds its row weight, +/// each column is then scaled by its weight. +fn evaluate_fq( + table: &BitTable<'_>, + row_weights: &[Fq], + column_weights: &[Fq], +) -> Fq { + (0..table.shape().columns()) + .into_par_iter() + .map(|column| { + let mut sum = Fq::ZERO; + for (index, element) in table.column(column).iter().enumerate() { + let base = index << PACK_BITS; + for (half, mut remaining) in [(0, element.lo), (64, element.hi)] { + while remaining != 0 { + sum += row_weights[base + half + remaining.trailing_zeros() as usize]; + remaining &= remaining - 1; + } + } + } + column_weights[column] * sum + }) + .sum() +} + +/// A committed batch with everything both sides hold: the assignment's +/// parameters, the source's opening scheme, and the map from the source to +/// the assignment. +pub struct Sha256Instance { + pub batch: Sha256Batch, + /// Shaped to the assignment. + pub params: BitZParams, + pub prover: prover::BitZProver, + pub verifier: verifier::BitZVerifier, + pub pcs: Pcs, + pub com: Root, + pub data: ProverData, + pub map: CompressionMap, +} + +/// A rejected SHA-256 proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sha256VerifyError { + /// The PIOP mock's claimed value is missing or malformed. + Claim, + Verify(verifier::VerifyError), +} + +impl Sha256Instance { + /// Generates and commits `2^log_compressions` independent compressions. + pub fn new(log_compressions: usize, profile: LigeritoProfile, seed: u64) -> Self { + Self::commit( + Sha256Batch::generate(log_compressions, seed), + profile, + compression_map(log_compressions), + ) + } + + /// Commits a generated batch under `profile`, with `map` from its source + /// to its assignment. + pub fn commit(batch: Sha256Batch, profile: LigeritoProfile, map: CompressionMap) -> Self { + let params = BitZParams::::new(batch.assignment_shape(), smallest_generator()).unwrap(); + let pcs = Pcs::new(&batch.source_shape(), profile, HashKind::Blake3).unwrap(); + let (com, data) = pcs.commit(&batch.source).unwrap(); + Self { + batch, + params, + prover: prover::BitZProver::new(params, WINDOW), + verifier: verifier::BitZVerifier::new(params, WINDOW), + pcs, + com, + data, + map, + } + } + + /// The statement both sides bind: the assignment's parameters, the + /// source's shape, the map, and the mocked PIOP's claim. + pub fn statement<'a>( + &'a self, + claim: &'a LinearClaim>, + ) -> VirtualStatement<'a, Q, CompressionMap> { + VirtualStatement::new(self.params, self.batch.source_shape(), &self.map, claim) + .expect("the map fits both shapes") + } + + /// The mocked PIOP's claim, then `prove_virtual` over the assignment, + /// opened against the source. + pub fn prove(&self) -> Proof { + let mut transcript = prover_transcript(); + let table = self.params.table(&self.batch.assignment).unwrap(); + let claim = MockSpartan::claim_prover(&self.params, &table, &mut transcript); + self.prover + .prove_virtual( + &self.statement(&claim), + &self.pcs, + &self.data, + VirtualWitness { + committed_bits: self.batch.source.clone(), + virtual_bits: &self.batch.assignment, + }, + &mut transcript, + ) + .expect("honest batch"); + transcript.finish() + } + + pub fn verify(&self, proof: &Proof) -> Result<(), Sha256VerifyError> { + let mut transcript = verifier_transcript(proof); + let claim = MockSpartan::claim_verifier(&self.params, &mut transcript) + .map_err(|_| Sha256VerifyError::Claim)?; + self.verifier + .verify_virtual(&self.statement(&claim), &self.pcs, self.com, transcript) + .map_err(Sha256VerifyError::Verify) + } +} diff --git a/crates/tests/tests/sha256.rs b/crates/tests/tests/sha256.rs new file mode 100644 index 00000000..70b7db42 --- /dev/null +++ b/crates/tests/tests/sha256.rs @@ -0,0 +1,26 @@ +//! SHA-256 compressions through the virtual pipeline: the mocked PIOP's claim +//! on the assignment, the fold, the grand product, the transposition onto the +//! committed bits, the sumcheck and the opening. + +use field::Q100; +use pcs::LigeritoProfile; +use tests::Sha256Instance; + +#[test] +fn a_batch_of_compressions_proves_and_verifies_against_the_committed_bits() { + // `2^9` compressions: the smallest batch whose source table clears the + // commitment floor. + let instance = Sha256Instance::::new(9, LigeritoProfile::Fast, 61); + let batch = &instance.batch; + assert_eq!( + batch.source.len(), + 1 << (batch.source_shape().log_bits() - 7) + ); + assert_eq!( + batch.assignment.len(), + 1 << (batch.assignment_shape().log_bits() - 7) + ); + + let proof = instance.prove(); + instance.verify(&proof).expect("honest proof"); +} diff --git a/crates/verifier/src/lib.rs b/crates/verifier/src/lib.rs index 2dc61841..1bdc63e4 100644 --- a/crates/verifier/src/lib.rs +++ b/crates/verifier/src/lib.rs @@ -6,6 +6,6 @@ pub mod setup; pub mod verify; pub use fold::ReceiveError; -pub use reduce::ReduceError; +pub use reduce::{ReduceError, gkr_reduce}; pub use setup::BitZVerifier; pub use verify::VerifyError; diff --git a/crates/verifier/src/reduce.rs b/crates/verifier/src/reduce.rs index 73de8980..1bee05f2 100644 --- a/crates/verifier/src/reduce.rs +++ b/crates/verifier/src/reduce.rs @@ -9,22 +9,28 @@ use common::{ClaimError, Fold, LinearClaim, OpeningQuery, Shape}; use field::F128; -use num_traits::ConstOne; +use num_traits::{ConstOne, ConstZero}; use transcript::VerifierState; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReduceError { /// The GKR transcript does not satisfy a sumcheck or child-product relation. GKR, + /// The fold's batch point has a zero coordinate, which the GKR rounds + /// divide by. + DegenerateChallenge, /// The derived weight counts do not match the configured shape. Claim(ClaimError), } -pub(crate) fn gkr_reduce( +pub fn gkr_reduce( transcript: &mut VerifierState, fold: &Fold, shape: &Shape, ) -> Result { + if fold.zeta.contains(&F128::ZERO) { + return Err(ReduceError::DegenerateChallenge); + } // Each layer halves the row count, leaving one product per column. let r1 = fold.row_images.len().max(1).ilog2(); From 4513286fef846b1b2b2b7014a2fb0f28b3af8ee5 Mon Sep 17 00:00:00 2001 From: Alexander Abdugafarov Date: Wed, 16 Sep 2026 16:06:55 +0100 Subject: [PATCH 2/4] Tweaks --- Cargo.lock | 1 - crates/common/src/table.rs | 4 +- crates/common/src/virtual_map.rs | 13 ++- crates/field/benches/binius64.rs | 9 +- crates/field/benches/gf128.rs | 10 +- crates/field/src/gf128.rs | 5 + crates/pcs/src/lib.rs | 5 +- crates/pcs/src/opening.rs | 59 +++++----- crates/pcs/src/opening/tests.rs | 5 +- crates/post_gkr/Cargo.toml | 5 - crates/post_gkr/benches/reduce.rs | 106 ------------------ crates/post_gkr/src/lib.rs | 157 +++++++++++++-------------- crates/post_gkr/src/sumcheck.rs | 132 +++++++++++----------- crates/prover/src/prove.rs | 4 +- crates/tests/benches/sha256.rs | 18 +-- crates/tests/benches/sha256_steps.rs | 3 +- crates/tests/src/lib.rs | 15 +-- 17 files changed, 218 insertions(+), 333 deletions(-) delete mode 100644 crates/post_gkr/benches/reduce.rs diff --git a/Cargo.lock b/Cargo.lock index 871bee6b..16bc4719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,7 +1046,6 @@ name = "post_gkr" version = "0.1.0" dependencies = [ "common", - "divan", "field", "num-traits", "poly", diff --git a/crates/common/src/table.rs b/crates/common/src/table.rs index c2e63051..61196980 100644 --- a/crates/common/src/table.rs +++ b/crates/common/src/table.rs @@ -346,7 +346,7 @@ mod tests { for (index, element) in packed.iter().enumerate() { let column = index / groups_per_column; let i_hi = index % groups_per_column; - let bits = u128::from(element.lo) | (u128::from(element.hi) << 64); + let bits = element.to_u128(); for v in 0..PACKED_BITS { assert_eq!( (bits >> v) & 1 == 1, @@ -401,7 +401,7 @@ mod tests { let table = BitTable::new(shape, &packed).unwrap(); for (index, element) in table.column(2).iter().enumerate() { - let bits = u128::from(element.lo) | (u128::from(element.hi) << 64); + let bits = element.to_u128(); for offset in 0..PACKED_BITS { let row = index * PACKED_BITS + offset; assert_eq!(table.bit(2, row), (bits >> offset) & 1 == 1, "row {row}"); diff --git a/crates/common/src/virtual_map.rs b/crates/common/src/virtual_map.rs index baa89939..a59e2d03 100644 --- a/crates/common/src/virtual_map.rs +++ b/crates/common/src/virtual_map.rs @@ -157,8 +157,9 @@ impl<'a, const Q: u128, M: VirtualMap> VirtualStatement<'a, Q, M> { /// /// Coefficients use `column * row_count + row` order. The map drops weights on /// virtual padding. The opening subtracts the constant-column weight from the - /// target and zero-pads the remaining weights to the commitment size. A - /// single-column `InnerProduct` holds the dense weights with column weight one. + /// target and zero-pads the remaining weights to the commitment size, refusing + /// more weights than it has. A single-column `InnerProduct` holds the dense + /// weights with column weight one. pub fn transpose_query(&self, query: OpeningQuery) -> Result { let shape = self.params.claim().shape(); let (weights, target) = match query { @@ -200,11 +201,11 @@ impl<'a, const Q: u128, M: VirtualMap> VirtualStatement<'a, Q, M> { } } -/// `column_weights (x) row_weights` written out per bit of `h`, one column +/// `row_weights (x) column_weights` written out per bit of `h`, one column /// after another. fn flatten(row_weights: &[F128], column_weights: &[F128]) -> Vec { let mut weights = vec![F128::ZERO; row_weights.len() * column_weights.len()]; - let column = |(slot, &scale): (&mut [F128], &F128)| { + let process_column = |(slot, &scale): (&mut [F128], &F128)| { for (weight, &row) in slot.iter_mut().zip(row_weights) { *weight = scale * row; } @@ -213,12 +214,12 @@ fn flatten(row_weights: &[F128], column_weights: &[F128]) -> Vec { weights .par_chunks_mut(row_weights.len()) .zip(column_weights) - .for_each(column); + .for_each(process_column); #[cfg(not(feature = "parallel"))] weights .chunks_mut(row_weights.len()) .zip(column_weights) - .for_each(column); + .for_each(process_column); weights } diff --git a/crates/field/benches/binius64.rs b/crates/field/benches/binius64.rs index b67ff43e..6350bc68 100644 --- a/crates/field/benches/binius64.rs +++ b/crates/field/benches/binius64.rs @@ -63,7 +63,7 @@ fn operands(count: usize, seed: u64) -> Vec { } fn to_binius(a: F128) -> Ghash { - Ghash::from((a.hi as u128) << 64 | a.lo as u128) + Ghash::from(a.to_u128()) } fn from_binius(a: Ghash) -> F128 { @@ -117,7 +117,7 @@ fn check_agreement(xs: &[F128], ys: &[F128]) { ); // The timed exponents are the operands read as integers, so raising the // generator to `x` here is the same call the `pow` row makes. - let e = (x.hi as u128) << 64 | x.lo as u128; + let e = x.to_u128(); assert_eq!( F128::GENERATOR.pow(e), from_binius(to_binius(F128::GENERATOR).pow([e as u64, (e >> 64) as u64])), @@ -342,10 +342,7 @@ fn main() { // Full-width exponents, square-and-multiply on both sides. This crate's // fixed-base comb has no counterpart in binius64, so it is left out. - let exps: Vec = xs - .iter() - .map(|x| (x.hi as u128) << 64 | x.lo as u128) - .collect(); + let exps: Vec = xs.iter().map(|x| x.to_u128()).collect(); let bgen = to_binius(F128::GENERATOR); c.run( "pow/square-and-multiply", diff --git a/crates/field/benches/gf128.rs b/crates/field/benches/gf128.rs index 46a3b828..e0051247 100644 --- a/crates/field/benches/gf128.rs +++ b/crates/field/benches/gf128.rs @@ -113,10 +113,7 @@ fn inverse(bencher: Bencher) { /// Full-width exponents: what the fold values are. #[divan::bench] fn pow_square_and_multiply(bencher: Bencher) { - let exps: Vec = xs() - .iter() - .map(|x| (x.hi as u128) << 64 | x.lo as u128) - .collect(); + let exps: Vec = xs().iter().map(|x| x.to_u128()).collect(); bencher.counter(ItemsCount::new(N)).bench_local(|| { for &e in &exps { black_box(F128::GENERATOR.pow(e)); @@ -127,10 +124,7 @@ fn pow_square_and_multiply(bencher: Bencher) { #[divan::bench] fn pow_comb_w8(bencher: Bencher) { let comb = FixedBasePow::new(F128::GENERATOR, 8); - let exps: Vec = xs() - .iter() - .map(|x| (x.hi as u128) << 64 | x.lo as u128) - .collect(); + let exps: Vec = xs().iter().map(|x| x.to_u128()).collect(); bencher.counter(ItemsCount::new(N)).bench_local(|| { for &e in &exps { black_box(comb.pow(e)); diff --git a/crates/field/src/gf128.rs b/crates/field/src/gf128.rs index 53c3bce2..c5e44e08 100644 --- a/crates/field/src/gf128.rs +++ b/crates/field/src/gf128.rs @@ -118,6 +118,11 @@ impl F128 { const fn words(self) -> [u64; 2] { [self.lo, self.hi] } + + /// `lo || hi` as one `u128`. + pub const fn to_u128(&self) -> u128 { + self.lo as u128 | ((self.hi as u128) << 64) + } } /// The bit pattern as 32 hex digits, `hi` first — how the polynomial reads on diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 68ab2c40..dd57ecb3 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -117,8 +117,9 @@ pub enum StatementBinding { /// Uses a statement that the caller already bound. /// /// The caller must bind the same PCS parameters, commitment, query variant, fields, and target. - /// For inner products, this covers both factor lengths, both factors, and the original target. - /// The opening code still binds the MLE claim that sumcheck returns. + /// For inner products, this covers both factor lengths, both factors (`Bind` absorbs a digest + /// of them), and the original target. The opening code still binds the MLE claim the sumcheck + /// returns. AlreadyBound, } diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index fdd7c850..64a594d3 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -7,14 +7,12 @@ use flock_core::pcs::pack::PACKING_WIDTH as CLAIM_COUNT; use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; -use crate::ligerito::{self, ReducedProver}; +use crate::ligerito::{self, ReducedProver, validate_prover_data}; use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle}; const MLE_STATEMENT_LABEL: &[u8] = b"bitz/pcs/mle-opening/v1"; const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v3"; const INNER_PRODUCT_DIGEST_CONTEXT: &str = "bitz/pcs/bit-inner-product-weights/v1"; -/// Weights per digest update: `2^16` elements, one megabyte. -const INNER_PRODUCT_DIGEST_CHUNK: usize = 1 << 16; const SUMCHECK_LABEL: &[u8] = b"bitz/pcs/inner-product-sumcheck/v2"; const MLE_CLAIMS_LABEL: &[u8] = b"bitz/pcs/mle-claims/v1"; const CHALLENGES_LABEL: &[u8] = b"bitz/pcs/ring-switch-challenges/v1"; @@ -122,22 +120,23 @@ pub(crate) fn prove( } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; - let prover = ReducedProver::new(pcs, data, packed_witness)?; + validate_prover_data(pcs, data)?; if statement_binding == StatementBinding::Bind { bind_inner_product_statement(pcs, &data.commitment().root, claim, transcript); } transcript.public_message(SUMCHECK_LABEL); - let reduced = post_gkr::prove(claim, prover.witness(), transcript)?; - let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; - // AlreadyBound covers the original claim, before the reduction produces this MLE claim. - bind_mle_statement( + let reduced = post_gkr::prove(claim, &packed_witness, transcript)?; + // The evaluation claim the sumcheck leaves is opened like any + // other, and bound whatever the caller's mode: `AlreadyBound` + // covers the original claim only. + prove( pcs, - &data.commitment().root, - &reduced.point, - reduced.target, + data, + packed_witness, + &reduced, + StatementBinding::Bind, transcript, - ); - prove_mle(prover, ring_switch, reduced.target, transcript) + ) } } } @@ -164,15 +163,13 @@ pub(crate) fn verify( } transcript.public_message(SUMCHECK_LABEL); let reduced = post_gkr::verify(claim, transcript)?; - let ring_switch = mle::RingSwitch::new(&reduced.point, pcs.params().m)?; - bind_mle_statement( + verify( pcs, - &commitment.0, - &reduced.point, - reduced.target, + commitment, + &reduced, + StatementBinding::Bind, transcript, - ); - verify_mle(pcs, commitment, ring_switch, reduced.target, transcript) + ) } } } @@ -282,26 +279,30 @@ fn bind_inner_product_statement( claim: &LinearClaim, transcript: &mut impl PublicTranscript, ) { - // Little-endian words, a megabyte at a time, hashed on the pool; the - // digest does not depend on the chunking. + transcript.public_message(INNER_PRODUCT_STATEMENT_LABEL); + transcript.public_message(root); + transcript.public_message(pcs); + transcript.public_message(&(claim.row_weights().len() as u64)); + transcript.public_message(&(claim.column_weights().len() as u64)); + + // Weights per digest update: `2^16` elements, one megabyte. + const INNER_PRODUCT_DIGEST_CHUNK: usize = 1 << 16; + + // Hashing this way is much faster that going through `public_message` directly. + // Does not depend on the chunking. let mut hasher = blake3::Hasher::new_derive_key(INNER_PRODUCT_DIGEST_CONTEXT); let mut buffer = Vec::with_capacity(INNER_PRODUCT_DIGEST_CHUNK * 16); for factor in [claim.row_weights(), claim.column_weights()] { for chunk in factor.chunks(INNER_PRODUCT_DIGEST_CHUNK) { buffer.clear(); for weight in chunk { - buffer.extend_from_slice(&weight.lo.to_le_bytes()); - buffer.extend_from_slice(&weight.hi.to_le_bytes()); + buffer.extend_from_slice(&weight.to_bytes()); } hasher.update_rayon(&buffer); } } - transcript.public_message(INNER_PRODUCT_STATEMENT_LABEL); - transcript.public_message(root); - transcript.public_message(pcs); - transcript.public_message(&(claim.row_weights().len() as u64)); - transcript.public_message(&(claim.column_weights().len() as u64)); transcript.public_message(hasher.finalize().as_bytes()); + transcript.public_message(&claim.target()); } diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index 286b94a4..9917ad19 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -73,10 +73,7 @@ fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { verify( &fixture.pcs, &fixture.root, - &OpeningQuery::Mle { - point: reduced.point, - target: reduced.target, - }, + &reduced, StatementBinding::Bind, &mut verifier, ) diff --git a/crates/post_gkr/Cargo.toml b/crates/post_gkr/Cargo.toml index 01b2c2d3..c20dc8c3 100644 --- a/crates/post_gkr/Cargo.toml +++ b/crates/post_gkr/Cargo.toml @@ -18,10 +18,5 @@ rayon = { workspace = true, optional = true } transcript = { workspace = true } [dev-dependencies] -divan = { workspace = true } rand_core = { workspace = true } rand_pcg = { workspace = true } - -[[bench]] -name = "reduce" -harness = false diff --git a/crates/post_gkr/benches/reduce.rs b/crates/post_gkr/benches/reduce.rs deleted file mode 100644 index 99af31d1..00000000 --- a/crates/post_gkr/benches/reduce.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! The post-GKR sumcheck on its own, prover and verifier. -//! -//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p post_gkr --bench reduce`. -//! Without the flag the field falls back to its portable kernel, which is -//! the whole difference on every multiplication. -//! -//! The claim holds by construction over a pseudo-random witness, so the -//! prover's own check passes and the rounds run. One weight per bit, the -//! shape a virtual map leaves: `m = 22`, the commitment floor, and -//! `m = 25`, the SHA-256 batch as the step bench commits it. - -use std::sync::OnceLock; - -use common::{LinearClaim, Shape}; -use divan::Bencher; -use divan::counter::ItemsCount; -use field::F128; -use num_traits::ConstZero; -use rand_core::{Rng, SeedableRng}; -use rand_pcg::Pcg64; -use transcript::{Proof, build_prover, build_verifier}; - -const LOG_BITS: &[usize] = &[22, 25]; - -const SEED: u64 = 0x_5245_4455_4345_0000; - -fn main() { - divan::main(); -} - -/// One witness and claim per size, built on first use. -struct Fixture { - packed: Vec, - claim: LinearClaim, -} - -static FIXTURES: OnceLock> = OnceLock::new(); - -fn fixture(log_bits: usize) -> &'static Fixture { - let fixtures = FIXTURES.get_or_init(|| { - LOG_BITS - .iter() - .map(|&log_bits| (log_bits, Fixture::new(log_bits))) - .collect() - }); - &fixtures - .iter() - .find(|(candidate, _)| *candidate == log_bits) - .expect("every size is built up front") - .1 -} - -fn random(rng: &mut Pcg64, count: usize) -> Vec { - (0..count) - .map(|_| F128::new(rng.next_u64(), rng.next_u64())) - .collect() -} - -impl Fixture { - fn new(log_bits: usize) -> Self { - let mut rng = Pcg64::seed_from_u64(SEED ^ log_bits as u64); - let packed = random(&mut rng, 1 << (log_bits - 7)); - let weights = random(&mut rng, 1 << log_bits); - let mut target = F128::ZERO; - for (index, element) in packed.iter().enumerate() { - let mut bits = u128::from(element.lo) | (u128::from(element.hi) << 64); - while bits != 0 { - target += weights[(index << 7) | bits.trailing_zeros() as usize]; - bits &= bits - 1; - } - } - let shape = Shape::new(log_bits, 0).unwrap(); - let claim = - LinearClaim::from_shape(&shape, weights, vec![F128::from(1u64)], target).unwrap(); - Self { packed, claim } - } - - fn proof(&self) -> Proof { - let mut transcript = build_prover("post_gkr-bench", "reduce"); - post_gkr::prove(&self.claim, &self.packed, &mut transcript).unwrap(); - transcript.finish() - } -} - -fn bits(log_bits: usize) -> ItemsCount { - ItemsCount::new(1usize << log_bits) -} - -#[divan::bench(args = LOG_BITS, sample_count = 5, sample_size = 1)] -fn prove(bencher: Bencher, log_bits: usize) { - let fixture = fixture(log_bits); - bencher.counter(bits(log_bits)).bench_local(|| { - let mut transcript = build_prover("post_gkr-bench", "reduce"); - post_gkr::prove(&fixture.claim, &fixture.packed, &mut transcript).unwrap() - }); -} - -#[divan::bench(args = LOG_BITS, sample_count = 5, sample_size = 1)] -fn verify(bencher: Bencher, log_bits: usize) { - let fixture = fixture(log_bits); - let proof = fixture.proof(); - bencher.counter(bits(log_bits)).bench_local(|| { - let mut transcript = build_verifier("post_gkr-bench", "reduce", &proof); - post_gkr::verify(&fixture.claim, &mut transcript).unwrap() - }); -} diff --git a/crates/post_gkr/src/lib.rs b/crates/post_gkr/src/lib.rs index 8c93cf26..23d38708 100644 --- a/crates/post_gkr/src/lib.rs +++ b/crates/post_gkr/src/lib.rs @@ -1,29 +1,27 @@ -//! Step 5.3 of 2.1. "A simple version of BitZ": from the grand product's linear -//! claim on the committed bits to the evaluation claim the opening takes. +//! Step 6 of 2.1. "The prime field case": from the GKR's inner product +//! claim on the committed bits to the MLE evaluation claim the opening takes. //! -//! The grand product (6. "A GKR protocol for low entropy batched grand -//! products via lookup tables") ends on ` = C - 1` +//! The GKR protocol ends on ` = C - 1` //! over the `2^t x 2^s` committed bits `f`, a `LinearClaim` with row //! factor `omega` and column factor `eq(., r_c)`. A virtualization `h = M f` -//! (2.2. "Virtual F_2-linear transforms in F2Z and NP-complete dually linear -//! relations") moves it onto `f` as ``, one weight per bit: the -//! same type with a single column of weight one. +//! (2.4. "Virtual F_2-linear transforms, NP-complete multi-domain linear +//! relations, and hybrid proof systems") moves it onto `f` as ``, +//! one weight per bit: the same type with a single column of weight one. //! //! The opening scheme (`pcs`) proves evaluation claims `MLE[f](r) = v` and -//! does the ring switch from the bits to the packed vector itself (Appendix -//! B. "Ring switching via Galois orbits"). This crate's sumcheck turns the -//! linear claim into such an evaluation claim: all `m = t + s` variables -//! are bound against the bits, the row ones first as the bits are indexed. -//! The verifier's closing weight `MLE[omega](rho_b) MLE[eq](rho_c)` costs -//! `2^t + 2^s` multiplications, so it is linear in the bits only when a -//! factor is. +//! does the ring switch from the bits to the packed vector itself (Binius's, +//! as Step 6 cites it). This crate's sumcheck turns the linear claim into +//! such an evaluation claim: all `m = t + s` variables are bound against the +//! bits, the row ones first as the bits are indexed. The verifier's closing +//! weight `MLE[rows](rho_b) MLE[columns](rho_c)` costs `2^t + 2^s` +//! multiplications, so it is linear in the bits only when a factor is. //! //! The first round is taken off the packed bits: a bit is zero or one, so //! the round's coefficients are sums of weights with no multiplication, and -//! the table the prover folds to has one entry per two bits. The weights -//! are never written out in full: the folded table is the folded row factor -//! tensored with the column factor. Proof: `2m + 1` elements, `rho` low -//! coordinate first ([`MleClaim`]). +//! the tables the prover folds to have one entry per two bits. The weights +//! are never written out in full: their folded table is the folded row +//! factor tensored with the column factor. Proof: `2m + 1` elements, `rho` +//! low coordinate first ([`OpeningQuery::Mle`]). //! //! # Transcript //! @@ -37,8 +35,9 @@ mod sumcheck; #[cfg(test)] mod test_util; -use common::LinearClaim; +use crate::sumcheck::{Pair, RoundMessage}; use common::shape::PACK_BITS; +use common::{LinearClaim, OpeningQuery}; use field::F128; use num_traits::{ConstOne, ConstZero}; #[cfg(feature = "parallel")] @@ -47,22 +46,6 @@ use poly::parallel::workload_size; use rayon::prelude::*; use transcript::{ProverState, VerifierState}; -use crate::sumcheck::{ - Pair, RoundMessage, advance, evaluate, folded, prove_evaluation, prove_rounds, - verify_evaluation, verify_rounds, -}; - -/// The evaluation claim the sumcheck leaves: `MLE[f](point) = target` over -/// the committed bits. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MleClaim { - /// The challenges, low coordinate first: the `t` row coordinates then - /// the `s` column ones. - pub point: Vec, - /// `v`, the prover's closing evaluation. - pub target: F128, -} - /// A reduction the prover cannot run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProveError { @@ -84,21 +67,22 @@ pub enum VerifyError { /// The bits one packed element carries. const ELEMENT_BITS: usize = 1 << PACK_BITS; -/// The even bit positions of a packed element. -const EVEN: u128 = 0x5555_5555_5555_5555_5555_5555_5555_5555; +/// The even bit positions of a packed element, `0b01010101...` +const EVEN: u128 = 0x55555555555555555555555555555555; -/// Packed elements per parallel task: the weights they carry fill the cache -/// budget. +/// Packed elements per parallel task, enough to fill the cache. #[cfg(feature = "parallel")] const ELEMENTS_PER_TASK: usize = workload_size::() / ELEMENT_BITS; /// Runs the sumcheck for `claim` on `packed`, the committed `f` column -/// major, and returns the evaluation claim it leaves. +/// major, and returns the evaluation claim it leaves: `MLE[f](rho) = v` as +/// an [`OpeningQuery::Mle`], `rho` the challenges low coordinate first and +/// `v` the prover's closing evaluation. pub fn prove( claim: &LinearClaim, packed: &[F128], transcript: &mut ProverState, -) -> Result { +) -> Result { prove_factors( claim.row_weights(), claim.column_weights(), @@ -114,7 +98,7 @@ pub fn prove( pub fn verify( claim: &LinearClaim, transcript: &mut VerifierState<'_>, -) -> Result { +) -> Result { verify_factors( claim.row_weights(), claim.column_weights(), @@ -131,7 +115,7 @@ fn prove_factors( target: F128, packed: &[F128], transcript: &mut ProverState, -) -> Result { +) -> Result { let factors = Factors { rows, columns }; if packed.len() != factors.packed_len() { return Err(ProveError::WitnessLengthMismatch); @@ -145,13 +129,10 @@ fn prove_factors( let challenge: F128 = transcript.verifier_message(); let running = advance(target, message, challenge); let mut pair = Pair::new(factors.folded(challenge), bind_first(packed, challenge)); - - let rounds = factors.log_bits() - 1; - let (rest, running) = prove_rounds(&mut pair, rounds, running, transcript); - let target = prove_evaluation(&pair, running, transcript); + let (rest, target) = sumcheck::prove(&mut pair, running, transcript); let mut point = vec![challenge]; point.extend(rest); - Ok(MleClaim { point, target }) + Ok(OpeningQuery::Mle { point, target }) } /// [`verify`] on the claim's parts. @@ -160,14 +141,16 @@ fn verify_factors( columns: &[F128], target: F128, transcript: &mut VerifierState<'_>, -) -> Result { +) -> Result { let log_rows = rows.len().trailing_zeros() as usize; let rounds = log_rows + columns.len().trailing_zeros() as usize; - let (point, running) = verify_rounds(rounds, target, transcript)?; // `MLE[rows (x) columns](rho) = MLE[rows](rho_b) MLE[columns](rho_c)`. - let weight = evaluate(rows, &point[..log_rows]) * evaluate(columns, &point[log_rows..]); - let target = verify_evaluation(weight, running, transcript)?; - Ok(MleClaim { point, target }) + let weight = |point: &[F128]| { + sumcheck::evaluate(rows, &point[..log_rows]) + * sumcheck::evaluate(columns, &point[log_rows..]) + }; + let (point, target) = sumcheck::verify(rounds, target, weight, transcript)?; + Ok(OpeningQuery::Mle { point, target }) } /// The weights `rows (x) columns` over the bits, read without being written @@ -179,10 +162,6 @@ struct Factors<'a> { } impl Factors<'_> { - fn log_bits(&self) -> usize { - (self.rows.len() * self.columns.len()).trailing_zeros() as usize - } - fn packed_len(&self) -> usize { (self.rows.len() >> PACK_BITS) * self.columns.len() } @@ -198,7 +177,10 @@ impl Factors<'_> { fn weighted_sum(&self, packed: &[F128]) -> F128 { let element = |(index, &element): (usize, &F128)| -> F128 { let (rows, column) = self.element(index); - column * set_bits(bits(element)).map(|v| rows[v]).sum::() + column + * iter_over_set_bits(element.to_u128()) + .map(|v| rows[v]) + .sum::() }; #[cfg(feature = "parallel")] return packed @@ -218,9 +200,9 @@ impl Factors<'_> { fn first_message(&self, packed: &[F128]) -> RoundMessage { let element = |(index, &element): (usize, &F128)| -> (F128, F128) { let (rows, column) = self.element(index); - let bits = bits(element); - let a0: F128 = set_bits(bits & EVEN).map(|v| rows[v]).sum(); - let a2: F128 = set_bits((bits ^ (bits >> 1)) & EVEN) + let bits = element.to_u128(); + let a0: F128 = iter_over_set_bits(bits & EVEN).map(|v| rows[v]).sum(); + let a2: F128 = iter_over_set_bits((bits ^ (bits >> 1)) & EVEN) .map(|v| rows[v] + rows[v + 1]) .sum(); (column * a0, column * a2) @@ -245,7 +227,7 @@ impl Factors<'_> { /// The weights with their first variable bound: the folded row factor /// tensored with the column factor, one entry per two bits. fn folded(&self, challenge: F128) -> Vec { - let rows = folded(self.rows, challenge); + let rows = sumcheck::folded(self.rows, challenge); let mut table = Vec::with_capacity(rows.len() * self.columns.len()); for &column in self.columns { table.extend(rows.iter().map(|&row| column * row)); @@ -254,14 +236,8 @@ impl Factors<'_> { } } -/// `lo || hi` as one word, bit `v` the row at offset `v` of the 128 the -/// element covers (`common::BitTable`). -fn bits(element: F128) -> u128 { - u128::from(element.lo) | (u128::from(element.hi) << 64) -} - /// The positions of the set bits of `word`, ascending. -fn set_bits(mut word: u128) -> impl Iterator { +fn iter_over_set_bits(mut word: u128) -> impl Iterator { std::iter::from_fn(move || { (word != 0).then(|| { let position = word.trailing_zeros() as usize; @@ -275,10 +251,10 @@ fn set_bits(mut word: u128) -> impl Iterator { /// `(f_0, f_1)`, `f_0 + rho (f_0 + f_1)`, one of `0`, `1`, `rho` and /// `1 + rho`. fn bind_first(packed: &[F128], challenge: F128) -> Vec { - let one_plus = F128::ONE + challenge; - let values = [F128::ZERO, one_plus, challenge, F128::ONE]; + let challenge_plus_one = challenge + F128::ONE; + let values = [F128::ZERO, challenge_plus_one, challenge, F128::ONE]; let element = |(slot, &element): (&mut [F128], &F128)| { - let bits = bits(element); + let bits = element.to_u128(); for (pair, value) in slot.iter_mut().enumerate() { *value = values[((bits >> (2 * pair)) & 3) as usize]; } @@ -298,6 +274,11 @@ fn bind_first(packed: &[F128], challenge: F128) -> Vec { table } +/// `h' = p(rho)` with `a_1 = h + a_2`. +fn advance(claim: F128, [a0, a2]: RoundMessage, challenge: F128) -> F128 { + a0 + challenge * (claim + a2 + challenge * a2) +} + #[cfg(test)] mod tests { use common::Shape; @@ -308,7 +289,15 @@ mod tests { use crate::sumcheck::inner_product; use crate::test_util::{Leaf, random, rng}; - fn reduced(leaf: &Leaf) -> (MleClaim, Proof) { + /// The evaluation claim's parts. + fn mle(query: &OpeningQuery) -> (&[F128], F128) { + let OpeningQuery::Mle { point, target } = query else { + panic!("the sumcheck leaves an evaluation claim"); + }; + (point, *target) + } + + fn reduced(leaf: &Leaf) -> (OpeningQuery, Proof) { let mut prover = build_prover("post_gkr-tests", "reduce"); let sent = prove_factors( &leaf.rows, @@ -321,7 +310,7 @@ mod tests { (sent, prover.finish()) } - fn verified(leaf: &Leaf, proof: &Proof) -> Result { + fn verified(leaf: &Leaf, proof: &Proof) -> Result { let mut verifier = build_verifier("post_gkr-tests", "reduce", proof); let received = verify_factors(&leaf.rows, &leaf.columns, leaf.target, &mut verifier)?; assert!(verifier.check_eof().is_ok()); @@ -332,11 +321,11 @@ mod tests { fn set_bits_are_read_in_ascending_order() { let element = F128::new(0b1011, 1 << 63); assert_eq!( - set_bits(bits(element)).collect::>(), + iter_over_set_bits(element.to_u128()).collect::>(), vec![0, 1, 3, 127] ); - assert_eq!(set_bits(0).count(), 0); - assert_eq!(set_bits(u128::MAX).count(), 128); + assert_eq!(iter_over_set_bits(0).count(), 0); + assert_eq!(iter_over_set_bits(u128::MAX).count(), 128); } /// Factored over several columns, and a single column of weight one: @@ -351,10 +340,11 @@ mod tests { let received = verified(&leaf, &proof).unwrap(); assert_eq!(sent, received); - assert_eq!(received.point.len(), 10); + let (point, target) = mle(&received); + assert_eq!(point.len(), 10); assert_eq!( - leaf.bits_extension().evaluate(&received.point).unwrap(), - received.target, + leaf.bits_extension().evaluate(point).unwrap(), + target, "{log_rows} x {log_columns}" ); } @@ -376,7 +366,8 @@ mod tests { let proof = prover.finish(); let mut verifier = build_verifier("post_gkr-tests", "reduce", &proof); assert_eq!(verify(&claim, &mut verifier), Ok(sent.clone())); - assert_eq!(leaf.evaluate(&sent.point), sent.target); + let (point, target) = mle(&sent); + assert_eq!(leaf.evaluate(point), target); } /// The first round off the bits sends what the generic round over the @@ -397,7 +388,7 @@ mod tests { let mut generic = Pair::new(weights.clone(), written_out.clone()); let mut prover = build_prover("post_gkr-tests", "reduce"); - let (point, _) = prove_rounds(&mut generic, 1, leaf.target, &mut prover); + let (point, _) = sumcheck::prove(&mut generic, leaf.target, &mut prover); let proof = prover.finish(); let message = factors.first_message(&leaf.packed); assert_eq!( @@ -407,7 +398,7 @@ mod tests { let fold = |table: Vec| { let mut folded = DenseMultilinearExtension { evaluations: table }; - folded.fold(&point).unwrap(); + folded.fold(&point[..1]).unwrap(); folded.evaluations }; assert_eq!(bind_first(&leaf.packed, point[0]), fold(written_out)); diff --git a/crates/post_gkr/src/sumcheck.rs b/crates/post_gkr/src/sumcheck.rs index 5a9e3d16..004b2d4e 100644 --- a/crates/post_gkr/src/sumcheck.rs +++ b/crates/post_gkr/src/sumcheck.rs @@ -1,6 +1,6 @@ //! The degree-two sumcheck: from `sum_x W(x) V(x) = h_0` over two tables to -//! `MLE[V](rho) = v`, with `MLE[W](rho) * v = h_n` left for the caller to -//! check. +//! `MLE[V](rho) = v`, the verifier checking `MLE[W](rho) * v = h_n` with the +//! `MLE[W](rho)` its caller computes. //! //! Each round splits off the lowest remaining variable of both tables and //! sends the round polynomial @@ -16,17 +16,16 @@ //! in any round surfaces in the closing check. Soundness error at most //! `2n / |E|` for `n` rounds, plus the probability that `MLE[W](rho) = 0`. +use crate::VerifyError; use common::shape::PACK_BITS; use field::{F128, Wide256}; +use poly::eq_table; #[cfg(feature = "parallel")] use poly::parallel::workload_size; -use poly::{DenseMultilinearExtension, eq_table}; #[cfg(feature = "parallel")] use rayon::prelude::*; use transcript::{ProverState, VerifierState}; -use crate::VerifyError; - /// `(a_0, a_2)` of `p(X) = a_0 + a_1 X + a_2 X^2`; `a_1` the running claim /// implies. pub(crate) type RoundMessage = [F128; 2]; @@ -53,7 +52,7 @@ impl Pair { } /// `(MLE[W](rho), MLE[V](rho))` once every variable is bound. - pub(crate) fn bound(&self) -> (F128, F128) { + fn bound(&self) -> (F128, F128) { debug_assert_eq!(self.weights.len(), 1); (self.weights[0], self.values[0]) } @@ -62,7 +61,28 @@ impl Pair { /// over adjacent entries, since `MLE[W](X, x') = w_0 + X (w_0 + w_1)` /// and likewise for `MLE[V]`. fn message(&self) -> RoundMessage { - let (a0, a2) = coefficients(&self.weights, &self.values); + fn coeffs(this: &Pair) -> (Wide256, Wide256) { + // Large tables are split into cache-sized chunks summed on the Rayon pool. + #[cfg(feature = "parallel")] + { + // An even chunk length keeps every pair inside one chunk. + let chunk = workload_size::() & !1; + if this.weights.len() > chunk { + return this + .weights + .par_chunks(chunk) + .zip(this.values.par_chunks(chunk)) + .map(|(w, v)| coefficients_serial(w, v)) + .reduce( + || (Wide256::zero(), Wide256::zero()), + |(a0, a2), (b0, b2)| (a0 + b0, a2 + b2), + ); + } + } + coefficients_serial(&this.weights, &this.values) + } + + let (a0, a2) = coeffs(self); [a0.reduce(), a2.reduce()] } @@ -72,9 +92,36 @@ impl Pair { } } +/// Runs the sumcheck over `pair`, folding it in place, and writes the +/// closing evaluation. Returns the challenges in the order they were drawn +/// and `v = MLE[V](rho)`. +pub(crate) fn prove( + pair: &mut Pair, + claim: F128, + transcript: &mut ProverState, +) -> (Vec, F128) { + let rounds = pair.weights.len().trailing_zeros() as usize; + let (point, running) = prove_rounds(pair, rounds, claim, transcript); + let evaluation = prove_evaluation(pair, running, transcript); + (point, evaluation) +} + +/// Replays the sumcheck from the records and checks the closing evaluation +/// against `weight(rho) = MLE[W](rho)`. Returns the challenges and `v`. +pub(crate) fn verify( + rounds: usize, + claim: F128, + weight: impl FnOnce(&[F128]) -> F128, + transcript: &mut VerifierState<'_>, +) -> Result<(Vec, F128), VerifyError> { + let (point, running) = verify_rounds(rounds, claim, transcript)?; + let evaluation = verify_evaluation(weight(&point), running, transcript)?; + Ok((point, evaluation)) +} + /// Runs `rounds` rounds over `pair`, folding it in place. Returns the /// challenges in the order they were drawn and the running claim. -pub(crate) fn prove_rounds( +fn prove_rounds( pair: &mut Pair, rounds: usize, mut claim: F128, @@ -85,7 +132,7 @@ pub(crate) fn prove_rounds( let message = pair.message(); transcript.prover_message(&message); let challenge: F128 = transcript.verifier_message(); - claim = advance(claim, message, challenge); + claim = super::advance(claim, message, challenge); point.push(challenge); pair.fold(challenge); } @@ -94,7 +141,7 @@ pub(crate) fn prove_rounds( /// Replays `rounds` rounds from the records alone. Returns the challenges /// and the running claim. -pub(crate) fn verify_rounds( +fn verify_rounds( rounds: usize, mut claim: F128, transcript: &mut VerifierState<'_>, @@ -105,19 +152,14 @@ pub(crate) fn verify_rounds( .prover_message() .map_err(|_| VerifyError::MalformedProof)?; let challenge: F128 = transcript.verifier_message(); - claim = advance(claim, message, challenge); + claim = super::advance(claim, message, challenge); point.push(challenge); } Ok((point, claim)) } -/// `h' = p(rho)` with `a_1 = h + a_2`. -pub(crate) fn advance(claim: F128, [a0, a2]: RoundMessage, challenge: F128) -> F128 { - a0 + challenge * (claim + a2 + challenge * a2) -} - /// Writes `v = MLE[V](rho)`, the one entry left in the folded pair. -pub(crate) fn prove_evaluation(pair: &Pair, claim: F128, transcript: &mut ProverState) -> F128 { +fn prove_evaluation(pair: &Pair, claim: F128, transcript: &mut ProverState) -> F128 { let (weight, evaluation) = pair.bound(); debug_assert_eq!(weight * evaluation, claim); transcript.prover_message(&evaluation); @@ -125,7 +167,7 @@ pub(crate) fn prove_evaluation(pair: &Pair, claim: F128, transcript: &mut Prover } /// Reads `v` and checks `MLE[W](rho) * v = h` for the caller's `MLE[W](rho)`. -pub(crate) fn verify_evaluation( +fn verify_evaluation( bound_weight: F128, claim: F128, transcript: &mut VerifierState<'_>, @@ -139,6 +181,11 @@ pub(crate) fn verify_evaluation( Ok(evaluation) } +/// Fixes the lowest remaining variable of `table` at `challenge`. +pub(crate) fn fold(table: &mut Vec, challenge: F128) { + *table = folded(table, challenge) +} + /// [`fold`] into a fresh table, for a table that is only borrowed. pub(crate) fn folded(table: &[F128], challenge: F128) -> Vec { let entry = |pair: &[F128]| pair[0] + challenge * (pair[0] + pair[1]); @@ -149,15 +196,6 @@ pub(crate) fn folded(table: &[F128], challenge: F128) -> Vec { table.chunks_exact(2).map(entry).collect() } -/// Fixes the lowest remaining variable of `table` at `challenge`. -pub(crate) fn fold(table: &mut Vec, challenge: F128) { - let mut extension = DenseMultilinearExtension { - evaluations: std::mem::take(table), - }; - extension.fold(&[challenge]).expect("a variable remains"); - *table = extension.evaluations; -} - /// `MLE[weights](point)`, one multiplication per weight: the weights have /// no succinct form. The equality table is factored at the pack width, so /// the larger factor is one element per 128 weights. @@ -190,28 +228,6 @@ pub(crate) fn inner_product(a: &[F128], b: &[F128]) -> F128 { .reduce() } -/// `(a_0, a_2)` over the adjacent pairs of `weights` and `values`, left -/// unreduced. Large tables are split into cache-sized chunks summed on the -/// Rayon pool. -fn coefficients(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) { - #[cfg(feature = "parallel")] - { - // An even chunk length keeps every pair inside one chunk. - let chunk = workload_size::() & !1; - if weights.len() > chunk { - return weights - .par_chunks(chunk) - .zip(values.par_chunks(chunk)) - .map(|(w, v)| coefficients_serial(w, v)) - .reduce( - || (Wide256::zero(), Wide256::zero()), - |(a0, a2), (b0, b2)| (a0 + b0, a2 + b2), - ); - } - } - coefficients_serial(weights, values) -} - fn coefficients_serial(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) { let mut a0 = Wide256::zero(); let mut a2 = Wide256::zero(); @@ -225,6 +241,7 @@ fn coefficients_serial(weights: &[F128], values: &[F128]) -> (Wide256, Wide256) #[cfg(test)] mod tests { use num_traits::{ConstOne, ConstZero}; + use poly::DenseMultilinearExtension; use transcript::{build_prover, build_verifier}; use super::*; @@ -244,18 +261,6 @@ mod tests { .sum() } - #[test] - fn the_coefficients_agree_between_the_chunked_and_the_serial_sums() { - // Larger than one cache-sized chunk, and not a multiple of it. - let pair = pair(15, &mut rng(0)); - let (a0, a2) = coefficients(&pair.weights, &pair.values); - let (b0, b2) = coefficients_serial(&pair.weights, &pair.values); - assert_eq!((a0.reduce(), a2.reduce()), (b0.reduce(), b2.reduce())); - let (a0, a2) = coefficients(&pair.weights[..6000], &pair.values[..6000]); - let (b0, b2) = coefficients_serial(&pair.weights[..6000], &pair.values[..6000]); - assert_eq!((a0.reduce(), a2.reduce()), (b0.reduce(), b2.reduce())); - } - #[test] fn the_message_is_the_round_polynomial() { let mut rng = rng(1); @@ -268,7 +273,10 @@ mod tests { ); assert_eq!(message[0], round_polynomial(&pair, F128::ZERO)); let rho = random(&mut rng); - assert_eq!(advance(claim, message, rho), round_polynomial(&pair, rho)); + assert_eq!( + crate::advance(claim, message, rho), + round_polynomial(&pair, rho) + ); } #[test] diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index 174bbe37..5ea66236 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -20,7 +20,7 @@ pub enum ProveError { Witness(TableError), /// The fold round failed. Fold(SendError), - /// The grand product left no claim. + /// The GKR left no claim. Reduction(ReduceError), /// The opening failed, so the reduction's claim was never discharged. Opening(OpeningProveError), @@ -129,7 +129,7 @@ impl BitZProver { .transpose_query(query) .map_err(ProveError::VirtualMap)?; - // Step 6: run PCS sumcheck, ring switching, and opening on committed bits. + // Step 6: the post-GKR sumcheck, ring switching, and opening on committed bits. // Bind the PCS parameters and transposed query before its challenges. pcs.prove_lin( data, diff --git a/crates/tests/benches/sha256.rs b/crates/tests/benches/sha256.rs index af3994a1..ff07899e 100644 --- a/crates/tests/benches/sha256.rs +++ b/crates/tests/benches/sha256.rs @@ -2,12 +2,12 @@ //! //! Ported from f2z-pcs's `benches/sha256_compressions.rs`. The witness is the //! circuit crate's; the committed bits `f` and the assignment `h = M f` are -//! laid out one compression per column (2.2. "Virtual F_2-linear transforms -//! in F2Z and NP-complete dually linear relations"). The PIOP -//! ([`MockSpartan`]) is mocked; the commitment, the fold, the grand product -//! (the `gkr` crate), the transposition onto `f`, the -//! post-GKR sumcheck and the opening are real. `sha256_steps` times the same -//! pipeline one step at a time. +//! laid out one compression per column (2.4. "Virtual F_2-linear transforms, +//! NP-complete multi-domain linear relations, and hybrid proof systems"). The +//! PIOP ([`MockSpartan`]) is mocked; the commitment, the fold, the grand product +//! (the `gkr` crate), the transposition onto `f`, the post-GKR sumcheck and +//! the opening are real. `sha256_steps` times the same pipeline one step at +//! a time. //! //! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256`. //! Knobs: @@ -189,7 +189,7 @@ fn witness(bencher: Bencher, shape: BenchShape) { .bench_local(|| Sha256Batch::generate(shape.log_compressions, SEED)); } -/// Step 1: the commitment to `f`. +/// The commitment to `f`. #[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] fn commit(bencher: Bencher, shape: BenchShape) { let instance = &fixture(shape).instance; @@ -198,8 +198,8 @@ fn commit(bencher: Bencher, shape: BenchShape) { .bench_local(|| instance.pcs.commit(&instance.batch.source).unwrap()); } -/// Steps 3 to 6 on the committed batch: the mocked PIOP's claim, the fold, -/// the grand product, the transposition, the sumcheck, the opening. +/// Everything after the commitment: the mocked PIOP's claim, the fold, the +/// grand product, the transposition, the sumcheck, the opening. #[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] fn prove(bencher: Bencher, shape: BenchShape) { let instance = &fixture(shape).instance; diff --git a/crates/tests/benches/sha256_steps.rs b/crates/tests/benches/sha256_steps.rs index 02eb9c5d..43f4b921 100644 --- a/crates/tests/benches/sha256_steps.rs +++ b/crates/tests/benches/sha256_steps.rs @@ -270,7 +270,8 @@ fn run(instance: &Sha256Instance, batch: &Sha256Batch) -> (Run, Proof) { (Run { prove, verify }, proof) } -/// The post-GKR sumcheck for whichever form the transposition left. +/// The post-GKR sumcheck on the claim the transposition left, on its own +/// transcript. fn sumcheck_prover(query: &OpeningQuery, packed: &[F128], transcript: &mut ProverState) { let OpeningQuery::InnerProduct { claim } = query else { unreachable!("the transposition leaves an inner-product claim"); diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index bfc7e70d..7615ba4e 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -169,8 +169,8 @@ pub fn verifier_transcript(proof: &Proof) -> VerifierState<'_> { } /// A batch of independent SHA-256 compressions as the two tables the virtual -/// pipeline works on (2.2. "Virtual F_2-linear transforms in F2Z and -/// NP-complete dually linear relations"). +/// pipeline works on (2.4. "Virtual F_2-linear transforms, NP-complete +/// multi-domain linear relations, and hybrid proof systems"). /// /// Compression `j` is column `j` of both tables. Its 20456 assignment cells /// `h_j = M_0 (1, f_j)` fill the assignment column's first rows, with the @@ -280,10 +280,10 @@ pub fn compression_map(log_compressions: usize) -> CompressionMap { /// The batch's map: `Id (x) M_0`, one compression per column. /// /// The transposition goes column by column: compression `c`'s cells are -/// rows `c 2^15 ..` of `h` and its bits rows `c 2^13 ..` of `f`, so `M_0^T` -/// moves the weights on the one onto the other. The constant cell is not in -/// the table, so its row weighs nothing; the constant column's weights add -/// up across the compressions and leave the target. +/// entries `c 2^15 ..` of `h` and its bits entries `c 2^13 ..` of `f`, so +/// `M_0^T` moves the weights on the one onto the other. The constant cell is +/// not in the table, so its row weighs nothing; the constant column's +/// weights add up across the compressions and leave the target. #[derive(Debug)] pub struct CompressionMap { pub compression: MaterializedMTranspose, @@ -337,7 +337,8 @@ impl VirtualMap for CompressionMap { } } -/// Stands in for the PIOP (step 3 of 2.1. "A simple version of BitZ"). +/// Stands in for the PIOP (Step 3 of 5. "An end-to-end F2Z-based SNARK over +/// any finitely generated ring"). /// /// A PIOP ends on an evaluation claim `MLE[h](r) = y` on the assignment. The /// mock squeezes `r` and has the prover compute `y` from `h` and send it, From a7c16e12a233adba5d53d7903411c32d23067e38 Mon Sep 17 00:00:00 2001 From: Alexander Abdugafarov Date: Thu, 17 Sep 2026 10:59:20 +0100 Subject: [PATCH 3/4] Removed in-house SHA-256 benchmark --- Cargo.lock | 6 - README.md | 20 -- crates/tests/Cargo.toml | 18 +- crates/tests/benches/sha256.rs | 217 ---------------- crates/tests/benches/sha256_steps.rs | 371 --------------------------- crates/tests/src/lib.rs | 367 +------------------------- crates/tests/tests/sha256.rs | 26 -- crates/verifier/src/lib.rs | 2 +- crates/verifier/src/reduce.rs | 2 +- 9 files changed, 6 insertions(+), 1023 deletions(-) delete mode 100644 crates/tests/benches/sha256.rs delete mode 100644 crates/tests/benches/sha256_steps.rs delete mode 100644 crates/tests/tests/sha256.rs diff --git a/Cargo.lock b/Cargo.lock index 37c5384d..283f787a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,22 +1589,16 @@ dependencies = [ name = "tests" version = "0.1.0" dependencies = [ - "blake3", "circuit", "common", "crypto-primitives", - "divan", "field", - "flock-core", "host", "num-traits", "pcs", - "poly", - "post_gkr", "prover", "rand_chacha 0.10.0", "rand_core 0.10.1", - "rayon", "transcript", "verifier", ] diff --git a/README.md b/README.md index e023aa0d..c747a14b 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,3 @@ We thank the authors and maintainers of the projects that support this implement - **[Binius64](https://github.com/binius-zk/binius64).** We adapt field reduction and interpolation routines from Binius64. We also use `binius-field` for benchmark comparisons. - **[WHIR](https://github.com/worldfnd/whir) and [Zinc+](https://github.com/NethermindEth/zinc-plus).** We adapt multilinear evaluation and workload sizing from WHIR. Our dense multilinear representation derives from Zinc+. - -## Benchmarking - -Independent SHA-256 compressions end to end, per batch size, with the PIOP -mocked and the grand product real: - -```sh -RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256 -``` - -The same pipeline one step at a time, prover and verifier, with medians per -step: - -```sh -RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256_steps -``` - -Each bench's header documents its knobs. Micro-benchmarks live next to their -crates: `cargo bench -p field`, `-p poly`, `-p circuit`, and `-p post_gkr` -for the post-GKR sumcheck on its own. diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 4cce85cf..7c06ffbb 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -10,31 +10,17 @@ publish = false doctest = false [dependencies] -blake3 = { workspace = true } -circuit = { workspace = true } common = { workspace = true } crypto-primitives = { workspace = true } field = { workspace = true, features = ["spongefish"] } host = { workspace = true } -num-traits = { workspace = true } pcs = { workspace = true } -poly = { workspace = true } prover = { workspace = true } rand_chacha = { workspace = true } rand_core = { workspace = true } -rayon = { workspace = true } transcript = { workspace = true } verifier = { workspace = true } [dev-dependencies] -divan = { workspace = true } -flock-core = { workspace = true } -post_gkr = { workspace = true } - -[[bench]] -name = "sha256" -harness = false - -[[bench]] -name = "sha256_steps" -harness = false +circuit = { workspace = true } +num-traits = { workspace = true } diff --git a/crates/tests/benches/sha256.rs b/crates/tests/benches/sha256.rs deleted file mode 100644 index ff07899e..00000000 --- a/crates/tests/benches/sha256.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Independent SHA-256 compressions end to end, per batch size. -//! -//! Ported from f2z-pcs's `benches/sha256_compressions.rs`. The witness is the -//! circuit crate's; the committed bits `f` and the assignment `h = M f` are -//! laid out one compression per column (2.4. "Virtual F_2-linear transforms, -//! NP-complete multi-domain linear relations, and hybrid proof systems"). The -//! PIOP ([`MockSpartan`]) is mocked; the commitment, the fold, the grand product -//! (the `gkr` crate), the transposition onto `f`, the post-GKR sumcheck and -//! the opening are real. `sha256_steps` times the same pipeline one step at -//! a time. -//! -//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256`. -//! Knobs: -//! - `BITZ_BENCH_SHAPES`: log2 of the compression counts, space or comma -//! separated; default `9 10 11 12`. The commitment floor puts the minimum -//! at 9; larger batches only cost time and memory. -//! - `BITZ_LIG_PROFILE`: `fast` (default), `slim` or `secure`. -//! - Divan's `DIVAN_SAMPLE_COUNT`, `DIVAN_SAMPLE_SIZE`, ...; the rows default -//! to three samples of one run each, the source's repetition count. -//! -//! As in the source, `witness` is outside `prove`; unlike it, `commit` is its -//! own row rather than part of `prove`. Any other `BITZ_*` variable aborts the -//! run. The header lists each batch's table sizes and its proof size after -//! verifying that proof; the prover is deterministic, so the timed proofs are -//! the same bytes. The allocation columns count the benchmarking thread only. -//! -//! [`MockSpartan`]: tests::MockSpartan - -use std::fmt::{self, Display}; -use std::sync::OnceLock; - -use divan::counter::ItemsCount; -use divan::{AllocProfiler, Bencher}; -use field::Q100; -use host::wire_proof; -use pcs::LigeritoProfile; -use tests::{Sha256Batch, Sha256Instance}; -use transcript::Proof; - -#[global_allocator] -static ALLOC: AllocProfiler = AllocProfiler::system(); - -/// `q = 2^100 - 15`; the assignment table's `2^15` rows are well within what -/// it admits. -const Q: u128 = Q100; - -const KNOWN_ENV: &[&str] = &["BITZ_BENCH_SHAPES", "BITZ_LIG_PROFILE"]; - -/// log2 of the compression counts: source tables of `2^22` to `2^25` bits. -const DEFAULT_SHAPES: &[usize] = &[9, 10, 11, 12]; - -const SEED: u64 = 0x_5348_4132_5600_0000; - -fn main() { - enforce_known_env(); - let _ = flock_core::init_perf_thread_pool(); - - println!( - "profile {:?}, {} threads", - profile(), - rayon::current_num_threads() - ); - for shape in shapes() { - let fixture = fixture(shape); - let batch = &fixture.instance.batch; - println!( - "{shape}: {} compressions, f 2^{} bits, h 2^{} bits, proof {} B (narg {} B + hints {} B), verified", - 1usize << shape.log_compressions, - batch.source_shape().log_bits(), - batch.assignment_shape().log_bits(), - fixture.bytes.len(), - fixture.proof.narg_string.len(), - fixture.proof.hints.len(), - ); - } - - divan::main(); -} - -/// Aborts on any exported `BITZ_*` variable this bench does not know. -fn enforce_known_env() { - let mut unknown: Vec = std::env::vars_os() - .filter_map(|(key, _)| key.into_string().ok()) - .filter(|key| key.starts_with("BITZ_") && !KNOWN_ENV.contains(&key.as_str())) - .collect(); - if unknown.is_empty() { - return; - } - unknown.sort(); - eprintln!( - "error: unknown BITZ_* variable(s): {}; known: {}", - unknown.join(", "), - KNOWN_ENV.join(", ") - ); - std::process::exit(2); -} - -fn profile() -> LigeritoProfile { - match std::env::var("BITZ_LIG_PROFILE").as_deref() { - Err(_) | Ok("fast") => LigeritoProfile::Fast, - Ok("slim") => LigeritoProfile::Slim, - Ok("secure") => LigeritoProfile::Secure, - Ok(other) => panic!("BITZ_LIG_PROFILE: unknown profile `{other}` (fast | slim | secure)"), - } -} - -/// `2^log_compressions` compressions; the row of the divan table. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct BenchShape { - log_compressions: usize, -} - -impl Display for BenchShape { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "2^{}", self.log_compressions) - } -} - -fn shapes() -> Vec { - let exponents: Vec = match std::env::var("BITZ_BENCH_SHAPES") { - Ok(list) => list - .split([',', ' ']) - .filter(|token| !token.is_empty()) - .map(|token| { - token.parse().unwrap_or_else(|_| { - panic!("BITZ_BENCH_SHAPES: `{token}` is not a log2 compression count") - }) - }) - .collect(), - Err(_) => DEFAULT_SHAPES.to_vec(), - }; - exponents - .into_iter() - .map(|log_compressions| BenchShape { log_compressions }) - .collect() -} - -/// One committed batch with the proof the timed rows reproduce. -struct Fixture { - instance: Sha256Instance, - proof: Proof, - bytes: Vec, -} - -impl Fixture { - fn new(shape: BenchShape, profile: LigeritoProfile, seed: u64) -> Self { - let instance = Sha256Instance::::new(shape.log_compressions, profile, seed); - let proof = instance.prove(); - let bytes = wire_proof::encode(&proof); - instance - .verify(&proof) - .expect("the fixture's own proof verifies"); - Self { - instance, - proof, - bytes, - } - } -} - -static FIXTURES: OnceLock> = OnceLock::new(); - -/// Every shape's fixture is built on the first call, so the rows share one -/// batch per shape. -fn fixture(shape: BenchShape) -> &'static Fixture { - let fixtures = FIXTURES.get_or_init(|| { - let profile = profile(); - shapes() - .into_iter() - .map(|shape| { - let seed = SEED ^ shape.log_compressions as u64; - (shape, Fixture::new(shape, profile, seed)) - }) - .collect() - }); - &fixtures - .iter() - .find(|(candidate, _)| *candidate == shape) - .expect("every shape is built up front") - .1 -} - -/// The circuit crate's witness generation, into the two tables. Outside -/// `prove`, as in the source. -#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] -fn witness(bencher: Bencher, shape: BenchShape) { - bencher - .counter(ItemsCount::new(1usize << shape.log_compressions)) - .bench_local(|| Sha256Batch::generate(shape.log_compressions, SEED)); -} - -/// The commitment to `f`. -#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] -fn commit(bencher: Bencher, shape: BenchShape) { - let instance = &fixture(shape).instance; - bencher - .counter(ItemsCount::new(1usize << shape.log_compressions)) - .bench_local(|| instance.pcs.commit(&instance.batch.source).unwrap()); -} - -/// Everything after the commitment: the mocked PIOP's claim, the fold, the -/// grand product, the transposition, the sumcheck, the opening. -#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] -fn prove(bencher: Bencher, shape: BenchShape) { - let instance = &fixture(shape).instance; - bencher - .counter(ItemsCount::new(1usize << shape.log_compressions)) - .bench_local(|| instance.prove()); -} - -#[divan::bench(args = shapes(), sample_count = 3, sample_size = 1)] -fn verify(bencher: Bencher, shape: BenchShape) { - let fixture = fixture(shape); - bencher - .counter(ItemsCount::new(1usize << shape.log_compressions)) - .bench_local(|| fixture.instance.verify(&fixture.proof).unwrap()); -} diff --git a/crates/tests/benches/sha256_steps.rs b/crates/tests/benches/sha256_steps.rs deleted file mode 100644 index 43f4b921..00000000 --- a/crates/tests/benches/sha256_steps.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! The SHA-256 pipeline one step at a time, prover and verifier. -//! -//! The same pipeline as `sha256`, driven through the crates' public steps -//! with a clock around each: the commitment (`pcs`), the mocked PIOP's claim, -//! the binding and the fold (`prover`/`verifier`), the grand product's leaf -//! claim (the `gkr` crate), its transposition onto the committed bits, and -//! the opening (`pcs`), which runs the post-GKR sumcheck (`post_gkr`) before -//! the ring switch. The sumcheck is also clocked alone, on a scratch -//! transcript, since the opening does not expose it as a step. Medians over -//! the repetitions are printed per step. As in the source, `prove` includes -//! the commitment and excludes witness generation. -//! -//! Run with `RUSTFLAGS="-C target-cpu=native" cargo bench -p tests --bench sha256_steps`. -//! Knobs: -//! - `BITZ_BENCH_SHAPES`: log2 of the compression counts, space or comma -//! separated; default `9 10 11 12`. -//! - `BITZ_BENCH_REPS`: measured repetitions after one warm-up; default 3. -//! - `BITZ_LIG_PROFILE`: `fast` (default), `slim` or `secure`. -//! -//! Each repetition generates a fresh batch and verifies its proof. The -//! warm-up also checks that the step-driven proof is byte for byte the one -//! `prove` produces, so the split cannot drift from the protocol. - -use std::time::{Duration, Instant}; - -use common::{OpeningQuery, VirtualMap}; -use field::{F128, Q100}; -use pcs::{CommitScheme, LigeritoProfile, StatementBinding}; -use tests::{MockSpartan, Sha256Batch, Sha256Instance, prover_transcript, verifier_transcript}; -use transcript::{Proof, ProverState, VerifierState}; - -const Q: u128 = Q100; - -const KNOWN_ENV: &[&str] = &["BITZ_BENCH_REPS", "BITZ_BENCH_SHAPES", "BITZ_LIG_PROFILE"]; -const DEFAULT_SHAPES: &[usize] = &[9, 10, 11, 12]; -const SEED: u64 = 0x_5348_4132_5654_4550; - -fn main() { - enforce_known_env(); - let _ = flock_core::init_perf_thread_pool(); - let profile = profile(); - let reps = reps(); - - println!( - "SHA-256 compressions, step by step: profile {profile:?}, {} threads, {reps} reps after 1 warm-up", - rayon::current_num_threads() - ); - for log_compressions in shapes() { - flock_core::scratch::clear(); - // Setup is excluded: the map, the parameters and the scheme are - // public and shape-only. One committed batch stands for them across - // the reps. - let setup = Instant::now(); - bench_instance( - Sha256Instance::::new(log_compressions, profile, SEED), - setup, - reps, - ); - } -} - -fn bench_instance(instance: Sha256Instance, setup: Instant, reps: usize) { - let setup = setup.elapsed(); - let batch = &instance.batch; - let log_compressions = batch.log_compressions; - println!( - "\n=== 2^{log_compressions} = {} compressions: f 2^{} bits, h 2^{} bits ===", - 1usize << log_compressions, - batch.source_shape().log_bits(), - batch.assignment_shape().log_bits(), - ); - println!( - " setup (excluded): {} witness + commit + map", - fmt(setup) - ); - - // Warm-up on the setup's own batch, and the check that the steps are the - // protocol: the one-call proof of the same batch must be the same bytes. - let (warm, proof) = run(&instance, &instance.batch); - assert_eq!(proof, instance.prove(), "the steps must reproduce prove"); - instance.verify(&proof).expect("the warm-up proof verifies"); - drop(warm); - - let mut witness = Vec::with_capacity(reps); - let mut runs = Vec::with_capacity(reps); - let mut last = None; - for rep in 0..reps { - let started = Instant::now(); - let batch = Sha256Batch::generate(log_compressions, SEED ^ (rep as u64 + 1)); - witness.push(started.elapsed()); - let (timings, proof) = run(&instance, &batch); - runs.push(timings); - last = Some(proof); - } - let proof = last.expect("at least one rep"); - - print_side("prove", &runs, |run| &run.prove, |run| run.prove_total()); - print_side("verify", &runs, |run| &run.verify, |run| run.verify_total()); - println!(" witness (excluded): {}", fmt(median(witness.into_iter()))); - println!( - " proof: {} B = narg {} B + hints {} B", - proof.narg_string.len() + proof.hints.len(), - proof.narg_string.len(), - proof.hints.len() - ); -} - -/// One repetition's step timings. -struct Run { - prove: Vec<(&'static str, Duration)>, - verify: Vec<(&'static str, Duration)>, -} - -impl Run { - fn prove_total(&self) -> Duration { - self.prove.iter().map(|(_, time)| *time).sum() - } - - fn verify_total(&self) -> Duration { - self.verify.iter().map(|(_, time)| *time).sum() - } -} - -/// Proves and verifies `batch` step by step on `instance`'s public setup. -/// The batch is committed inside the prover's clock. -fn run(instance: &Sha256Instance, batch: &Sha256Batch) -> (Run, Proof) { - let mut prove = Vec::new(); - let mut time = |label, work: &mut dyn FnMut()| { - let started = Instant::now(); - work(); - prove.push((label, started.elapsed())); - }; - - let committed = batch.source.clone(); - let mut data = None; - time("commit", &mut || { - data = Some(instance.pcs.commit(&batch.source).unwrap()); - }); - let (com, data) = data.unwrap(); - - let mut transcript = prover_transcript(); - let table = instance.params.table(&batch.assignment).unwrap(); - let mut claim = None; - time("PIOP claim (mock)", &mut || { - claim = Some(MockSpartan::claim_prover( - &instance.params, - &table, - &mut transcript, - )); - }); - let claim = claim.unwrap(); - - let statement = instance.statement(&claim); - time("bind", &mut || { - transcript.public_message(b"bitz/virtual-statement/v1"); - transcript.public_message(&com.0); - transcript.public_message(statement.params()); - transcript.public_message(&instance.map.digest()); - transcript.public_message(&claim); - }); - - let mut fold = None; - time("fold", &mut || { - fold = Some( - instance - .prover - .send_fold(&claim, &table, &mut transcript) - .unwrap(), - ); - }); - let fold = fold.unwrap(); - - let mut leaf = None; - time("GKR leaf", &mut || { - leaf = Some(prover::gkr_reduce(&mut transcript, &fold, &table).unwrap()); - }); - - let mut query = None; - time("transposition", &mut || { - query = Some(statement.transpose_query(leaf.take().unwrap()).unwrap()); - }); - let query = query.unwrap(); - - // Off the protocol's transcript: the opening runs this inside itself. - let mut scratch = None; - time("sumcheck (alone)", &mut || { - let mut transcript = prover_transcript(); - sumcheck_prover(&query, &batch.source, &mut transcript); - scratch = Some(transcript.finish()); - }); - let scratch = scratch.unwrap(); - - let mut committed = Some(committed); - time("opening", &mut || { - instance - .pcs - .prove_lin( - &data, - committed.take().unwrap(), - &query, - StatementBinding::Bind, - &mut transcript, - ) - .unwrap(); - }); - let proof = transcript.finish(); - - let mut verify = Vec::new(); - let mut time = |label, work: &mut dyn FnMut()| { - let started = Instant::now(); - work(); - verify.push((label, started.elapsed())); - }; - - let mut transcript = verifier_transcript(&proof); - let mut claim = None; - time("PIOP claim (mock)", &mut || { - claim = Some(MockSpartan::claim_verifier(&instance.params, &mut transcript).unwrap()); - }); - let claim = claim.unwrap(); - - let statement = instance.statement(&claim); - time("bind", &mut || { - transcript.public_message(b"bitz/virtual-statement/v1"); - transcript.public_message(&com.0); - transcript.public_message(statement.params()); - transcript.public_message(&instance.map.digest()); - transcript.public_message(&claim); - }); - - let mut fold = None; - time("fold", &mut || { - fold = Some( - instance - .verifier - .receive_fold(&claim, &mut transcript) - .unwrap(), - ); - }); - let fold = fold.unwrap(); - - let mut leaf = None; - time("GKR leaf", &mut || { - leaf = Some(verifier::gkr_reduce(&mut transcript, &fold, instance.params.shape()).unwrap()); - }); - - let mut query = None; - time("transposition", &mut || { - query = Some(statement.transpose_query(leaf.take().unwrap()).unwrap()); - }); - let query = query.unwrap(); - - time("sumcheck (alone)", &mut || { - let mut transcript = verifier_transcript(&scratch); - sumcheck_verifier(&query, &mut transcript); - }); - - time("opening", &mut || { - instance - .pcs - .verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) - .unwrap(); - }); - - let mut transcript = Some(transcript); - time("exhaustion", &mut || { - transcript.take().unwrap().check_eof().unwrap(); - }); - - (Run { prove, verify }, proof) -} - -/// The post-GKR sumcheck on the claim the transposition left, on its own -/// transcript. -fn sumcheck_prover(query: &OpeningQuery, packed: &[F128], transcript: &mut ProverState) { - let OpeningQuery::InnerProduct { claim } = query else { - unreachable!("the transposition leaves an inner-product claim"); - }; - post_gkr::prove(claim, packed, transcript).unwrap(); -} - -fn sumcheck_verifier(query: &OpeningQuery, transcript: &mut VerifierState<'_>) { - let OpeningQuery::InnerProduct { claim } = query else { - unreachable!("the transposition leaves an inner-product claim"); - }; - post_gkr::verify(claim, transcript).unwrap(); -} - -fn print_side( - side: &str, - runs: &[Run], - steps: impl Fn(&Run) -> &Vec<(&'static str, Duration)>, - total: impl Fn(&Run) -> Duration, -) { - println!(" {side}: {}", fmt(median(runs.iter().map(&total)))); - for (index, (label, _)) in steps(&runs[0]).iter().enumerate() { - let step = median(runs.iter().map(|run| steps(run)[index].1)); - println!(" {label:<18} {}", fmt(step)); - } -} - -fn median(samples: impl Iterator) -> Duration { - let mut samples: Vec = samples.collect(); - samples.sort(); - samples[samples.len() / 2] -} - -fn fmt(time: Duration) -> String { - let micros = time.as_secs_f64() * 1e6; - if micros < 1_000.0 { - format!("{micros:8.1} us") - } else if micros < 1_000_000.0 { - format!("{:8.2} ms", micros / 1e3) - } else { - format!("{:8.3} s ", micros / 1e6) - } -} - -fn reps() -> usize { - let reps = std::env::var("BITZ_BENCH_REPS") - .map(|value| { - value - .parse() - .unwrap_or_else(|_| panic!("BITZ_BENCH_REPS: `{value}` is not a count")) - }) - .unwrap_or(3); - assert!(reps > 0, "BITZ_BENCH_REPS must be positive"); - reps -} - -fn shapes() -> Vec { - match std::env::var("BITZ_BENCH_SHAPES") { - Ok(list) => list - .split([',', ' ']) - .filter(|token| !token.is_empty()) - .map(|token| { - token.parse().unwrap_or_else(|_| { - panic!("BITZ_BENCH_SHAPES: `{token}` is not a log2 compression count") - }) - }) - .collect(), - Err(_) => DEFAULT_SHAPES.to_vec(), - } -} - -fn profile() -> LigeritoProfile { - match std::env::var("BITZ_LIG_PROFILE").as_deref() { - Err(_) | Ok("fast") => LigeritoProfile::Fast, - Ok("slim") => LigeritoProfile::Slim, - Ok("secure") => LigeritoProfile::Secure, - Ok(other) => panic!("BITZ_LIG_PROFILE: unknown profile `{other}` (fast | slim | secure)"), - } -} - -/// Aborts on any exported `BITZ_*` variable this bench does not know. -fn enforce_known_env() { - let mut unknown: Vec = std::env::vars_os() - .filter_map(|(key, _)| key.into_string().ok()) - .filter(|key| key.starts_with("BITZ_") && !KNOWN_ENV.contains(&key.as_str())) - .collect(); - if unknown.is_empty() { - return; - } - unknown.sort(); - eprintln!( - "error: unknown BITZ_* variable(s): {}; known: {}", - unknown.join(", "), - KNOWN_ENV.join(", ") - ); - std::process::exit(2); -} diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 7615ba4e..81f51aeb 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -9,26 +9,13 @@ //! The fixtures live here rather than under `tests/` so they compile once //! rather than once per test binary. -use circuit::matrix_transpose::{MTransposeGenerator, MaterializedMTranspose}; -use circuit::sha256::{COMPRESSION_HINT_BITS, COMPRESSION_INPUT_BITS, compression_circuit}; -use circuit::witgen::Witgen; -use common::{ - BitTable, BitZParams, LinearClaim, Root, Shape, TransposedWeights, VirtualMap, VirtualMapError, - VirtualStatement, shape::PACK_BITS, -}; +use common::{BitTable, BitZParams, LinearClaim, Root, Shape}; use crypto_primitives::LiftElement; use field::{F128, Fq, gf128::smallest_generator}; -use num_traits::{ConstOne, ConstZero}; use pcs::{HashKind, LigeritoProfile, Pcs, ProverData}; -use poly::eq_table; -use prover::VirtualWitness; use rand_chacha::ChaCha8Rng; use rand_core::{Rng, SeedableRng}; -use rayon::prelude::*; -use transcript::{ - Proof, ProverState, VerificationError, VerificationResult, VerifierState, build_prover, - build_verifier, -}; +use transcript::{Proof, ProverState, VerifierState, build_prover, build_verifier}; /// The specification's fixed modulus, `2^100 − 15`. Under it the fold bound /// admits every row width up to `t = 27`, so the reference split @@ -167,353 +154,3 @@ pub fn prover_transcript() -> ProverState { pub fn verifier_transcript(proof: &Proof) -> VerifierState<'_> { build_verifier(SESSION, INSTANCE, proof) } - -/// A batch of independent SHA-256 compressions as the two tables the virtual -/// pipeline works on (2.4. "Virtual F_2-linear transforms, NP-complete -/// multi-domain linear relations, and hybrid proof systems"). -/// -/// Compression `j` is column `j` of both tables. Its 20456 assignment cells -/// `h_j = M_0 (1, f_j)` fill the assignment column's first rows, with the -/// circuit's constant cell left out: it is public, and the transposition -/// accounts for it. The source column holds the compression's 7144 -/// committed bits, block and state both inputs. Only power-of-two batches, -/// so every column is live. -#[derive(Debug, Clone)] -pub struct Sha256Batch { - pub log_compressions: usize, - /// `f`, column major, `2^SOURCE_LOG_ROWS` rows. - pub source: Vec, - /// `h`, column major, `2^ASSIGNMENT_LOG_ROWS` rows. - pub assignment: Vec, -} - -/// `7144 <= 2^13` committed bits per compression. -pub const SOURCE_LOG_ROWS: usize = 13; -/// `20456 <= 2^15` assignment cells per compression. -pub const ASSIGNMENT_LOG_ROWS: usize = 15; - -/// Committed bits per independent compression: the 768 input bits and the -/// hint bits. -pub const SOURCE_BITS: usize = COMPRESSION_INPUT_BITS + COMPRESSION_HINT_BITS; - -impl Sha256Batch { - /// Runs `2^log_compressions` compressions on random blocks and states. - pub fn generate(log_compressions: usize, seed: u64) -> Self { - let columns: Vec<(Vec, Vec)> = (0..1u64 << log_compressions) - .into_par_iter() - .map(|compression| { - let mut rng = ChaCha8Rng::seed_from_u64(seed ^ compression.rotate_left(32)); - let inputs: [bool; COMPRESSION_INPUT_BITS] = - std::array::from_fn(|_| rng.next_u32() & 1 == 1); - let mut witgen = Witgen::with_inputs_and_capacity(&inputs, SOURCE_BITS); - let _ = compression_circuit(&mut witgen, &inputs); - let (source, assignment) = witgen.into_witnesses(); - debug_assert_eq!(source.bit_len(), SOURCE_BITS); - debug_assert_eq!(assignment.bit_len(), ASSIGNMENT_CELLS + 1); - ( - pack_column(source.words(), SOURCE_LOG_ROWS), - pack_column(&drop_constant_cell(assignment.words()), ASSIGNMENT_LOG_ROWS), - ) - }) - .collect(); - - let mut source = Vec::with_capacity(columns.len() << (SOURCE_LOG_ROWS - 7)); - let mut assignment = Vec::with_capacity(columns.len() << (ASSIGNMENT_LOG_ROWS - 7)); - for (f, h) in columns { - source.extend(f); - assignment.extend(h); - } - Self { - log_compressions, - source, - assignment, - } - } - - pub fn source_shape(&self) -> Shape { - Shape::new(SOURCE_LOG_ROWS, self.log_compressions).unwrap() - } - - pub fn assignment_shape(&self) -> Shape { - Shape::new(ASSIGNMENT_LOG_ROWS, self.log_compressions).unwrap() - } -} - -/// Assignment cells per compression, the constant cell excluded. -const ASSIGNMENT_CELLS: usize = 20_456; - -/// One column of `2^log_rows` bits from little-endian words, zero padded. -fn pack_column(words: &[u64], log_rows: usize) -> Vec { - (0..1usize << (log_rows - 7)) - .map(|element| { - let word = |index: usize| words.get(index).copied().unwrap_or(0); - F128::new(word(2 * element), word(2 * element + 1)) - }) - .collect() -} - -/// The integer witness without its leading constant cell: every bit moved -/// down one place. -fn drop_constant_cell(words: &[u64]) -> Vec { - (0..words.len()) - .map(|index| (words[index] >> 1) | words.get(index + 1).map_or(0, |next| next << 63)) - .collect() -} - -/// `M_0^T` for one compression: `M_0` has a row per assignment cell (the -/// constant cell first) and a column per committed bit (the constant first). -fn compression_transpose() -> MaterializedMTranspose { - let mut generator = MTransposeGenerator::new(COMPRESSION_INPUT_BITS); - let inputs = generator.take_boxed_inputs::(); - let _ = compression_circuit(&mut generator, &inputs); - generator.finish() -} - -/// The batch's map over `2^log_compressions` independent compressions. -pub fn compression_map(log_compressions: usize) -> CompressionMap { - CompressionMap { - compression: compression_transpose(), - log_compressions, - } -} - -/// The batch's map: `Id (x) M_0`, one compression per column. -/// -/// The transposition goes column by column: compression `c`'s cells are -/// entries `c 2^15 ..` of `h` and its bits entries `c 2^13 ..` of `f`, so -/// `M_0^T` moves the weights on the one onto the other. The constant cell is -/// not in the table, so its row weighs nothing; the constant column's -/// weights add up across the compressions and leave the target. -#[derive(Debug)] -pub struct CompressionMap { - pub compression: MaterializedMTranspose, - pub log_compressions: usize, -} - -impl VirtualMap for CompressionMap { - fn transpose(&self, weights: &[F128]) -> Result { - if weights.len() < self.h_len() { - return Err(VirtualMapError::WeightCountMismatch); - } - let cells = self.compression.row_count() - 1; - let bits = self.compression.column_count() - 1; - let columns: Vec> = (0..1usize << self.log_compressions) - .into_par_iter() - .map(|compression| { - let mut challenges = Vec::with_capacity(cells + 1); - challenges.push(F128::ZERO); - challenges - .extend_from_slice(&weights[compression << ASSIGNMENT_LOG_ROWS..][..cells]); - self.compression.apply(&challenges).unwrap() - }) - .collect(); - let constant_weight = columns.iter().map(|column| column[0]).sum(); - let mut on_bits = vec![F128::ZERO; self.f_len() - 1]; - for (compression, column) in columns.iter().enumerate() { - on_bits[compression << SOURCE_LOG_ROWS..][..bits].copy_from_slice(&column[1..]); - } - Ok(TransposedWeights::new(on_bits, constant_weight)) - } - - fn digest(&self) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"bitz/tests/compression-map/v1"); - hasher.update(&(self.log_compressions as u64).to_le_bytes()); - hasher.update(&self.compression.digest()); - *hasher.finalize().as_bytes() - } - - /// Through the last compression's cells, in `h`'s layout. - fn h_len(&self) -> usize { - let last = (1usize << self.log_compressions) - 1; - (last << ASSIGNMENT_LOG_ROWS) + self.compression.row_count() - 1 - } - - /// Through the last compression's bits, in `f`'s layout, plus the - /// constant. - fn f_len(&self) -> usize { - let last = (1usize << self.log_compressions) - 1; - 1 + (last << SOURCE_LOG_ROWS) + self.compression.column_count() - 1 - } -} - -/// Stands in for the PIOP (Step 3 of 5. "An end-to-end F2Z-based SNARK over -/// any finitely generated ring"). -/// -/// A PIOP ends on an evaluation claim `MLE[h](r) = y` on the assignment. The -/// mock squeezes `r` and has the prover compute `y` from `h` and send it, -/// where the PIOP would leave the verifier holding it. -#[derive(Debug, Clone, Copy)] -pub struct MockSpartan; - -impl MockSpartan { - /// Squeezes `r`, evaluates `MLE[h](r)` over `table`, and sends it. - pub fn claim_prover( - params: &BitZParams, - table: &BitTable<'_>, - transcript: &mut ProverState, - ) -> LinearClaim> { - let shape = params.shape(); - let row_point: Vec> = (0..shape.log_rows()) - .map(|_| sample_fq(transcript.verifier_message())) - .collect(); - let column_point: Vec> = (0..shape.log_columns()) - .map(|_| sample_fq(transcript.verifier_message())) - .collect(); - let row_weights = eq_table(&row_point); - let column_weights = eq_table(&column_point); - let target = evaluate_fq(table, &row_weights, &column_weights); - transcript.prover_message(&target); - - LinearClaim::new(params, row_weights, column_weights, target).unwrap() - } - - /// Squeezes the same `r` and reads `y`. - pub fn claim_verifier( - params: &BitZParams, - transcript: &mut VerifierState<'_>, - ) -> VerificationResult>> { - let shape = params.shape(); - let row_point: Vec> = (0..shape.log_rows()) - .map(|_| sample_fq(transcript.verifier_message())) - .collect(); - let column_point: Vec> = (0..shape.log_columns()) - .map(|_| sample_fq(transcript.verifier_message())) - .collect(); - let target = transcript.prover_message::>()?; - - LinearClaim::new( - params, - eq_table(&row_point), - eq_table(&column_point), - target, - ) - .map_err(|_| VerificationError) - } -} - -/// A field element from 256 squeezed bits: the bias is below `2^-150`. -fn sample_fq(bytes: [u8; 32]) -> Fq { - let (low, high) = bytes.split_at(16); - let low = u128::from_le_bytes(low.try_into().unwrap()); - let high = u128::from_le_bytes(high.try_into().unwrap()); - let shift = Fq::from(u128::MAX) + Fq::ONE; - Fq::from(high) * shift + Fq::from(low) -} - -/// `` over `F_q`: each set bit adds its row weight, -/// each column is then scaled by its weight. -fn evaluate_fq( - table: &BitTable<'_>, - row_weights: &[Fq], - column_weights: &[Fq], -) -> Fq { - (0..table.shape().columns()) - .into_par_iter() - .map(|column| { - let mut sum = Fq::ZERO; - for (index, element) in table.column(column).iter().enumerate() { - let base = index << PACK_BITS; - for (half, mut remaining) in [(0, element.lo), (64, element.hi)] { - while remaining != 0 { - sum += row_weights[base + half + remaining.trailing_zeros() as usize]; - remaining &= remaining - 1; - } - } - } - column_weights[column] * sum - }) - .sum() -} - -/// A committed batch with everything both sides hold: the assignment's -/// parameters, the source's opening scheme, and the map from the source to -/// the assignment. -pub struct Sha256Instance { - pub batch: Sha256Batch, - /// Shaped to the assignment. - pub params: BitZParams, - pub prover: prover::BitZProver, - pub verifier: verifier::BitZVerifier, - pub pcs: Pcs, - pub com: Root, - pub data: ProverData, - pub map: CompressionMap, -} - -/// A rejected SHA-256 proof. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Sha256VerifyError { - /// The PIOP mock's claimed value is missing or malformed. - Claim, - Verify(verifier::VerifyError), -} - -impl Sha256Instance { - /// Generates and commits `2^log_compressions` independent compressions. - pub fn new(log_compressions: usize, profile: LigeritoProfile, seed: u64) -> Self { - Self::commit( - Sha256Batch::generate(log_compressions, seed), - profile, - compression_map(log_compressions), - ) - } - - /// Commits a generated batch under `profile`, with `map` from its source - /// to its assignment. - pub fn commit(batch: Sha256Batch, profile: LigeritoProfile, map: CompressionMap) -> Self { - let params = BitZParams::::new(batch.assignment_shape(), smallest_generator()).unwrap(); - let pcs = Pcs::new(&batch.source_shape(), profile, HashKind::Blake3).unwrap(); - let (com, data) = pcs.commit(&batch.source).unwrap(); - Self { - batch, - params, - prover: prover::BitZProver::new(params, WINDOW), - verifier: verifier::BitZVerifier::new(params, WINDOW), - pcs, - com, - data, - map, - } - } - - /// The statement both sides bind: the assignment's parameters, the - /// source's shape, the map, and the mocked PIOP's claim. - pub fn statement<'a>( - &'a self, - claim: &'a LinearClaim>, - ) -> VirtualStatement<'a, Q, CompressionMap> { - VirtualStatement::new(self.params, self.batch.source_shape(), &self.map, claim) - .expect("the map fits both shapes") - } - - /// The mocked PIOP's claim, then `prove_virtual` over the assignment, - /// opened against the source. - pub fn prove(&self) -> Proof { - let mut transcript = prover_transcript(); - let table = self.params.table(&self.batch.assignment).unwrap(); - let claim = MockSpartan::claim_prover(&self.params, &table, &mut transcript); - self.prover - .prove_virtual( - &self.statement(&claim), - &self.pcs, - &self.data, - VirtualWitness { - committed_bits: self.batch.source.clone(), - virtual_bits: &self.batch.assignment, - }, - &mut transcript, - ) - .expect("honest batch"); - transcript.finish() - } - - pub fn verify(&self, proof: &Proof) -> Result<(), Sha256VerifyError> { - let mut transcript = verifier_transcript(proof); - let claim = MockSpartan::claim_verifier(&self.params, &mut transcript) - .map_err(|_| Sha256VerifyError::Claim)?; - self.verifier - .verify_virtual(&self.statement(&claim), &self.pcs, self.com, transcript) - .map_err(Sha256VerifyError::Verify) - } -} diff --git a/crates/tests/tests/sha256.rs b/crates/tests/tests/sha256.rs deleted file mode 100644 index 70b7db42..00000000 --- a/crates/tests/tests/sha256.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! SHA-256 compressions through the virtual pipeline: the mocked PIOP's claim -//! on the assignment, the fold, the grand product, the transposition onto the -//! committed bits, the sumcheck and the opening. - -use field::Q100; -use pcs::LigeritoProfile; -use tests::Sha256Instance; - -#[test] -fn a_batch_of_compressions_proves_and_verifies_against_the_committed_bits() { - // `2^9` compressions: the smallest batch whose source table clears the - // commitment floor. - let instance = Sha256Instance::::new(9, LigeritoProfile::Fast, 61); - let batch = &instance.batch; - assert_eq!( - batch.source.len(), - 1 << (batch.source_shape().log_bits() - 7) - ); - assert_eq!( - batch.assignment.len(), - 1 << (batch.assignment_shape().log_bits() - 7) - ); - - let proof = instance.prove(); - instance.verify(&proof).expect("honest proof"); -} diff --git a/crates/verifier/src/lib.rs b/crates/verifier/src/lib.rs index 1bdc63e4..2dc61841 100644 --- a/crates/verifier/src/lib.rs +++ b/crates/verifier/src/lib.rs @@ -6,6 +6,6 @@ pub mod setup; pub mod verify; pub use fold::ReceiveError; -pub use reduce::{ReduceError, gkr_reduce}; +pub use reduce::ReduceError; pub use setup::BitZVerifier; pub use verify::VerifyError; diff --git a/crates/verifier/src/reduce.rs b/crates/verifier/src/reduce.rs index 1bee05f2..b5e7c442 100644 --- a/crates/verifier/src/reduce.rs +++ b/crates/verifier/src/reduce.rs @@ -23,7 +23,7 @@ pub enum ReduceError { Claim(ClaimError), } -pub fn gkr_reduce( +pub(crate) fn gkr_reduce( transcript: &mut VerifierState, fold: &Fold, shape: &Shape, From bced0b527923dd9254e2a39375a34675f962efcd Mon Sep 17 00:00:00 2001 From: Alexander Abdugafarov Date: Thu, 17 Sep 2026 14:18:25 +0100 Subject: [PATCH 4/4] Minor tweaks --- crates/pcs/src/lib.rs | 5 ++--- crates/pcs/src/opening.rs | 4 +--- crates/post_gkr/src/lib.rs | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index dd57ecb3..68ab2c40 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -117,9 +117,8 @@ pub enum StatementBinding { /// Uses a statement that the caller already bound. /// /// The caller must bind the same PCS parameters, commitment, query variant, fields, and target. - /// For inner products, this covers both factor lengths, both factors (`Bind` absorbs a digest - /// of them), and the original target. The opening code still binds the MLE claim the sumcheck - /// returns. + /// For inner products, this covers both factor lengths, both factors, and the original target. + /// The opening code still binds the MLE claim that sumcheck returns. AlreadyBound, } diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index d0224bea..1bdf5d54 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -279,9 +279,7 @@ fn bind_mle_statement( } /// Binds both tensor factors before the first sumcheck challenge: their -/// lengths, a digest of their weights, and the target. A factor can be one -/// weight per committed bit, and absorbing it whole would cost the sponge -/// as much as the sumcheck costs the prover. +/// lengths, a digest of their weights, and the target. fn bind_inner_product_statement( pcs: &Pcs, root: &[u8; 32], diff --git a/crates/post_gkr/src/lib.rs b/crates/post_gkr/src/lib.rs index 02092a8d..d4ba69de 100644 --- a/crates/post_gkr/src/lib.rs +++ b/crates/post_gkr/src/lib.rs @@ -130,7 +130,7 @@ fn prove_factors( transcript.prover_message(&message); let challenge: F128 = transcript.verifier_message(); let running = advance(target, message, challenge); - let mut pair = Pair::new(factors.folded(challenge), bind_first(packed, challenge)); + let mut pair = Pair::new(factors.fold(challenge), bind_first(packed, challenge)); let (rest, target) = sumcheck::prove(&mut pair, running, transcript); let mut point = vec![challenge]; point.extend(rest); @@ -228,7 +228,7 @@ impl Factors<'_> { /// The weights with their first variable bound: the folded row factor /// tensored with the column factor, one entry per two bits. - fn folded(&self, challenge: F128) -> Vec { + fn fold(self, challenge: F128) -> Vec { let rows = sumcheck::folded(self.rows, challenge); let mut table = Vec::with_capacity(rows.len() * self.columns.len()); for &column in self.columns { @@ -404,7 +404,7 @@ mod tests { folded.evaluations }; assert_eq!(bind_first(&leaf.packed, point[0]), fold(written_out)); - assert_eq!(factors.folded(point[0]), fold(weights)); + assert_eq!(factors.fold(point[0]), fold(weights)); } /// Every record off by one bit fails the closing check; a different