Skip to content

perf(prover): parallelize + vectorize hot path (4.4× Prove speedup)#6

Merged
ThomasPiellard merged 8 commits into
mainfrom
perf/airquotient
May 27, 2026
Merged

perf(prover): parallelize + vectorize hot path (4.4× Prove speedup)#6
ThomasPiellard merged 8 commits into
mainfrom
perf/airquotient

Conversation

@gbotrel

@gbotrel gbotrel commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacks four optimizations from optim_plan_ideas.md on the proving hot path. On the bench workload (40 PLONK instances of size 2^17, Poseidon2, 32-core box):

metric baseline first commit this PR total delta
Prove wall 41.87s 24.45s 9.60s −4.36×
Prove par 1.09× 2.13× 5.40× +4.95×
Prove allocated 12.89 GiB 12.27 GiB 12.39 GiB −4%
peak heap 4.86 GiB 7.66 GiB 7.69 GiB +58%

Wall time drops from ~42s → ~10s; par rises 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)

  • Per-module ComputeQuotient* dispatched via parallel.Execute on a sorted module list.
  • dag.(*DAG).Clone() deep-copies nodes + *expr.Leaf so each goroutine mutates Leaf.Idx independently (required because cross-module Lookup / CopyConstraint shares leaves).
  • poly.DomainCache.Get is now sync.Mutex-protected for concurrent access; pr.airTrace.SetBase/SetExt + chunkDomains writes batched under a tiny lock window.
  • computeExt/BaseAIRChunks now stop at canonical-Normal form (skip FFT + BitReverse + FFTInverse + BitReverse identity); chunks are 3-index sub-slices of the quotient backing array instead of make + copy.

2. perf(dag,poly): use ext/koalabear Vector ops in hot inner loops (item #4)

  • dag.{add,sub,mul}ChildVectorIntoExt and poly.{Ext,}EvaluateLagrangeWithWeights were scalar Go loops over N≈2^17. Switched to gnark-crypto ext.Vector / koalabear.Vector AVX-512 entry points (Add, Sub, Mul, MulByElement, InnerProduct[ByElement]).
  • mulChildVectorIntoExt alone was ~15% of Prove CPU (cumulative, including E4.Mul).
  • Base-rail add/sub paths touch dst[j].B0.A0 directly 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)

  • Encode path was FFTInverse(DIF) → BitReverse → FFT(DIF) → BitReverse. The two BitReverse calls on sizes up to 2^20 fell through gnark-crypto's threshold to bitReverseNaive (~5.9% of Prove CPU).
  • New path scatters the n bit-reversed inverse-FFT coefficients directly into N bit-reversed slots (zero-padding the gaps) and runs the forward FFT in DIT order. DIT consumes bit-reversed input and produces normal output — no explicit BitReverse needed.
  • New helper scatterBitReversedCoeffs[T any] is generic over both poly.Polynomial (Base) and poly.ExtPolynomial.

4. perf(prover): parallelize zeta evaluations, vectorize DeepQuotient accumulation

  • ComputeAIRQuotients per-chunk evaluate-at-zeta + ComputeEvaluationsAtZeta per-module are now parallelized; results merged serially under Proof.SetValueAtZetaExt.
  • ComputeDeepQuotient: the inner C_s[x] += alpha · col[x] accumulation now uses ext.Vector.MulAccByElement (base columns) or ScalarMul into a per-size scratch buffer + Add (ext columns). Constant columns (len == 1) keep the scalar broadcast form.

Bench tooling (bench/main.go)

  • Fixed par metric: 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 reports par ≈ 1.0×.
  • Default workload bumped to -instances 40 -log2-size 17 for usable pprof samples.

Docs

Test plan

  • go test -count=1 ./internal/... ./prover/... ./integration_test/... — all green, including end-to-end verifier checks in integration_test/gnark_plonk and integration_test/go_corset/zkc.
  • go run ./bench → Prove wall 9.60s, par 5.40× (vs 41.87s / 1.09× baseline).
  • Reviewer: confirm no consumer outside prover/ writes to DomainCache concurrently in a way that conflicts with the new mutex.
  • Reviewer: spot-check scatterBitReversedCoeffs against the original BitReverse → zero-pad → BitReverse semantics (a unit test in internal/reedsolomon would be welcome).
  • Reviewer: confirm the len(col) == 1 broadcast path in addScaled{Ext,Base}Column matches 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 (reuse EvalWorkspace across 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: ComputeAIRQuotients runs per module in parallel (parallel.Execute), with dag.DAG.Clone() per worker so ComputeQuotient* can mutate Leaf.Idx safely 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 and make+copy per chunk. ComputeEvaluationsAtZeta (and AIR chunk evals at ζ) are parallelized per module; results are merged into the proof serially. ComputeDeepQuotient uses gnark-crypto ext.Vector helpers (MulAccByElement, ScalarMul/Add) for column accumulation.

Supporting libs: DomainCache is mutex-protected for concurrent Get. DAG mixed ext add/sub/mul paths and Lagrange inner products use vector APIs. Reed–Solomon encode avoids two BitReverse passes via scatterBitReversedCoeffs and a DIT forward FFT.

Tooling: New bench/main.go drives aggregated PLONK instances, writes pprof profiles, and reports par from real on-CPU time (user + GC), not idle-inflated totals. .gitignore ignores profile output dirs.

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

ThomasPiellard and others added 2 commits May 26, 2026 17:31
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>
Copilot AI review requested due to automatic review settings May 26, 2026 16:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Comment thread optim_plan_ideas.md Outdated

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 thread optim_plan_ideas.md Outdated
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`):
Comment thread optim_plan_ideas.md Outdated

**Evidence:** `internal/dag/dag.go:1401` is a scalar Go loop:
```go
for j := range N {
gbotrel and others added 3 commits May 26, 2026 18:30
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>
@gbotrel gbotrel changed the title perf(prover): parallelize ComputeAIRQuotients (1.7x Prove speedup) perf(prover): parallelize + vectorize hot path (4.4× Prove speedup) May 26, 2026
@ThomasPiellard ThomasPiellard merged commit 0a1ded1 into main May 27, 2026
1 check passed
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.

3 participants