perf(blobs): speed up unconstrained blob evaluation ~2.2x - #25439
Open
AztecBot wants to merge 1 commit into
Open
perf(blobs): speed up unconstrained blob evaluation ~2.2x#25439AztecBot wants to merge 1 commit into
AztecBot wants to merge 1 commit into
Conversation
Collaborator
Author
|
✅ Adversarial verdict: PASS ✅ Claude Review — completed VERDICT: PASS Reviewed exact head |
Collaborator
Author
|
Claude Review: Starting automated code review... workflow run |
This was referenced Sep 10, 2026
Collaborator
Author
|
Claude Review: Starting automated code review... workflow run |
TomAFrench
marked this pull request as ready for review
September 10, 2026 12:02
TomAFrench
requested review from
IlyasRidhuan,
LeilaWang,
iAmMichaelConnor,
iakovenkos and
ledwards2225
as code owners
September 10, 2026 12:02
AztecBot
force-pushed
the
cb/unconstrained-blob-eval-speedup
branch
from
September 10, 2026 12:29
2eb81f4 to
3b7603e
Compare
AztecBot
enabled auto-merge
September 10, 2026 12:29
Collaborator
Author
|
Claude Review: Starting automated code review... workflow run |
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)*
TomAFrench
force-pushed
the
cb/unconstrained-blob-eval-speedup
branch
from
September 10, 2026 16:09
3b7603e to
7191977
Compare
Collaborator
Author
|
Claude Review: Starting automated code review... workflow run |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #10323.
The TS
evaluateBlobsoracle exists only because simulating the real blobevaluation 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.
bignumandbigcurveare untouched. Both are in-tree copies ofnoir-lang/noir-bignumandnoir-lang/noir_bigcurve; an earlier revision ofthis PR edited them, and everything has since been lifted into
blobso thevendored crates stay byte-for-byte identical to upstream.
git diff nextonthose two directories is empty. The blob-local versions are built on the public
API those crates already export (
bignum::internal::__mul/__add, theBigNumandBigCurvetraits), and measure the same as the versions thatedited 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 thetime in two places, neither of them specific to blobs:
BlobAccumulator::accumulate— viaBigCurve::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.
__mul/__addpairs per blob, each paying afull 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 innerproduct over any
BigNum. Limb products accumulate unreduced into nativeFieldcolumns, and a whole batch of terms is reduced at once instead of onereduction per product.
__compute_sum— the unconstrained-only branch ofbarycentric_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 the2 * N-limb accumulator aslow + high * 2^(120 * N)so that each half fits anN-limb operand. That costs three__muls per batch rather than the oneBarrett reduction a
bignum-internal version would use, but it also removesBarrett's
2^(2 * MOD_BITS + 6)validity range from the batch-size bound, sothe batch can be much larger: for
BLS12_381_Frit is 2730 terms, making the4096-term sum cost 6 reductions rather than 4096.
utils/unconstrained_mul_add.nr:__mul_add.addend + scalar * pointonBLS12-381 via a 4-bit fixed-window Jacobian ladder (
dbl-2009-landadd-2007-bl, with the exceptional cases the formulas do not cover handledexplicitly), converting one final point to affine.
BlobAccumulator::accumulatedispatches on
std::runtime::is_unconstrained()and takes it in place ofevaluate_linear_expression; the constrained branch is unchanged.Measurements
Native ACVM via
noir-executeon a compiledevaluate_blobs_and_batch::<6>harness, best of 3, shared host — treat these as ratios rather than absolute
wall-clock.
nextCost of keeping it out of the libraries
The same harness against the earlier revision that edited
bignumandbigcurve: 2.99s and 1.63s. Identical within noise, so moving the logic intoblobcosts nothing measurable.The constrained circuit is unchanged
nargo infoonrollup-checkpoint-rootreports 1,389,848 ACIR opcodes and468,075 Brillig opcodes for
main, byte-identical tonext. The fast pathsare behind
is_unconstrained()or in an already-unconstrained function, so theycompile out of the circuit entirely — the earlier revision moved the Brillig
count by 9 because its
is_unconstrained()branch sat insidebigcurve::mul,which other callers reach.
Tests
nargo test -p blob: 59 passed.__mul_addagainst the constrained affine replay for a full-width scalar, asparse 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
acoefficient is zero, which thedoubling formula assumes.
__sum_of_productsagainst a reduce-every-product reference across fivemoduli, 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_agreepins the two execution pathsof
evaluate_blobs_and_batchto the sameBlobAccumulator, with a non-emptystart 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(thecomposer 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 batchinversion, 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)from1/(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_fieldsfracs actually matter, sincethe composer already asserts the trailing fields are zero — that takes this to
near zero for the sparse blocks that network tests produce.
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.
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 · group:
slackbot· requested by Tom (@TomAFrench) · Slack thread