Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d94d220
build: add zbench v0.13.0 dependency
GrapeBaBa May 12, 2026
67dbf9b
build, bench: wire zbench module and add smoke bench
GrapeBaBa May 12, 2026
df0ccac
bench: add deterministic PRNG helper for fixture generation
GrapeBaBa May 12, 2026
51a9c87
bench, build: add XMSS PROD verify/sign benches (bench-xmss)
GrapeBaBa May 12, 2026
69a57e0
bench, docs/perf: profile workflow + xmss baseline
GrapeBaBa May 12, 2026
efc99d0
spectest, bench: expose state-transition fixture helpers for benches
GrapeBaBa May 12, 2026
ff4bc9c
bench, build: add STF benches (apply_raw_block, apply_transition) + b…
GrapeBaBa May 12, 2026
0f23a36
bench, build: add aggregation benches (proof aggregate, with children…
GrapeBaBa May 12, 2026
3d1f56e
bench, build: add forkchoice + SSZ benches + baseline
GrapeBaBa May 12, 2026
31978ed
utils, build: add slot_probe module + -Dslot-probes build option
GrapeBaBa May 12, 2026
fbdd676
node/chain: wire slot_probe at onBlock, produceBlock, onGossip, onInt…
GrapeBaBa May 12, 2026
ea321eb
node/forkchoice: wire slot_probe at aggregateUnlocked, acceptNewAttes…
GrapeBaBa May 12, 2026
5b16821
bench, docs/perf: README — running benches, profiling, refreshing bas…
GrapeBaBa May 12, 2026
43bb836
bench, docs/perf, utils: fix genesis leak, doc nits, comment slot_pro…
GrapeBaBa May 12, 2026
80677fb
docs/perf: aggregation profile symbolized — Poseidon1 78% of in-binar…
GrapeBaBa May 12, 2026
d061202
perf: drop in-process probes; add continuous low-rate capture for pro…
GrapeBaBa May 13, 2026
f0684bc
perf: drop committed bench baselines + profile notes
GrapeBaBa May 13, 2026
3af90b1
scripts: fold bench/attach/slice into a single profile.sh
GrapeBaBa May 13, 2026
90592b3
scripts/profile.sh: pass --save-only to bench; fail slice cleanly on …
GrapeBaBa May 13, 2026
fc4b313
scripts/profile.sh: strip trailing slash on attach out-dir
GrapeBaBa May 13, 2026
1b21291
Merge branch 'main' into perf-profile-bench
GrapeBaBa May 13, 2026
e52b604
Merge branch 'main' into perf-profile-bench
ch4r10t33r May 13, 2026
e06ed41
Merge branch 'main' into perf-profile-bench
GrapeBaBa May 19, 2026
58bfc7a
Merge branch 'main' into perf-profile-bench
anshalshukla May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ zig-pkg/
# Shadow network simulator (generated by lean-quickstart/run-shadow.sh)
shadow.yaml
shadow.data/

# Profile artifacts (output of scripts/profile.sh and scripts/profile-continuous.sh)
*.samply.json
*.perf.data
*.svg
42 changes: 42 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# bench/

Top-level zbench harness for zeam. See
`docs/superpowers/specs/2026-05-12-perf-profile-bench-design.md` for design rationale.

## Targets

| Binary | Source | Coverage |
|---|---|---|
| `bench-xmss` | `xmss_bench.zig` | XMSS PROD verify (single + batch_32), sign |
| `bench-stf` | `stf_bench.zig` | `apply_raw_block`, `apply_transition` |
| `bench-aggregation` | `aggregation_bench.zig` | `AggregatedSignatureProof.aggregate` (gossip-only + with-children) |
| `bench-forkchoice-ssz` | `forkchoice_ssz_bench.zig` | `ForkChoice.{onBlock,onAttestation}`, SSZ block/state encode/decode |
| `bench-smoke` | `smoke_bench.zig` | Build harness smoke test |

## Running

```sh
zig build bench # all targets
zig build bench-xmss # single target
```

All bench binaries are built `ReleaseFast` regardless of `-Doptimize=...`. Debug-mode numbers are noise.

## Profiling

```sh
scripts/profile.sh xmss # samply on macOS, perf on linux
```

Profile artifacts land under `docs/perf/profiles/` (gitignored).

## Baselines

Last captured zbench reports live in `docs/perf/baselines/<target>.txt`. Refresh
procedure in `docs/perf/README.md`.

## Conventions

- All bench fixtures are **deterministic** (`bench/common/rng.zig` for synthetic, leanSpec test vectors for STF). Numbers must be reproducible across machines.
- `ReleaseFast` is the only meaningful optimize mode for bench.
- Each bench's `main` does setup once before `bench.run(...)`. Per-iteration bodies do only the measured operation.
286 changes: 286 additions & 0 deletions bench/aggregation_bench.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
const std = @import("std");
const zbench = @import("zbench");
const xmss = @import("@zeam/xmss");
const types = @import("@zeam/types");

const AggregatedSignatureProof = types.AggregatedSignatureProof;
const AggregationBits = types.AggregationBits;
const aggregationBitsSet = types.aggregationBitsSet;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Number of gossip participants for the `proof_aggregate_only` bench.
const NUM_GOSSIP_SIGNERS: usize = 8;

/// Epoch used in all bench iterations (slot 0, epoch 0 is fine for perf work).
const BENCH_EPOCH: u64 = 0;

/// Fixed 32-byte message hash used across all iterations.
var g_message: [32]u8 = undefined;

// ---------------------------------------------------------------------------
// Global state for `proof_aggregate_only`
// ---------------------------------------------------------------------------

/// Keypairs kept alive for the duration of the bench.
var g_keypairs: [NUM_GOSSIP_SIGNERS]xmss.KeyPair = undefined;

/// Raw public key handles — parallel with g_keypairs.
var g_pks: [NUM_GOSSIP_SIGNERS]*const xmss.HashSigPublicKey = undefined;

/// Raw signature handles produced during setup — parallel with g_keypairs.
var g_sigs: [NUM_GOSSIP_SIGNERS]*xmss.HashSigSignature = undefined;

/// AggregationBits with all NUM_GOSSIP_SIGNERS bits set.
var g_participants: AggregationBits = undefined;

// ---------------------------------------------------------------------------
// Global state for `proof_aggregate_with_2_children`
// (only used when both child proofs are successfully pre-built during setup)
// ---------------------------------------------------------------------------

/// Pre-built child proofs (gossip-only, 4 signers each).
var g_child_proofs: [2]AggregatedSignatureProof = undefined;

/// Per-child public key slices passed to aggregate().
///
/// children_pub_keys: []const []*const xmss.HashSigPublicKey
/// We represent this as two fixed arrays + a slice-of-slices built in main.
var g_child0_pks: [NUM_GOSSIP_SIGNERS / 2]*const xmss.HashSigPublicKey = undefined;
var g_child1_pks: [NUM_GOSSIP_SIGNERS / 2]*const xmss.HashSigPublicKey = undefined;
var g_children_pub_key_slices: [2][]*const xmss.HashSigPublicKey = undefined;

/// Set to true if children were built successfully — guards the bench body.
var g_children_ready: bool = false;

// ---------------------------------------------------------------------------
// Benchmark bodies
// ---------------------------------------------------------------------------

/// Bench: `proof_aggregate_only`
///
/// Calls AggregatedSignatureProof.aggregate() with 8 raw XMSS signers and
/// no children. Uses LOG_INV_RATE_PROD (= 2) matching production code.
fn benchAggregateOnly(allocator: std.mem.Allocator) void {
var result = AggregatedSignatureProof.init(allocator) catch unreachable;
defer result.deinit();

// Cast: bench body cannot return error, so we unwrap.
var pk_ptrs: [NUM_GOSSIP_SIGNERS]*const xmss.HashSigPublicKey = g_pks;
var sig_ptrs: [NUM_GOSSIP_SIGNERS]*const xmss.HashSigSignature = undefined;
for (g_sigs, 0..) |s, i| sig_ptrs[i] = s;

AggregatedSignatureProof.aggregate(
allocator,
g_participants,
&.{},
&.{},
&pk_ptrs,
&sig_ptrs,
&g_message,
BENCH_EPOCH,
&result,
) catch |err| {
std.debug.panic("proof_aggregate_only: aggregate failed: {}", .{err});
};
}

/// Bench: `proof_aggregate_with_2_children`
///
/// Calls AggregatedSignatureProof.aggregate() with xmss_participants=null and
/// 2 pre-built child proofs (the minimum to satisfy the >=2 children constraint).
fn benchAggregateWith2Children(allocator: std.mem.Allocator) void {
if (!g_children_ready) return;

var result = AggregatedSignatureProof.init(allocator) catch unreachable;
defer result.deinit();

AggregatedSignatureProof.aggregate(
allocator,
null,
&g_child_proofs,
&g_children_pub_key_slices,
&.{},
&.{},
&g_message,
BENCH_EPOCH,
&result,
) catch |err| {
std.debug.panic("proof_aggregate_with_2_children: aggregate failed: {}", .{err});
};
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

pub fn main(init: std.process.Init) !void {
_ = init;
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Fixed message hash.
@memset(&g_message, 0xCD);

// -----------------------------------------------------------------------
// Setup: generate NUM_GOSSIP_SIGNERS keypairs and sign the message.
// -----------------------------------------------------------------------
for (0..NUM_GOSSIP_SIGNERS) |i| {
// Use distinct seed per signer so we get independent keys.
var seed_buf: [32]u8 = undefined;
const seed = std.fmt.bufPrint(&seed_buf, "aggregation_bench_signer_{d}", .{i}) catch unreachable;
g_keypairs[i] = try xmss.KeyPair.generate(allocator, seed, 0, 4);
g_pks[i] = g_keypairs[i].public_key;
const sig = try g_keypairs[i].sign(&g_message, @intCast(BENCH_EPOCH));
g_sigs[i] = sig.handle;
}
defer {
// Free signatures (handles owned by us).
for (g_sigs) |sh| {
var s = xmss.Signature{ .handle = sh };
s.deinit();
}
// Free keypairs.
for (&g_keypairs) |*kp| kp.deinit();
}

// Build participants AggregationBits with all NUM_GOSSIP_SIGNERS set.
g_participants = try AggregationBits.init(allocator);
defer g_participants.deinit();
for (0..NUM_GOSSIP_SIGNERS) |i| {
try aggregationBitsSet(&g_participants, i, true);
}

// Sanity-check: run aggregate_only once before handing to zbench.
{
var pk_ptrs: [NUM_GOSSIP_SIGNERS]*const xmss.HashSigPublicKey = g_pks;
var sig_ptrs: [NUM_GOSSIP_SIGNERS]*const xmss.HashSigSignature = undefined;
for (g_sigs, 0..) |s, i| sig_ptrs[i] = s;

var sanity = try AggregatedSignatureProof.init(allocator);
defer sanity.deinit();
try AggregatedSignatureProof.aggregate(
allocator,
g_participants,
&.{},
&.{},
&pk_ptrs,
&sig_ptrs,
&g_message,
BENCH_EPOCH,
&sanity,
);
std.log.info("sanity proof_aggregate_only OK, proof_data len={d}", .{sanity.proof_data.len()});
}

// -----------------------------------------------------------------------
// Setup: build 2 child proofs for proof_aggregate_with_2_children.
// Each child uses NUM_GOSSIP_SIGNERS/2 = 4 of the already-generated keys.
// -----------------------------------------------------------------------
build_children: {
// Child 0: signers 0..3
for (0..NUM_GOSSIP_SIGNERS / 2) |i| {
g_child0_pks[i] = g_pks[i];
}
var child0_participants = AggregationBits.init(allocator) catch break :build_children;
defer child0_participants.deinit();
for (0..NUM_GOSSIP_SIGNERS / 2) |i| {
aggregationBitsSet(&child0_participants, i, true) catch break :build_children;
}

var child0_sig_ptrs: [NUM_GOSSIP_SIGNERS / 2]*const xmss.HashSigSignature = undefined;
for (0..NUM_GOSSIP_SIGNERS / 2) |i| child0_sig_ptrs[i] = g_sigs[i];

g_child_proofs[0] = AggregatedSignatureProof.init(allocator) catch break :build_children;
AggregatedSignatureProof.aggregate(
allocator,
child0_participants,
&.{},
&.{},
&g_child0_pks,
&child0_sig_ptrs,
&g_message,
BENCH_EPOCH,
&g_child_proofs[0],
) catch {
g_child_proofs[0].deinit();
break :build_children;
};

// Child 1: signers 4..7
for (0..NUM_GOSSIP_SIGNERS / 2) |i| {
g_child1_pks[i] = g_pks[NUM_GOSSIP_SIGNERS / 2 + i];
}
var child1_participants = AggregationBits.init(allocator) catch {
g_child_proofs[0].deinit();
break :build_children;
};
defer child1_participants.deinit();
for (0..NUM_GOSSIP_SIGNERS / 2) |i| {
aggregationBitsSet(&child1_participants, NUM_GOSSIP_SIGNERS / 2 + i, true) catch {
g_child_proofs[0].deinit();
break :build_children;
};
}

var child1_sig_ptrs: [NUM_GOSSIP_SIGNERS / 2]*const xmss.HashSigSignature = undefined;
for (0..NUM_GOSSIP_SIGNERS / 2) |i| child1_sig_ptrs[i] = g_sigs[NUM_GOSSIP_SIGNERS / 2 + i];

g_child_proofs[1] = AggregatedSignatureProof.init(allocator) catch {
g_child_proofs[0].deinit();
break :build_children;
};
AggregatedSignatureProof.aggregate(
allocator,
child1_participants,
&.{},
&.{},
&g_child1_pks,
&child1_sig_ptrs,
&g_message,
BENCH_EPOCH,
&g_child_proofs[1],
) catch {
g_child_proofs[0].deinit();
g_child_proofs[1].deinit();
break :build_children;
};

// Populate children_pub_key_slices.
g_children_pub_key_slices[0] = &g_child0_pks;
g_children_pub_key_slices[1] = &g_child1_pks;
g_children_ready = true;

std.log.info("sanity proof_aggregate_with_2_children setup OK, child0_proof_data len={d}, child1_proof_data len={d}", .{
g_child_proofs[0].proof_data.len(),
g_child_proofs[1].proof_data.len(),
});
}

defer if (g_children_ready) {
g_child_proofs[0].deinit();
g_child_proofs[1].deinit();
};

// -----------------------------------------------------------------------
// zbench runner
// -----------------------------------------------------------------------
var bench = zbench.Benchmark.init(allocator, .{});
defer bench.deinit();

// Aggregation is slow (seconds range), so cap iterations low.
try bench.add("proof_aggregate_only", benchAggregateOnly, .{ .iterations = 5 });

if (g_children_ready) {
try bench.add("proof_aggregate_with_2_children", benchAggregateWith2Children, .{ .iterations = 5 });
} else {
std.log.warn("proof_aggregate_with_2_children: skipped (child setup failed)", .{});
}

const io = std.Io.Threaded.global_single_threaded.io();
const stdout = std.Io.File.stdout();
try bench.run(io, stdout);
}
58 changes: 58 additions & 0 deletions bench/common/fixtures.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const std = @import("std");
const spectest = @import("zeam_spectests");
const types = @import("@zeam/types");

const read_max_bytes: usize = 16 * 1024 * 1024; // 16 MiB upper bound per fixture file.

/// Load a leanSpec state-transition fixture JSON and return parsed `pre`
/// state + first `block` for STF benches. Caller owns deinit on both.
///
/// `rel_path` must be relative to the process working directory (repo root
/// when run via `zig build bench-stf`).
pub fn loadStateTransitionCase(
allocator: std.mem.Allocator,
rel_path: []const u8,
) !struct { pre: types.BeamState, block: types.BeamBlock } {
// Use std.Io.Dir (Zig 0.16+) — std.fs.Dir no longer exists.
const io = std.Io.Threaded.global_single_threaded.io();
const cwd = std.Io.Dir.cwd();
const payload = try cwd.readFileAlloc(io, rel_path, allocator, .limited(read_max_bytes));
defer allocator.free(payload);

var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{ .ignore_unknown_fields = true });
defer parsed.deinit();

const root = switch (parsed.value) {
.object => |m| m,
else => return error.InvalidFixture,
};

var it = root.iterator();
const first = it.next() orelse return error.InvalidFixture;
const case = switch (first.value_ptr.*) {
.object => |m| m,
else => return error.InvalidFixture,
};

const pre_val = case.get("pre") orelse return error.InvalidFixture;

const blocks_val = case.get("blocks") orelse return error.InvalidFixture;
const blocks_arr = switch (blocks_val) {
.array => |a| a,
else => return error.InvalidFixture,
};
if (blocks_arr.items.len == 0) return error.NoBlocks;
const block_val = blocks_arr.items[0];

const ctx = spectest.fixtures.Context{
.fixture_label = "bench",
.case_name = "bench",
};

var pre = try spectest.fixtures.buildState(allocator, ctx, pre_val);
errdefer pre.deinit();

const block = try spectest.fixtures.decodeBlock(allocator, block_val);

return .{ .pre = pre, .block = block };
}
Loading
Loading