Skip to content

Commit f585729

Browse files
committed
feat: support native loadState
- add native `loadState` that mirrors TS implementation - add bindings - add benchmarks to bench naive + loadState difference part of #347
1 parent aef4420 commit f585729

7 files changed

Lines changed: 701 additions & 1 deletion

File tree

bindings/napi/BeaconStateView.zig

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,52 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options:
937937
return .{ .cached_state = post_state };
938938
}
939939

940+
/// Load another state by reusing this state's validators / inactivity_scores subtrees
941+
/// where the byte ranges are unchanged. Mirrors TS `loadState` for ~500ms-per-reload speedup.
942+
pub fn loadOtherState(
943+
self: *const BeaconStateView,
944+
state_bytes: js.Uint8Array,
945+
seed_validators_bytes: ?js.Uint8Array,
946+
_: ?js.Value,
947+
) !BeaconStateView {
948+
const cached_state = try self.requireState();
949+
const state_bytes_slice = try state_bytes.toSlice();
950+
const seed_validators_bytes_slice: ?[]const u8 =
951+
if (seed_validators_bytes) |b| try b.toSlice() else null;
952+
953+
var result = try st.loadState(
954+
allocator,
955+
&pool.state.pool,
956+
cached_state.config,
957+
cached_state.state,
958+
state_bytes_slice,
959+
seed_validators_bytes_slice,
960+
);
961+
962+
result.modified_validators.deinit(allocator);
963+
errdefer {
964+
result.state.deinit();
965+
allocator.destroy(result.state);
966+
}
967+
968+
const new_cached_state = try allocator.create(CachedBeaconState);
969+
errdefer allocator.destroy(new_cached_state);
970+
971+
try new_cached_state.init(
972+
allocator,
973+
result.state,
974+
.{
975+
.config = cached_state.config,
976+
.index_to_pubkey = cached_state.epoch_cache.index_to_pubkey,
977+
.pubkey_to_index = cached_state.epoch_cache.pubkey_to_index,
978+
},
979+
// as of Feb 2026, it's not necessary to sync pubkey cache as it's shared across states in Lodestar
980+
.{ .skip_sync_pubkeys = true },
981+
);
982+
983+
return .{ .cached_state = new_cached_state };
984+
}
985+
940986
fn requireState(self: *const BeaconStateView) !*CachedBeaconState {
941987
return self.cached_state orelse error.InvalidState;
942988
}

bindings/perf/loadState.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {config} from "@lodestar/config/default";
2+
import * as era from "@lodestar/era";
3+
import {loadState as loadStateTS} from "@lodestar/state-transition";
4+
import {ssz} from "@lodestar/types";
5+
import {bench, describe} from "@chainsafe/benchmark";
6+
import bindings from "../src/index.js";
7+
import {getFirstEraFilePath} from "../test/eraFiles.ts";
8+
9+
const reader = await era.era.EraReader.open(config, getFirstEraFilePath());
10+
const stateBytes = await reader.readSerializedState();
11+
await reader.close();
12+
13+
bindings.pool.ensureCapacity(10_000_000);
14+
bindings.pubkeys.ensureCapacity(2_000_000);
15+
try {
16+
bindings.pubkeys.load("./mainnet.pkix");
17+
} catch (_e) {
18+
// ignore
19+
}
20+
21+
const seedState = bindings.BeaconStateView.createFromBytes(stateBytes);
22+
const seedValidatorsBytes = seedState.serializeValidators();
23+
24+
const tsSeedState = ssz.fulu.BeaconState.deserializeToViewDU(stateBytes);
25+
26+
describe("loadState: native vs TS (mainnet)", () => {
27+
bench({
28+
fn: () => {
29+
seedState.loadOtherState(stateBytes);
30+
},
31+
id: "native (internal serialize seed)",
32+
});
33+
34+
bench({
35+
fn: () => {
36+
loadStateTS(config, tsSeedState, stateBytes);
37+
},
38+
id: "TS (internal serialize seed)",
39+
});
40+
41+
bench({
42+
fn: () => {
43+
seedState.loadOtherState(stateBytes, seedValidatorsBytes);
44+
},
45+
id: "native (prebuilt seedValidatorsBytes)",
46+
});
47+
48+
bench({
49+
fn: () => {
50+
loadStateTS(config, tsSeedState, stateBytes, seedValidatorsBytes);
51+
},
52+
id: "TS (prebuilt seedValidatorsBytes)",
53+
});
54+
});

bindings/src/index.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,18 @@ declare class BeaconStateView {
213213
createdWithTransferCache: boolean;
214214
// isStateValidatorsNodesPopulated(): boolean;
215215

216-
// loadOtherState(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void;
216+
/**
217+
* Tree-reuse optimized: builds a new state from `stateBytes` while sharing this
218+
* state's validators / inactivity_scores subtree nodes for unchanged entries.
219+
* Pass `seedValidatorsBytes` to skip the internal serialization of seed validators.
220+
* Note: `preloadValidatorsAndBalances` is currently a no-op (native state is
221+
* always populated).
222+
*/
223+
loadOtherState(
224+
stateBytes: Uint8Array,
225+
seedValidatorsBytes?: Uint8Array,
226+
opts?: {preloadValidatorsAndBalances?: boolean}
227+
): BeaconStateView;
217228
serialize(): Uint8Array;
218229
serializedSize(): number;
219230
serializeToBytes(output: Uint8Array, offset: number): number;

bindings/test/beaconStateView.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,4 +623,27 @@ describe("BeaconStateView", () => {
623623
expect(increments.length).toBe(state.validatorCount);
624624
});
625625
});
626+
627+
describe("loadOtherState", () => {
628+
it("hashTreeRoot matches createFromBytes", () => {
629+
const reloaded = state.loadOtherState(stateBytes);
630+
expect(reloaded.hashTreeRoot()).toEqual(state.hashTreeRoot());
631+
});
632+
633+
it("serialized output round-trips", () => {
634+
const reloaded = state.loadOtherState(stateBytes);
635+
const reloadedBytes = reloaded.serialize();
636+
// Avoid vitest's expensive deep-comparison on ~300MB buffers.
637+
expect(reloadedBytes.length).toBe(stateBytes.length);
638+
// Compare bit-for-bit via Buffer.equals (fast native compare).
639+
expect(Buffer.from(reloadedBytes).equals(Buffer.from(stateBytes))).toBe(true);
640+
});
641+
642+
it("seedValidatorsBytes path matches no-seed path", () => {
643+
const seedValidatorsBytes = state.serializeValidators();
644+
const a = state.loadOtherState(stateBytes);
645+
const b = state.loadOtherState(stateBytes, seedValidatorsBytes);
646+
expect(b.hashTreeRoot()).toEqual(a.hashTreeRoot());
647+
});
648+
});
626649
});

src/state_transition/root.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ pub const getEffectiveBalanceIncrementsZeroInactive = @import("./utils/balance.z
9292

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

95+
pub const loadState = @import("./utils/load_state.zig").loadState;
96+
pub const LoadStateResult = @import("./utils/load_state.zig").LoadStateResult;
97+
9598
pub const test_utils = @import("test_utils/root.zig");
9699

97100
pub const bls = @import("utils/bls.zig");
@@ -109,5 +112,6 @@ test {
109112
testing.refAllDecls(@This());
110113
testing.refAllDecls(seed);
111114
testing.refAllDecls(state_transition);
115+
testing.refAllDecls(@import("./utils/load_state.zig"));
112116
testing.refAllDecls(EpochShuffling);
113117
}

0 commit comments

Comments
 (0)