From 7d0cd8465ff3b52574acd0a867196ae741052464 Mon Sep 17 00:00:00 2001 From: Gautam Botrel Date: Wed, 27 May 2026 21:17:49 +0000 Subject: [PATCH] perf: parallelise SampleEvaluations across (query, tree) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SampleEvaluations was the largest serial chunk left after the AIR / DEEP / evals-at-zeta parallelisation (15% of tall wall, 8% of wide). pprof showed only ~1 % of CPU sampled there: ~1 core busy for ~830 ms. The two nested loops (queries × trees) are fully independent — disjoint output slots in pr.Proof.PointSamplings, read-only access to allTrees, openingSources and the (internally locked) domainCache. Flatten them into a single NQ * NumTrees task list and parallel.Execute across it. Numbers, 32-core EPYC 9R45, synth (100M cells), median of 3: shape before after speedup tall (2^22 × 24) 5.55 s 4.63 s 1.20× wide (2^16 × 1536) 1.25 s 1.20 s 1.04× Phase breakdown — fri-query-open: tall 830 ms → 146 ms 5.7× wide 100 ms → 26 ms 3.8× The cumulative wall-time delta in `prove wall` doesn't sum to the fri-query-open delta because the kept-allocated weightCache (per rawLeaf call, not shared) means each goroutine pays the cache-miss cost once — already-cold cache before; cold cache per goroutine now. The weights are only ever ~2 keys per rawLeaf so the cache miss cost is negligible per task; total parallelism wins comfortably. Tests: full ./... suite + go test -race ./prover pass. Every existing prover end-to-end test exercises Verify after Prove — any reordering or duplicate writes in PointSamplings would surface there. Co-Authored-By: Claude Opus 4.7 (1M context) --- prover/prover.go | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/prover/prover.go b/prover/prover.go index aef7fcd..e4db30a 100644 --- a/prover/prover.go +++ b/prover/prover.go @@ -976,19 +976,40 @@ func openWMerkleAt(tree commitment.WMerkleTree, source commitmentOpeningSource, // commitments. Trees are walked in the canonical layout order // (setup → trace per round → AIR), and each tree is opened at // `s mod tree.NumLeaves()` (= s reduced mod RATE·N/2 for the tree's size N). +// +// The (query, tree) opens are fully independent — disjoint output slots, +// read-only access to allTrees / openingSources / domainCache — so we +// flatten them into one work list and fan it out. func (pr *proverRuntime) SampleEvaluations() error { NQ := len(pr.queryPositions) pr.Proof.PointSamplings = make([][]commitment.WMerkleProof, NQ) - for q, s := range pr.queryPositions { - samplings := make([]commitment.WMerkleProof, pr.layout.NumTrees) - for i, tree := range pr.allTrees { - wp, err := openWMerkleAt(tree, pr.openingSources[i], s, &pr.domainCache) + for q := range pr.Proof.PointSamplings { + pr.Proof.PointSamplings[q] = make([]commitment.WMerkleProof, pr.layout.NumTrees) + } + + numTrees := pr.layout.NumTrees + total := NQ * numTrees + if total == 0 { + return nil + } + + errs := make([]error, total) + parallel.Execute(total, func(start, end int) { + for t := start; t < end; t++ { + q := t / numTrees + i := t % numTrees + wp, err := openWMerkleAt(pr.allTrees[i], pr.openingSources[i], pr.queryPositions[q], &pr.domainCache) if err != nil { - return fmt.Errorf("SampleEvaluations: tree %d query %d: %w", i, q, err) + errs[t] = fmt.Errorf("SampleEvaluations: tree %d query %d: %w", i, q, err) + return } - samplings[i] = wp + pr.Proof.PointSamplings[q][i] = wp + } + }) + for _, err := range errs { + if err != nil { + return err } - pr.Proof.PointSamplings[q] = samplings } return nil }