From 591e9fe08970f01972deb7b555e48103fb9f9168 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 20 Oct 2026 05:22:30 +0200 Subject: [PATCH 1/3] ci: compare Goldilocks and BN254 benchmarks with main --- .github/scripts/build_csp_pr_comment.py | 43 +++++++++++++++- .github/workflows/csp-benchmarks.yml | 57 +++++++++++++++++++--- tooling/provekit-fixtures/tests/profile.rs | 33 +++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/.github/scripts/build_csp_pr_comment.py b/.github/scripts/build_csp_pr_comment.py index 31e9670f9..8a6e1469c 100644 --- a/.github/scripts/build_csp_pr_comment.py +++ b/.github/scripts/build_csp_pr_comment.py @@ -11,6 +11,7 @@ import argparse import csv +import math from pathlib import Path MARKER = "" @@ -47,7 +48,7 @@ def fmt_kb_to_bytes(rss_kb: float) -> str: def fmt_ms(ms: float) -> str: - if ms <= 0: + if ms <= 0 or not math.isfinite(ms): return "—" if ms < 1000: return f"{ms:.0f} ms" @@ -78,7 +79,7 @@ def fmt_delta(current: float, baseline: float | None) -> str: for this circuit. - Returns "(±0.0%)" / "(+1.2%)" / "(-3.4%)" otherwise. """ - if baseline is None: + if baseline is None or not math.isfinite(baseline) or not math.isfinite(current): return "" if baseline <= 0: # Baseline collected zero (e.g., older CSV without this metric). @@ -87,6 +88,8 @@ def fmt_delta(current: float, baseline: float | None) -> str: if current <= 0: return "" delta_pct = (current - baseline) / baseline * 100.0 + if not math.isfinite(delta_pct): + return "" if abs(delta_pct) < 0.05: return " (±0.0%)" sign = "+" if delta_pct > 0 else "" @@ -111,6 +114,32 @@ def read_rows(csv_path: Path) -> list[dict[str, str]]: return list(csv.DictReader(f)) +def render_field_comparison(results: Path, baseline: Path, base_sha: Path) -> str: + sha = base_sha.read_text().strip() if base_sha.is_file() else "" + valid_sha = len(sha) == 40 and all(c in "0123456789abcdef" for c in sha) + current = index_baseline(read_rows(results)) + previous = index_baseline(read_rows(baseline)) if valid_sha else {} + lines = [ + "### Field benchmarks: squaring chain", + "", + f"Main commit: `{sha}`." if valid_sha else "_Main commit unavailable; comparison unavailable._", + "ZK commitments; SHA-256; eight threads.", + "Mean of three runs after one warmup; commitment and proving only.", + "Negative changes mean less time. Missing or invalid timings appear as —.", + "", + "| Field | Witness entries | Main time | PR time | Change (%) |", + "|---|---:|---:|---:|---:|", + ] + for field in ("Goldilocks", "BN254"): + for size in (18, 20): + key = f"{field} squaring chain 2^{size}" + old = previous.get(key, {}).get("prover_time_ms", 0) + new = current.get(key, {}).get("prover_time_ms", 0) + change = fmt_delta(new, old).strip() or "—" + lines.append(f"| {field} | {2 ** size:,} | {fmt_ms(old)} | {fmt_ms(new)} | **{change}** |") + return "\n".join(lines) + + def index_baseline(rows: list[dict[str, str]]) -> dict[str, dict[str, float]]: """Index baseline rows by circuit name with float metric values.""" out: dict[str, dict[str, float]] = {} @@ -179,6 +208,7 @@ def compose_comment( sha: str, status: str, runs_per_circuit: str, + field_report: str = "", ) -> str: short_sha = sha[:12] if sha else "unknown" table = render_table(rows, baseline, has_baseline_file) @@ -207,6 +237,10 @@ def compose_comment( MARKER, "## CSP benchmarks", "", + field_report, + "", + "### Noir circuits", + "", "| Metric | Value |", "|--------|-------|", f"| Workflow status | {status_with_icon(status)} |", @@ -276,6 +310,11 @@ def main() -> None: sha=args.sha, status=args.status, runs_per_circuit=args.runs_per_circuit, + field_report=render_field_comparison( + args.results_csv.with_name("fields.csv"), + args.results_csv.with_name("fields-baseline.csv"), + args.results_csv.with_name("field-base-sha.txt"), + ), ) if len(body) > MAX_COMMENT_CHARS: cut = body[: MAX_COMMENT_CHARS - 80].rstrip() diff --git a/.github/workflows/csp-benchmarks.yml b/.github/workflows/csp-benchmarks.yml index 66b59e676..88d3af953 100644 --- a/.github/workflows/csp-benchmarks.yml +++ b/.github/workflows/csp-benchmarks.yml @@ -61,14 +61,6 @@ jobs: run: | bash scripts/run_csp_benchmarks.sh - - name: Upload bench artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: csp-bench-logs-${{ github.run_id }} - path: csp-bench-logs/ - retention-days: 7 - - name: Fetch baseline from latest successful main run if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true @@ -93,11 +85,60 @@ jobs: if gh run download "${BASELINE_RUN_ID}" \ --name "csp-bench-logs-${BASELINE_RUN_ID}" \ --dir csp-bench-logs/baseline; then + BASELINE_SHA=$(gh run view "$BASELINE_RUN_ID" --json headSha --jq .headSha) echo "BASELINE_RUN_ID=${BASELINE_RUN_ID}" >> "$GITHUB_ENV" + echo "BASELINE_SHA=${BASELINE_SHA}" >> "$GITHUB_ENV" else echo "Baseline artifact not retrievable; deltas will not be shown." fi + - name: Benchmark Goldilocks and BN254 against main + if: ${{ !cancelled() }} + shell: bash + env: + RAYON_NUM_THREADS: "8" + run: | + profile() { + # Separate targets keep main and PR build artifacts isolated. + cargo test --manifest-path "$1/Cargo.toml" --target-dir "$1/target" --locked --release \ + -p provekit-fixtures --test profile "${@:2}" + } + measure() { + profile "$1" ci_field_benchmarks -- --exact --ignored --nocapture --test-threads=1 | + tee "csp-bench-logs/$2.log" | sed -n 's/^field_bench,//p' > "csp-bench-logs/$2.csv.tmp" + mv "csp-bench-logs/$2.csv.tmp" "csp-bench-logs/$2.csv" + } + mkdir -p csp-bench-logs + sha256sum .github/workflows/csp-benchmarks.yml rust-toolchain.toml \ + tooling/provekit-fixtures/tests/profile.rs tooling/provekit-fixtures/src/{harness,builders}.rs \ + > csp-bench-logs/fields-config.txt + if [[ -n "${BASELINE_SHA:-}" && -s csp-bench-logs/baseline/fields.csv ]] && \ + cmp -s csp-bench-logs/fields-config.txt csp-bench-logs/baseline/fields-config.txt; then + cp csp-bench-logs/baseline/fields.csv csp-bench-logs/fields-baseline.csv + printf '%s\n' "$BASELINE_SHA" > csp-bench-logs/field-base-sha.txt + elif [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then + git fetch --depth=1 origin main + git worktree add --detach field-baseline FETCH_HEAD + git -C field-baseline rev-parse HEAD > csp-bench-logs/field-base-sha.txt + for source in tooling/provekit-fixtures/tests/profile.rs tooling/provekit-fixtures/src/{harness,builders}.rs; do + cp "$source" "field-baseline/$source" + done + profile field-baseline --no-run + fi + profile . --no-run + if [[ -d field-baseline ]]; then + measure field-baseline fields-baseline + fi + measure . fields + + - name: Upload bench artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: csp-bench-logs-${{ github.run_id }} + path: csp-bench-logs/ + retention-days: 7 + - name: Build sticky PR comment body if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true diff --git a/tooling/provekit-fixtures/tests/profile.rs b/tooling/provekit-fixtures/tests/profile.rs index 1c7d61c2b..db0c6f7af 100644 --- a/tooling/provekit-fixtures/tests/profile.rs +++ b/tooling/provekit-fixtures/tests/profile.rs @@ -57,6 +57,39 @@ where ) } +fn benchmark_field

(field: &str, log_size: u32) +where + P: FieldHash, + Standard: Distribution> + Distribution>, +{ + P::register(); + run_one::

(log_size); // Warmup; every proof is verified. + let mut prove = Duration::ZERO; + for _ in 0..3 { + prove += run_one::

(log_size).0; + } + println!( + "field_bench,{field} squaring chain 2^{log_size},{:.3}", + prove.as_secs_f64() * 1000.0 / 3.0, + ); +} + +#[test] +#[ignore = "CI benchmark; run --release -- --ignored --nocapture"] +fn ci_field_benchmarks() { + assert_eq!( + provekit_common::WitnessCommitmentMode::default(), + provekit_common::WitnessCommitmentMode::ZeroKnowledge, + ); + println!("\nfield_bench,circuit,prover_time_ms"); + for size in [18, 20] { + benchmark_field::("Goldilocks", size); + } + for size in [18, 20] { + benchmark_field::("BN254", size); + } +} + /// Run one (field, size) cell and print its row immediately (so partial results /// survive a later OOM). Wrapped in `catch_unwind` so a panic in one cell does /// not abort the rest of the sweep. From 6aee9fed14461eb28424dbbfe386a3d66677d16b Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 20 Oct 2026 05:17:14 +0200 Subject: [PATCH 2/3] perf: reuse covector evaluations and parallelize accumulation --- provekit/common/src/prefix_covector.rs | 30 +++++++++++++++++++++----- provekit/prover/src/whir_r1cs.rs | 19 +++------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/provekit/common/src/prefix_covector.rs b/provekit/common/src/prefix_covector.rs index cfdb79cd3..24292fad3 100644 --- a/provekit/common/src/prefix_covector.rs +++ b/provekit/common/src/prefix_covector.rs @@ -1,5 +1,7 @@ use { + crate::utils::workload_size, ark_ff::{Field, One, Zero}, + rayon::prelude::*, whir::{ algebra::{embedding::Embedding, linear_form::LinearForm, mixed_dot, multilinear_extend}, buffer::{Buffer, BufferOps}, @@ -77,12 +79,11 @@ impl LinearForm for PrefixCovector { } fn accumulate(&self, accumulator: &mut [F], scalar: F) { - for (acc, val) in accumulator[..self.vector.len()] - .iter_mut() + accumulator[..self.vector.len()] + .par_iter_mut() .zip(&self.vector) - { - *acc += scalar * *val; - } + .with_min_len(workload_size::()) + .for_each(|(acc, &val)| *acc += scalar * val); } } @@ -381,6 +382,25 @@ mod tests { FieldElement::from(n) } + #[test] + fn prefix_accumulation_preserves_existing_values_and_padding() { + use whir::algebra::fields::Field64_3; + + let scalar = Field64_3::new(fe(2), fe(3), fe(5)); + for size in [4, 1 << 14] { + let weights: Vec<_> = (0..size) + .map(|i| Field64_3::new(fe(i as u64), fe(7), fe(11))) + .collect(); + let mut expected = vec![scalar; 2 * size]; + let mut actual = expected.clone(); + for (acc, &weight) in expected.iter_mut().zip(&weights) { + *acc += scalar * weight; + } + PrefixCovector::new(weights, 2 * size).accumulate(&mut actual, scalar); + assert_eq!(actual, expected); + } + } + #[test] fn mle_evaluate_matches_full_vector() { let domain_size = 16; // 2^4 diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index bab35462f..9621ad38f 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -362,7 +362,7 @@ where .into_iter() .next() .expect("single-commitment path requires at least one commitment"); - let (mut weights, evals) = create_weights_and_evaluations::<3, _>( + let (mut weights, mut evals) = create_weights_and_evaluations::<3, _>( &embedding, scheme.m, &commitment.polynomial, @@ -391,17 +391,16 @@ where public_weight, ); merlin.prover_message(&public_eval); + evals.insert(0, public_eval); } - let evaluations = compute_evaluations(&embedding, &weights, &commitment.polynomial); - let WhirR1CSCommitment { witness, .. } = commitment; let form_refs = linear_form_refs(&weights); let final_claim = scheme .whir_witness - .prove(&mut merlin, witness, &form_refs, &evaluations); + .prove(&mut merlin, witness, &form_refs, &evals); spark_row.zip(spark_weights).map(|(row, spark_weights)| { let [claimed_a, claimed_b, claimed_c] = @@ -889,18 +888,6 @@ fn create_weights_and_evaluations( (weights, evals) } -fn compute_evaluations( - embedding: &M, - weights: &[PrefixCovector], - polynomial: &Buffer, -) -> Vec { - let polynomial = polynomial.to_slice(); - weights - .iter() - .map(|w| mixed_dot(embedding, w.vector(), &polynomial[..w.vector().len()])) - .collect() -} - fn compute_public_weight_evaluation( embedding: &M, weights: &mut Vec>, From 3dd0eb388aadef8c5de1f73d16df564a406a20d2 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 20 Oct 2026 06:20:49 +0200 Subject: [PATCH 3/3] bench: keep field benchmarks outside CI --- .github/scripts/build_csp_pr_comment.py | 43 +--------------- .github/workflows/csp-benchmarks.yml | 57 +++------------------- tooling/provekit-fixtures/tests/profile.rs | 13 +++-- 3 files changed, 18 insertions(+), 95 deletions(-) diff --git a/.github/scripts/build_csp_pr_comment.py b/.github/scripts/build_csp_pr_comment.py index 8a6e1469c..31e9670f9 100644 --- a/.github/scripts/build_csp_pr_comment.py +++ b/.github/scripts/build_csp_pr_comment.py @@ -11,7 +11,6 @@ import argparse import csv -import math from pathlib import Path MARKER = "" @@ -48,7 +47,7 @@ def fmt_kb_to_bytes(rss_kb: float) -> str: def fmt_ms(ms: float) -> str: - if ms <= 0 or not math.isfinite(ms): + if ms <= 0: return "—" if ms < 1000: return f"{ms:.0f} ms" @@ -79,7 +78,7 @@ def fmt_delta(current: float, baseline: float | None) -> str: for this circuit. - Returns "(±0.0%)" / "(+1.2%)" / "(-3.4%)" otherwise. """ - if baseline is None or not math.isfinite(baseline) or not math.isfinite(current): + if baseline is None: return "" if baseline <= 0: # Baseline collected zero (e.g., older CSV without this metric). @@ -88,8 +87,6 @@ def fmt_delta(current: float, baseline: float | None) -> str: if current <= 0: return "" delta_pct = (current - baseline) / baseline * 100.0 - if not math.isfinite(delta_pct): - return "" if abs(delta_pct) < 0.05: return " (±0.0%)" sign = "+" if delta_pct > 0 else "" @@ -114,32 +111,6 @@ def read_rows(csv_path: Path) -> list[dict[str, str]]: return list(csv.DictReader(f)) -def render_field_comparison(results: Path, baseline: Path, base_sha: Path) -> str: - sha = base_sha.read_text().strip() if base_sha.is_file() else "" - valid_sha = len(sha) == 40 and all(c in "0123456789abcdef" for c in sha) - current = index_baseline(read_rows(results)) - previous = index_baseline(read_rows(baseline)) if valid_sha else {} - lines = [ - "### Field benchmarks: squaring chain", - "", - f"Main commit: `{sha}`." if valid_sha else "_Main commit unavailable; comparison unavailable._", - "ZK commitments; SHA-256; eight threads.", - "Mean of three runs after one warmup; commitment and proving only.", - "Negative changes mean less time. Missing or invalid timings appear as —.", - "", - "| Field | Witness entries | Main time | PR time | Change (%) |", - "|---|---:|---:|---:|---:|", - ] - for field in ("Goldilocks", "BN254"): - for size in (18, 20): - key = f"{field} squaring chain 2^{size}" - old = previous.get(key, {}).get("prover_time_ms", 0) - new = current.get(key, {}).get("prover_time_ms", 0) - change = fmt_delta(new, old).strip() or "—" - lines.append(f"| {field} | {2 ** size:,} | {fmt_ms(old)} | {fmt_ms(new)} | **{change}** |") - return "\n".join(lines) - - def index_baseline(rows: list[dict[str, str]]) -> dict[str, dict[str, float]]: """Index baseline rows by circuit name with float metric values.""" out: dict[str, dict[str, float]] = {} @@ -208,7 +179,6 @@ def compose_comment( sha: str, status: str, runs_per_circuit: str, - field_report: str = "", ) -> str: short_sha = sha[:12] if sha else "unknown" table = render_table(rows, baseline, has_baseline_file) @@ -237,10 +207,6 @@ def compose_comment( MARKER, "## CSP benchmarks", "", - field_report, - "", - "### Noir circuits", - "", "| Metric | Value |", "|--------|-------|", f"| Workflow status | {status_with_icon(status)} |", @@ -310,11 +276,6 @@ def main() -> None: sha=args.sha, status=args.status, runs_per_circuit=args.runs_per_circuit, - field_report=render_field_comparison( - args.results_csv.with_name("fields.csv"), - args.results_csv.with_name("fields-baseline.csv"), - args.results_csv.with_name("field-base-sha.txt"), - ), ) if len(body) > MAX_COMMENT_CHARS: cut = body[: MAX_COMMENT_CHARS - 80].rstrip() diff --git a/.github/workflows/csp-benchmarks.yml b/.github/workflows/csp-benchmarks.yml index 88d3af953..66b59e676 100644 --- a/.github/workflows/csp-benchmarks.yml +++ b/.github/workflows/csp-benchmarks.yml @@ -61,6 +61,14 @@ jobs: run: | bash scripts/run_csp_benchmarks.sh + - name: Upload bench artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: csp-bench-logs-${{ github.run_id }} + path: csp-bench-logs/ + retention-days: 7 + - name: Fetch baseline from latest successful main run if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true @@ -85,60 +93,11 @@ jobs: if gh run download "${BASELINE_RUN_ID}" \ --name "csp-bench-logs-${BASELINE_RUN_ID}" \ --dir csp-bench-logs/baseline; then - BASELINE_SHA=$(gh run view "$BASELINE_RUN_ID" --json headSha --jq .headSha) echo "BASELINE_RUN_ID=${BASELINE_RUN_ID}" >> "$GITHUB_ENV" - echo "BASELINE_SHA=${BASELINE_SHA}" >> "$GITHUB_ENV" else echo "Baseline artifact not retrievable; deltas will not be shown." fi - - name: Benchmark Goldilocks and BN254 against main - if: ${{ !cancelled() }} - shell: bash - env: - RAYON_NUM_THREADS: "8" - run: | - profile() { - # Separate targets keep main and PR build artifacts isolated. - cargo test --manifest-path "$1/Cargo.toml" --target-dir "$1/target" --locked --release \ - -p provekit-fixtures --test profile "${@:2}" - } - measure() { - profile "$1" ci_field_benchmarks -- --exact --ignored --nocapture --test-threads=1 | - tee "csp-bench-logs/$2.log" | sed -n 's/^field_bench,//p' > "csp-bench-logs/$2.csv.tmp" - mv "csp-bench-logs/$2.csv.tmp" "csp-bench-logs/$2.csv" - } - mkdir -p csp-bench-logs - sha256sum .github/workflows/csp-benchmarks.yml rust-toolchain.toml \ - tooling/provekit-fixtures/tests/profile.rs tooling/provekit-fixtures/src/{harness,builders}.rs \ - > csp-bench-logs/fields-config.txt - if [[ -n "${BASELINE_SHA:-}" && -s csp-bench-logs/baseline/fields.csv ]] && \ - cmp -s csp-bench-logs/fields-config.txt csp-bench-logs/baseline/fields-config.txt; then - cp csp-bench-logs/baseline/fields.csv csp-bench-logs/fields-baseline.csv - printf '%s\n' "$BASELINE_SHA" > csp-bench-logs/field-base-sha.txt - elif [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then - git fetch --depth=1 origin main - git worktree add --detach field-baseline FETCH_HEAD - git -C field-baseline rev-parse HEAD > csp-bench-logs/field-base-sha.txt - for source in tooling/provekit-fixtures/tests/profile.rs tooling/provekit-fixtures/src/{harness,builders}.rs; do - cp "$source" "field-baseline/$source" - done - profile field-baseline --no-run - fi - profile . --no-run - if [[ -d field-baseline ]]; then - measure field-baseline fields-baseline - fi - measure . fields - - - name: Upload bench artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: csp-bench-logs-${{ github.run_id }} - path: csp-bench-logs/ - retention-days: 7 - - name: Build sticky PR comment body if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true diff --git a/tooling/provekit-fixtures/tests/profile.rs b/tooling/provekit-fixtures/tests/profile.rs index db0c6f7af..d7510e75f 100644 --- a/tooling/provekit-fixtures/tests/profile.rs +++ b/tooling/provekit-fixtures/tests/profile.rs @@ -1,8 +1,11 @@ //! `#[ignore]` prover profiling harness. //! -//! Two probes (run with `--release`): +//! Profiling probes (run with `--release`): //! //! ```text +//! # Repeated squaring-chain benchmarks: Goldilocks first, then BN254 +//! RAYON_NUM_THREADS=8 cargo test -p provekit-fixtures --release --test profile field_benchmarks -- --exact --ignored --nocapture +//! //! # Size sweep: BF vs EF vs bn254 at 2^14..2^20 //! cargo test -p provekit-fixtures --release --test profile size_sweep -- --ignored --nocapture //! @@ -69,19 +72,19 @@ where prove += run_one::

(log_size).0; } println!( - "field_bench,{field} squaring chain 2^{log_size},{:.3}", + "{field} squaring chain 2^{log_size},{:.3}", prove.as_secs_f64() * 1000.0 / 3.0, ); } #[test] -#[ignore = "CI benchmark; run --release -- --ignored --nocapture"] -fn ci_field_benchmarks() { +#[ignore = "manual benchmark; run --release -- --ignored --nocapture"] +fn field_benchmarks() { assert_eq!( provekit_common::WitnessCommitmentMode::default(), provekit_common::WitnessCommitmentMode::ZeroKnowledge, ); - println!("\nfield_bench,circuit,prover_time_ms"); + println!("\ncircuit,prover_time_ms"); for size in [18, 20] { benchmark_field::("Goldilocks", size); }