Skip to content

perf: optimize keccack permutation - #1802

Open
yelhousni wants to merge 2 commits into
masterfrom
perf/zkgolf-keccak
Open

perf: optimize keccack permutation#1802
yelhousni wants to merge 2 commits into
masterfrom
perf/zkgolf-keccak

Conversation

@yelhousni

@yelhousni yelhousni commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

The circuit implements only the 1600-bit permutation. Hash functions such as SHA3 and SHAKE wrap this permutation in a sponge construction and add padding, domain separation, absorbing, and squeezing logic.

This PR ports some of https://zk.golf/challenges/keccak-f1600 optimizations.

Keccak-f[1600] operates on a 1600-bit state arranged as 25 lanes of 64 bits:

A[x, y][z], where x in [0, 4], y in [0, 4], z in [0, 63]

The gnark implementation stores these lanes in row-major form:

state[x + 5*y][z] == A[x, y][z]

The permutation has 24 rounds. Each round applies five steps.

theta

Theta mixes each lane with the parity of neighboring columns.

C[x]    = A[x,0] xor A[x,1] xor A[x,2] xor A[x,3] xor A[x,4]
D[x]    = C[x-1] xor ROT(C[x+1], 1)
A[x,y]  = A[x,y] xor D[x]

All x indices are modulo 5. Lane rotations are modulo 64.

rho and pi

Rho rotates each lane by a fixed offset. Pi permutes the lane positions.

In a circuit these steps are almost free: they are wire rewrites. No boolean operation has to be constrained when a lane is only moved or its bits are re-indexed.

chi

Chi is the only nonlinear step. It updates each row with a bitwise expression:

A[x,y] = A[x,y] xor ((not A[x+1,y]) and A[x+2,y])

This is the expensive part in a naive circuit because it combines XOR, NOT, and AND on every bit of every lane in every round.

iota

Iota xors a round constant into lane A[0,0].

For bits, xoring by a constant 1 is 1 - bit; xoring by 0 does nothing. Like rho and pi, this does not need a multiplication row when the bit is already known to be boolean.

Previous gnark implementation

The public API is:

func Permute(uapi *uints.BinaryField[uints.U64], input [25]uints.U64) [25]uints.U64

The previous implementation kept the state as [25]uints.U64 and expressed the round function with the byte-oriented uints API:

theta: uapi.Xor(...)
rho:   uapi.Lrot(...)
chi:   uapi.Xor(a, uapi.And(uapi.Not(b), c))
iota:  uapi.Xor(a, roundConstant)

This style is compact and easy to audit, but it pays for general byte lookups and byte-level bitwise operations. Keccak is naturally a bit circuit: all useful work is XOR, AND, NOT, rotation, and lane rewiring. The optimized implementation therefore moves the permutation core from byte-level lanes to bit-level lanes.

Optimized gnark implementation

The exported API is unchanged. Permute now:

  1. Decomposes each uints.U64 lane into 64 little-endian bits.
  2. Applies an internal bit-level permutation.
  3. Packs the 64 output bits of each lane back into uints.U64.

The implementation chooses between two internal paths:

R1CS path:     custom one-row identities for xor3 and chi
generic path:  frontend bit operations, with an SCS-specific and-not row

The R1CS path is selected only when the compiler exposes R1CS linear expressions and the field characteristic is greater than 3. The characteristic condition matters because the custom identities rely on small nonzero factors.

Hints are used to assign the output bit of the custom rows. The rows themselves constrain the hinted value, so the hints are not trusted.

zk.golf optimizations

The zk.golf Keccak-f[1600] record submission uses two main ideas:

  1. Replace pairwise XOR chains with one-row three-input XOR.
  2. Replace chi with one row per output bit.

As of 2026-07-30, the record submission for the zk.golf keccak-f1600 challenge reports:

constraints:  92160
allocations:  92160
score:        184320

The same core row count appears in gnark's internal R1CS bit permutation. The exported gnark Permute count is higher because it also includes the uints.U64 API boundary: input byte decomposition, output byte packing, and output equality
in the count test.

One-row xor3

