Skip to content

Cheaper loop steps and MSM windows: −6% cost, −1.3 kB - #7

Merged
mjthatch merged 1 commit into
research/torusfrom
research/cheaper-steps
Oct 20, 2026
Merged

mjthatch merged 1 commit into
research/torusfrom
research/cheaper-steps

Conversation

@mjthatch

@mjthatch mjthatch commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the torus PR.

Summary

The step functions now get only the values they read, and the MSM windows share their bit-splitting code. Nothing about what is checked changes.

torus this PR
program cost 65.5 M mWU 61.5 M mWU
pruned program 46.4 kB 45.1 kB
witness 21.0 kB 21.0 kB
spend on Elements 23.3.1 regtest 68.1 kWU, 17.0 kvB 66.8 kWU, 16.7 kvB
budget minus cost 2.0 M mWU 4.7 M mWU

The spend's weight is still set by its bytes. So the cost saving shows up as slack, room that a future change can spend to save bytes. The bytes come from the MSM change.

How it works

Changes measured one after another on the whole verifier (tests/test_groth16.py):

change program cost
a step function gets the 19 run values it reads, not all 30 +315 B −2.60 M
an MSM window projects its byte once and splits it with a shared function −1,615 B −0.15 M
a(t): the inner Horner terms are added after the reduction −0.14 M
z⁵, z⁷, r³ are kept beside the loop; −B and −π²(B) are computed where they are used −0.37 M
a step without a commitment gets only the 12 run values every step reads −0.76 M

The last three rows change the program by −3 B together.

Why the environment matters. Two Simplicity costs depend on width:

  • iden costs 100 plus the width of its input;
  • comp costs 100 plus the width of the value it passes on.

So every node that hands the environment along pays for all of it. Before, a step function's environment held the whole loop state, including all 30 run values. These are values computed once in the setup: the powers of z, the points at z, a_c(z) ± z, r, r² and r³. That is 7,680 bits, paid for at every node that passes the environment on, in every one of the 88 steps.

The run values now come in three groups:

group values read by
RUN_ANY (12) z³, ζ, z³ζ, −xP·z, yP, xL·z, xL·zζ, yL, xC·z, xC·zζ, yC, r every step function
RUN_COMMIT (7) z, z², z⁴, z⁶, z⁸, z¹⁰, r² committed steps only: a(z) and the third Horner power
RUN_REST (6) a_c(z) ± z, B and ψ(B) at ζ the additions' arguments and the end

How each group reaches the steps:

  • The loop state is ((core, h), ((RUN_ANY, RUN_COMMIT), RUN_REST)).
  • A step without a commitment gets RUN_ANY as one projection. A committed step gets both of the first two groups, also as one projection.
  • The loop puts RUN_REST back beside the result. That costs a pair and a projection per step, about 3.6 B each time, which is the +315 B.

Some values are not carried through the loop at all:

  • z⁵, z⁷ and r³ are read only by Q(z) and the end's Horner step. The setup computes them next to the end relation's pn and npd and keeps them beside the loop.
  • −B = (XB, −YB) is computed at each of the 13 subtractive additions, by one product with a constant.
  • −π²(B) = (ω·XB, YB) is computed at its one addition.

MSM windows. Each window used to extract its 8 bits one at a time from the scalar, each bit through its own projection path, and then pair them into the case tree's input. Now:

  • the window projects its byte from the scalar once (a path of depth 5);
  • it calls BITS8, a function defined once that turns a byte into the case tree's input (b₇, (b₆, … (b₀, 1))).

A window now has 57 bytes of code of its own instead of 107, which is 1.6 kB over the 32 windows. The case tree, the selected entry and the 8 pruned branches per window are unchanged.

a(t) by Horner's rule. A term a_k·1 inside a dot product costs two multiplication jets and two additions. Added after the reduction instead, it costs one addition. Where this applies:

  • The inner Horner steps of EVAL: a_k < 2²⁵⁴, so the running value stays below 3.1 p, well under p_s (5.3 p).
  • Not the last step. The end relation multiplies four such values, and their bounds must stay within the CRT bound, so a(t) has to come out reduced.

