Skip to content

Pack polynomials as centered representatives (one bit less per coefficient) - #24

Open
nilsfleischhacker wants to merge 2 commits into
flashbots:mainfrom
nilsfleischhacker:nils/centered-packing
Open

nilsfleischhacker wants to merge 2 commits into
flashbots:mainfrom
nilsfleischhacker:nils/centered-packing

Conversation

@nilsfleischhacker

Copy link
Copy Markdown
Collaborator

pack_poly and pack_poly64 packed each coefficient as a signed value in [-m, m], i.e. in ⌈log₂(2m+1)⌉ bits, although an element of Z_m only needs ⌈log₂ m⌉ bits. For the KAHE modulus (q ≈ 2^48) that is 49 instead of 48 bits per coefficient, about 2% of every ciphertext and thus of almost all of the recipient's ingress.

This PR maps each coefficient to its centered representative in [-(m-1)/2, (m-1)/2] while packing and packs with bound (m-1)/2. This is exact for any odd modulus: every residue has exactly one centered representative, so ⌈log₂ m⌉ bits always suffice. Unpacking is unchanged apart from the bound and returns centered representatives. All equality checks on KahePoly, CsPoly and HVCPoly already compare modulo the modulus. As a side effect, the packed bytes now depend only on the residues, not on the in-memory representative.

Implementation:

  • center_i64/center_i32 get a division-free fast path for inputs in (-m, m), which is what the ring arithmetic leaves behind (e.g. KahePoly::add_assign reduces with %). Other inputs still go through rem_euclid. The results are identical for all inputs; a new test checks this against rem_euclid on the edge cases.
  • pack_bits/pack_bits64 take any iterator of values (slices still work), so pack_poly(64) centers the coefficients on the fly without a temporary array.
    The change affects everything packed through pack_poly/pack_poly64: client posts (ciphertext and commitment root), Commitment::to_bytes (also signed in RS mode), and pack_cs_shares. The share inside sealed openings is packed separately and unchanged (see Openings carry the share, which the verifier can compute itself #22).

This changes the wire format. Old and new encodings are incompatible.

Wire sizes (exact, from packed_len)

Before After Δ
KAHE polynomial 12544 B (49 bit) 12288 B (48 bit) −2.0%
Client post, Prony, 300 clients, 16 KB (μ = 603) 7,568,386 B 7,413,762 B −154,624 B (−2.0%)
Client post, IBLT, 300 clients, 16 KB (μ = 1802) 22,608,642 B 22,147,074 B −461,568 B (−2.0%)
Client post, Prony, 300 clients, 1 KB (μ = 38) 481,026 B 471,042 B −2.1%
Client post, Prony, 100 clients, 16 KB (μ = 203) 2,550,786 B 2,498,562 B −2.0%
Commitment root 4352 B (17 bit) 4096 B (16 bit) −256 B
Share polynomial (pack_cs_shares, e.g. agg_share) 4864 B (19 bit) 4608 B (18 bit) −256 B

At 300 clients with 16 KB messages (Prony), the recipient downloads about 46 MB less per round.

Timing

Measured on an Intel Core Ultra 7 165U. Each figure is the median over 5 interleaved rounds, each round itself the median of 51 (posts) or 1001 (single polynomials) repetitions. "Centered" is the first commit alone, "fast" includes the second.

Operation Before Centered Fast
Client post to_bytes (Prony, 300 × 16 KB) 7.13 ms 8.95 ms 8.49 ms
Client post from_bytes 10.31 ms 9.77 ms 9.91 ms
Commitment::to_bytes 4.40 µs 7.67 µs 5.52 µs
Commitment::from_bytes 4.37 µs 3.79 µs 3.79 µs
pack_cs_shares (one poly) 4.44 µs 8.49 µs 6.10 µs
unpack_cs_shares (one poly) 4.59 µs 4.31 µs 4.32 µs

Centering with rem_euclid costs a division per coefficient; the division-free fast path removes most of that. Packing a 7.4 MB post still takes about 1.4 ms longer than before, which is less than the time it takes to transmit the saved 155 KB on any link slower than about 900 Mbit/s. Unpacking is slightly faster, since there are fewer bits to read.

The protocol's role functions keep posts and commitments in memory and never call these packers (only RS-mode signing packs one root per client), so the roles are unaffected.

Tests

  • poly_packing_is_centered_and_tight: for all three moduli, random inputs in (-m, m) and the edge cases (±(m−1)/2, ±(m+1)/2, ±(m−1), and inputs outside (-m, m)) round-trip modulo m, and the packed length is ⌈log₂ m⌉ bits per coefficient.
  • centering_matches_rem_euclid: the fast center_i64/center_i32 agree with the rem_euclid definition, including at i64::MIN/i32::MIN and i64::MAX/i32::MAX.
    All 154 tests pass in debug and release builds (debug matters, since the range checks are debug_assert!s). cargo fmt --check and cargo clippy --all-targets are clean.

nilsfleischhacker and others added 2 commits November 9, 2026 15:28
pack_poly and pack_poly64 offset each coefficient by the full modulus m,
so a value in [-m, m] took ceil(log2(2m+1)) bits. An element of Z_m only
needs ceil(log2 m) bits: reduce each coefficient to its centred
representative in [-(m-1)/2, (m-1)/2] and pack with bound (m-1)/2.

This saves one bit per coefficient: KAHE ciphertexts 49 -> 48 bits
(about 2% of every client post), commitment roots 17 -> 16 bits, and
share polynomials (pack_cs_shares) 19 -> 18 bits. Unpacking returns
centred representatives; all polynomial equality is already mod the
modulus. The packed bytes now depend only on the residues.

Changes the wire format of client posts, commitments and packed shares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
center_i64/center_i32 take a division-free fast path for inputs in
(-m, m) and fall back to rem_euclid otherwise. pack_bits/pack_bits64
accept any iterator, so pack_poly(64) centers on the fly without a
temporary array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant