Skip to content

refactor(bb): enforce even virtual_size in Polynomial ctor; remove hot-loop branches in sumcheck#22791

Closed
AztecBot wants to merge 1 commit into
merge-train/barretenbergfrom
claudebox/f897ef0858dd0c13-3
Closed

refactor(bb): enforce even virtual_size in Polynomial ctor; remove hot-loop branches in sumcheck#22791
AztecBot wants to merge 1 commit into
merge-train/barretenbergfrom
claudebox/f897ef0858dd0c13-3

Conversation

@AztecBot

Copy link
Copy Markdown
Collaborator

Summary

Replaces the per-iteration bounds checks I added in #22790 with a single invariant in Polynomial::allocate_backing_memory: virtual_size is always rounded up to the next even value. With that invariant, every sumcheck and gemini fold-pair read is safe by construction — the hot loops in extend_edges and partially_evaluate go back to a single, branch-free pair load.

Sergei's pushback on #22790 was that gating each read on (idx < end_index) is fine for correctness but adds an unwanted hot-loop branch and a per-poly end_index() lookup. The invariant moves the cost to allocation time (one extra +1 in the rare odd case) and removes it from the hot path entirely.

Why this works

The original assertion fires when a polynomial has virtual_size_ == end_index_ and a fold-pair read lands at exactly end_index:

  • extend_edges (USE_SHORT_MONOMIALS branch): main loop iterates edge_idx ∈ [0, max_end_index + (max_end_index & 1)), so when max_end_index is odd the last edge reads at index max_end_index.
  • partially_evaluate: for (i = 0; i < limit; i += 2) { ... poly[i+1] ...; } reads poly[limit] whenever limit = end_index is odd.
  • gemini::compute_fold_polynomials already handles the odd-tail explicitly and is unaffected.

For witness/selector polys (virtual_size = dyadic_size > end_index) the read silently returns 0 from the virtual-zero region. For gemini_masking_poly allocated via the legacy 1-arg Polynomial::random(polynomial_size) overload (which sets virtual_size = polynomial_size = end_index), the read trips BB_ASSERT_DEBUG(index < virtual_size_ + virtual_padding) in debug builds.

By rounding virtual_size up to even at allocation, virtual_size > end_index whenever end_index is odd, and virtual_size >= end_index otherwise. The fold-pair read at index = end_index therefore always satisfies index < virtual_size_, with no branching needed in the hot loop.

Cost

virtual_size only sets the assertion bound — backing memory is end - start, so this is free at runtime. 1 << n-shaped allocations (dyadic_size, all powers of two) are already even and unchanged.

Verification

oink_prover.cpp::commit_to_masking_poly is reverted to the original 1-arg Polynomial::random(polynomial_size) form — no allocation-site fix needed because the ctor handles it.

Default preset:

  • polynomials_tests: 70/70 PASS
  • commitment_schemes_tests (excluding KZG which needs SRS): 88 PASS, 2 pre-existing skips
  • sumcheck_tests: 29 PASS, 2 pre-existing skips

Debug preset (-D_GLIBCXX_DEBUG, clang++-20):

  • dsl_tests HonkRecursionConstraintTestWithoutPredicate/0.GenerateVKFromConstraints (UltraFlavor): PASSED (156.7 s)
  • dsl_tests HonkRecursionConstraintTestWithoutPredicate/1.GenerateVKFromConstraints (UltraZKRecursiveFlavor — the originally failing test): PASSED (161.4 s)
  • sumcheck_tests: 29/29 passing
  • ultra_honk_tests --gtest_filter='*ZK*:*Mask*:*Boundary*:*Sumcheck*': 37 passing, 6 pre-existing skips

Out of scope (Sergei's goal #2)

Changing prover_instance->dyadic_size() to return the actual circuit size (max_end_index, even-aligned) rather than the next power of 2 is a much larger surgery and is not in this PR. It touches:

  • Polynomial::evaluate_mle (polynomial.hpp:430-433) hard-asserts virtual_size == 1 << n. Used by flavor/multilinear_batching_flavor.cpp and vm2/constraining/verifier.cpp.
  • SumcheckProver(multivariate_n, ...) derives multivariate_d = numeric::get_msb(multivariate_n) — the protocol's hypercube size must remain a power of two for the multilinear sumcheck math (the per-round halving recursion reaches 1 only when multivariate_n is a power of 2).
  • ~30 call sites of dyadic_size() use it both as poly virtual_size (where max_end_index_even is fine) and as the protocol size (where pow2 is required).

The cleanest path is to introduce a separate circuit_size() accessor (= max_end_index_even) for the polynomial-allocation use cases, leave dyadic_size()/multivariate_n() as the protocol's pow2, and migrate sites one at a time. Tracking this as a follow-up; analysis in https://gist.github.com/AztecBot/1a1357fc40a373cc9d268404e524eadc (analysis-v3.md).

Supersedes

ClaudeBox log: https://claudebox.work/s/f897ef0858dd0c13?run=3

ClaudeBox log: https://claudebox.work/s/f897ef0858dd0c13?run=3

@AztecBot AztecBot added ci-draft Run CI on draft PRs. claudebox Owned by claudebox. it can push to this PR. labels Apr 25, 2026
@AztecBot

AztecBot commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Automatically closing this stale claudebox draft PR (no updates for 5+ days). Re-open if still needed.

@AztecBot AztecBot closed this May 1, 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. claudebox Owned by claudebox. it can push to this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant