Skip to content

logUp optimization: no committed inverses - #69

Merged
gabriel-barrett merged 2 commits into
mainfrom
logup-optimization
Jul 31, 2026
Merged

logUp optimization: no committed inverses#69
gabriel-barrett merged 2 commits into
mainfrom
logup-optimization

Conversation

@gabriel-barrett

Copy link
Copy Markdown
Member

logUp: chained partial accumulators, no committed inverses

Replaces the committed-inverse logUp layout with chained partial
accumulators. Stage 2 previously committed (1 + L) extension columns
per circuit — a running accumulator plus one message inverse per lookup —
tied together by L message-inverse constraints and three accumulator
constraints (first row, transition, last row). It now commits
max(L, 1) columns and folds exactly L constraints.

How it works

Stage-2 slot j holds acc_j, the running sum entering lookup j's
step on that row. Each step asserts acc_{j+1} − acc_j = mult_j / m_j,
multiplied through by the message m_j = β + fingerprint(γ, args_j) to
stay polynomial:

interior (j < L−1):  m_j · (acc_{j+1} − acc_j) − mult_j = 0
wrap     (j = L−1):  m_j · (acc_0′ − acc_j + is_last_row·Δ) − mult_j = 0

where acc_0′ is the next row's first slot and Δ = acc_final − acc_initial (the existing publics, slots 3 and 2).

The wrap step needs no transition selector: on the trace subgroup "next
row" is rotation by the generator, so the accumulator chain is a
cycle. Telescoping the steps around the cycle cancels every
accumulator term and forces the circuit's multiset sum to zero; the
is_last_row·Δ term perturbs exactly one link, converting that into
"the multiset sum equals the public accumulator difference". No
first/transition/last-row constraints remain, and the committed
accumulator is gauge-free (only differences are constrained; the prover
starts it at zero by convention). A circuit with no lookups keeps a
single pass-through column with acc′ − acc + is_last_row·Δ = 0,
forcing Δ = 0.

Selector normalization

The wrap constraint consumes is_last_row additively, so its scale
is load-bearing. Plonky3's selector is the unnormalized Lagrange
numerator (xⁿ − 1)/(x − g⁻¹), whose value at the last row is n·g
(pinned by a new test against the textbook Lagrange basis product,
across several domain sizes). The prover and verifier now normalize the
selectors protocol-wide — is_first_row by 1/n, is_last_row by
1/(n·g) — so both evaluate to exactly 1 at their row. Vanishing-form
user constraints are unaffected by the rescale, and the witness-side
lookup evaluation already used indicator value 1.

Effects

  • Stage-2 width (1+L)·D → max(L,1)·D; logUp constraints L+3 → L.
  • Degrees are uniform: every step is max(deg(args)+1, deg(mult)) (the
    injection is selector·publics, degree multiple 1, absorbed by
    add = max) — never above the old profile, lower where the old
    last-row constraint peaked.
  • Witness build: the same batch inversion, then a single pass with one
    multiply-add per (row, lookup), pushing each partial accumulator; the
    circuit's local sum feeds the global public accumulator chain.
  • Zero-multiplicity (padding) rows force the accumulators to ride
    through unchanged.

Soundness

Unchanged Schwartz–Zippel structure: a zero message (m_j = 0) would
leave a step unconstrained, but the challenges are sampled after
commitment, so that event carries the same negligible N/|F_ext| term
that previously justified the committed inverses (where it made the
proof unsatisfiable instead). The cyclic telescoping is an exact
polynomial identity.

Benchmarks

Measured against the pre-change rev (2501b4f) with identical
instrumentation on a lookup-heavy circuit (16 lookups × 4 args,
width 40, 2^14 rows):

phase before after
stage-2 witness build ~9.6 ms ~9.6 ms
quotient constraint eval ~18.9 ms ~19.0 ms

No regression, with one fewer committed column and fewer α terms.

Testing

29/29: the new selector-normalization pin test; the spec ↔ direct-
evaluator pin test re-derived for the chained constraints; and the full
e2e set (even/odd lookups — which proves with a nonzero per-circuit
Δ, the exact case that would expose a wrong normalization constant —
u32-add, blake3, byte operations, sparse activation, and the verifier
tamper-rejection suite). Clippy/fmt clean.

