From 2dabd4279fb93016f4fd41d1e3cbb58a7de72318 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Wed, 19 Aug 2026 12:44:41 +0100 Subject: [PATCH 1/5] Test timings and effect of enabling avx2 --- .github/workflows/avx2-experiment.yml | 98 +++++++++++++++++++ .../workflows/scripts/summarise-timings.py | 86 ++++++++++++++++ .github/workflows/test-timings.yml | 44 +++++++++ src/backends/plonky2/emptypod.rs | 5 +- src/backends/plonky2/mod.rs | 45 +++++++++ src/backends/plonky2/recursion/circuit.rs | 6 +- 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/avx2-experiment.yml create mode 100755 .github/workflows/scripts/summarise-timings.py create mode 100644 .github/workflows/test-timings.yml diff --git a/.github/workflows/avx2-experiment.yml b/.github/workflows/avx2-experiment.yml new file mode 100644 index 00000000..ee3251b9 --- /dev/null +++ b/.github/workflows/avx2-experiment.yml @@ -0,0 +1,98 @@ +name: AVX2 experiment + +# Does compiling with AVX2 speed up proving? plonky2 selects its Goldilocks +# packing at compile time (field/src/packable.rs), and nothing in this repo sets +# a target feature, so every build today uses the scalar 1-wide path even on +# hardware with a 4-wide implementation sitting in the tree. +# +# Both variants are built and run in ONE job on ONE runner deliberately. +# Splitting them across jobs would reintroduce host-to-host variance, which is +# large enough here (suite wall clock has ranged 563s-988s on identical code) to +# swamp the effect being measured. +on: + workflow_dispatch: + inputs: + test: + description: "Test to measure (one test, run serially)" + required: false + default: "backends::plonky2::mainpod::tests::test_main_zu_kyc" + repeats: + description: "Alternating measurements per variant" + required: false + default: "3" + +jobs: + avx2: + name: AVX2 experiment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + # RUSTFLAGS is set per step below, so stop the action from imposing its + # own. Caching is off: each variant needs its own full build anyway, + # and this job must not touch the entry that PRs restore from. + rustflags: "" + cache: false + + - name: Record the CPU + # If the runner lacks AVX2 the second binary would die with SIGILL, since + # plonky2 has no runtime fallback. Fail here with something readable. + run: | + grep -m1 "model name" /proc/cpuinfo + if grep -qm1 avx2 /proc/cpuinfo; then + echo "avx2: present" + else + echo "::error::this runner has no AVX2; rerun to get a different host" + exit 1 + fi + grep -qm1 avx512f /proc/cpuinfo && echo "note: avx512 also present (not used)" || true + + - name: Build baseline + env: + CARGO_TARGET_DIR: target-base + RUSTFLAGS: "" + run: cargo test --release --features time --no-run + + - name: Build with AVX2 + env: + CARGO_TARGET_DIR: target-avx2 + RUSTFLAGS: "-C target-feature=+avx2" + run: cargo test --release --features time --no-run + + - name: Measure + # Alternate the variants so any thermal or noisy-neighbour drift over the + # job's lifetime is shared between them rather than landing on whichever + # ran second. + run: | + set -o pipefail + : > base.log + : > avx2.log + for i in $(seq 1 ${{ inputs.repeats }}); do + echo "=== round $i ===" + CARGO_TARGET_DIR=target-base RUSTFLAGS="" \ + cargo test --release --features time '${{ inputs.test }}' \ + -- --exact --nocapture --test-threads=1 2>&1 | tee -a base.log + CARGO_TARGET_DIR=target-avx2 RUSTFLAGS="-C target-feature=+avx2" \ + cargo test --release --features time '${{ inputs.test }}' \ + -- --exact --nocapture --test-threads=1 2>&1 | tee -a avx2.log + done + + - name: Compare + if: always() + # Read the per-scope deltas, not just the total. AVX2 should move + # "build Merkle tree", "compute quotient polys" and "FFT + blinding", + # and leave "transpose LDEs" (memory bound) and "run N generators" + # (scalar) roughly alone. A uniform shift across every scope is noise, + # not a result. + run: .github/workflows/scripts/summarise-timings.py base.log avx2.log + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: avx2-experiment + path: | + base.log + avx2.log diff --git a/.github/workflows/scripts/summarise-timings.py b/.github/workflows/scripts/summarise-timings.py new file mode 100755 index 00000000..f84fef87 --- /dev/null +++ b/.github/workflows/scripts/summarise-timings.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Aggregate pod2/plonky2 timing lines into a per-scope summary. + +Recognises two line shapes: + + timed "MainPod::prove": 7.068943638s <- pod2's `timed!` macro + [.. DEBUG plonky2::util::timing] 0.5s to fft <- plonky2's TimingTree + +Usage: + summarise-timings.py [log] per-scope totals (stdin if no arg) + summarise-timings.py base.log other.log compare two runs, scope by scope + +Tests run in parallel unless --test-threads=1 is passed, in which case samples +interleave and cannot be attributed to a test. Totals across the run still tell +you where the time goes, which is the point. +""" +import re +import sys +from collections import defaultdict + +POD2 = re.compile(r'timed "([^"]+)": ([0-9.]+)(ms|µs|us|ns|s)\b') +PLONKY2 = re.compile(r"(?:\| )*([0-9.]+)s to (.+?)\s*$") +UNIT = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "µs": 1e-6, "ns": 1e-9} + + +def parse(lines): + totals = defaultdict(list) + for line in lines: + m = POD2.search(line) + if m: + totals[m.group(1)].append(float(m.group(2)) * UNIT[m.group(3)]) + continue + if "util::timing" in line or line.lstrip().startswith("| "): + m = PLONKY2.search(line) + if m: + totals[m.group(2)].append(float(m.group(1))) + return totals + + +def summarise(totals): + rows = sorted(totals.items(), key=lambda kv: -sum(kv[1])) + print(f"{'scope':<44}{'n':>5}{'total':>11}{'mean':>10}{'max':>10}") + print("-" * 80) + for name, xs in rows: + print( + f"{name[:43]:<44}{len(xs):>5}{sum(xs):>10.2f}s" + f"{sum(xs) / len(xs):>9.2f}s{max(xs):>9.2f}s" + ) + + +def compare(base, other, base_name, other_name): + # Compare totals per scope. A scope missing from one side is reported as + # such rather than as a delta, since that means the runs were not + # equivalent and any percentage would be meaningless. + names = sorted(set(base) | set(other), key=lambda n: -sum(base.get(n, [0]))) + print(f"{'scope':<44}{base_name:>11}{other_name:>11}{'delta':>10}") + print("-" * 76) + for name in names: + if name not in base or name not in other: + side = base_name if name in base else other_name + print(f"{name[:43]:<44}{'(only in ' + side + ')':>32}") + continue + a, b = sum(base[name]), sum(other[name]) + delta = f"{(b - a) / a * 100:+.1f}%" if a > 0 else "n/a" + print(f"{name[:43]:<44}{a:>10.2f}s{b:>10.2f}s{delta:>10}") + + +if __name__ == "__main__": + args = sys.argv[1:] + if len(args) == 2: + with open(args[0]) as f: + base = parse(f) + with open(args[1]) as f: + other = parse(f) + if not base or not other: + sys.exit("no timing lines found in one of the logs") + compare(base, other, "base", "avx2") + else: + src = open(args[0]) if args else sys.stdin + totals = parse(src) + if not totals: + sys.exit( + "no timing lines found " + "(did you pass --features time and --nocapture?)" + ) + summarise(totals) diff --git a/.github/workflows/test-timings.yml b/.github/workflows/test-timings.yml new file mode 100644 index 00000000..d5a87254 --- /dev/null +++ b/.github/workflows/test-timings.yml @@ -0,0 +1,44 @@ +name: Test timings + +# Manual only: this exists to answer "what is CI actually spending its time on", +# not to gate anything. It costs nothing until someone runs it. +on: + workflow_dispatch: + inputs: + filter: + description: "Test name filter (empty runs the whole suite)" + required: false + default: "" + +jobs: + timings: + name: Test timings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + # Read the tests.yml cache but never write to it: the feature set here + # differs, and this job should not disturb the entry PRs restore from. + cache-shared-key: tests-release + cache-save-if: false + - name: Run tests with timing + # `--nocapture` is load-bearing. The `timed!` macro and plonky2's + # TimingTree both print, and libtest swallows stdout without it, so the + # timings silently vanish. Tests still run in parallel, so samples + # interleave and cannot be attributed to an individual test; the totals + # are what this job is for. + run: | + set -o pipefail + cargo test --release --features time,db_rocksdb ${{ inputs.filter }} \ + -- --nocapture 2>&1 | tee timings.log + - name: Summarise + if: always() + run: .github/workflows/scripts/summarise-timings.py < timings.log + - name: Upload full log + if: always() + uses: actions/upload-artifact@v4 + with: + name: timings + path: timings.log diff --git a/src/backends/plonky2/emptypod.rs b/src/backends/plonky2/emptypod.rs index 91820c2e..888d16b9 100644 --- a/src/backends/plonky2/emptypod.rs +++ b/src/backends/plonky2/emptypod.rs @@ -130,7 +130,10 @@ impl EmptyPod { let mut pw = PartialWitness::::new(); empty_pod_verify_target.set_targets(&mut pw, vd_set.root())?; - let proof = timed!("EmptyPod prove", data.prove(pw)?); + let proof = timed!( + "EmptyPod prove", + crate::backends::plonky2::prove_with_timing("EmptyPod prove", data, pw)? + ); let common_hash = hash_common_data(&data.common).expect("hash ok"); Ok(EmptyPod { params: Params::default(), diff --git a/src/backends/plonky2/mod.rs b/src/backends/plonky2/mod.rs index fef391d3..08fd9e7e 100644 --- a/src/backends/plonky2/mod.rs +++ b/src/backends/plonky2/mod.rs @@ -39,6 +39,51 @@ use crate::{ timed, }; +/// Prove `circuit_data`, and under the `time` feature also report plonky2's own +/// breakdown of the proof: witness generation, wire polynomials and their +/// commitment, partial products, quotient polynomials and the opening proofs. +/// +/// `CircuitData::prove` hands plonky2 a throwaway `TimingTree` and discards all +/// of that, which leaves a proof as a single opaque number. Since proving +/// dominates the test suite, that is the number worth splitting up. +pub(crate) fn prove_with_timing( + name: &str, + circuit_data: &basetypes::CircuitData, + pw: plonky2::iop::witness::PartialWitness, +) -> anyhow::Result { + #[cfg(not(feature = "time"))] + { + let _ = name; + circuit_data.prove(pw) + } + #[cfg(feature = "time")] + { + use plonky2::{plonk::prover::prove, util::timing::TimingTree}; + + // plonky2 reports the tree through the `log` crate, so with no logger + // installed the timings vanish without a trace. Installing one from + // library code is only tolerable because this is behind `time`, a + // diagnostic-only feature. RUST_LOG still wins if it is set. + static LOGGER: std::sync::Once = std::sync::Once::new(); + LOGGER.call_once(|| { + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("debug"), + ) + .try_init(); + }); + + let mut timing = TimingTree::new(name, log::Level::Debug); + let proof = prove( + &circuit_data.prover_only, + &circuit_data.common, + pw, + &mut timing, + )?; + timing.print(); + Ok(proof) + } +} + pub fn cache_get_standard_rec_main_pod_common_circuit_data( ) -> CacheEntry { let params = Params::default(); diff --git a/src/backends/plonky2/recursion/circuit.rs b/src/backends/plonky2/recursion/circuit.rs index a70de8a2..060df409 100644 --- a/src/backends/plonky2/recursion/circuit.rs +++ b/src/backends/plonky2/recursion/circuit.rs @@ -181,7 +181,11 @@ pub fn prove_rec_circuit( proofs, verifier_datas, )?; - Ok(circuit_data.prove(pw)?) + Ok(crate::backends::plonky2::prove_with_timing( + "prove_rec_circuit", + circuit_data, + pw, + )?) } impl RecursiveCircuit { From 5b6d3fd8d2ffb13156500abaa16b44a74c10867b Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Wed, 19 Aug 2026 12:55:38 +0100 Subject: [PATCH 2/5] Enable new workflows --- .github/workflows/avx2-experiment.yml | 18 +++++++++++++++--- .github/workflows/test-timings.yml | 13 ++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/avx2-experiment.yml b/.github/workflows/avx2-experiment.yml index ee3251b9..c3dc7151 100644 --- a/.github/workflows/avx2-experiment.yml +++ b/.github/workflows/avx2-experiment.yml @@ -10,6 +10,11 @@ name: AVX2 experiment # large enough here (suite wall clock has ranged 563s-988s on identical code) to # swamp the effect being measured. on: + # TEMPORARY: remove before merging. workflow_dispatch only registers from the + # default branch, so this is the only way to exercise the workflow before it + # lands on main. + push: + branches: [test-timings] workflow_dispatch: inputs: test: @@ -65,17 +70,24 @@ jobs: # Alternate the variants so any thermal or noisy-neighbour drift over the # job's lifetime is shared between them rather than landing on whichever # ran second. + # + # Inputs go through env rather than being interpolated into the script: + # a dispatch input pasted straight into `run:` would let anyone who can + # trigger the workflow run arbitrary commands. + env: + TEST: ${{ inputs.test || 'backends::plonky2::mainpod::tests::test_main_zu_kyc' }} + REPEATS: ${{ inputs.repeats || '3' }} run: | set -o pipefail : > base.log : > avx2.log - for i in $(seq 1 ${{ inputs.repeats }}); do + for i in $(seq 1 "$REPEATS"); do echo "=== round $i ===" CARGO_TARGET_DIR=target-base RUSTFLAGS="" \ - cargo test --release --features time '${{ inputs.test }}' \ + cargo test --release --features time "$TEST" \ -- --exact --nocapture --test-threads=1 2>&1 | tee -a base.log CARGO_TARGET_DIR=target-avx2 RUSTFLAGS="-C target-feature=+avx2" \ - cargo test --release --features time '${{ inputs.test }}' \ + cargo test --release --features time "$TEST" \ -- --exact --nocapture --test-threads=1 2>&1 | tee -a avx2.log done diff --git a/.github/workflows/test-timings.yml b/.github/workflows/test-timings.yml index d5a87254..5926d4c4 100644 --- a/.github/workflows/test-timings.yml +++ b/.github/workflows/test-timings.yml @@ -3,6 +3,11 @@ name: Test timings # Manual only: this exists to answer "what is CI actually spending its time on", # not to gate anything. It costs nothing until someone runs it. on: + # TEMPORARY: remove before merging. workflow_dispatch only registers from the + # default branch, so this is the only way to exercise the workflow before it + # lands on main. + push: + branches: [test-timings] workflow_dispatch: inputs: filter: @@ -29,9 +34,15 @@ jobs: # timings silently vanish. Tests still run in parallel, so samples # interleave and cannot be attributed to an individual test; the totals # are what this job is for. + # + # The filter goes through env rather than being interpolated into the + # script: a dispatch input pasted straight into `run:` would let anyone + # who can trigger the workflow run arbitrary commands. + env: + FILTER: ${{ inputs.filter || '' }} run: | set -o pipefail - cargo test --release --features time,db_rocksdb ${{ inputs.filter }} \ + cargo test --release --features time,db_rocksdb ${FILTER:+"$FILTER"} \ -- --nocapture 2>&1 | tee timings.log - name: Summarise if: always() From cec30ddda62dc28316706977bc93b95a6774924f Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Wed, 19 Aug 2026 13:30:57 +0100 Subject: [PATCH 3/5] Allocator tests --- .github/workflows/allocator-experiment.yml | 115 +++++++++++++++++++++ Cargo.toml | 11 ++ examples/main_pod_points.rs | 8 ++ src/lib.rs | 14 +++ 4 files changed, 148 insertions(+) create mode 100644 .github/workflows/allocator-experiment.yml diff --git a/.github/workflows/allocator-experiment.yml b/.github/workflows/allocator-experiment.yml new file mode 100644 index 00000000..41612c4e --- /dev/null +++ b/.github/workflows/allocator-experiment.yml @@ -0,0 +1,115 @@ +name: Allocator experiment + +# Issue #398: does jemalloc or mimalloc beat the system allocator for proving? +# Also checks whether an allocator win composes with the AVX2 win, since the two +# attack different bottlenecks (allocation vs field arithmetic). +# +# As with the AVX2 experiment, every variant is built and run in ONE job on ONE +# runner. Host-to-host variance on GitHub runners is large enough to swamp the +# effect being measured. +on: + # TEMPORARY: remove before merging. workflow_dispatch only registers from the + # default branch, so this is the only way to exercise the workflow before it + # lands on main. + push: + branches: [test-timings] + workflow_dispatch: + inputs: + repeats: + description: "Alternating measurements per variant" + required: false + default: "2" + +jobs: + allocators: + name: Allocator experiment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + # RUSTFLAGS is set per step below. Caching is off: the variants need + # their own builds, and this job must not disturb the entry PRs + # restore from. + rustflags: "" + cache: false + + - name: Record the CPU + run: | + grep -m1 "model name" /proc/cpuinfo + if ! grep -qm1 avx2 /proc/cpuinfo; then + echo "::error::this runner has no AVX2; rerun to get a different host" + exit 1 + fi + + # Variants are grouped by RUSTFLAGS and share a target directory within a + # group, so only pod2 and the allocator crate rebuild between them. A + # separate target dir per variant would rebuild plonky2 five times. + - name: Build allocator variants + env: + CARGO_TARGET_DIR: target-plain + RUSTFLAGS: "" + run: | + mkdir -p bin + for feat in "" "jemalloc" "mimalloc"; do + name="${feat:-system}" + cargo build --release --example main_pod_points ${feat:+--features "$feat"} + cp target-plain/release/examples/main_pod_points "bin/$name" + done + + - name: Build AVX2 variants + env: + CARGO_TARGET_DIR: target-avx2 + RUSTFLAGS: "-C target-feature=+avx2" + run: | + for feat in "" "mimalloc"; do + name="avx2${feat:+-$feat}" + cargo build --release --example main_pod_points ${feat:+--features "$feat"} + cp target-avx2/release/examples/main_pod_points "bin/$name" + done + + - name: Measure + # Alternate the variants so drift over the job's lifetime is shared + # between them rather than landing on whichever ran last. + env: + REPEATS: ${{ inputs.repeats || '2' }} + run: | + set -o pipefail + : > results.csv + for i in $(seq 1 "$REPEATS"); do + for v in system jemalloc mimalloc avx2 avx2-mimalloc; do + start=$(date +%s.%N) + "./bin/$v" > /dev/null + end=$(date +%s.%N) + awk -v v="$v" -v s="$start" -v e="$end" \ + 'BEGIN { printf "%s,%.3f\n", v, e - s }' | tee -a results.csv + done + done + + - name: Compare + if: always() + # Read this against the AVX2 experiment: if mimalloc and avx2 each help + # and avx2-mimalloc is close to the sum of the two, they compose. + run: | + awk -F, ' + { sum[$1] += $2; n[$1]++ } + END { + base = sum["system"] / n["system"] + printf "%-18s%10s%10s\n", "variant", "mean", "delta" + printf "%s\n", "--------------------------------------" + split("system jemalloc mimalloc avx2 avx2-mimalloc", order, " ") + for (i = 1; i <= 5; i++) { + v = order[i] + if (!(v in sum)) continue + m = sum[v] / n[v] + printf "%-18s%9.2fs%9.1f%%\n", v, m, (m - base) / base * 100 + } + }' results.csv + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: allocator-experiment + path: results.csv diff --git a/Cargo.toml b/Cargo.toml index 796ba3ef..c4588117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,13 @@ good_lp = { version = "1.8", default-features = false, features = [ annotate-snippets = "0.11" rocksdb = { version = "0.24.0", optional = true } # keyvalue database for merkletree +# Alternative global allocators, for the experiment in #398. Proving is +# allocation-heavy, so the allocator is worth measuring. These are only ever +# installed by pod2's own test harness and examples, never by pod2 as a +# dependency (see the `cfg(test)` gate in src/lib.rs). +mimalloc = { version = "0.1.52", optional = true } +tikv-jemallocator = { version = "0.7.0", optional = true } + # Uncomment for debugging with https://github.com/ed255/plonky2/ at branch `feat/debug`. The repo directory needs to be checked out next to the pod2 repo directory. # [patch."https://github.com/0xPARC/plonky2"] # plonky2 = { path = "../plonky2/plonky2" } @@ -84,6 +91,10 @@ db_rocksdb = ["rocksdb"] # dependency out of slim builds; on for the test target unconditionally via # `[dev-dependencies] good_lp` so the parity sweep always builds. milp = ["dep:good_lp"] +# Swap the global allocator in the test harness and examples. Mutually +# exclusive, and diagnostic only. +mimalloc = ["dep:mimalloc"] +jemalloc = ["dep:tikv-jemallocator"] # Uncomment in order to enable debug information in the release builds. This allows getting panic backtraces with a performance similar to regular release. # [profile.release] diff --git a/examples/main_pod_points.rs b/examples/main_pod_points.rs index 279e07af..810361c0 100644 --- a/examples/main_pod_points.rs +++ b/examples/main_pod_points.rs @@ -10,6 +10,14 @@ //! Run in mock mode: `cargo run --release --example main_pod_points -- --mock` use std::env; +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[cfg(feature = "jemalloc")] +#[global_allocator] +static GLOBAL_ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + use pod2::{ backends::plonky2::{ basetypes::DEFAULT_VD_SET, mainpod::Prover, mock::mainpod::MockProver, diff --git a/src/lib.rs b/src/lib.rs index cc21288b..8f4eaa1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,20 @@ #![allow(clippy::large_enum_variant)] // TODO: Remove this in another PR #![feature(mapped_lock_guards)] +// Alternative global allocators (#398). Gated on `cfg(test)` so this applies +// only to pod2's own test harness: a library has no business choosing the +// allocator for the programs that depend on it. +#[cfg(all(feature = "mimalloc", feature = "jemalloc"))] +compile_error!("features `mimalloc` and `jemalloc` are mutually exclusive"); + +#[cfg(all(test, feature = "mimalloc"))] +#[global_allocator] +static GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[cfg(all(test, feature = "jemalloc"))] +#[global_allocator] +static GLOBAL_ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + pub mod backends; pub mod cache; pub mod frontend; From ca50d65f4d179df63b212c76d1c51d05c0c27714 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Wed, 19 Aug 2026 14:58:31 +0100 Subject: [PATCH 4/5] Finalise preferred test config in CI --- .github/workflows/allocator-experiment.yml | 115 --------------------- .github/workflows/avx2-experiment.yml | 110 -------------------- .github/workflows/test-timings.yml | 5 - .github/workflows/tests.yml | 33 +++++- 4 files changed, 30 insertions(+), 233 deletions(-) delete mode 100644 .github/workflows/allocator-experiment.yml delete mode 100644 .github/workflows/avx2-experiment.yml diff --git a/.github/workflows/allocator-experiment.yml b/.github/workflows/allocator-experiment.yml deleted file mode 100644 index 41612c4e..00000000 --- a/.github/workflows/allocator-experiment.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Allocator experiment - -# Issue #398: does jemalloc or mimalloc beat the system allocator for proving? -# Also checks whether an allocator win composes with the AVX2 win, since the two -# attack different bottlenecks (allocation vs field arithmetic). -# -# As with the AVX2 experiment, every variant is built and run in ONE job on ONE -# runner. Host-to-host variance on GitHub runners is large enough to swamp the -# effect being measured. -on: - # TEMPORARY: remove before merging. workflow_dispatch only registers from the - # default branch, so this is the only way to exercise the workflow before it - # lands on main. - push: - branches: [test-timings] - workflow_dispatch: - inputs: - repeats: - description: "Alternating measurements per variant" - required: false - default: "2" - -jobs: - allocators: - name: Allocator experiment - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - # RUSTFLAGS is set per step below. Caching is off: the variants need - # their own builds, and this job must not disturb the entry PRs - # restore from. - rustflags: "" - cache: false - - - name: Record the CPU - run: | - grep -m1 "model name" /proc/cpuinfo - if ! grep -qm1 avx2 /proc/cpuinfo; then - echo "::error::this runner has no AVX2; rerun to get a different host" - exit 1 - fi - - # Variants are grouped by RUSTFLAGS and share a target directory within a - # group, so only pod2 and the allocator crate rebuild between them. A - # separate target dir per variant would rebuild plonky2 five times. - - name: Build allocator variants - env: - CARGO_TARGET_DIR: target-plain - RUSTFLAGS: "" - run: | - mkdir -p bin - for feat in "" "jemalloc" "mimalloc"; do - name="${feat:-system}" - cargo build --release --example main_pod_points ${feat:+--features "$feat"} - cp target-plain/release/examples/main_pod_points "bin/$name" - done - - - name: Build AVX2 variants - env: - CARGO_TARGET_DIR: target-avx2 - RUSTFLAGS: "-C target-feature=+avx2" - run: | - for feat in "" "mimalloc"; do - name="avx2${feat:+-$feat}" - cargo build --release --example main_pod_points ${feat:+--features "$feat"} - cp target-avx2/release/examples/main_pod_points "bin/$name" - done - - - name: Measure - # Alternate the variants so drift over the job's lifetime is shared - # between them rather than landing on whichever ran last. - env: - REPEATS: ${{ inputs.repeats || '2' }} - run: | - set -o pipefail - : > results.csv - for i in $(seq 1 "$REPEATS"); do - for v in system jemalloc mimalloc avx2 avx2-mimalloc; do - start=$(date +%s.%N) - "./bin/$v" > /dev/null - end=$(date +%s.%N) - awk -v v="$v" -v s="$start" -v e="$end" \ - 'BEGIN { printf "%s,%.3f\n", v, e - s }' | tee -a results.csv - done - done - - - name: Compare - if: always() - # Read this against the AVX2 experiment: if mimalloc and avx2 each help - # and avx2-mimalloc is close to the sum of the two, they compose. - run: | - awk -F, ' - { sum[$1] += $2; n[$1]++ } - END { - base = sum["system"] / n["system"] - printf "%-18s%10s%10s\n", "variant", "mean", "delta" - printf "%s\n", "--------------------------------------" - split("system jemalloc mimalloc avx2 avx2-mimalloc", order, " ") - for (i = 1; i <= 5; i++) { - v = order[i] - if (!(v in sum)) continue - m = sum[v] / n[v] - printf "%-18s%9.2fs%9.1f%%\n", v, m, (m - base) / base * 100 - } - }' results.csv - - - name: Upload results - if: always() - uses: actions/upload-artifact@v4 - with: - name: allocator-experiment - path: results.csv diff --git a/.github/workflows/avx2-experiment.yml b/.github/workflows/avx2-experiment.yml deleted file mode 100644 index c3dc7151..00000000 --- a/.github/workflows/avx2-experiment.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: AVX2 experiment - -# Does compiling with AVX2 speed up proving? plonky2 selects its Goldilocks -# packing at compile time (field/src/packable.rs), and nothing in this repo sets -# a target feature, so every build today uses the scalar 1-wide path even on -# hardware with a 4-wide implementation sitting in the tree. -# -# Both variants are built and run in ONE job on ONE runner deliberately. -# Splitting them across jobs would reintroduce host-to-host variance, which is -# large enough here (suite wall clock has ranged 563s-988s on identical code) to -# swamp the effect being measured. -on: - # TEMPORARY: remove before merging. workflow_dispatch only registers from the - # default branch, so this is the only way to exercise the workflow before it - # lands on main. - push: - branches: [test-timings] - workflow_dispatch: - inputs: - test: - description: "Test to measure (one test, run serially)" - required: false - default: "backends::plonky2::mainpod::tests::test_main_zu_kyc" - repeats: - description: "Alternating measurements per variant" - required: false - default: "3" - -jobs: - avx2: - name: AVX2 experiment - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - # RUSTFLAGS is set per step below, so stop the action from imposing its - # own. Caching is off: each variant needs its own full build anyway, - # and this job must not touch the entry that PRs restore from. - rustflags: "" - cache: false - - - name: Record the CPU - # If the runner lacks AVX2 the second binary would die with SIGILL, since - # plonky2 has no runtime fallback. Fail here with something readable. - run: | - grep -m1 "model name" /proc/cpuinfo - if grep -qm1 avx2 /proc/cpuinfo; then - echo "avx2: present" - else - echo "::error::this runner has no AVX2; rerun to get a different host" - exit 1 - fi - grep -qm1 avx512f /proc/cpuinfo && echo "note: avx512 also present (not used)" || true - - - name: Build baseline - env: - CARGO_TARGET_DIR: target-base - RUSTFLAGS: "" - run: cargo test --release --features time --no-run - - - name: Build with AVX2 - env: - CARGO_TARGET_DIR: target-avx2 - RUSTFLAGS: "-C target-feature=+avx2" - run: cargo test --release --features time --no-run - - - name: Measure - # Alternate the variants so any thermal or noisy-neighbour drift over the - # job's lifetime is shared between them rather than landing on whichever - # ran second. - # - # Inputs go through env rather than being interpolated into the script: - # a dispatch input pasted straight into `run:` would let anyone who can - # trigger the workflow run arbitrary commands. - env: - TEST: ${{ inputs.test || 'backends::plonky2::mainpod::tests::test_main_zu_kyc' }} - REPEATS: ${{ inputs.repeats || '3' }} - run: | - set -o pipefail - : > base.log - : > avx2.log - for i in $(seq 1 "$REPEATS"); do - echo "=== round $i ===" - CARGO_TARGET_DIR=target-base RUSTFLAGS="" \ - cargo test --release --features time "$TEST" \ - -- --exact --nocapture --test-threads=1 2>&1 | tee -a base.log - CARGO_TARGET_DIR=target-avx2 RUSTFLAGS="-C target-feature=+avx2" \ - cargo test --release --features time "$TEST" \ - -- --exact --nocapture --test-threads=1 2>&1 | tee -a avx2.log - done - - - name: Compare - if: always() - # Read the per-scope deltas, not just the total. AVX2 should move - # "build Merkle tree", "compute quotient polys" and "FFT + blinding", - # and leave "transpose LDEs" (memory bound) and "run N generators" - # (scalar) roughly alone. A uniform shift across every scope is noise, - # not a result. - run: .github/workflows/scripts/summarise-timings.py base.log avx2.log - - - name: Upload logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: avx2-experiment - path: | - base.log - avx2.log diff --git a/.github/workflows/test-timings.yml b/.github/workflows/test-timings.yml index 5926d4c4..842e8483 100644 --- a/.github/workflows/test-timings.yml +++ b/.github/workflows/test-timings.yml @@ -3,11 +3,6 @@ name: Test timings # Manual only: this exists to answer "what is CI actually spending its time on", # not to gate anything. It costs nothing until someone runs it. on: - # TEMPORARY: remove before merging. workflow_dispatch only registers from the - # default branch, so this is the only way to exercise the workflow before it - # lands on main. - push: - branches: [test-timings] workflow_dispatch: inputs: filter: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8a7a86f..c52f9bea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,10 +28,37 @@ jobs: # saving from PRs just burns into the repo's 10 GB cache budget and can # evict the one entry every PR actually restores from. cache-save-if: ${{ github.ref == 'refs/heads/main' }} + # Proving is field-arithmetic heavy and plonky2 picks its Goldilocks + # packing at compile time, so without this every run uses the scalar + # 1-wide path. Measured at roughly -9% on MainPod::prove, reproduced on + # both Intel and AMD runners. + # + # A fixed feature, never `target-cpu=native`: rust-cache restores rlibs + # built on a different host, and this fleet mixes hosts with and + # without AVX-512, so `native` would bake in instructions the next + # runner may not have. AVX2 alone is safe on any x86 runner. + rustflags: "-D warnings -C target-feature=+avx2" + - name: Check the runner has AVX2 + # plonky2 has no runtime fallback, so a runner without AVX2 would die + # with SIGILL somewhere inside a proof. Fail here with a readable reason. + run: | + grep -qm1 avx2 /proc/cpuinfo || { + echo "::error::runner lacks AVX2; drop +avx2 from rustflags in this workflow" + exit 1 + } - name: Run tests # RocksDB is disabled by default but we still want to test it. - run: cargo test --release --features db_rocksdb + # + # mimalloc is CI-only, via a feature rather than a default: proving + # allocates heavily and this measured about -5% here (more on machines + # with more cores). pod2 must not impose an allocator on the programs + # that depend on it, so the hook is `cfg(test)` gated and applications + # that want it set their own `#[global_allocator]`. + # + # The example steps below must use the same feature set, or pod2 gets + # rebuilt between the steps. + run: cargo test --release --features db_rocksdb,mimalloc - name: Run example 1 - run: cargo run --release --features db_rocksdb --example main_pod_points -- --mock + run: cargo run --release --features db_rocksdb,mimalloc --example main_pod_points -- --mock - name: Run example 2 - run: cargo run --release --features db_rocksdb --example signed_dict + run: cargo run --release --features db_rocksdb,mimalloc --example signed_dict From e53ef3f2b5f5b766a7ef3066e81d5fb37c94989a Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Wed, 19 Aug 2026 14:51:38 +0000 Subject: [PATCH 5/5] Add +pclmulqdq alongside +avx2 in CI rustflags --- .github/workflows/tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c52f9bea..dc4380de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,15 @@ jobs: # built on a different host, and this fleet mixes hosts with and # without AVX-512, so `native` would bake in instructions the next # runner may not have. AVX2 alone is safe on any x86 runner. - rustflags: "-D warnings -C target-feature=+avx2" + # + # `+pclmulqdq` is not optional. Build scripts see these features via + # CARGO_CFG_TARGET_FEATURE, and librocksdb-sys passes `-mavx2` to its + # C++ compiler when it sees avx2, which switches rocksdb onto a + # PCLMUL-based CRC32 path. It only passes `-mpclmul` when it also sees + # `pclmulqdq`, so avx2 alone fails to compile crc32c.cc. Note that + # `-C target-cpu=x86-64-v3` does NOT imply pclmulqdq and hits the same + # wall. PCLMULQDQ predates AVX2 in hardware, so this adds no floor. + rustflags: "-D warnings -C target-feature=+avx2,+pclmulqdq" - name: Check the runner has AVX2 # plonky2 has no runtime fallback, so a runner without AVX2 would die # with SIGILL somewhere inside a proof. Fail here with a readable reason.