Where it doesn't:

  • the step's new y and Q(z): both are loop state, and their bounds would no longer stay below p_s;
  • a(z) in a committed step: a(z) ± z would exceed p_s.

Where it stands

bytes
line constants of γ and δ 22,528
program code ~10,500
MSM (entries and pruned-branch commitments) 10,240
37 committed values 7,104
Q 6,656
B's slopes 5,632
the rest ~3,400

Most of what is left is data: key constants, table lookups, and the values the identity needs. Here is how I checked whether the 4.7 M slack can buy bytes:

  • δ given by its chain instead of line constants: −2.7 kB for +6.8 M mWU. It still doesn't fit, because the budget shrinks with the bytes.
  • B's chain in F_p2 components. B's line has degree 21 in w, not 9, because x, y and λx − y are unreduced F_p2 products. That line degree is what sets the degree cap at 216. With exact F_p2 arithmetic no step relation would exceed degree 168, but the end relation has degree 200 (the Frobenius images of a_c), so Q would shrink by only 16 words (0.5 kB). The cost is about 90 more jets per step, roughly +9 M.
  • The cyclotomic torus T₆ (4 words per committed value instead of 6):
    • The parametrisation is neat. With s = b₀w + b₁w³ + b₂w⁵ and b_i ∈ F_p2, (s + 1)/(s − 1) lies in T₆ exactly when b₀b₂ − b₁² = 1/(3ξ).
    • But the relations would have to track f^((p⁶−1)(p²+1)). Each line would then also be evaluated at ±gz, which doubles the degree the lines add to each step.
    • The extra commitments would cost more than the 2 words saved on each.

Changes

  • groth/pairing.py:
    • RUN_ANY, RUN_COMMIT, RUN_REST;
    • END gets z⁵, z⁷, r³;
    • DERIVED (−YB, ω·XB) is computed in step_value;
    • state_shape and run_shape give each step function its own shape;
    • rd_plus;
    • the Horner steps of EVAL.
  • groth/curve.py: bits_fn (BITS8); select_point projects an aligned chunk when the width is a power of two.
  • Docs, benchmarks, README, examples/multiplier/ (new regtest spend).

Testing

  • All tests pass (fp, poly, curve, pairing, g2_check, binding, groth16, bn254_ref), and the predicted costs equal hand's.
  • Elements 23.3.1 regtest, tools/regtest.sh:
    • proof spend 9e3770ac…: 66,775 WU, vsize 16,694;
    • the redirected proof is rejected (Assertion failed inside jet);
    • second spend 116f7d63… (the example files): 66,779 WU;
    • key path cd7a7455….
  • The example's program and witness regenerate byte for byte.

🤖 Generated with Claude Code

Every node that passes an environment on costs its width, so the step
functions now get only what they read:

- the 25 values computed in the setup are split into those a step
  function reads (12 for every step, 7 more for a committed one) and
  those only the additions' arguments and the end read; the loop passes
  the second group around the function call;
- z^5, z^7 and r^3, read only at the end, are kept beside the loop
  instead of in its state, and -B and -pi^2(B) are computed where they
  are used.

An MSM window projects its 8 bits from the scalar as one chunk and
splits them with a shared function, BITS8: 57 bytes of code per window
instead of 107.  The Horner steps of a(t) add the coefficient after the
reduction instead of as a product with 1.

Regtest spend: 68.1 kWU -> 66.8 kWU; cost 65.5 M -> 61.5 M mWU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mjthatch
mjthatch force-pushed the research/cheaper-steps branch from ad89659 to 099dd7e Compare October 15, 2026 11:27
@mjthatch
mjthatch merged commit 6cc81ea into research/torus Oct 20, 2026
16 checks passed
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