Skip to content

fix(types): encode a beacon state without holding it twice - #741

Open
MegaRedHand wants to merge 6 commits into
mainfrom
fix/types-state-encode-without-regrowth
Open

MegaRedHand wants to merge 6 commits into
mainfrom
fix/types-state-encode-without-regrowth

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

libssz's container encoder writes each fixed-size field past the end of the buffer and then moves it into place, and its to_ssz reserves only the encoding's length. So when a state's last field is fixed-size, encoding it reallocates a buffer the size of the whole state to twice that, copying it, just for that last field. Fulu's state ends on proposer_lookahead, gloas's on ptc_window. A 44 MB fulu state encoded with plain to_ssz ended in an 88 MB buffer.

On a mainnet state (hundreds of MB) that is one extra state-sized copy per encode. Two places pay it:

  • The state writer, on every import. encode_state_value encoded the state, then copied it into a second buffer behind its fork tag. At a snapshot, the writer also cloned the result, one copy for the States row and one for its snapshot memo.
  • The Beacon API's SSZ state response, /eth/v2/debug/beacon/states/{state_id}, on every request.

What Changed

  • crates/common/types/src/beacon/containers/mod.rs:
    • BeaconState::ssz_append_capacity(): the encoding's length plus the largest fixed-size field of that variant's state. The beacon forks share one list; a lean state has its own, since its widest fixed field is a block header, not 2 MiB.
    • BeaconState::ssz_append(buf) reserves that much past buf's current length, then appends the encoding. A caller with a prefix (the fork tag) needs no sizing of its own.
    • BeaconState::to_ssz() goes through ssz_append instead of libssz's to_ssz. That fixes every caller of the enum, the API response included.
  • crates/storage/src/state_codec.rs: encode_state_value pushes the fork tag, then appends the state into the same buffer.
  • crates/storage/src/state_writer.rs, api/traits.rs, backend/rocksdb.rs: a snapshot write no longer clones the encoded state. The plan hands over one buffer and a memoize_snapshot flag; the writer puts it into the batch from a borrow, through a new StorageWriteBatch::put(table, &key, &value), then moves the same buffer into the memo.

Correctness / Behavior Guarantees

  • Same bytes as before; only the allocation changes.
  • The headroom is the largest fixed-size field (randao_mixes on mainnet, 2 MiB; ptc_window on minimal). Any fixed-size field can then be written past the end, wherever it sits in the container, so the bound holds for every fork, including the earlier ones whose state also ends on a fixed-size field (phase0's finalized_checkpoint, altair's next_sync_committee).
  • The returned Vec carries that headroom as spare capacity: a few MiB at most, next to a state of hundreds of MB.
  • A snapshot write now holds the encoded state plus RocksDB's own copy in the batch until commit, instead of three copies.
  • Callers that encode a per-fork struct directly still go through libssz's to_ssz. I found none outside tests.
  • Deferred: fixing the overhang in libssz itself would cover blocks and sidecars too, but needs a libssz PR and a rev bump.

Tests Added / Run

  • ssz_append_never_regrows_a_buffer_with_its_capacity_to_spare, in state_transition's helpers/test_state.rs (the one place a state of every fork is built): a small state of every fork, phase0 through gloas, plus a lean genesis with no validators. to_ssz and an append after a one-byte prefix both reserve exactly the capacity and never regrow, and the encoding round-trips. Run on both presets. Verified to fail for the lean case without its own headroom.
  • a_beacon_state_value_is_its_tag_then_its_ssz_in_one_buffer: the tag byte, then the state's SSZ, in one buffer of exactly the reserved capacity. The value round-trips through decode_state_value.
  • The shared backend suite checks the new put against RocksDB and the in-memory backend.
  • cargo test --profile release-fast -p ethlambda-types --lib: 232 passed. -p ethlambda-storage --lib: 211 passed, 1 ignored. -p ethlambda-state-transition --lib ssz_append_never_regrows, with and without preset-minimal: passed.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean. Ran cargo clippy --locked -p ethlambda-types -p ethlambda-storage -p ethlambda-state-transition --all-targets -- -D warnings (clean), not the full workspace target
  • Ran make test (test-consensus plus test-node, at release-fast) — all passing. Ran only the tests above

libssz's container encoder writes each fixed-size field past the end of
the buffer and then moves it into place, and its to_ssz reserves only the
encoding's length. Fulu's state ends on a fixed-size field
(proposer_lookahead) and so does gloas's (ptc_window), so encoding either
regrows a buffer the size of the whole state to twice that for its last
field, copying it. Encoding a 44 MB fulu state ended with an 88 MB
buffer.