For boolean inputs a, b, c, the R1CS path computes:

z = a xor b xor c

with one rank-1 row:

(z + 2a + 2b + 7c) * (a + b - 4c + 1) = 6a + 6b - 24c

For boolean a, b, and c, and characteristic greater than 3, the right factor is never zero on the boolean cube. That makes the equation uniquely pin z to a xor b xor c.

This improves theta. A five-input column parity is computed with two xor3 rows:

t    = xor3(A[x,0], A[x,1], A[x,2])
C[x] = xor3(t,      A[x,3], A[x,4])

Then the normal theta update:

A[x,y] = A[x,y] xor C[x-1] xor ROT(C[x+1], 1)

is applied directly with one more xor3 row. There is no separate D[x] variable.

Per round, theta costs:

column parity: 5 columns * 64 bits * 2 rows  =   640
D folding:     25 lanes  * 64 bits * 1 row   =  1600
theta total:                                      2240

One-row chi

For boolean inputs a, b, c, chi computes:

z = a xor ((not b) and c)

with one rank-1 row:

(z + 3a - b - c) * (4a + b + c - 3) = 4a + 2b

For boolean a, b, and c, and characteristic greater than 3, the second factor is nonzero on the boolean cube. The row therefore uniquely pins z to the chi output bit.

Per round, chi costs:

25 lanes * 64 bits * 1 row = 1600

R1CS core count

Rho, pi, and iota are rewiring or constant bit flips. The optimized R1CS core therefore has only theta and chi rows:

theta: 2240 rows/round
chi:   1600 rows/round
total: 3840 rows/round

3840 rows/round * 24 rounds = 92160 rows

The exported gnark count for one permutation plus output equality is:

R1CS constraints: 94160
SCS constraints:  158486

The R1CS difference from 92160 is the public uints.U64 boundary around the core, not extra permutation logic.

SCS notes

The current SCS frontend exposes rows of the form:

qL*a + qR*b + qO*o + qM*a*b + qC = 0

This is enough to optimize:

z = (not b) and c = c - b*c

as one SCS row:

b*c - c + z = 0

The generic path uses that row for the and-not subexpression in chi.

The zk.golf xor3 and one-row chi identities multiply wider linear expressions involving more than the three SCS row variables. Transplanting them directly into gnark SCS would require additional gate or blueprint support; doing it with the current three-slot row shape would introduce temporary variables and lose the intended savings.

Sources


Note

High Risk
This replaces the entire Keccak-F1600 constraint system with custom R1CS rows and hints; any algebraic or wiring bug would break soundness of a widely reused crypto primitive, though existing functional tests and constraint benchmarks mitigate that.

Overview
Keccak-F1600 is reimplemented as a bit-level permutation: Permute still takes [25]uints.U64, but it now decomposes lanes with ToBits, runs an internal 25×64-bit round function, and packs back with FromBits. Documented cost drops from ~193k/~292k to ~94k Groth16 and ~158k Plonk constraints per permutation.

On R1CS (field characteristic > 3), theta and chi use single-row custom identities (xor3R1CS, chiR1CS) with registered hints and direct BlueprintGenericR1C instructions, following zk.golf-style optimizations. Plonk/SCS use a generic bit path with a dedicated andNot Plonk row for chi; rho/pi/iota stay mostly rewiring and constant bit flips.

The uints package gains ToBits/FromBits on bytes and wide integers plus BinaryField.API() so callers like keccakf can use the raw frontend API at the bit boundary. Tests add a U64 bits round-trip circuit and TestKeccakfCount to log R1CS/SCS constraint counts.

Reviewed by Cursor Bugbot for commit 25328c4. Bugbot is set up for automated code reviews on this repo. Configure here.

@yelhousni yelhousni added this to the v0.14.N milestone Jul 30, 2026
@yelhousni
yelhousni requested review from gbotrel and ivokub July 30, 2026 19:57
@yelhousni yelhousni self-assigned this Jul 30, 2026
@yelhousni yelhousni added type: perf dep: linea Issues affecting Linea downstream labels Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dep: linea Issues affecting Linea downstream type: perf

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant