Skip to content

perf: micro-bench suite + continuous profile capture for production - #861

Open
GrapeBaBa wants to merge 24 commits into
mainfrom
perf-profile-bench
Open

perf: micro-bench suite + continuous profile capture for production#861
GrapeBaBa wants to merge 24 commits into
mainfrom
perf-profile-bench

Conversation

@GrapeBaBa

Copy link
Copy Markdown
Member

Summary

Investigation-driven micro-bench suite for the four CPU-critical paths the #803 threading-refactor doc named as "must not run on the libxev IO thread": XMSS PROD verify, STF, signature aggregation, forkchoice + SSZ. Plus in-process slot-budget probes for live-load measurement.

What's new

  • bench/ directory with 5 binaries (smoke, xmss, stf, aggregation, forkchoice-ssz), each invokable via zig build bench-<name> or all together via zig build bench. All built ReleaseFast regardless of -Doptimize=....
  • scripts/profile.sh wrapping samply (macOS) / perf + flamegraph (linux) on the bench binaries. Profile artifacts gitignored under docs/perf/profiles/; findings logged in notes.md.
  • pkgs/utils/src/slot_probe.zig behind a -Dslot-probes build option. Default off → NoopProbe compiles away to zero cost (verified: binary size identical before/after). 6 probe call sites: chain.zig (onBlock, produceBlock, onGossip, onInterval) and forkchoice.zig (aggregateUnlocked, acceptNewAttestationsUnlocked).
  • docs/perf/baselines/ — text-format zbench reports for each target; git diff is the regression detector.
  • bench/README.md + docs/perf/README.md — running benches, profiling, refreshing baselines, slot probes.