BeaconState now reserves the encoding's length plus its largest
fixed-size field (ssz_append_capacity) and appends into that, so
to_ssz never regrows; the Beacon API's SSZ state response goes through
it. The storage layer's encode_state_value, which runs on every import,
appends behind its fork tag into one such buffer instead of copying a
separately encoded state, which removed a second state-sized copy from
the follower's per-slot memory spike.
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 741: encode a beacon state without holding it twice

No blocking issues. The change is small and the reasoning holds.

  • Buffer sizing (containers/mod.rs, ssz_append_capacity): encoded_len() plus the largest fixed-size field is a sound bound on the headroom libssz's container encoder needs. The max is taken over every fixed-size field type, so a fork whose last field is fixed (fulu proposer_lookahead, gloas ptc_window) can't force a regrow. The largest field is RandaoMixes at about 2 MB. That is negligible next to a state of hundreds of MB.
  • Appending after the fork tag (state_codec.rs): encode_state_value now calls ssz_append into a buffer that already holds the 1-byte tag. This is only correct if libssz computes container offsets relative to where the append started, not to the start of the buffer. The new electra test covers it. It has variable-size fields, asserts bytes[1..] == to_ssz(), and round-trips through decode_state_value, so a wrong offset base would fail it.
  • Tests:
    • The fulu and gloas test asserts buf.capacity() == capacity, so a future libssz change that regrows the buffer will fail the test.
    • Both tests rely on Vec honouring with_capacity exactly. That is true in practice, but it is not guaranteed by the docs. The assertion is a reasonable regression guard.

Nits, non-blocking:

  • ssz_append_capacity runs encoded_len(), which walks the whole state. That is cheap next to the encode itself, but the doc comment could say so.
  • ssz_append_capacity is pub only for the storage crate's use. Keeping it that way is fine, but its doc comment should say that callers must pass a buffer with at least that much spare capacity.
  • The fixed-field list is hand-maintained. A new fork that adds a larger fixed-size field would silently bring back the double-hold. The capacity-assert tests would catch it only for forks they cover. Consider a short comment telling people to update the list when a fork adds a fixed-size field. Adding an electra and lean case to the first test would also help.

I haven't built the PR or run its tests.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which optimizes SSZ encoding for BeaconState to avoid buffer regrowth and double memory usage. Let me analyze each file.

crates/common/types/src/beacon/containers/mod.rs

Correctness Issues

Line 386-395 (ssz_append_capacity): The largest_fixed_field calculation is fragile and may become incorrect when new forks are added.

let largest_fixed_field = [
    <shared::RandaoMixes as libssz::SszEncode>::fixed_size(),
    <shared::BlockRoots as libssz::SszEncode>::fixed_size(),
    <altair::SyncCommittee as libssz::SszEncode>::fixed_size(),
    <gloas::PayloadTimelinessCommitteeWindow as libssz::SszEncode>::fixed_size(),
]

Problem: This hardcodes specific types from specific forks. If a future fork introduces a larger fixed-size field, this silently breaks. The comment mentions fulu's proposer_lookahead and gloas's ptc_window, but proposer_lookahead is not in this array.

Wait — let me re-read. The comment says "fulu's proposer_lookahead, gloas's ptc_window" but the array includes gloas::PayloadTimelinessCommitteeWindow (which is ptc_window) but no fulu::ProposerLookahead or equivalent. Is proposer_lookahead variable-size? If it's fixed-size and larger than the others, this is a bug.

Line 393: expect("the list is not empty") on a hardcoded array is fine, but consider unwrap_unchecked() or just unwrap() with a comment since it's a const-foldable property.

Documentation Issues

Line 369-370: Typo in comment — "fulu or gloas state" should be "Fulu or Gloas state" (capitalization inconsistent with rest of codebase).

Line 372: "which reserves only the encoding's length and so holds a fulu or gloas state twice while encoding it" — this is slightly misleading. The issue isn't the fork name, it's the encoder's write-past-end behavior for fixed-size fields after variable-size fields.

Testing

Lines 2226-2243: The test ssz_append_never_regrows_a_buffer_with_its_capacity_to_spare has a subtle issue:

assert_eq!(buf.capacity(), capacity, "{fork}: ssz_append regrew");

This assertion is too strict. Vec::capacity() can be larger than requested; the allocator may round up. This test may spuriously fail on different allocators or platforms. You should assert buf.capacity() >= capacity or buf.capacity() == capacity with a comment acknowledging this is implementation-dependent.

Similarly for encoded.capacity() on line 2242.

crates/storage/src/state_codec.rs

