Skip to content
Open
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
691895e
feat: add fork_choice module with core types and error definitions
GrapeBaBa Mar 4, 2026
ab77ced
refactor(fork-choice): co-locate errors, add VoteTracker, refine types
GrapeBaBa Mar 6, 2026
6fcc7c5
refactor(fork-choice): flatten ProtoNode, align VoteTracker with Gloa…
GrapeBaBa Mar 7, 2026
02c5df2
feat(fork-choice): add computeDeltas with TS-aligned logic and tests
GrapeBaBa Mar 7, 2026
5496fa6
refactor(fork-choice): simplify VoteTracker tests with table-driven g…
GrapeBaBa Mar 7, 2026
87e8a63
feat(fork-choice): add ProtoArray with TS-aligned onBlock, node compa…
GrapeBaBa Mar 9, 2026
5ca2614
feat(fork-choice): add applyScoreChanges, findHead, and ePBS viabilit…
GrapeBaBa Mar 10, 2026
313e149
feat(forkchoice): applyScoreChanges recheck
GrapeBaBa Mar 15, 2026
6d46988
refactor(fork-choice): simplify test helpers and add tree comments
GrapeBaBa Mar 16, 2026
b209ec0
feat(config): add gloas to ForkSeq enum
GrapeBaBa Mar 18, 2026
f2b871e
refactor(fork-choice): convert ProtoNode and ProtoArray to comptime f…
GrapeBaBa Mar 18, 2026
53b5746
fix(fork-choice): address code review findings
GrapeBaBa Mar 18, 2026
318e7b3
feat(fork-choice): add AnyProtoArray runtime dispatch and fork upgrade
GrapeBaBa Mar 18, 2026
23d7d62
Revert "refactor(fork-choice): convert ProtoNode and ProtoArray to co…
GrapeBaBa Mar 18, 2026
9dba9c8
feat(fork-choice): implement maybePrune, validateLatestHash, and ance…
GrapeBaBa Mar 20, 2026
ecd9e86
feat(fork-choice): implement ForkChoice orchestrator struct (task 12)
GrapeBaBa Mar 20, 2026
427dd23
feat(fork_choice): implement query iterator and prune
GrapeBaBa Mar 22, 2026
2e89a53
revert(config): remove gloas from ForkSeq enum
GrapeBaBa Mar 22, 2026
8994324
feat(fork-choice): add CI, isDescendant PayloadStatus, getCanonicalBl…
GrapeBaBa Mar 22, 2026
6cada3f
feat(fork-choice): add ForkChoiceStore, merge proto_node into proto_a…
GrapeBaBa Mar 23, 2026
410bfa6
style(fork-choice): fix zig fmt trailing whitespace
GrapeBaBa Mar 23, 2026
8158d52
feat(fork_types): add AnyIndexedAttestation union type
GrapeBaBa Mar 23, 2026
c2f743a
feat(fork-choice): rewrite ForkChoice struct fields, init/deinit to a…
GrapeBaBa Mar 23, 2026
815e6b1
feat(fork-choice): implement private updateCheckpoints and updateUnre…
GrapeBaBa Mar 23, 2026
841d886
feat(fork-choice): implement core logic methods (Tasks 6-11)
GrapeBaBa Mar 23, 2026
e6ffe6b
feat(fork-choice): implement attestation processing, queries, travers…
GrapeBaBa Mar 23, 2026
f6e8ded
feat(fork-choice): implement proposer boost reorg methods (Tasks 20-22)
GrapeBaBa Mar 23, 2026
4a0f909
feat(fork-choice): update validateLatestHash with irrecoverable_error…
GrapeBaBa Mar 23, 2026
1824f55
feat(fork-choice): update root.zig exports, remove HeadResult (Task 24)
GrapeBaBa Mar 23, 2026
74f0a55
feat(fork-choice): implement full onBlock with CachedBeaconState (Tas…
GrapeBaBa Mar 23, 2026
adba174
refactor(fork-choice): align ForkChoice.init with TS constructor (dep…
GrapeBaBa Mar 23, 2026
fa56c89
refactor(fork-choice): remove payload_present from VoteTracker
GrapeBaBa Mar 23, 2026
d803224
refactor(fork-choice): in-place init and unmanaged maps (TigerStyle)
GrapeBaBa Mar 23, 2026
d0637f6
refactor(fork-choice): split init into create + init (TigerStyle in-p…
GrapeBaBa Mar 23, 2026
b4d521d
refactor(fork-choice): Gloas ePBS support and code quality improvements
GrapeBaBa Mar 27, 2026
8118b94
fix(bench): add state_transition dep and widen validateOnAttestation …
GrapeBaBa Mar 27, 2026
7112b91
refactor(fork-choice): unify test comment style and add bench/gloas f…
GrapeBaBa Mar 27, 2026
282bc98
Merge remote-tracking branch 'origin/main' into gr/feature/forkchoice-z
GrapeBaBa Mar 27, 2026
7b5b2f0
fix(bindings): exclude blinded block path for ePBS forks (gloas+)
GrapeBaBa Mar 27, 2026
a7378de
fix(build): add fork_choice bench targets to zbuild.zon and sync buil…
GrapeBaBa Mar 27, 2026
aa9cc81
fix(fork-types): update fulu->gloas upgrade to use direct allocator/pool
GrapeBaBa Mar 27, 2026
a090508
fix(bench): exclude ePBS forks from executionPayload benchmarks
GrapeBaBa Mar 27, 2026
13e88c3
fix(spec-tests): add gloas fork to spec test switch statements
GrapeBaBa Mar 27, 2026
667d58f
refactor(fork-choice): improve safety and consistency
GrapeBaBa Mar 29, 2026
c71c8b8
style(fork-choice): use .empty for unmanaged container init
GrapeBaBa Mar 29, 2026
1d83b2d
refactor(fork-choice): use in-place init for ProtoArray and ForkChoic…
GrapeBaBa Mar 29, 2026
0f91f05
fix(fork-choice): update all test call sites for in-place init pattern
GrapeBaBa Mar 29, 2026
62e8c88
refactor(fork-choice): merge ProtoArrayError into ForkChoiceError
GrapeBaBa Mar 29, 2026
18b9d98
refactor(fork-choice): define ForkChoiceError as superset of ProtoArr…
GrapeBaBa Mar 29, 2026
23cf6ae
refactor(fork-choice): move ForkChoiceError definition to fork_choice…
GrapeBaBa Mar 29, 2026
066fd3c
refactor(fork-choice): cleanup naming and reduce redundant computation
GrapeBaBa Mar 30, 2026
0491dc5
refactor(fork-choice): inline extracted helpers back into onBlock and…
GrapeBaBa Mar 30, 2026
40cad05
fix(fork-choice): align proto_array and fork_choice logic with TS Lod…
GrapeBaBa Mar 30, 2026
4b4eba4
refactor(fork-choice): tighten invariants and clean up minor issues
GrapeBaBa Mar 31, 2026
8c6d122
refactor(fork-choice): remove unused setProposerBoost/clearProposerBoost
GrapeBaBa Mar 31, 2026
8869839
ci: add fork-choice benchmarks to CI build step
GrapeBaBa Mar 31, 2026
52acd85
refactor(fork-choice): remove test section comments and add missing t…
GrapeBaBa Apr 1, 2026
f85a743
fix(fork-choice): format proto_array test code
GrapeBaBa Apr 1, 2026
d9b5985
fix(fork-choice): fix two bugs and add onAttestation unit tests
GrapeBaBa Apr 2, 2026
a97a220
fix(fork-choice): fix hashTreeRoot, getFieldRoot, and defer-ordering …
GrapeBaBa Apr 2, 2026
94dd4f1
fix(fork-choice): address code review feedback
GrapeBaBa Apr 7, 2026
9ee58ac
refactor(fork_choice): align parameter and variable names with TypeSc…
GrapeBaBa Apr 9, 2026
3405e17
fix(fork-choice): port 3 upstream fixes from lodestar unstable
GrapeBaBa Apr 14, 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
3 changes: 3 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ jobs:
- name: Run state-transition tests
run: |
zig build test:state_transition
- name: Run fork-choice tests
run: |
zig build test:fork_choice

- name: Build benchmarks
run: |
Expand Down
167 changes: 167 additions & 0 deletions bench/fork_choice/compute_deltas.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! Benchmark for `computeDeltas` — the core weight-propagation function used by fork choice.
//!
//! Ported from the TypeScript benchmark in `packages/fork-choice/test/perf/computeDeltas.test.ts`.
//! Measures performance across varying validator counts and inactive-validator percentages.

const std = @import("std");
const builtin = @import("builtin");
const zbench = @import("zbench");
const fork_choice = @import("fork_choice");

const computeDeltas = fork_choice.computeDeltas;
const DeltasCache = fork_choice.DeltasCache;
const EquivocatingIndices = fork_choice.EquivocatingIndices;
const VoteIndex = fork_choice.VoteIndex;
const NULL_VOTE_INDEX = fork_choice.NULL_VOTE_INDEX;

const ComputeDeltasBench = struct {
num_proto_nodes: u32,
current_indices: []VoteIndex,
next_indices: []VoteIndex,
old_balances: []u16,
new_balances: []u16,
equivocating: *EquivocatingIndices,
deltas_cache: *DeltasCache,
/// Snapshot of initial current_indices for reset before each iteration.
/// computeDeltas mutates current_indices (sets current = next after processing),
/// so without reset, subsequent iterations hit the unchanged fast path (no-op).
initial_current_indices: []const VoteIndex,

pub fn run(self: ComputeDeltasBench, allocator: std.mem.Allocator) void {
// Reset current_indices to initial state (matching TS beforeEach).
@memcpy(self.current_indices, self.initial_current_indices);

const result = computeDeltas(
allocator,
self.deltas_cache,
self.num_proto_nodes,
self.current_indices,
self.next_indices,
self.old_balances,
self.new_balances,
self.equivocating,
) catch unreachable;
_ = result;
}

pub fn deinit(self: *ComputeDeltasBench, allocator: std.mem.Allocator) void {
allocator.free(self.current_indices);
allocator.free(self.next_indices);
allocator.free(self.old_balances);
allocator.free(self.new_balances);
allocator.free(self.initial_current_indices);
self.deltas_cache.deinit(allocator);
}
};

/// Build a `ComputeDeltasBench` for the given validator count and inactive percentage.
///
/// For active validators: current_index = num_proto_nodes / 2, next_index = num_proto_nodes / 2 + 1.
/// For inactive validators (determined by modulo on `inactive_pct`): both indices are NULL_VOTE_INDEX.
/// Balances are all set to 32 (one effective-balance increment).
fn setupBench(
allocator: std.mem.Allocator,
num_validators: u32,
num_proto_nodes: u32,
inactive_pct: u32,
equivocating: *EquivocatingIndices,
deltas_cache: *DeltasCache,
) !ComputeDeltasBench {
const current_indices = try allocator.alloc(VoteIndex, num_validators);
const next_indices = try allocator.alloc(VoteIndex, num_validators);
const old_balances = try allocator.alloc(u16, num_validators);
const new_balances = try allocator.alloc(u16, num_validators);

const active_current: VoteIndex = num_proto_nodes / 2; // 150
const active_next: VoteIndex = num_proto_nodes / 2 + 1; // 151

// Divisor for modulo-based inactive selection. E.g. 10% -> every 10th, 50% -> every 2nd.
const modulo: u32 = if (inactive_pct > 0) 100 / inactive_pct else 0;

for (0..num_validators) |i| {
const is_inactive = modulo > 0 and (i % modulo == 0);
if (is_inactive) {
current_indices[i] = NULL_VOTE_INDEX;
next_indices[i] = NULL_VOTE_INDEX;
} else {
current_indices[i] = active_current;
next_indices[i] = active_next;
}
old_balances[i] = 32;
new_balances[i] = 32;
}

// Snapshot of initial current_indices for per-iteration reset.
const initial_current_indices = try allocator.alloc(VoteIndex, num_validators);
@memcpy(initial_current_indices, current_indices);

return .{
.num_proto_nodes = num_proto_nodes,
.current_indices = current_indices,
.next_indices = next_indices,
.old_balances = old_balances,
.new_balances = new_balances,
.equivocating = equivocating,
.deltas_cache = deltas_cache,
.initial_current_indices = initial_current_indices,
};
}

pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
const allocator = if (builtin.mode == .Debug) debug_allocator.allocator() else std.heap.c_allocator;
defer if (builtin.mode == .Debug) {
std.debug.assert(debug_allocator.deinit() == .ok);
};
const stdout = std.io.getStdOut().writer();

var bench = zbench.Benchmark.init(allocator, .{});

const num_proto_nodes: u32 = 300;
const inactive_pcts = [_]u32{ 0, 10, 20, 50 };
const validator_counts = [_]u32{ 1_400_000, 2_100_000 };

// Shared equivocating indices: {1, 2, 3, 4, 5}
const equivocating = try allocator.create(EquivocatingIndices);
defer {
equivocating.deinit(allocator);
allocator.destroy(equivocating);
}
equivocating.* = .empty;
for ([_]u64{ 1, 2, 3, 4, 5 }) |idx| {
try equivocating.put(allocator, idx, {});
}

// Track benchmark instances for cleanup after bench.run().
const BENCH_COUNT = validator_counts.len * inactive_pcts.len;
var delta_caches: [BENCH_COUNT]*DeltasCache = undefined;
var bench_instances: [BENCH_COUNT]*ComputeDeltasBench = undefined;
var bench_idx: usize = 0;

// Register each benchmark combination.
inline for (validator_counts) |vc| {
inline for (inactive_pcts) |pct| {
const dc = try allocator.create(DeltasCache);
dc.* = .empty;
const b = try allocator.create(ComputeDeltasBench);
b.* = try setupBench(allocator, vc, num_proto_nodes, pct, equivocating, dc);
delta_caches[bench_idx] = dc;
bench_instances[bench_idx] = b;
bench_idx += 1;
const b_const: *const ComputeDeltasBench = b;
try bench.addParam(
std.fmt.comptimePrint("deltas {d}k i{d}%", .{ vc / 1000, pct }),
b_const,
.{},
);
}
}
defer for (0..BENCH_COUNT) |i| {
bench_instances[i].deinit(allocator);
allocator.destroy(delta_caches[i]);
allocator.destroy(bench_instances[i]);
};

defer bench.deinit();
try bench.run(stdout);
}
197 changes: 197 additions & 0 deletions bench/fork_choice/on_attestation.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//! Benchmark for fork-choice `onAttestation` operation.
//!
//! Measures how fast the fork choice can process indexed attestations.
//!
//! Setup: 600K validators, 64-block linear chain, 0 equivocated.
//! - Unaggregated: 3 committees x 135 validators = 405 single-validator attestations.
//! - Aggregated: 64 committees x 11 aggregators x 135 validators = 704 aggregated attestations.
//! - Total: 1109 attestations per iteration.
//!
//! - Computes SSZ hashTreeRoot per attestation inside the measured loop (matching TS).
//! - 64 unique attestation data roots (one per committee index), so the
//! validation cache hits ~1045 times and misses ~64 times per iteration.
//! - Clears the validation cache at the start of each run() to match TS
//! beforeEach behavior (fresh ForkChoice per iteration).

const std = @import("std");
const builtin = @import("builtin");
const zbench = @import("zbench");
const fork_choice = @import("fork_choice");
const consensus_types = @import("consensus_types");
const fork_types = @import("fork_types");

const ForkChoice = fork_choice.ForkChoice;
const AnyIndexedAttestation = fork_types.AnyIndexedAttestation;
const Phase0IndexedAttestation = consensus_types.phase0.IndexedAttestation.Type;
const AttestationData = consensus_types.phase0.AttestationData;

const util = @import("util.zig");

const ZERO_HASH = @import("constants").ZERO_HASH;

// ── Constants matching the TS benchmark ──

const UNAGG_COMMITTEES: u32 = 3;
const VALIDATORS_PER_COMMITTEE: u32 = 135;
const AGG_COMMITTEES: u32 = 64;
const AGGREGATORS_PER_COMMITTEE: u32 = 11;

const UNAGG_COUNT: u32 = UNAGG_COMMITTEES * VALIDATORS_PER_COMMITTEE; // 405
const AGG_COUNT: u32 = AGG_COMMITTEES * AGGREGATORS_PER_COMMITTEE; // 704
const TOTAL_ATT_COUNT: u32 = UNAGG_COUNT + AGG_COUNT; // 1109

/// Benchmark struct for onAttestation.
///
/// Pre-builds all 1109 attestations during setup. Each run() iteration
/// computes SSZ hashTreeRoot per attestation inside the loop (matching TS).
const OnAttestationBench = struct {
fc: *ForkChoice,
phase0_atts: []Phase0IndexedAttestation,
any_atts: []AnyIndexedAttestation,

pub fn run(self: OnAttestationBench, allocator: std.mem.Allocator) void {
// Clear validation cache to match TS behavior (fresh ForkChoice per iteration).
self.fc.validated_attestation_datas.clearRetainingCapacity();

for (self.any_atts, self.phase0_atts) |*att, *phase0_att| {
// Compute hashTreeRoot of AttestationData inside the loop, matching TS behavior.
var att_data_root: [32]u8 = undefined;
AttestationData.hashTreeRoot(&phase0_att.data, &att_data_root) catch unreachable;
self.fc.onAttestation(allocator, att, att_data_root, false) catch unreachable;
}
}

pub fn deinit(self: *OnAttestationBench, allocator: std.mem.Allocator) void {
for (self.phase0_atts) |*att| {
att.attesting_indices.deinit(allocator);
}
allocator.free(self.phase0_atts);
allocator.free(self.any_atts);
util.deinitForkChoice(allocator, self.fc);
}
};

/// Build the OnAttestationBench instance.
///
/// 1. Initialize a ForkChoice with 600K validators and 64 blocks.
/// 2. Compute head via updateAndGetHead.
/// 3. Advance current_slot to 64 so attestations at slot 63 are past-slot.
/// 4. Pre-build 405 unaggregated + 704 aggregated attestations with per-committee roots.
fn setupBench(allocator: std.mem.Allocator) !OnAttestationBench {
const fc = try util.initializeForkChoice(allocator, .{
.initial_block_count = 64,
.initial_validator_count = 600_000,
.initial_equivocated_count = 0,
});

// Compute head so fc.head is populated.
_ = fc.updateAndGetHead(allocator, .{ .get_canonical_head = {} }) catch unreachable;

// Advance store time so attestations at slot 63 are past-slot (immediate apply).
fc.fc_store.current_slot = 64;

// Head block root is the target for all attestations.
const head_root = fc.head.block_root;

// Attestation parameters:
// att_slot = 63 (past slot relative to current_slot = 64)
// target_epoch = floor(63 / 32) = 1
// target_root = head block root
// beacon_block_root = head block root
const att_slot: u64 = 63;
const target_epoch: u64 = 1; // floor(63 / SLOTS_PER_EPOCH=32)

// Allocate attestation storage.
const phase0_atts = try allocator.alloc(Phase0IndexedAttestation, TOTAL_ATT_COUNT);
const any_atts = try allocator.alloc(AnyIndexedAttestation, TOTAL_ATT_COUNT);

var att_idx: u32 = 0;

// ── Unaggregated attestations: 3 committees x 135 validators ──
// Each attestation has exactly one attesting index.
// TS: index = committeeIndex, so committees 0-2 have unique data.
for (0..UNAGG_COMMITTEES) |c| {
for (0..VALIDATORS_PER_COMMITTEE) |v| {
const vi: u64 = @as(u64, c) * VALIDATORS_PER_COMMITTEE + @as(u64, v);

phase0_atts[att_idx] = .{
.attesting_indices = .{},
.data = .{
.slot = att_slot,
.index = @intCast(c),
.beacon_block_root = head_root,
.source = .{ .epoch = 0, .root = ZERO_HASH },
.target = .{ .epoch = target_epoch, .root = head_root },
},
.signature = [_]u8{0} ** 96,
};
try phase0_atts[att_idx].attesting_indices.append(allocator, vi);
any_atts[att_idx] = .{ .phase0 = &phase0_atts[att_idx] };
att_idx += 1;
}
}

// ── Aggregated attestations: 64 committees x 11 aggregators x 135 validators ──
// Each attestation has 135 attesting indices.
// TS: index = committeeIndex, so committees 0-63 have unique data.
// Committees 0-2 share roots with unaggregated attestations above.
for (0..AGG_COMMITTEES) |c| {
for (0..AGGREGATORS_PER_COMMITTEE) |a| {
const start_index: u64 = @as(u64, c) * VALIDATORS_PER_COMMITTEE * AGGREGATORS_PER_COMMITTEE +
@as(u64, a) * VALIDATORS_PER_COMMITTEE;

phase0_atts[att_idx] = .{
.attesting_indices = .{},
.data = .{
.slot = att_slot,
.index = @intCast(c),
.beacon_block_root = head_root,
.source = .{ .epoch = 0, .root = ZERO_HASH },
.target = .{ .epoch = target_epoch, .root = head_root },
},
.signature = [_]u8{0} ** 96,
};
for (0..VALIDATORS_PER_COMMITTEE) |v| {
try phase0_atts[att_idx].attesting_indices.append(allocator, start_index + @as(u64, v));
}
any_atts[att_idx] = .{ .phase0 = &phase0_atts[att_idx] };
att_idx += 1;
}
}

std.debug.assert(att_idx == TOTAL_ATT_COUNT);

return .{
.fc = fc,
.phase0_atts = phase0_atts,
.any_atts = any_atts,
};
}

fn deinitBench(allocator: std.mem.Allocator, b: OnAttestationBench) void {
for (b.phase0_atts) |att| {
var a = att.attesting_indices;
a.deinit(allocator);
}
allocator.free(b.phase0_atts);
allocator.free(b.any_atts);
util.deinitForkChoice(allocator, b.fc);
}

pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
const allocator = if (builtin.mode == .Debug) debug_allocator.allocator() else std.heap.c_allocator;
defer if (builtin.mode == .Debug) {
std.debug.assert(debug_allocator.deinit() == .ok);
};
const stdout = std.io.getStdOut().writer();

var bench = zbench.Benchmark.init(allocator, .{});

const b = try setupBench(allocator);
defer deinitBench(allocator, b);
try bench.addParam("onAttestation 1109 attestations (vc=600000 bc=64)", &b, .{});

defer bench.deinit();
try bench.run(stdout);
}
Loading
Loading