perf(prover): parallelize + vectorize hot path (4.4× Prove speedup)#6
Merged
Conversation
Per-module AIR quotient computation is now dispatched via parallel.Execute over modules sorted by name. On the bench workload (40 PLONK instances of size 2^17), Prove drops from 41.87s → 24.45s wall (1.71× speedup); par rises from 1.09× → 2.13× on a 32-core box. Correctness: - DomainCache is now thread-safe (sync.Mutex around lazy domain build). - Added dag.DAG.Clone(): per-goroutine deep copy of nodes + Leaf pointers, needed because ComputeQuotient* mutates Leaf.Idx for its local indexing and modules can share *expr.Leaf via cross-module arguments (Lookup, CopyConstraint). - Trace map writes for chunk results are batched under a single mutex. Memory: - New poly.Coset[Ext]LagrangeNormalToCanonicalWithCache helpers stop at canonical Normal form; computeAIRChunks uses them and skips the FFT → BitReverse → FFTInverse → BitReverse round-trip that previously bracketed the chunking step. Saves 2 size-bigSize FFTs per module. - Chunks are now sub-slices of the quotient backing array (three-index slice with capacity bound) instead of fresh make + copy. Saves numChunks·N·sizeof(E4|base) of allocation per module. - Net: 12.89 GiB → 12.27 GiB allocated per Prove (-5%), with the same end-to-end test/verify behavior. Bench (bench/main.go): - Fixed the parallelization metric: was using /cpu/classes/total which is GOMAXPROCS × wall (includes idle); now uses /cpu/classes/user + /cpu/classes/gc, giving real on-CPU time. - Defaults bumped to -instances 40 -log2-size 17 so Prove runs ~20-40s and short single-threaded sections become visible in pprof. Also adds optim_plan_ideas.md cataloguing the broader perf opportunities (this PR addresses #1 from that plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves prover performance by parallelizing per-module AIR quotient computation and reducing memory churn in the quotient chunking pipeline, while also updating bench tooling and adding a written optimisation plan for future work.
Changes:
- Parallelizes
proverRuntime.ComputeAIRQuotients()across modules, adding per-goroutine DAG cloning and locking around shared trace writes. - Adds “coset-Lagrange Normal → canonical Normal” conversion helpers and switches quotient chunking to bounded subslices (avoiding per-chunk
make+copy). - Introduces a new end-to-end benchmark (
bench/main.go) and adds an optimisation planning document.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
prover/prover.go |
Parallel per-module AIR quotient computation; new chunking helpers; synchronized writes to shared trace/maps. |
internal/poly/domain_cache.go |
Makes DomainCache.Get mutex-protected for concurrent use. |
internal/poly/compute_quotient.go |
Splits coset conversion into canonical-only helpers to avoid redundant FFT round-trips. |
internal/dag/dag.go |
Adds (*DAG).Clone() deep copy to avoid cross-goroutine mutation races on shared leaves/nodes. |
bench/main.go |
Adds an end-to-end prover benchmark with improved CPU parallelism metric and profiling output. |
optim_plan_ideas.md |
Adds a draft optimisation plan with profiling evidence and implementation ideas. |
.gitignore |
Ignores generated .pprof files and bench_profiles/ output directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+21
to
23
|
|
||
| // DomainCache memoizes FFT domains by cardinality. Safe for concurrent use. | ||
| type DomainCache struct { |
|
|
||
| Status: draft, 2026-05-26. All numbers in this document come from `bench/main.go` | ||
| (in this repo) running on a 32-core / 32-GOMAXPROCS Linux box with AVX-512, | ||
| gnark-crypto v0.20.1, Go 1.26. Workload: 40 aggregated PLONK instances of size |
Comment on lines
+83
to
+87
| **Evidence:** | ||
| - `prover/prover.go:420` iterates `for moduleName, module := range pr.program.Modules` | ||
| sequentially. With 40 instances of the bench, there are 40+ modules whose | ||
| AIR quotients are independent. | ||
| - pprof tree under `ComputeAIRQuotients` (`go tool pprof -focus=ComputeAIRQuotients -tree`): |
|
|
||
| **Evidence:** `internal/dag/dag.go:1401` is a scalar Go loop: | ||
| ```go | ||
| for j := range N { |
dag.{add,sub,mul}ChildVectorIntoExt and poly.{Ext,}EvaluateLagrangeWithWeights
each ran scalar Go loops over N=2^17 elements on the proving hot path. Switching
to gnark-crypto's `ext.Vector` / `koalabear.Vector` SIMD (AVX-512) entry points:
- mulChildVectorIntoExt was ~15% of Prove CPU (per optim_plan_ideas.md #4);
- (Ext)EvaluateLagrangeWithWeights now uses `InnerProduct[ByElement]` instead
of an accumulating loop.
The base-rail add/sub paths skip the lift-to-E4 indirection and touch only
the B0.A0 coefficient directly, since the lifted value is zero in the other
slots.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per optim_plan_ideas.md #3 option B: the Encode path was FFTInverse(DIF) → BitReverse → FFT(DIF) → BitReverse, where each BitReverse on N up to 2^20 fell through gnark-crypto's threshold to `bitReverseNaive` (~5.9% of Prove CPU). We instead scatter the n bit-reversed inverse-FFT coefficients directly into N bit-reversed positions (zero-padding the gaps) and run the forward FFT in DIT order, which consumes bit-reversed input and produces normal output — no explicit BitReverse needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cumulation ComputeAIRQuotients: the per-chunk evaluate-at-zeta loop (over both Base and Ext chunks) now runs through parallel.Execute on a deterministically sorted key list; the proof map is written serially after the parallel section. ComputeEvaluationsAtZeta: was a serial loop over modules. Now parallelized per-module; each goroutine buffers (key, val) pairs locally and the proof map is merged in the main goroutine. Errors are collected per-module without a mutex. ComputeDeepQuotient: the inner accumulation `C_s[x] += alpha · col[x]` (scaling each trace column by alphaAcc and folding into C_s) was a scalar loop. Now goes through `ext.Vector.MulAccByElement` (base columns) or `ScalarMul` + `Add` via a per-size scratch buffer (ext columns). Constant columns (len == 1) still use the broadcast scalar form. Scratch is allocated once per size and reused across all shifts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Stacks four optimizations from
optim_plan_ideas.mdon the proving hot path. On the bench workload (40 PLONK instances of size 2^17, Poseidon2, 32-core box):parWall time drops from ~42s → ~10s;
parrises from ~1× → ~5.4× as the per-module proving work overlaps and the surviving serial loops cost less per element.Changes (commit by commit)
1.
perf(prover): parallelize ComputeAIRQuotients(item #1 from the plan)ComputeQuotient*dispatched viaparallel.Executeon a sorted module list.dag.(*DAG).Clone()deep-copies nodes +*expr.Leafso each goroutine mutatesLeaf.Idxindependently (required because cross-module Lookup / CopyConstraint shares leaves).poly.DomainCache.Getis nowsync.Mutex-protected for concurrent access;pr.airTrace.SetBase/SetExt+chunkDomainswrites batched under a tiny lock window.computeExt/BaseAIRChunksnow stop at canonical-Normal form (skipFFT + BitReverse + FFTInverse + BitReverseidentity); chunks are 3-index sub-slices of the quotient backing array instead ofmake + copy.2.
perf(dag,poly): use ext/koalabear Vector ops in hot inner loops(item #4)dag.{add,sub,mul}ChildVectorIntoExtandpoly.{Ext,}EvaluateLagrangeWithWeightswere scalar Go loops over N≈2^17. Switched to gnark-cryptoext.Vector/koalabear.VectorAVX-512 entry points (Add,Sub,Mul,MulByElement,InnerProduct[ByElement]).mulChildVectorIntoExtalone was ~15% of Prove CPU (cumulative, including E4.Mul).add/subpaths touchdst[j].B0.A0directly since the lifted base coefficient is zero in the other slots — drops the lift indirection.3.
perf(rs): drop two BitReverse passes per Encode via DIT FFT(item #3 option B)FFTInverse(DIF) → BitReverse → FFT(DIF) → BitReverse. The twoBitReversecalls on sizes up to 2^20 fell through gnark-crypto's threshold tobitReverseNaive(~5.9% of Prove CPU).scatterBitReversedCoeffs[T any]is generic over bothpoly.Polynomial(Base) andpoly.ExtPolynomial.4.
perf(prover): parallelize zeta evaluations, vectorize DeepQuotient accumulationComputeAIRQuotientsper-chunk evaluate-at-zeta +ComputeEvaluationsAtZetaper-module are now parallelized; results merged serially underProof.SetValueAtZetaExt.ComputeDeepQuotient: the innerC_s[x] += alpha · col[x]accumulation now usesext.Vector.MulAccByElement(base columns) orScalarMulinto a per-size scratch buffer +Add(ext columns). Constant columns (len == 1) keep the scalar broadcast form.Bench tooling (
bench/main.go)parmetric: was/cpu/classes/total(=GOMAXPROCS × wall, counts idle slots). Now uses/cpu/classes/user + /cpu/classes/gc(real on-CPU time). A serial section now correctly reportspar ≈ 1.0×.-instances 40 -log2-size 17for usable pprof samples.Docs
optim_plan_ideas.mdcatalogues remaining perf opportunities with profile evidence andfile:linepointers. This PR implements items feat: Easier to use Fiat-Shamir transcript #1, chore: pin GitHub Actions to commit SHAs #3 (option B), Feat/recursion #4 and a partial perf(prover): parallelize + vectorize hot path (4.4× Prove speedup) #6 (parallel zeta evals; full FRI-query parallelism still pending).Test plan
go test -count=1 ./internal/... ./prover/... ./integration_test/...— all green, including end-to-end verifier checks inintegration_test/gnark_plonkandintegration_test/go_corset/zkc.go run ./bench→ Prove wall 9.60s,par5.40× (vs 41.87s / 1.09× baseline).prover/writes toDomainCacheconcurrently in a way that conflicts with the new mutex.scatterBitReversedCoeffsagainst the originalBitReverse → zero-pad → BitReversesemantics (a unit test ininternal/reedsolomonwould be welcome).len(col) == 1broadcast path inaddScaled{Ext,Base}Columnmatches the original branch behavior.Remaining (in
optim_plan_ideas.md)After re-profiling on this branch, the biggest remaining targets are #2 (batch-invert in
DeepQuotientExt), #5 (reuseEvalWorkspaceacross modules per worker), and #9 (pool the per-iteration N-sized E4 scratch buffers to bring peak heap back below 5 GiB).🤖 Generated with Claude Code
Note
Medium Risk
Changes core proving math paths (parallel DAG/quotient, FFT layout in RS encode, shared DomainCache locking); correctness relies on clone/scatter semantics matching prior behavior—tests pass but peak heap rises and RS scatter lacks dedicated unit tests called out in the PR.
Overview
This PR speeds up the Prove path (reported ~4× wall time on the bundled bench workload) by parallelizing per-module work, cutting redundant FFT/allocation work, and vectorizing large field loops.
Prover:
ComputeAIRQuotientsruns per module in parallel (parallel.Execute), withdag.DAG.Clone()per worker soComputeQuotient*can mutateLeaf.Idxsafely when modules share leaves. Quotient chunking stops at coset-Lagrange → canonical (Coset*NormalToCanonicalWithCache) and sub-slices the backing array instead of extra FFT round-trips andmake+copyper chunk.ComputeEvaluationsAtZeta(and AIR chunk evals at ζ) are parallelized per module; results are merged into the proof serially.ComputeDeepQuotientuses gnark-cryptoext.Vectorhelpers (MulAccByElement,ScalarMul/Add) for column accumulation.Supporting libs:
DomainCacheis mutex-protected for concurrentGet. DAG mixed ext add/sub/mul paths and Lagrange inner products use vector APIs. Reed–Solomon encode avoids twoBitReversepasses viascatterBitReversedCoeffsand a DIT forward FFT.Tooling: New
bench/main.godrives aggregated PLONK instances, writes pprof profiles, and reportsparfrom real on-CPU time (user + GC), not idle-inflated totals..gitignoreignores profile output dirs.Reviewed by Cursor Bugbot for commit 58e62e2. Bugbot is set up for automated code reviews on this repo. Configure here.