Correctness and Performance

Line 31:

let mut bytes = Vec::with_capacity(1 + state.ssz_append_capacity());

This is correct and achieves the optimization goal.

Line 33-34:

bytes.push(state.fork_name().selector());
state.ssz_append(&mut bytes);

Correct ordering: selector byte first, then SSZ payload.

Testing

Line 73-88: Same issue as above — bytes.capacity() assertion is too strict and may fail on different allocators.

Line 82: assert_eq!(bytes[1..], state.to_ssz()) — this verifies equivalence with the old path, which is good for regression testing.

Cross-Cutting Concerns

API Design

The new ssz_append and ssz_append_capacity methods expose libssz implementation details (the write-past-end behavior) at the BeaconState level. This creates a tight coupling. Consider:

  1. Whether libssz itself should provide an append_to API with proper capacity hinting
  2. Whether ssz_append_capacity should be pub — it's currently pub in mod.rs, making it part of the crate's public API

Memory Safety

No issues. The &mut Vec<u8> pattern is safe and idiomatic.

Consensus Criticality

This is not consensus-critical code — it's an encoding optimization. The actual SSZ bytes produced are identical; only the memory allocation pattern changes. The test verifies this equivalence.

Summary

Severity Issue Location
Medium capacity() equality assertions are too strict, may spuriously fail mod.rs:2239,2242, state_codec.rs:83
Low proposer_lookahead mentioned but not in largest_fixed_field array — verify if this is correct mod.rs:comment at 380
Low Typo in doc comment ("a fulu or gloas state") mod.rs:370
Low ssz_append_capacity exposes libssz internals as public API mod.rs:388

Recommendations

  1. Fix capacity assertions (most important):

    // Instead of:
    assert_eq!(buf.capacity(), capacity, ...);
    // Use:
    assert!(buf.capacity() >= capacity, "{fork}: ssz_append regrew: got {}, expected {}", buf.capacity(), capacity);
  2. Verify proposer_lookahead: Confirm whether Fulu's proposer_lookahead is fixed-size and if so, whether it could exceed the current largest_fixed_field. If it's variable-size, update the comment to clarify.

  3. Consider making ssz_append_capacity pub(crate) if external callers don't need it.

  4. Add a compile-time or macro-generated check for largest_fixed_field that fails when new forks are added with larger fixed fields, rather than relying on manual maintenance.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Review

  • Looks good overall. The patch is tightly scoped to SSZ serialization/storage buffering, and I don’t see any direct consensus-risk changes to fork choice, attestation processing, justification/finalization, state transition logic, XMSS handling, or SSZ decoding semantics.

  • crates/common/types/src/beacon/containers/mod.rs:391 looks correct: ssz_append_capacity() intentionally over-allocates by the largest fixed-size field to cover libssz’s temporary write-past-end behavior for trailing fixed-size fields. Since the serialized bytes still come from ssz_append, output compatibility should be preserved.

  • crates/storage/src/state_codec.rs:31 is a solid performance win. Writing SSZ directly into the tagged buffer avoids the extra full-state allocation/copy from extend_from_slice(&state.to_ssz()), which matters for large mainnet states.

  • Maintainability concern at crates/common/types/src/beacon/containers/mod.rs:392: the largest_fixed_field list is hand-maintained. If a future fork adds a larger trailing fixed-size field and this list is not updated, the code still works functionally but silently loses the no-regrowth guarantee. I’d suggest a short invariant comment or a broader regression test covering all fork fixtures.

  • Related point at crates/common/types/src/beacon/containers/mod.rs:393: the optimization depends on a non-obvious libssz implementation detail. The existing doc comment helps, but a test or helper closer to each fork’s definition would make future changes safer.

  • No security red flags: no new decoding surface, no weakened validation, and reduced peak memory pressure is a net positive for node robustness.

  • I couldn’t run the targeted tests here because cargo attempted to write to a read-only Rustup temp path in this environment; that looks environmental rather than PR-related.

If you want, I can convert this into a concise ready-to-post GitHub review comment.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

MegaRedHand added a commit that referenced this pull request Oct 30, 2026
libssz's container encoder writes each fixed-size field past the end of
the buffer and then moves it into place, and its to_ssz reserves only the
encoding's length. Fulu's state ends on a fixed-size field
(proposer_lookahead) and so does gloas's (ptc_window), so encoding either
regrows a buffer the size of the whole state to twice that for its last
field, copying it. Encoding a 44 MB fulu state ended with an 88 MB
buffer.