Note: downstream (ix) must follow: the vk codec's derived
stage2_width/constraint_count, the Lean verifier's direct logUp
evaluation, and its trace_selectors normalization.

Replace the committed-inverse logUp layout with chained partial
accumulators. Stage 2 previously committed (1 + L) extension columns
per circuit — a running accumulator plus one message inverse per
lookup — tied together by L message-inverse constraints and three
accumulator constraints (first row, transition, last row). Now it
commits max(L, 1) columns: acc_j (slot j) is the running sum ENTERING
lookup j's step on that row, and each step asserts
acc_{j+1} - acc_j = mult_j/m_j multiplied through by the message:

  interior (j < L-1):  m_j * (acc_{j+1} - acc_j) - mult_j = 0
  wrap     (j = L-1):  m_j * (acc_0' - acc_j + is_last_row*D) - mult_j = 0

where acc_0' is the next row's first slot and D = acc_final -
acc_initial (the existing publics, slots 3 and 2).

The wrap step needs no transition selector: on the trace subgroup
"next row" is rotation by the generator, so the chain is a CYCLE.
Telescoping the steps around the cycle cancels every accumulator term
and forces the circuit's multiset sum to zero; the is_last_row*D term
perturbs exactly one link, turning that into "the multiset sum equals
the public accumulator difference". No first/transition/last-row
constraints remain, and the committed accumulator is gauge-free (only
differences are constrained; the prover starts it at zero).

The wrap step consumes is_last_row ADDITIVELY, so it must be the
NORMALIZED last-row Lagrange selector: p3's is_last_row is the unnormalized numerator
(x^n - 1)/(x - g^{-1}) whose value at the last row is n*g (pinned by a
new test against the textbook Lagrange product). The prover and
verifier now normalize is_first_row by 1/n and is_last_row by 1/(n*g);
vanishing-form user constraints are unaffected by the rescale, and the
witness-side lookup evaluation already used indicator values 1.

Effects:
- stage-2 width (1+L)*D -> max(L,1)*D; logUp constraints L+3 -> L.
- Degrees are uniform: every step is max(deg(args)+1, deg(mult)) (the
  injection is selector*publics, degree multiple 1, absorbed by
  add = max) — never above the old profile, lower where the old
  last-row constraint peaked.
- Witness build: same batch inversion, then a single pass with one
  multiply-add per (row, lookup), pushing each partial accumulator;
  the circuit's local sum feeds the global public chain.
- Zero-multiplicity (padding) rows force the accumulators to ride
  through unchanged.

Benchmarked against the pre-change rev (2501b4f) with identical
instrumentation (lookup-heavy circuit, 16 lookups x 4 args, 2^14
rows): stage-2 build ~9.6 ms on both; quotient constraint eval
~19.0 ms vs ~18.9 ms — no regression, with one fewer committed column
and fewer alpha terms.

29/29 tests (pin test re-derived against the new spec; even/odd e2e
exercises a nonzero per-circuit accumulator difference, pinning the
selector normalization through a real proof); clippy/fmt clean.
The wrap constraint consumes the last-row selector additively, which
previously meant rescaling the is_first_row/is_last_row selector
vectors over the whole quotient domain, per circuit — two extra full
vector passes inside the quotient phase. But the selector only needs
its normalized VALUE where it multiplies the boundary injection, and
the injected difference is constant across the domain:

  is_last_norm * delta  ==  is_last_raw * (delta / (n*g))

so the constant is now absorbed into delta once per circuit (two field
multiplies) and the selectors stay exactly as p3 provides them —
standard unnormalized semantics for user constraints, zero per-point
normalization work. logup_constraint_values takes the pre-scaled delta
alongside the raw selector; the pin test passes delta raw with an
arbitrary selector value, since the identity holds for any scale and
the normalization is a caller contract.

Constraint values, proofs, and the transcript are unchanged for the
logUp argument itself (identical products); user constraints see p3's
raw selector values again. 29/29 tests, clippy/fmt clean.
@gabriel-barrett
gabriel-barrett merged commit be1755e into main Jul 31, 2026
6 checks passed
@gabriel-barrett
gabriel-barrett deleted the logup-optimization branch July 31, 2026 16:34
@gabriel-barrett
gabriel-barrett restored the logup-optimization branch July 31, 2026 16:36
@gabriel-barrett
gabriel-barrett deleted the logup-optimization branch July 31, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants