Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
74 changes: 72 additions & 2 deletions bindings/napi/BeaconStateView.zig
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,6 @@ pub fn createdWithTransferCache(self: *const BeaconStateView) !js.Boolean {

// pub fn BeaconStateView_isStateValidatorsNodesPopulated

// pub fn BeaconStateView_loadOtherState

pub fn serialize(self: *const BeaconStateView) !js.Uint8Array {
const env = js.env();
const cached_state = try self.requireState();
Expand Down Expand Up @@ -937,6 +935,78 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options:
return .{ .cached_state = post_state };
}

/// Load another state by reusing this state's validators / inactivity_scores subtrees
/// where the byte ranges are unchanged.
pub fn loadOtherState(
self: *const BeaconStateView,
state_bytes: js.Uint8Array,
seed_validators_bytes: ?js.Uint8Array,
_: ?js.Value,
) !BeaconStateView {
const cached_state = try self.requireState();
const state_bytes_slice = try state_bytes.toSlice();
const seed_validators_bytes_slice: ?[]const u8 =
if (seed_validators_bytes) |b| try b.toSlice() else null;

var result = try st.loadState(
allocator,
&pool.state.pool,
cached_state.config,
cached_state.state,
state_bytes_slice,
seed_validators_bytes_slice,
);

result.modified_validators.deinit(allocator);
errdefer {
result.state.deinit();
allocator.destroy(result.state);
}

const new_cached_state = try allocator.create(CachedBeaconState);
errdefer allocator.destroy(new_cached_state);

try new_cached_state.init(
allocator,
result.state,
.{
.config = cached_state.config,
.index_to_pubkey = cached_state.epoch_cache.index_to_pubkey,
.pubkey_to_index = cached_state.epoch_cache.pubkey_to_index,
},
// as of Feb 2026, it's not necessary to sync pubkey cache as it's shared across states in Lodestar
.{ .skip_sync_pubkeys = true },
);

return .{ .cached_state = new_cached_state };
}

/// Bench-only: run `st.loadState` and immediately tear down the result. Mirrors what
/// TS `loadState` measures (no CachedBeaconState wrap, no EpochCache build) so that
/// native vs TS comparisons isolate the SSZ tree-rebuild cost.
pub fn loadOtherStateBench(
self: *const BeaconStateView,
state_bytes: js.Uint8Array,
seed_validators_bytes: ?js.Uint8Array,
) !void {
const cached_state = try self.requireState();
const state_bytes_slice = try state_bytes.toSlice();
const seed_validators_bytes_slice: ?[]const u8 =
if (seed_validators_bytes) |b| try b.toSlice() else null;

var result = try st.loadState(
allocator,
&pool.state.pool,
cached_state.config,
cached_state.state,
state_bytes_slice,
seed_validators_bytes_slice,
);
result.modified_validators.deinit(allocator);
result.state.deinit();
allocator.destroy(result.state);
}

fn requireState(self: *const BeaconStateView) !*CachedBeaconState {
return self.cached_state orelse error.InvalidState;
}
Expand Down
54 changes: 54 additions & 0 deletions bindings/perf/loadState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {bench, describe} from "@chainsafe/benchmark";
import {config} from "@lodestar/config/default";
import * as era from "@lodestar/era";
import {loadState as loadStateTS} from "@lodestar/state-transition";
import {ssz} from "@lodestar/types";
import bindings from "../src/index.js";
import {getFirstEraFilePath} from "../test/eraFiles.ts";

const reader = await era.era.EraReader.open(config, getFirstEraFilePath());
const stateBytes = await reader.readSerializedState();
await reader.close();

bindings.pool.ensureCapacity(10_000_000);
bindings.pubkeys.ensureCapacity(2_000_000);
try {
bindings.pubkeys.load("./mainnet.pkix");
} catch (_e) {
// ignore
}

const seedState = bindings.BeaconStateView.createFromBytes(stateBytes);
const seedValidatorsBytes = seedState.serializeValidators();

const tsSeedState = ssz.fulu.BeaconState.deserializeToViewDU(stateBytes);

describe("loadState: native vs TS (mainnet)", () => {
bench({
fn: () => {
seedState.loadOtherStateBench(stateBytes);
},
id: "native (internal serialize seed)",
});

bench({
fn: () => {
loadStateTS(config, tsSeedState, stateBytes);
},
id: "TS (internal serialize seed)",
});

bench({
fn: () => {
seedState.loadOtherStateBench(stateBytes, seedValidatorsBytes);
},
id: "native (prebuilt seedValidatorsBytes)",
});

bench({
fn: () => {
loadStateTS(config, tsSeedState, stateBytes, seedValidatorsBytes);
},
id: "TS (prebuilt seedValidatorsBytes)",
});
});
19 changes: 18 additions & 1 deletion bindings/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,24 @@ declare class BeaconStateView {
createdWithTransferCache: boolean;
// isStateValidatorsNodesPopulated(): boolean;

// loadOtherState(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void;
/**
* Tree-reuse optimized: builds a new state from `stateBytes` while sharing this
* state's validators / inactivity_scores subtree nodes for unchanged entries.
* Pass `seedValidatorsBytes` to skip the internal serialization of seed validators.
* Note: `preloadValidatorsAndBalances` is currently a no-op (native state is
* always populated).
*/
loadOtherState(
stateBytes: Uint8Array,
seedValidatorsBytes?: Uint8Array,
opts?: {preloadValidatorsAndBalances?: boolean}
): BeaconStateView;
/**
* Bench-only: runs the SSZ tree-rebuild from `loadOtherState` and discards the result.
* No CachedBeaconState wrap, no EpochCache build — for apples-to-apples comparison
* against `@lodestar/state-transition`'s `loadState`.
*/
loadOtherStateBench(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void;
serialize(): Uint8Array;
serializedSize(): number;
serializeToBytes(output: Uint8Array, offset: number): number;
Expand Down
21 changes: 21 additions & 0 deletions bindings/test/beaconStateView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,4 +623,25 @@ describe("BeaconStateView", () => {
expect(increments.length).toBe(state.validatorCount);
});
});

describe("loadOtherState", () => {
it("hashTreeRoot matches createFromBytes", () => {
const reloaded = state.loadOtherState(stateBytes);
expect(reloaded.hashTreeRoot()).toEqual(state.hashTreeRoot());
});

it("serialized output round-trips", () => {
const reloaded = state.loadOtherState(stateBytes);
const reloadedBytes = reloaded.serialize();
expect(reloadedBytes.length).toBe(stateBytes.length);
expect(Buffer.from(reloadedBytes).equals(Buffer.from(stateBytes))).toBe(true);
});

it("seedValidatorsBytes path matches no-seed path", () => {
const seedValidatorsBytes = state.serializeValidators();
const a = state.loadOtherState(stateBytes);
const b = state.loadOtherState(stateBytes, seedValidatorsBytes);
expect(b.hashTreeRoot()).toEqual(a.hashTreeRoot());
});
});
});
4 changes: 2 additions & 2 deletions src/state_transition/cache/epoch_cache.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ pub const EpochCacheImmutableData = struct {
};

pub const EpochCacheOpts = struct {
skip_sync_committee_cache: bool,
skip_sync_pubkeys: bool,
skip_sync_committee_cache: bool = false,
skip_sync_pubkeys: bool = false,
};

const proposer_weight: f64 = @floatFromInt(c.PROPOSER_WEIGHT);
Expand Down
4 changes: 4 additions & 0 deletions src/state_transition/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ pub const getEffectiveBalanceIncrementsZeroInactive = @import("./utils/balance.z

pub const WithdrawalsResult = @import("./block/process_withdrawals.zig").WithdrawalsResult;

pub const loadState = @import("./utils/load_state.zig").loadState;
pub const LoadStateResult = @import("./utils/load_state.zig").LoadStateResult;

pub const test_utils = @import("test_utils/root.zig");

pub const bls = @import("utils/bls.zig");
Expand All @@ -109,5 +112,6 @@ test {
testing.refAllDecls(@This());
testing.refAllDecls(seed);
testing.refAllDecls(state_transition);
testing.refAllDecls(@import("./utils/load_state.zig"));
testing.refAllDecls(EpochShuffling);
}
Loading
Loading