BeaconState now reserves the encoding's length plus its largest
fixed-size field (ssz_append_capacity) and appends into that, so
to_ssz never regrows; the Beacon API's SSZ state response goes through
it. The storage layer's encode_state_value, which runs on every import,
appends behind its fork tag into one such buffer instead of copying a
separately encoded state, which removed a second state-sized copy from
the follower's per-slot memory spike.

Cherry-picked from fix/types-state-encode-without-regrowth at 699f77a.
@pablodeymo

Copy link
Copy Markdown
Collaborator

Looked at this against libssz at 5cb1437. The bound is right: append_fixed
overhangs by exactly the field's size, and the derive's all-fixed fast path
means a nested fixed container (SyncCommittee, Checkpoint, ...) adds no
second layer, so "largest fixed-size field" is tight. Mainnet's is
randao_mixes (2 MiB), minimal's is ptc_window (3072 bytes, above
randao_mixes at 2048), and the minimal CI run covers the no-regrow assert.
No security concern: nothing here touches untrusted input, and the SSZ state
endpoint now peaks at one state plus 2 MiB per request instead of two states.

Two low-priority notes, neither blocking:

  1. ssz_append_capacity names four types by hand. A future fork that adds a
    fixed-size field larger than those would silently lose the no-regrow
    guarantee, and the test only covers fulu, gloas and (through the codec
    test) electra. Iterating every fork in
    ssz_append_never_regrows_a_buffer_with_its_capacity_to_spare would turn
    that into a test failure rather than a quiet regression.
  2. The spare capacity rides along in anything that keeps the returned Vec,
    such as the state writer's snapshot_memo. At 2 MiB next to a
    hundreds-of-MB state that is fine, and shrink_to_fit would cost the copy
    this PR removes, so just noting it.

The test covered fulu and gloas only, so a later fork with a fixed-size
field larger than any ssz_append_capacity counts would have lost the
guarantee without failing anything. It now builds a small state of every
fork, the hard case since little variable-size data follows each fixed
field, and lives with the state-transition crate's test builders because
ethlambda-types cannot build a state of every fork.
Each caller had to remember to size the buffer with ssz_append_capacity,
and to add its own prefix to it. One that forgot (vec![tag] and then
ssz_append, or a reserve of encoded_len only) regrew a buffer the size
of the state and held two copies of it, the very spike this change set
out to remove. ssz_append now reserves the room past the buffer's current
length itself, so a prefix is covered, and to_ssz and the state codec no
longer compute it.
Every variant reserved the largest fixed-size field of any beacon fork,
2 MiB on mainnet, including a lean state, whose widest fixed field is a
block header. Each lean snapshot and to_ssz of a lean state allocated
megabytes it never used, often more than the state itself. A lean state
now reserves its own largest fixed field. The beacon forks keep one
shared list, since every one of them has randao_mixes and a list per fork
would save at most a kilobyte on minimal.

The every-fork test now covers a lean genesis too; with no headroom for
it, that case regrows.
plan_beacon_state_write cloned the encoded target at every snapshot, one
copy for the States row and one for the writer's snapshot memo, so a
snapshot write still held a mainnet state twice, plus RocksDB's own copy
in the batch. The plan now hands over one buffer and a flag. The writer
puts it into the batch from a borrow, through a new
StorageWriteBatch::put that takes slices (RocksDB copies the value into
its batch either way), and then moves the same buffer into the memo.
The floor is now the target plus the batch's copy until commit.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

@pablodeymo thanks for checking the bound against libssz.

  1. Every fork: ec13e5d6a. The test keeps its name but moved to state_transition's helpers/test_state.rs, since ethlambda-types can't build a state of every fork. It runs phase0 through gloas on both presets, plus a lean genesis since 1389f9a4b.
  2. The spare capacity in snapshot_memo: left as it is, per your note.

Three more from a second review pass:

  • 5fbfed2a1: ssz_append reserves its own room past the buffer's current length. A caller can no longer forget ssz_append_capacity or miscount a prefix, and to_ssz and encode_state_value no longer compute it.
  • 1389f9a4b: a lean state reserves its own largest fixed field, a block header, instead of the 2 MiB a mainnet beacon state needs. It does need some: with none, the test's lean case regrows.
  • 9b6c03b55: plan_beacon_state_write cloned the encoded target at every snapshot, one copy for the row and one for the memo. The writer now puts it into the batch from a borrow, through a new StorageWriteBatch::put, and moves the same buffer into the memo. A snapshot write holds the state plus RocksDB's batch copy, not three copies.

Fixing the overhang in libssz itself would also cover blocks and sidecars. I left that for a separate libssz PR, since it needs a rev bump.

This branch has not been deployed

No deployments
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.

2 participants