Headline findings (from docs/perf/profiles/notes.md)

  • XMSS verify ~500µs single, ~19ms batch_32, ~2ms sign. Dominated by Poseidon1 / p3_monty_31 inside the Rust FFI (hashsig_glue), already NEON-vectorized and parallelized across 10 rayon workers. Not optimizable at the Zig level — confirms BeamNode threading model refactor — 8-point plan (supersedes #798-#802) #803's "must move off IO thread" framing.
  • Aggregation ~655ms (gossip-only) / ~3.9s (recursive). Against the 800ms slot-interval budget (4-sec slot / 5 intervals), this exceeds budget by 1× / 5×. The chain-worker thread in BeamNode threading model refactor — 8-point plan (supersedes #798-#802) #803 slice (c) is required, not nice-to-have.
  • STF ~150µs (apply_raw_block) vs ~100µs (apply_transition with validateResult=false). The 50µs delta is the hash_tree_root cost — ~30% of per-block STF work.
  • Forkchoice ~80-100µs, allocator-bound (not crypto-bound). Profile shows no Rust FFI in the hot path; cost is Zig AutoHashMap puts + protoArray index inserts. Optimization avenue: arena allocation / lock-free structures, not faster math.

Out of scope (documented as follow-ups)

  • Devnet macro-profile harness with lean-quickstart
  • CI integration / nightly bench / regression gating
  • JSON baselines + automated diff tooling
  • Network / RPC bench targets (blocksByRoot, gossip)
  • libp2p / Rust-side bench
  • Deferred bench variants: compute_aggregated_signatures (fixture too large for single-task scope), fc_acceptNewAttestations (needs multi-validator state buildup), hash_tree_root_cached_miss/hit (cache lacks force-miss API; STF bench covers it indirectly)

Test Plan

  • zig build bench — all 5 targets produce zbench reports (smoke + xmss + stf + aggregation + forkchoice-ssz)
  • zig build test --summary all — full suite passes with probes OFF (default)
  • zig build test -Dslot-probes=true --summary all — full suite passes with probes ON
  • zig build stress-quick — passes both probe modes (no panics / UAFs / deadlocks)
  • cargo fmt --check + cargo clippy -D warnings + zig fmt --check . — all clean
  • Binary size identical with probes OFF (NoopProbe zero-cost verified)
  • Baselines captured for all 4 real targets under docs/perf/baselines/
  • Profile findings recorded in docs/perf/profiles/notes.md (4 entries)

GrapeBaBa added 16 commits May 12, 2026 15:16
…aseline

- New bench/stf_bench.zig: two variants benchmarking state transition via
  SSZ-roundtrip clone + apply_raw_block / apply_transition (validateResult=false)
- Updated bench/common/fixtures.zig: replaced std.fs.Dir (removed in Zig 0.16)
  with std.Io.Dir.cwd().readFileAlloc for fixture loading
- Registered bench-stf step in build.zig following the lean xmss pattern
- Captured docs/perf/baselines/stf.txt: ~151µs apply_raw_block, ~102µs apply_transition
- Appended stf profile note to docs/perf/profiles/notes.md
…y cost

Re-recorded with -Cdebuginfo=line-tables-only -Cstrip=none and atos-resolved.

Breakdown per worker thread: Poseidon1 internal 30%, MDS karatsuba 22%, Poseidon1 external 14%, Poseidon1Compress 12%, Keccak p1600 7%, sumcheck 4%, quintic ext 3%, ShakePRF 2%, other 6%. Combined Poseidon = 78%.

Conclusion: aggregation cost lives entirely in Plonky3+leansig FFI; no zeam-side fix can move it. Real levers are upstream (Poseidon1->Poseidon2 ~2x, FFT-based MDS, GPU prover) or protocol-level (extend inclusion deadline).
…duction

Replaces the -Dslot-probes instrumentation with attach-mode profiling
tooling. No source modifications are needed in production: perf (Linux)
and samply (macOS) attach by PID and sample continuously at ~0.5% CPU
overhead, rotating chunks for async upload to object storage.

Probe call sites at chain.{onBlock,produceBlock,onGossip,onInterval}
and forkchoice.{aggregate,acceptNewAttestations}Unlocked emitted "over
budget" log warnings as a tail signal. The same information is already
exposed by existing Prometheus histograms (lean_committee_signatures_
aggregation_time_seconds, zeam_chain_onblock_duration_seconds, etc.),
so the probe layer was redundant once a continuous-capture pipeline
exists alongside metrics.

Removed:
- pkgs/utils/src/slot_probe.zig (RealProbe/NoopProbe module)
- build.zig -Dslot-probes option
- Probe call sites in chain.zig and forkchoice.zig

Added:
- scripts/profile-continuous.sh — attach perf/samply to a running zeam,
  19Hz default, 200MB chunk rotation (Linux) or 5-min chunks (macOS),
  designed for systemd-managed always-on capture
- scripts/profile-slice.sh — extract a time-window flamegraph from a
  rotated chunk after correlating a Prometheus event to wall-clock
- docs/perf/README.md — replaces "Slot-budget probes" section with the
  continuous-capture workflow
@GrapeBaBa GrapeBaBa changed the title perf: micro-bench suite + slot-budget probes for #803 hot paths perf: micro-bench suite + continuous profile capture for production May 13, 2026
GrapeBaBa added 5 commits May 13, 2026 12:25
Neither is source. baselines/*.txt are zbench stdout snapshots tied to
one machine (Apple M5) — they noise-diff every perf-touching PR and
nothing in code/CI consumes them. profiles/notes.md was a free-text
findings log; the same content belongs in PR descriptions or commit
messages where it surfaces during review rather than rotting in a file.

Profile-artifact gitignore patterns (*.samply.json, *.perf.data, *.svg)
moved from docs/perf/profiles/.gitignore to the root .gitignore — no
tracked files anywhere in the repo conflict, so the narrow scope was
unnecessary.

README updated: replaces "Refreshing a baseline" with a one-liner on
diffing your own before/after runs locally; drops baselines/ and
notes.md references.
Three scripts collapsed into one entry point with subcommands:

  profile.sh bench  <name>                              one-shot bench
  profile.sh attach <pattern> [out-dir]                 continuous attach
  profile.sh slice  <chunk> [start end] [out.svg]       flamegraph slice

The bench and attach paths really are different shapes (run-binary vs
attach-PID-with-rotation) so they stay as separate code branches inside
one file. slice was a 3-line pipe wrapped in argument-parsing ceremony;
it now lives next to its peers rather than as its own tiny script.

Env-var tunables (PROFILE_FREQ_HZ etc.) apply to `attach` only and are
documented in the usage banner.
…macOS

Two cross-platform fixes found while running profile.sh bench smoke
end-to-end on macOS:

* bench mode now passes --save-only to samply. Without it samply opens
  a local web UI after recording and blocks until the browser tab is
  closed — breaks any non-interactive use (CI, scripted profiling).
  The JSON file is still written; load it later with
  `samply load <file>` or upload to https://profiler.firefox.com/.

* slice mode now detects samply JSON inputs and macOS hosts up front
  and prints an actionable message instead of failing with "perf not
  on PATH". samply JSON is self-contained — Firefox Profiler does
  time-window slicing interactively in its UI, no CLI needed.
Cosmetic fix: 'attach <pattern> /tmp/foo/' was producing chunk paths like
'/tmp/foo//samply-<ts>.json' (double slash). Harmless to the FS but ugly
in logs and uploader keys.

Found by end-to-end attach validation against a real mockNetwork zeam on
macOS (3 chunks written cleanly, 25 threads, ~6-7k samples each, SIGTERM
trap shut samply down cleanly with all chunks flushed).
Resolved conflicts:
- pkgs/spectest/src/lib.zig: keep both 'fixtures' helper struct
  (from this branch) and 'refAllDeclsRecursive' Zig 0.16 compat
  (from main, PR #715)
- pkgs/spectest/src/runner/state_transition_runner.zig: keep both
  decodeBlock (this branch) and parseAggregationBits/parseAttestationData
  (from main)

Submodule pointers (lean-quickstart, leanSpec) adopted from main.
@GrapeBaBa
GrapeBaBa marked this pull request as ready for review May 13, 2026 08:21
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