From 7191977e70e9572450afeeef6684d36b9c996ea6 Mon Sep 17 00:00:00 2001 From: AztecBot <49558828+AztecBot@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:29:05 +0000 Subject: [PATCH] perf(blobs): speed up unconstrained blob evaluation ~2.2x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #10323. The TS `evaluateBlobs` oracle exists only because simulating the real blob evaluation was too slow. This removes most of that cost. It does **not** remove the oracle yet — see "What's left" below for the remaining gap and the numbers to decide on. **`bignum` and `bigcurve` are untouched.** Both are in-tree copies of `noir-lang/noir-bignum` and `noir-lang/noir_bigcurve`; an earlier revision of this PR edited them, and everything has since been lifted into `blob` so the vendored crates stay byte-for-byte identical to upstream. `git diff next` on those two directories is empty. The blob-local versions are built on the public API those crates already export (`bignum::internal::__mul`/`__add`, the `BigNum` and `BigCurve` traits), and measure the same as the versions that edited the libraries — see "Cost of keeping it out of the libraries". ## What was slow Profiling `evaluate_blobs_and_batch::<6>` under Brillig (native ACVM) put the time in two places, neither of them specific to blobs: 1. **`BlobAccumulator::accumulate`** — via `BigCurve::evaluate_linear_expression`. That function always generates the Jacobian witness *and* replays the whole MSM in affine arithmetic to constrain it. Unconstrained execution has no constraints to satisfy, so both the batched transcript inversion (~640 entries) and the replay were pure overhead. ~0.9s per accumulation. 2. **The barycentric sum** — 4096 `__mul`/`__add` pairs per blob, each paying a full Barrett reduction. ~0.26s per blob. ## Changes All three files are in `crates/blob`. **`utils/sum_of_products.nr`: `__sum_of_products`.** A delayed-reduction inner product over any `BigNum`. Limb products accumulate unreduced into native `Field` columns, and a whole batch of terms is reduced at once instead of one reduction per product. `__compute_sum` — the unconstrained-only branch of `barycentric_evaluate_blob_at_z` — uses it for the 4096-term barycentric sum; the constrained branch's partial-sum scheme is untouched. The batch is reduced through `bignum`'s public `__mul`, by splitting the `2 * N`-limb accumulator as `low + high * 2^(120 * N)` so that each half fits an `N`-limb operand. That costs three `__mul`s per batch rather than the one Barrett reduction a `bignum`-internal version would use, but it also removes Barrett's `2^(2 * MOD_BITS + 6)` validity range from the batch-size bound, so the batch can be much larger: for `BLS12_381_Fr` it is 2730 terms, making the 4096-term sum cost 6 reductions rather than 4096. **`utils/unconstrained_mul_add.nr`: `__mul_add`.** `addend + scalar * point` on BLS12-381 via a 4-bit fixed-window Jacobian ladder (`dbl-2009-l` and `add-2007-bl`, with the exceptional cases the formulas do not cover handled explicitly), converting one final point to affine. `BlobAccumulator::accumulate` dispatches on `std::runtime::is_unconstrained()` and takes it in place of `evaluate_linear_expression`; the constrained branch is unchanged. ## Measurements Native ACVM via `noir-execute` on a compiled `evaluate_blobs_and_batch::<6>` harness, best of 3, shared host — treat these as ratios rather than absolute wall-clock. | scenario | `next` | this PR | speedup | |---|---|---|---| | 6 full blobs, 6 accumulations (worst case) | 6.52s | 2.99s | 2.2x | | checkpoint-root fixture shape (12 fields, non-empty start accumulator) | 3.06s | 1.56s | 2.0x | ### Cost of keeping it out of the libraries The same harness against the earlier revision that edited `bignum` and `bigcurve`: 2.99s and 1.63s. Identical within noise, so moving the logic into `blob` costs nothing measurable. ### The constrained circuit is unchanged `nargo info` on `rollup-checkpoint-root` reports **1,389,848 ACIR opcodes and 468,075 Brillig opcodes** for `main`, byte-identical to `next`. The fast paths are behind `is_unconstrained()` or in an already-unconstrained function, so they compile out of the circuit entirely — the earlier revision moved the Brillig count by 9 because its `is_unconstrained()` branch sat inside `bigcurve::mul`, which other callers reach. ## Tests `nargo test -p blob`: 59 passed. - `__mul_add` against the constrained affine replay for a full-width scalar, a sparse scalar, and an addend equal to the product (which forces the equal-operand case in the final addition); against a plain bit-by-bit double-and-add reference across six scalars including the window boundary at 15/16; and for a zero scalar, a base point at infinity, and an addend at infinity. A test asserts the curve's `a` coefficient is zero, which the doubling formula assumes. - `__sum_of_products` against a reduce-every-product reference across five moduli, at term counts either side of the batch boundary, plus a 3000-term case that forces a mid-sum flush and a case built from the field's maximal element (where a wrongly sized accumulator overflows). - `constrained_and_unconstrained_evaluation_agree` pins the two execution paths of `evaluate_blobs_and_batch` to the same `BlobAccumulator`, with a non-empty start accumulator so the scalar multiplication is exercised. This is the test that matters most: a simulated accumulator that differs from the constrained one is a public input the circuit cannot reproduce. ## What's left before the oracle can go With the oracle removed, a checkpoint-root simulation costs ~1.6s for a small checkpoint and ~3.0s for six full blobs. Measured on the existing Noir tests, dropping the mock adds ~26s to `rollup_structure_tests::with_both_roots` (the composer runs the blob step four times per test), and there are ~60 such tests. The remaining time, for a small checkpoint: - **`compute_fracs`: ~0.86s** — 4096 subtractions, a 4096-element batch inversion, and 4096 multiplications, independent of how full the blobs are. Two ways to cut it: the roots of unity admit a recursive halving (`1/(z - w^i)` from `1/(z^2 - w^2i)`) that replaces the batch inversion's ~12288 multiplications with ~4096, worth roughly 1.6x on this step; and in the unconstrained path only the first `num_fields` fracs actually matter, since the composer already asserts the trailing fields are zero — that takes this to near zero for the sparse blocks that network tests produce. - **EC accumulation: ~0.25s per accumulated blob** — a 255-bit scalar multiplication per blob. The structural fix is to batch a checkpoint's blobs into one MSM so they share the doubling chain, which would help the constrained circuit too, but it reshapes the accumulator abstraction. - **Barycentric evaluation: ~0.05s for all six blobs**, down from ~1.6s. Happy to take the fracs work next if you want the oracle gone in one go — say the word and I'll size it against what you consider acceptable for checkpoint root simulation. --- *Created by [claudebox](https://claudebox.work/v2/sessions/99aba4482349eaa5/jobs/4) · group: `slackbot` · requested by Tom (@TomAFrench) · [Slack thread](https://aztecfoundation.slack.com/archives/D0B586H14KG/p1788969921421879?thread_ts=1788969921.421879&cid=D0B586H14KG)* --- .../crates/blob/src/abis/blob_accumulator.nr | 26 +- .../crates/blob/src/blob.nr | 15 +- .../crates/blob/src/blob_batching.nr | 112 ++++++ .../crates/blob/src/utils/mod.nr | 4 + .../crates/blob/src/utils/sum_of_products.nr | 256 +++++++++++++ .../blob/src/utils/unconstrained_mul_add.nr | 348 ++++++++++++++++++ 6 files changed, 745 insertions(+), 16 deletions(-) create mode 100644 noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/sum_of_products.nr create mode 100644 noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/unconstrained_mul_add.nr diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/abis/blob_accumulator.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/abis/blob_accumulator.nr index 24629ce607dc..dbea60054ee5 100644 --- a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/abis/blob_accumulator.nr +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/abis/blob_accumulator.nr @@ -1,4 +1,4 @@ -use crate::utils::compress_to_blob_commitment; +use crate::utils::{__mul_add, compress_to_blob_commitment}; use super::{ batching_blob_commitment::BatchingBlobCommitment, BLSPoint, final_blob_accumulator::FinalBlobAccumulator, @@ -6,6 +6,7 @@ use super::{ use bigcurve::{BigCurve, curves::bls12_381::{BLS12_381, BLS12_381Scalar}}; use bignum::{BigNum, BLS12_381_Fq, BLS12_381_Fr}; use std::ops::{Add, Mul}; +use std::runtime::is_unconstrained; use types::{ constants::{ BLOB_ACCUMULATOR_LENGTH, DOM_SEP__BLOB_GAMMA_ACC, DOM_SEP__BLOB_HASHED_Y_LIMBS, @@ -128,11 +129,24 @@ impl BlobAccumulator { ); // Equivalent to self.c_acc.add(other.c_i.point.mul(BLS12_381Scalar::from_bignum(self.gamma_pow_acc))) - let c_acc = BLS12_381::evaluate_linear_expression( - [other.c_i.point], - [BLS12_381Scalar::from_bignum(self.gamma_pow_acc)], - [self.c_acc], - ); + let c_acc = if is_unconstrained() { + // `evaluate_linear_expression` generates a Jacobian witness and then replays the + // whole multiplication in affine arithmetic to constrain it. With no constraints to + // satisfy there is nothing to replay, and both the replay and the batched transcript + // inversion that feeds it are pure overhead. + // + // Safety: unconstrained execution emits no constraints, so there is nothing to + // verify; `__mul_add` is checked against the replay in its own tests. + unsafe { + __mul_add(other.c_i.point, self.gamma_pow_acc, self.c_acc) + } + } else { + BLS12_381::evaluate_linear_expression( + [other.c_i.point], + [BLS12_381Scalar::from_bignum(self.gamma_pow_acc)], + [self.c_acc], + ) + }; Self { blob_commitments_hash_acc: sha256_to_field(self diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob.nr index 8f8e72c97fe2..8fb150257f9c 100644 --- a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob.nr +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob.nr @@ -1,4 +1,5 @@ use crate::config::{D_INV, LOG_FIELDS_PER_BLOB, ROOTS}; +use crate::utils::__sum_of_products; use bignum::{BigNum, BLS12_381_Fr}; use std::ops::{Mul, Neg}; @@ -387,16 +388,10 @@ unconstrained fn __compute_sum( // sum = / y_i . --------- // /____ z - w^i // i=0 - - let mut sum = BLS12_381_Fr::zero(); - for i in 0..FIELDS_PER_BLOB { - // y_k * ( w^k / (z - w^k) ) - let summand = ys[i].__mul(fracs[i]); - - // partial_sum + ( y_k * ( w^k / (z - w^k) ) -> partial_sum - sum = sum.__add(summand); - } - sum + // + // `__sum_of_products` batches the modular reductions across the whole sum, which matters at + // this length: a `__mul`/`__add` chain pays a Barrett reduction on each of the d terms. + __sum_of_products(ys, fracs) } mod tests { diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob_batching.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob_batching.nr index 26fdc0da07ce..f8f69b45e2a8 100644 --- a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob_batching.nr +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/blob_batching.nr @@ -358,6 +358,118 @@ mod tests { assert_eq(final_acc.blob_commitments_hash, blob_commitments_hash_3_blobs_from_ts); } + unconstrained fn evaluate_blobs_and_batch_unconstrained( + blobs_as_fields: [Field; FIELDS_PER_BLOB * NumBlobs], + num_fields: u32, + blob_fields_hash: Field, + kzg_commitments_points: [BLSPoint; NumBlobs], + final_blob_challenges: FinalBlobBatchingChallenges, + start_accumulator: BlobAccumulator, + challenge_z: BLS12_381_Fr, + ) -> BlobAccumulator { + evaluate_blobs_and_batch( + blobs_as_fields, + num_fields, + blob_fields_hash, + kzg_commitments_points, + final_blob_challenges, + start_accumulator, + challenge_z, + ) + } + + /// Unconstrained execution takes shortcuts the circuit cannot: a batched inner product for the + /// barycentric sum, and a Jacobian-only scalar multiplication in the accumulator. Simulation + /// predicts the public inputs the circuit will then have to reproduce exactly, so the two paths + /// have to agree on every field of the accumulator. + /// + /// The start accumulator is non-empty so that the blobs go through `accumulate` (which does the + /// scalar multiplication) rather than `init`. + #[test] + fn constrained_and_unconstrained_evaluation_agree() { + let num_fields = FIELDS_PER_BLOB + 37; + let mut blob_fields = [0; FIELDS_PER_BLOB * 2]; + for i in 0..num_fields { + blob_fields[i] = 8 + i as Field; + } + + let commitments = [ + BatchingBlobCommitment::from_limbs( + [ + 0xc1c8bbec58d7e25cc840d31e1a0361, + 0xdfebfabcadc58a67da75e8c3ee4a09, + 0x5504cd781b65efd4f539e321e4d17a, + 0x171570, + ], + [ + 0x96af7267d106a96b4353b5ed0bf0a6, + 0xb785a85e0f1404abe16503604906e6, + 0x471e2147e13fe3eaa97ada6f828112, + 0x15e24d, + ], + ) + .point, + BatchingBlobCommitment::from_limbs( + [ + 0xab3ed5948aa3d00fe77b1b2876ecd7, + 0x9d649e59ee9920f46f5f586bc9f6cb, + 0x001f300677e564710b67c3ef2b1f46, + 0x0ac039, + ], + [ + 0xaa5703192a3733107a7f7ba1fa98ce, + 0xc67513549bde2e39d6b6607670cd6b, + 0x0727a2b1e640aec8dbc7709a0c38be, + 0x13ed3a, + ], + ) + .point, + ]; + + let final_challenges = FinalBlobBatchingChallenges { + z: 0x1f4a21e3a1ab23739c164d0df1596c870180aab5f810c548097dd6371ac3186d, + gamma: BLS12_381_Fr::from_limbs([ + 0xf8b8cfdf51c20cc6ffc9820eb0d204, + 0x36482d033a8c01a3089bba6714ca12, + 0x14e8, + ]), + }; + let challenge_z = BLS12_381_Fr::from(final_challenges.z); + + let mut start_accumulator = BlobAccumulator::empty(); + start_accumulator.blob_commitments_hash_acc = 11; + start_accumulator.z_acc = 22; + start_accumulator.y_acc = challenge_z; + start_accumulator.c_acc = commitments[1]; + start_accumulator.gamma_acc = 33; + start_accumulator.gamma_pow_acc = final_challenges.gamma; + + let in_circuit = evaluate_blobs_and_batch( + blob_fields, + num_fields, + 0x1c501e5d16d469f6b3e06418af17686f4d34f69fa4ed5d9690850a3e88f30810, + commitments, + final_challenges, + start_accumulator, + challenge_z, + ); + + // Safety: test code; the point of the test is to compare the two execution paths. + let simulated = unsafe { + evaluate_blobs_and_batch_unconstrained( + blob_fields, + num_fields, + 0x1c501e5d16d469f6b3e06418af17686f4d34f69fa4ed5d9690850a3e88f30810, + commitments, + final_challenges, + start_accumulator, + challenge_z, + ) + }; + + assert_eq(in_circuit, simulated); + } + #[test] fn test_empty_blob() { let blob = [0; FIELDS_PER_BLOB]; diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/mod.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/mod.nr index 920250edbcda..0231da71ff83 100644 --- a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/mod.nr +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/mod.nr @@ -1,7 +1,11 @@ mod compress_to_blob_commitment; +mod sum_of_products; +mod unconstrained_mul_add; mod validate_final_blob_batching_challenges; mod validate_point; pub use compress_to_blob_commitment::compress_to_blob_commitment; +pub(crate) use sum_of_products::__sum_of_products; +pub(crate) use unconstrained_mul_add::__mul_add; pub use validate_final_blob_batching_challenges::validate_final_blob_batching_challenges; pub use validate_point::validate_canonical_representation_if_infinity; diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/sum_of_products.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/sum_of_products.nr new file mode 100644 index 000000000000..01b292880180 --- /dev/null +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/sum_of_products.nr @@ -0,0 +1,256 @@ +//! Inner product over a `BigNum` with delayed reduction (unconstrained). +//! +//! Built on `bignum`'s public `internal` API so that the vendored `bignum` crate stays a +//! byte-for-byte copy of `noir-lang/noir-bignum`. + +use bignum::{BigNum, internal::{__add, __mul}, params::BigNumParams}; + +/// The radix `BigNum` stores its limbs in. +global TWO_POW_120: u128 = 0x1000000000000000000000000000000; +global TWO_POW_120_AS_FIELD: Field = 0x1000000000000000000000000000000; + +/// A limb product is below `2^240`, and a native `Field` holds values below `2^253` with room to +/// spare, so a single column has room for `2^13` limb products. +global MAX_PRODUCTS_PER_COLUMN_BITS: u32 = 13; + +/// Computes `sum_i(lhs[i] * rhs[i])` (unconstrained). +/// +/// Accumulates the limb products of many terms into one unreduced value and reduces a batch of +/// them at a time, rather than reducing after every product as a `__mul`/`__add` chain does. For +/// a long inner product that replaces nearly all of the modular reductions with plain native +/// field multiply-accumulates. +/// +/// # Safety +/// **UNCONSTRAINED**: no constraints are generated. Use the result as a witness and constrain it +/// separately (e.g. with `evaluate_quadratic_expression`). +pub unconstrained fn __sum_of_products(lhs: [BN; M], rhs: [BN; M]) -> BN +where + BN: BigNum, +{ + BN::from_limbs_unsafe(__sum_of_product_limbs( + BN::params(), + lhs.map(|term: BN| term.get_limbs()), + rhs.map(|term: BN| term.get_limbs()), + )) +} + +/// How many products the column accumulator may hold before it has to be reduced. +/// +/// Two bounds apply: +/// - one term contributes at most `N` limb products to any single column, and a column is a +/// native `Field`, so at most `2^13 / N` terms fit; +/// - carry-propagated, the accumulator is `2 * N` limbs of 120 bits and each term is below +/// `modulus^2`, so the chunk also has to fit in the bits the modulus leaves free there. A +/// modulus filling nearly all of its limbs leaves none, and reduces after every product. +fn __chunk_size(num_limbs: u32, mod_bits: u32) -> u32 { + let column_bound: u32 = (1 << MAX_PRODUCTS_PER_COLUMN_BITS) / num_limbs; + let accumulator_headroom_bits: u32 = 240 * num_limbs - 2 * mod_bits; + + let bound = if accumulator_headroom_bits >= MAX_PRODUCTS_PER_COLUMN_BITS { + column_bound + } else { + let accumulator_bound: u32 = (1 << accumulator_headroom_bits) - 1; + if accumulator_bound < column_bound { + accumulator_bound + } else { + column_bound + } + }; + + if bound == 0 { + 1 + } else { + bound + } +} + +/// Split a `Field` into its low 120 bits and the remaining high bits (unconstrained). +/// +/// The `as u128` cast truncates to the low 128 bits, so the remainder recovers the low limb +/// exactly; subtracting it first makes the field division an integer division. +unconstrained fn __split_120_bits(x: Field) -> (u128, Field) { + let low: u128 = (x as u128) % TWO_POW_120; + let high: Field = (x - low as Field) / TWO_POW_120_AS_FIELD; + (low, high) +} + +/// Carry-propagate unreduced limb columns into 120-bit limbs (unconstrained). +unconstrained fn __normalize_columns(columns: [Field; W]) -> [u128; W] { + let mut normalized: [u128; W] = [0; W]; + let mut next: Field = columns[0]; + for i in 0..(W - 1) { + let (low, high): (u128, Field) = __split_120_bits(next); + normalized[i] = low; + next = columns[i + 1] + high; + } + let (low, high): (u128, Field) = __split_120_bits(next); + normalized[W - 1] = low; + + // A non-zero final carry means the accumulator outgrew the array: the chunk size is wrong. + assert(high == 0, "sum_of_products accumulator overflow"); + + normalized +} + +/// Reduce a `2 * N`-limb integer modulo `params.modulus` (unconstrained). +/// +/// Splits the value as `low + high * 2^(120 * N)` so that each half fits an `N`-limb operand, +/// then folds the high half back in through `radix = 2^(120 * N) mod modulus`. Both halves go +/// into `__mul` unreduced, which is what `__mul` is for: it reduces the product it forms, and a +/// factor below `2^(120 * N)` against `one` stays inside the range where that is exact. +unconstrained fn __reduce_wide( + params: BigNumParams, + radix: [u128; N], + columns: [Field; 2 * N], +) -> [u128; N] { + let wide: [u128; 2 * N] = __normalize_columns(columns); + + let mut low: [u128; N] = [0; N]; + let mut high: [u128; N] = [0; N]; + for i in 0..N { + low[i] = wide[i]; + high[i] = wide[N + i]; + } + + let mut one: [u128; N] = [0; N]; + one[0] = 1; + + // `high` is reduced before it meets `radix`: an unreduced factor against a modulus-sized one + // would leave the range that `__mul`'s Barrett reduction is exact over. + let high_folded = __mul(params, __mul(params, high, one), radix); + __add(params.modulus, __mul(params, low, one), high_folded) +} + +/// `sum_i(lhs[i] * rhs[i]) mod params.modulus`, over raw limbs (unconstrained). +unconstrained fn __sum_of_product_limbs( + params: BigNumParams, + lhs: [[u128; N]; M], + rhs: [[u128; N]; M], +) -> [u128; N] { + let chunk_size: u32 = __chunk_size(N, MOD_BITS); + + // `2^(120 * N) mod modulus`, built as `2^(120 * (N - 1)) * 2^120`. `BigNum` requires at + // least two limbs, so both factors are representable. + let mut top_limb: [u128; N] = [0; N]; + top_limb[N - 1] = 1; + let mut two_pow_120: [u128; N] = [0; N]; + two_pow_120[1] = 1; + let radix: [u128; N] = __mul(params, top_limb, two_pow_120); + + let mut result: [u128; N] = [0; N]; + let mut columns: [Field; 2 * N] = [0; 2 * N]; + let mut pending: u32 = 0; + + for i in 0..M { + for j in 0..N { + for k in 0..N { + columns[j + k] += (lhs[i][j] as Field) * (rhs[i][k] as Field); + } + } + + pending += 1; + if pending == chunk_size { + result = __add( + params.modulus, + result, + __reduce_wide(params, radix, columns), + ); + columns = [0; 2 * N]; + pending = 0; + } + } + + if pending != 0 { + result = __add( + params.modulus, + result, + __reduce_wide(params, radix, columns), + ); + } + + result +} + +mod tests { + use super::__sum_of_products; + use bignum::{BigNum, BLS12_377_Fq, BLS12_381_Fq, BLS12_381_Fr, ED25519_Fq, U256}; + + /// Reference inner product: reduce after every product. + unconstrained fn reference(lhs: [BN; M], rhs: [BN; M]) -> BN + where + BN: BigNum, + { + let mut expected: BN = BN::zero(); + for i in 0..M { + expected = expected.__add(lhs[i].__mul(rhs[i])); + } + expected + } + + /// The reduction is batched, so the interesting term counts are the ones that leave a + /// partial final batch to flush. + unconstrained fn check() + where + BN: BigNum, + { + let mut lhs: [BN; M] = std::mem::zeroed(); + let mut rhs: [BN; M] = std::mem::zeroed(); + for i in 0..M { + lhs[i] = BN::derive_from_seed([i as u8, 7]); + rhs[i] = BN::derive_from_seed([11, i as u8]); + } + + assert_eq(__sum_of_products(lhs, rhs), reference(lhs, rhs)); + } + + #[test] + unconstrained fn single_term() { + check::<1, BLS12_381_Fr>(); + check::<1, U256>(); + } + + #[test] + unconstrained fn several_terms() { + check::<7, BLS12_381_Fr>(); + check::<7, BLS12_381_Fq>(); + check::<7, BLS12_377_Fq>(); + check::<7, ED25519_Fq>(); + check::<7, U256>(); + } + + #[test] + unconstrained fn many_terms() { + check::<70, BLS12_381_Fr>(); + check::<70, BLS12_381_Fq>(); + check::<70, BLS12_377_Fq>(); + check::<70, ED25519_Fq>(); + check::<70, U256>(); + } + + /// Every term is the field's largest representable value, which is where an accumulator + /// sized wrongly for the modulus overflows. + #[test] + unconstrained fn maximal_terms() { + let max: BLS12_381_Fr = BLS12_381_Fr::modulus().__sub(BLS12_381_Fr::one()); + let terms: [BLS12_381_Fr; 70] = [max; 70]; + + assert_eq(__sum_of_products(terms, terms), reference(terms, terms)); + } + + /// More terms than fit one batch, so the accumulator is flushed mid-sum and the running + /// result is carried into the next batch. + #[test] + unconstrained fn more_terms_than_one_batch() { + let max: BLS12_381_Fq = BLS12_381_Fq::modulus().__sub(BLS12_381_Fq::one()); + let terms: [BLS12_381_Fq; 3000] = [max; 3000]; + + assert_eq(__sum_of_products(terms, terms), reference(terms, terms)); + } + + #[test] + unconstrained fn zero_terms_sum_to_zero() { + let terms: [BLS12_381_Fr; 5] = [BLS12_381_Fr::zero(); 5]; + + assert_eq(__sum_of_products(terms, terms), BLS12_381_Fr::zero()); + } +} diff --git a/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/unconstrained_mul_add.nr b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/unconstrained_mul_add.nr new file mode 100644 index 000000000000..6bb896889df7 --- /dev/null +++ b/noir-projects/fnd/noir-protocol-circuits/crates/blob/src/utils/unconstrained_mul_add.nr @@ -0,0 +1,348 @@ +//! BLS12-381 scalar multiplication in Jacobian coordinates (unconstrained). +//! +//! `BigCurve`'s scalar multiplication produces an affine transcript for a circuit to replay +//! against, which costs a batched inversion over the whole transcript plus the replay itself. +//! Unconstrained execution has no constraints to satisfy and so nothing to replay: it only wants +//! the result. This ladder computes that directly and pays a single modular inversion to convert +//! the one final point. +//! +//! Lives here rather than in `bigcurve` so that the vendored crate stays a byte-for-byte copy of +//! `noir-lang/noir_bigcurve`. + +use crate::abis::BLSPoint; +use bigcurve::BigCurve; +use bignum::{BigNum, BLS12_381_Fq, BLS12_381_Fr}; + +/// Scalar bits consumed per ladder step, and the `2^WINDOW_BITS - 1` multiples of the base point +/// that a step may need to add. +global WINDOW_BITS: u32 = 4; +global WINDOW_TABLE_SIZE: u32 = 15; + +/// A `BigNum` limb is 120 bits, so it holds 30 nibbles. +global NIBBLES_PER_LIMB: u32 = 30; + +/// Computes `addend + scalar * point` (unconstrained). +/// +/// This is the unconstrained counterpart of `BigCurve::evaluate_linear_expression` for one +/// multiplication and one addition, and must agree with it exactly: the accumulator it feeds is +/// a circuit public input, so a simulated value that differs from the constrained one is a value +/// the circuit cannot reproduce. +/// +/// # Safety +/// **UNCONSTRAINED**: no constraints are generated. The result is a witness; a circuit that +/// depends on it has to derive it again through the constrained path. +pub unconstrained fn __mul_add( + point: BLSPoint, + scalar: BLS12_381_Fr, + addend: BLSPoint, +) -> BLSPoint { + let product = __scalar_mul(JacobianPoint::from_affine(point), scalar); + product.add(JacobianPoint::from_affine(addend)).to_affine() +} + +/// A point in Jacobian coordinates: `(X, Y, Z)` is the affine point `(X / Z^2, Y / Z^3)`. +/// `Z == 0` is the point at infinity. +struct JacobianPoint { + x: BLS12_381_Fq, + y: BLS12_381_Fq, + z: BLS12_381_Fq, +} + +/// Fixed-window scalar multiplication, MSB-first (unconstrained). +unconstrained fn __scalar_mul(point: JacobianPoint, scalar: BLS12_381_Fr) -> JacobianPoint { + // table[i] holds `(i + 1) * point`. + let mut table: [JacobianPoint; WINDOW_TABLE_SIZE] = [point; WINDOW_TABLE_SIZE]; + for i in 1..WINDOW_TABLE_SIZE { + table[i] = table[i - 1].add(point); + } + + let limbs = scalar.get_limbs(); + let num_nibbles = limbs.len() * NIBBLES_PER_LIMB; + + let mut accumulator = JacobianPoint::infinity(); + for i in 0..num_nibbles { + // Doubling the point at infinity yields the point at infinity, so skipping the leading + // zero nibbles of the scalar is what keeps this to one doubling per scalar bit rather + // than one per bit of the limb array. + if !accumulator.is_infinity() { + for _ in 0..WINDOW_BITS { + accumulator = accumulator.double(); + } + } + + let nibble = __nibble(limbs, num_nibbles - 1 - i); + if nibble != 0 { + accumulator = accumulator.add(table[nibble - 1]); + } + } + + accumulator +} + +/// Read the nibble at `index`, counting from the least significant one (unconstrained). +unconstrained fn __nibble(limbs: [u128; N], index: u32) -> u32 { + let limb = limbs[index / NIBBLES_PER_LIMB]; + let shift: u128 = (WINDOW_BITS * (index % NIBBLES_PER_LIMB)) as u128; + ((limb >> shift) & 15) as u32 +} + +unconstrained fn __twice(value: BLS12_381_Fq) -> BLS12_381_Fq { + value.__add(value) +} + +impl JacobianPoint { + unconstrained fn infinity() -> Self { + Self { x: BLS12_381_Fq::one(), y: BLS12_381_Fq::one(), z: BLS12_381_Fq::zero() } + } + + unconstrained fn from_affine(point: BLSPoint) -> Self { + if point.is_infinity() { + JacobianPoint::infinity() + } else { + Self { x: point.x, y: point.y, z: BLS12_381_Fq::one() } + } + } + + unconstrained fn is_infinity(self) -> bool { + self.z.__is_zero() + } + + /// Convert to affine, which costs one modular inversion. + /// + /// Only worth doing on a final result: `bigcurve` converts whole transcripts instead, where + /// batching the inversions is what makes the conversion affordable. + unconstrained fn to_affine(self) -> BLSPoint { + if self.is_infinity() { + BLSPoint::point_at_infinity() + } else { + let z_inv = self.z.__invmod(); + let zz = z_inv.__sqr(); + let zzz = zz.__mul(z_inv); + BLSPoint::from_coordinates_unsafe(self.x.__mul(zz), self.y.__mul(zzz), false) + } + } + + /// `dbl-2009-l` from the EFD, which assumes the curve's `a` coefficient is zero. + /// BLS12-381 is `y^2 = x^3 + 4`, so it is; `curve_a_coefficient_is_zero` pins that down. + unconstrained fn double(self) -> Self { + let a = self.x.__sqr(); + let b = self.y.__sqr(); + let c = b.__sqr(); + let d = __twice(self.x.__add(b).__sqr().__sub(a).__sub(c)); + let e = a.__add(a).__add(a); + let f = e.__sqr(); + + let x = f.__sub(__twice(d)); + let y = e.__mul(d.__sub(x)).__sub(__twice(__twice(__twice(c)))); + let z = __twice(self.y.__mul(self.z)); + + Self { x, y, z } + } + + /// `add-2007-bl` from the EFD, with the exceptional cases the formula does not cover handled + /// up front: either operand at infinity, the two operands equal (a doubling), and the two + /// operands negatives of each other (infinity). + unconstrained fn add(self, other: Self) -> Self { + if self.is_infinity() { + other + } else if other.is_infinity() { + self + } else { + let z1z1 = self.z.__sqr(); + let z2z2 = other.z.__sqr(); + let u1 = self.x.__mul(z2z2); + let u2 = other.x.__mul(z1z1); + let s1 = self.y.__mul(other.z).__mul(z2z2); + let s2 = other.y.__mul(self.z).__mul(z1z1); + + if u1.__eq(u2) { + if s1.__eq(s2) { + self.double() + } else { + JacobianPoint::infinity() + } + } else { + let h = u2.__sub(u1); + let i = __twice(h).__sqr(); + let j = h.__mul(i); + let r = __twice(s2.__sub(s1)); + let v = u1.__mul(i); + + let x = r.__sqr().__sub(j).__sub(__twice(v)); + let y = r.__mul(v.__sub(x)).__sub(__twice(s1.__mul(j))); + let z = self.z.__add(other.z).__sqr().__sub(z1z1).__sub(z2z2).__mul(h); + + Self { x, y, z } + } + } + } +} + +mod tests { + use crate::abis::BLSPoint; + use super::{__mul_add, JacobianPoint}; + use bigcurve::{BigCurve, curves::bls12_381::BLS12_381Scalar}; + use bignum::{BigNum, BLS12_381_Fq, BLS12_381_Fr}; + + /// `BLS12_381_Fr::modulus() - 1`, the largest scalar the accumulator can carry. + unconstrained fn max_scalar() -> BLS12_381_Fr { + BLS12_381_Fr::modulus().__sub(BLS12_381_Fr::one()) + } + + /// A scalar whose nibbles include zeros in the middle and at the top, which is where the + /// leading-zero skip and the "no table lookup this step" branch come in. + unconstrained fn sparse_scalar() -> BLS12_381_Fr { + BLS12_381_Fr::from_limbs([0x0f00000000000000000000000000f0, 0, 1]) + } + + /// A real blob commitment, so that the tests do not all run against the generator. + /// `from_coordinates` checks it is on the curve. + fn other_point() -> BLSPoint { + BLSPoint::from_coordinates( + BLS12_381_Fq::from_limbs([ + 0xc1c8bbec58d7e25cc840d31e1a0361, + 0xdfebfabcadc58a67da75e8c3ee4a09, + 0x5504cd781b65efd4f539e321e4d17a, + 0x171570, + ]), + BLS12_381_Fq::from_limbs([ + 0x96af7267d106a96b4353b5ed0bf0a6, + 0xb785a85e0f1404abe16503604906e6, + 0x471e2147e13fe3eaa97ada6f828112, + 0x15e24d, + ]), + false, + ) + } + + /// Plain MSB-first double-and-add over the scalar's bits. It shares the group law with + /// `__mul_add` but none of its windowing, so it is what pins down the table construction and + /// the leading-zero skip; `matches_constrained_*` is what pins down the group law itself. + unconstrained fn reference_mul_add( + point: BLSPoint, + scalar: BLS12_381_Fr, + addend: BLSPoint, + ) -> BLSPoint { + let base = JacobianPoint::from_affine(point); + let limbs = scalar.get_limbs(); + let num_bits = limbs.len() * 120; + + let mut accumulator = JacobianPoint::infinity(); + for i in 0..num_bits { + let bit_index = num_bits - 1 - i; + if !accumulator.is_infinity() { + accumulator = accumulator.double(); + } + if (limbs[bit_index / 120] >> ((bit_index % 120) as u128)) & 1 == 1 { + accumulator = accumulator.add(base); + } + } + + accumulator.add(JacobianPoint::from_affine(addend)).to_affine() + } + + unconstrained fn assert_matches_reference( + point: BLSPoint, + scalar: BLS12_381_Fr, + addend: BLSPoint, + ) { + assert_eq(__mul_add(point, scalar, addend), reference_mul_add(point, scalar, addend)); + } + + /// The ladder skips the affine replay entirely, so the group law it uses has to be checked + /// against the replay rather than against another Jacobian computation. + unconstrained fn assert_matches_constrained( + point: BLSPoint, + scalar: BLS12_381_Fr, + addend: BLSPoint, + ) { + let expected = BLSPoint::evaluate_linear_expression( + [point], + [BLS12_381Scalar::from_bignum(scalar)], + [addend], + ); + + assert_eq(__mul_add(point, scalar, addend), expected); + } + + #[test] + unconstrained fn matches_constrained_for_a_large_scalar() { + assert_matches_constrained(other_point(), max_scalar(), BLSPoint::one()); + } + + #[test] + unconstrained fn matches_constrained_for_a_sparse_scalar() { + assert_matches_constrained(BLSPoint::one(), sparse_scalar(), other_point()); + } + + #[test] + unconstrained fn matches_reference_across_scalars() { + let point = other_point(); + let addend = BLSPoint::one(); + + // 15 and 16 sit either side of a window boundary, where a step first has to carry into + // the next nibble. + let scalars = [ + max_scalar(), + sparse_scalar(), + BLS12_381_Fr::from(1), + BLS12_381_Fr::from(15), + BLS12_381_Fr::from(16), + BLS12_381_Fr::from(0x123456789abcdef), + ]; + for i in 0..scalars.len() { + assert_matches_reference(point, scalars[i], addend); + } + } + + /// With a scalar of one the addend equals the product, so the final addition runs into the + /// equal-operand case that `add-2007-bl` does not cover. + #[test] + unconstrained fn matches_constrained_when_the_addend_equals_the_product() { + assert_matches_constrained(other_point(), BLS12_381_Fr::from(1), other_point()); + } + + #[test] + unconstrained fn zero_scalar_leaves_the_addend() { + assert_eq(__mul_add(other_point(), BLS12_381_Fr::zero(), BLSPoint::one()), BLSPoint::one()); + } + + #[test] + unconstrained fn multiplying_the_point_at_infinity_leaves_the_addend() { + assert_eq( + __mul_add(BLSPoint::point_at_infinity(), max_scalar(), other_point()), + other_point(), + ); + } + + #[test] + unconstrained fn an_addend_at_infinity_contributes_nothing() { + assert_eq( + __mul_add( + other_point(), + BLS12_381_Fr::from(1), + BLSPoint::point_at_infinity(), + ), + other_point(), + ); + assert_matches_reference(other_point(), max_scalar(), BLSPoint::point_at_infinity()); + } + + #[test] + unconstrained fn a_zero_scalar_and_an_addend_at_infinity_give_infinity() { + let result = __mul_add( + other_point(), + BLS12_381_Fr::zero(), + BLSPoint::point_at_infinity(), + ); + + assert(result.is_infinity()); + assert_eq(result, BLSPoint::point_at_infinity()); + } + + /// The doubling formula the ladder uses drops the curve's `a` coefficient. + #[test] + fn curve_a_coefficient_is_zero() { + assert_eq(BLSPoint::a(), BLS12_381_Fq::zero()); + } +}