Skip to content

Performance & large circuits

View Markdown

Small circuits need no tuning. This page is for the other end: circuits with millions of constraints, where memory is the constraint that bites first. All of it is about Groth16 on BLS12-381, the focus of the current release. Verification cost doesn’t grow with the circuit (a few pairings plus one scalar multiplication per public input), so everything here concerns proving and setup.

PhaseWhat happensWhat it needs
CompileBuild the circuit graph, emit R1CS rowsHeap proportional to the circuit graph
WitnessEvaluate every gateHeap for the graph plus one value per wire
Setup (dev) or import (ceremony)Produce the proving key: several points per wireDisk for the key; heap if you keep the key in memory
Prove: HFFTs over the constraint domainHeap for a few domain-sized arrays
Prove: MSMsMulti-scalar multiplications over the keyReads the whole key once; CPU-bound

The default prover in zeroj-crypto has no native dependencies. Several design choices keep large circuits within ordinary machines:

  • Allocation-lean arithmetic. Field elements and curve points are packed long[] limbs in Montgomery form rather than objects, so the hot loops allocate very little.
  • Memory-mapped key stores. A store-backed proving key is memory-mapped (through the Java FFM API), so its points live in the OS page cache instead of the Java heap. The sparse store format stores points at infinity as one bit each.
  • Streaming setup. setupToStore writes every key point straight to the mapped files, so the key is never fully resident.
  • Packed constraints and witnesses. R1CSFlat stores the matrices in CSR form, and FlatScalars stores the witness as packed limbs instead of millions of BigIntegers.
  • Ordered peaks. Groth16Pipeline generates the witness before it maps the cached constraints, and drops the constraints before the MSMs, so the big memory peaks never overlap.
  • Parallel MSMs. ProverBackend.PURE_JAVA splits large MSMs across cores; the result is bit-identical to the single-threaded ProverBackend.PURE_JAVA_SERIAL.

The scale reference: a 19M-constraint circuit

Section titled “The scale reference: a 19M-constraint circuit”

ZeroJ’s largest real circuit proves Cardano account ownership in-circuit: CIP-1852 key derivation from a root key to a payment key hash. It has 19,075,097 constraints, about 43.7 million wires, and an FFT domain of 2²⁵. It drove the memory work, and ZeroJ’s design records report these measurements:

MeasurementResultContext
Dev setup, before the setup work~90 GB heap, ~47 min23 GB key bundle, in-heap setup
Setup → prove → verify, afterSetup 9.6 min, prove 161 s, verify 0.17 sOne Docker container capped at 16 GB total memory, -Xmx8g, sparse key store
Sparse vs dense key store9.3 GB vs 24.2 GB of point filesSame circuit
Prove heap floorAbout 7 GBSet by witness generation for this circuit, not by proving

With a store-backed key, memory splits into two budgets:

  • Java heap (-Xmx) holds the circuit graph during compile and witness generation, and the FFT buffers during proving. It doesn’t hold the key.
  • Page cache holds the memory-mapped key files. The OS manages it: with spare RAM the key stays cached and proving is fast; under pressure it pages key data in from disk, which is slower but still works. That’s how the 19M circuit fits in a 16 GB cap with an 8 GB heap.

So don’t give the heap all your RAM. Size -Xmx to your measured heap floor plus headroom, and leave the rest for page cache. Groth16Pipeline.estimateProvePhaseHeapBytes(numWires, domain) gives a lower bound for the prove phase only; witness generation for your circuit can need more. Keep the heap below about 32 GB where you can, because larger heaps turn off compressed object pointers and inflate every object.

  1. Compile once to the packed form: compileR1CS(CurveId.BLS12_381) and use r1cs.flat().
  2. Put the key in a store: Groth16Keys.setupToStore(..., true) for development keys, or ZkeyPkStoreImporter.importToPkStore(...) for a ceremony key. Never setupInMemory at this size.
  3. Prove through Groth16Pipeline, so the r1cs.bin constraint cache skips recompiling on later runs and the memory peaks stay ordered. See Groth16Pipeline.
  4. Release the circuit graph before packing the witness, then pack with FlatScalars.packConsuming(...).
  5. Measure the witness peak. calculateWitnessFlat / calculateWitnessFlatChunked avoid boxing, which helps typical circuits. In a bit-heavy circuit most wires hold 0 or 1 and share the same BigInteger objects, so the boxed witness plus packConsuming used less memory for the 19M circuit. Try both.
  6. Run it as its own JVM process with explicit flags, rather than inside a build tool’s default test JVM, which usually has a small heap.

zeroj-crypto-blst plugs the native blst library’s multi-scalar multiplication into the prover through the FFM API. It’s purely a performance option: proofs are bit-identical to pure Java, and cross-provider equivalence is tested.

implementation 'org.zeroj:zeroj-crypto-blst' // brings zeroj-crypto and zeroj-blst; version from the BOM
import org.zeroj.cryptoblst.BlstProverBackend;
try (var keys = Groth16Keys.load(keysDir)) {
var proof = keys.prove(BlstProverBackend.create(), witness, r1cs.constraints());
}

BlstProverBackend.create() is multi-core; createSerial() makes one native call per MSM. The JVM needs native access:

Terminal window
java --enable-native-access=ALL-UNNAMED -Xmx8g -jar prover.jar

What to know before you adopt it:

  • Measure first. blst measured about 5× faster than the pure-Java prover of the time on 2¹²–2¹⁶ benchmark circuits. After the later memory and FFT work, the pure-Java prover matches it at the 19M scale, so the win depends on your circuit size.
  • It uses native memory outside -Xmx. At the 19M scale, blst’s native MSM buffers took several GB on top of the heap (about 8.4 GB during the G2 MSM alone), and the process was killed under a 16 GB cap. Use it on large-memory machines only.
  • Supply chain. libblst is built from source at pinned tag v0.3.15 and bundled in zeroj-blst for Linux (x86-64 and AArch64), macOS on Apple silicon, and Windows. Intel Macs fall back to pure Java.
FlagWhen
-Xmx<size>Always, for large circuits. Size to your measured floor plus headroom.
--enable-native-access=ALL-UNNAMEDWhen you use zeroj-crypto-blst or zeroj-blst
-Dzeroj.allowInsecureTrustedSetup=trueOnly for development setup; never in production launch scripts

The pure-Java prover has no JNI, so it can be compiled with native-image. For a long, CPU-bound prove, though, native image is a deployment choice, not a speed lever: a warmed-up JIT matches or beats it. Native image pays off for startup time and footprint, which matter most for short-lived verifiers and CLIs.

zeroj-blst ships native-image configuration that enables native access and bundles the libblst binaries as image resources. Depending on your GraalVM version, the build may also need the experimental Foreign API support options noted in that configuration. ZeroJ’s own zeroj-ceremony CLI is built as a native binary with --no-fallback --enable-native-access=ALL-UNNAMED, and its contribute and finalize commands (streaming, memory-mapped key processing) run in that binary. Build and test your own image in CI rather than assuming.

Both benchmarks are opt-in Gradle tasks in the ZeroJ repository, heavy by design:

Terminal window
# Pure-Java Groth16 prover scale benchmark (circuit sizes as log2)
./gradlew :zeroj-crypto:benchmark -Dzeroj.bench.logs=12,14,16 -PbenchHeap=10g
# blst MSM and full-prove speedup versus pure Java
./gradlew :zeroj-crypto-blst:blstBench

Recorded end-to-end benchmark reports, including five-million-entry Poseidon MPF and JMT state runs, are in docs/benchmarks.

Further reading: ADR-0029 (prover performance and blst), alternate prover backends.