perf: optimize keccack permutation - #1802
Open
yelhousni wants to merge 2 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
The gnark implementation stores these lanes in row-major form:
The permutation has 24 rounds. Each round applies five steps.
theta
Theta mixes each lane with the parity of neighboring columns.
All
xindices 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:
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:
The previous implementation kept the state as
[25]uints.U64and expressed the round function with the byte-orienteduintsAPI: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.
Permutenow:uints.U64lane into 64 little-endian bits.uints.U64.The implementation chooses between two internal paths:
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:
As of 2026-07-30, the record submission for the zk.golf
keccak-f1600challenge reports:The same core row count appears in gnark's internal R1CS bit permutation. The exported gnark
Permutecount is higher because it also includes theuints.U64API boundary: input byte decomposition, output byte packing, and output equalityin the count test.
One-row xor3
For boolean inputs
a,b,c, the R1CS path computes:with one rank-1 row:
For boolean
a,b, andc, and characteristic greater than 3, the right factor is never zero on the boolean cube. That makes the equation uniquely pinztoa xor b xor c.This improves theta. A five-input column parity is computed with two
xor3rows:Then the normal theta update:
is applied directly with one more
xor3row. There is no separateD[x]variable.Per round, theta costs:
One-row chi
For boolean inputs
a,b,c, chi computes:with one rank-1 row:
For boolean
a,b, andc, and characteristic greater than 3, the second factor is nonzero on the boolean cube. The row therefore uniquely pinszto the chi output bit.Per round, chi costs:
R1CS core count
Rho, pi, and iota are rewiring or constant bit flips. The optimized R1CS core therefore has only theta and chi rows:
The exported gnark count for one permutation plus output equality is:
The R1CS difference from
92160is the publicuints.U64boundary around the core, not extra permutation logic.SCS notes
The current SCS frontend exposes rows of the form:
This is enough to optimize:
as one SCS row:
The generic path uses that row for the
and-notsubexpression in chi.The zk.golf
xor3and 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:
Permutestill takes[25]uints.U64, but it now decomposes lanes withToBits, runs an internal 25×64-bit round function, and packs back withFromBits. 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 directBlueprintGenericR1Cinstructions, following zk.golf-style optimizations. Plonk/SCS use a generic bit path with a dedicatedandNotPlonk row for chi; rho/pi/iota stay mostly rewiring and constant bit flips.The
uintspackage gainsToBits/FromBitson bytes and wide integers plusBinaryField.API()so callers like keccakf can use the raw frontend API at the bit boundary. Tests add a U64 bits round-trip circuit andTestKeccakfCountto 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.