Skip to content

perf(blobs): speed up unconstrained blob evaluation ~2.2x - #25439

Open
AztecBot wants to merge 1 commit into
nextfrom
cb/unconstrained-blob-eval-speedup
Open

perf(blobs): speed up unconstrained blob evaluation ~2.2x#25439
AztecBot wants to merge 1 commit into
nextfrom
cb/unconstrained-blob-eval-speedup

Conversation

@AztecBot

@AztecBot AztecBot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

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 __muls 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 · group: slackbot · requested by Tom (@TomAFrench) · Slack thread

@AztecBot AztecBot added ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure claude-review Triggers an automated Claude code review claudebox Owned by claudebox. it can push to this PR. labels Sep 9, 2026
@AztecBot

AztecBot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial verdict: PASS

Claude Review — completed
live status
11m

VERDICT: PASS

Reviewed exact head 7191977e70e9572450afeeef6684d36b9c996ea6. No blocking findings. All 69 blob tests passed, including constrained/unconstrained agreement; independent arithmetic checks also passed. No code changes.

Review and test evidence

gist

@AztecBot

AztecBot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Review: Starting automated code review... workflow run

@AztecBot AztecBot added claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention and removed claude-review Triggers an automated Claude code review labels Sep 9, 2026
@AztecBot AztecBot added claude-review Triggers an automated Claude code review and removed claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention labels Sep 10, 2026
@AztecBot

Copy link
Copy Markdown
Collaborator Author

Claude Review: Starting automated code review... workflow run

@AztecBot AztecBot added claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention and removed claude-review Triggers an automated Claude code review labels Sep 10, 2026
@TomAFrench
TomAFrench marked this pull request as ready for review September 10, 2026 12:02
@AztecBot
AztecBot force-pushed the cb/unconstrained-blob-eval-speedup branch from 2eb81f4 to 3b7603e Compare September 10, 2026 12:29
@AztecBot
AztecBot enabled auto-merge September 10, 2026 12:29
@AztecBot AztecBot removed claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention labels Sep 10, 2026
@AztecBot AztecBot added the claude-review Triggers an automated Claude code review label Sep 10, 2026
@AztecBot

Copy link
Copy Markdown
Collaborator Author

Claude Review: Starting automated code review... workflow run

@AztecBot AztecBot added claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention and removed claude-review Triggers an automated Claude code review labels Sep 10, 2026
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
TomAFrench force-pushed the cb/unconstrained-blob-eval-speedup branch from 3b7603e to 7191977 Compare September 10, 2026 16:09
@AztecBot AztecBot added claude-review Triggers an automated Claude code review and removed claude-review-complete Claude code review has been completed claude-review-error Adversarial ClaudeBox review needs attention labels Sep 10, 2026
@AztecBot

Copy link
Copy Markdown
Collaborator Author

Claude Review: Starting automated code review... workflow run

@AztecBot AztecBot added claude-review-complete Claude code review has been completed claude-review-passed Adversarial ClaudeBox review passed and removed claude-review Triggers an automated Claude code review labels Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure claude-review-complete Claude code review has been completed claude-review-passed Adversarial ClaudeBox review passed claudebox Owned by claudebox. it can push to this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants